Compare commits

..

10 Commits

Author SHA1 Message Date
Maofeng 94506aa349 feat(ui): expose advanced popover positioning
Forward explicit anchors and position-method selection through PopoverContent so consumers can position popovers against virtual or custom anchors without bypassing the shared primitive.
2026-07-31 16:06:44 +08:00
Maofeng d364e5e39f feat(icons): extract shared workspace icon package
Create @workspace/icons as the single Hugeicons-backed Icon primitive and icon-data export surface, with size presets and inherited current color.

Migrate blocks and the web app to the shared package, remove duplicated direct Hugeicons dependencies and the blocks-local Icon component, and update the lockfile.
2026-07-31 16:06:39 +08:00
Maofeng 38d2defeb1 feat(blocks): add context-driven global search
Add a command-style global search dialog with asynchronous cancellation, paginated grouped results, highlighting, loading and error states, recent query history, and a configurable Mod+K shortcut.

Make search sources and result actions host-defined through SearchProvider and SearchAdapter, so the block has no route, database, or HTTP dependency. Export the block, register i18n discovery, and cover adapter pagination and selection behavior.
2026-07-31 16:06:29 +08:00
Maofeng 324d8c0244 feat(blocks): add context-driven media library
Provide a reusable image and video library with folders, filters, search, pagination, favorites, upload targets, detail preview, contextual actions, and single or multi-select picker flows.

Keep persistence, upload, storage, and reference resolution behind MediaProvider's MediaAdapter; export the block and register its English and Simplified Chinese catalogs. Include adapter-bound UI coverage.
2026-07-31 16:04:15 +08:00
Maofeng 263b400860 feat(lexical): support local image data URLs
Allow the image insertion dialog to select a local image and embed it as a Base64 data URL, while retaining URL-based insertion and leaving video URL-only.

Extract shared abortable FileReader handling for clipboard uploads and dialog selection; add English and Simplified Chinese messages plus an interaction test covering file selection through insertion.
2026-07-31 16:03:54 +08:00
Maofeng cbf5ee784f feat(web): integrate localized Lexical editor demo
Register Lexical's English and Simplified Chinese catalogs in the application loader, import its global styles, and add the workspace dependency with its resolved lockfile graph.\n\nReplace the dashboard placeholder with a controlled rich-text editor demo that composes default actions, fixed and bubble toolbars, draggable blocks, generated HTML output, and reset behavior.
2026-07-31 15:09:02 +08:00
Maofeng 0d817537be feat(lexical): add declarative rich text editor
Introduce @workspace/lexical with composable actions, toolbars, embeds, rich-text nodes, media uploads, selection utilities, HTML serialization, and theme styling.\n\nLocalize built-in controls through MessageDescriptor catalogs for English and Simplified Chinese, while providing an English I18nProvider fallback for standalone editor use. Include focused unit coverage for actions, controls, serialization, plugins, public API, and catalogs.
2026-07-31 15:07:09 +08:00
Maofeng 1fe9c1021a refactor(blocks): consume runtime localized text
Replace the blocks-local LocalizedText implementation with the shared i18n runtime primitive in navigation and appearance blocks.\n\nRemove the former blocks component entry point as requested; consumers must now import LocalizedText from @workspace/i18n.
2026-07-31 15:07:03 +08:00
Maofeng 8d4d389982 feat(i18n): expose localized text runtime primitive
Add a DOM-free LocalizedText component for MessageDescriptor values and export it from the runtime entry point.\n\nAlso expose provider detection so packages with a standalone fallback provider can avoid invoking Lingui hooks outside a provider. Cover localized rendering without an extra wrapper node.
2026-07-31 15:06:58 +08:00
Maofeng d36028d67b build: migrate workspace tooling to Oxc
Replace Turbo task orchestration with Bun workspace scripts and remove the Prettier configuration. Add Oxlint and Oxfmt project configuration, including Tailwind class sorting, generated-file exclusions, and unified check/fix commands. Apply the new formatter and resolve Hook dependency errors surfaced by Oxlint.
2026-07-30 15:50:51 +08:00
184 changed files with 12534 additions and 336 deletions
-3
View File
@@ -16,9 +16,6 @@ dist-ssr
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
# turbo
.turbo
# generated i18n build schema # generated i18n build schema
.i18n .i18n
+22
View File
@@ -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"
}
+33
View File
@@ -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"
]
}
-6
View File
@@ -1,6 +0,0 @@
dist/
node_modules/
.turbo/
coverage/
pnpm-lock.yaml
.pnpm-store/
-11
View File
@@ -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"]
}
+4 -1
View File
@@ -7,8 +7,11 @@
"@workspace/blocks/appearance/locales/{locale}", "@workspace/blocks/appearance/locales/{locale}",
"@workspace/blocks/chats/locales/{locale}", "@workspace/blocks/chats/locales/{locale}",
"@workspace/blocks/layout/locales/{locale}", "@workspace/blocks/layout/locales/{locale}",
"@workspace/blocks/media/locales/{locale}",
"@workspace/blocks/navigation/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"], "include": ["src", "../../packages/blocks/src", "../../packages/ui/src"],
"exclude": ["**/*.test.{ts,tsx}", "**/node_modules/**"] "exclude": ["**/*.test.{ts,tsx}", "**/node_modules/**"]
+2 -3
View File
@@ -6,7 +6,6 @@
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build && tsc -b", "build": "vite build && tsc -b",
"format": "prettier --write \"**/*.{ts,tsx}\"",
"i18n": "workspace-i18n", "i18n": "workspace-i18n",
"i18n:compile": "workspace-i18n compile", "i18n:compile": "workspace-i18n compile",
"i18n:extract": "workspace-i18n extract", "i18n:extract": "workspace-i18n extract",
@@ -20,8 +19,6 @@
"@base-ui/react": "^1.6.0", "@base-ui/react": "^1.6.0",
"@base-ui/utils": "^0.3.1", "@base-ui/utils": "^0.3.1",
"@floating-ui/utils": "^0.2.12", "@floating-ui/utils": "^0.2.12",
"@hugeicons/core-free-icons": "^4.2.3",
"@hugeicons/react": "^1.1.9",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@lingui/core": "^6", "@lingui/core": "^6",
"@tanstack/react-form": "^1.33.2", "@tanstack/react-form": "^1.33.2",
@@ -30,7 +27,9 @@
"@tanstack/react-router": "^1.170.18", "@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32", "@tanstack/react-start": "^1.168.32",
"@workspace/blocks": "workspace:*", "@workspace/blocks": "workspace:*",
"@workspace/icons": "workspace:*",
"@workspace/i18n": "workspace:*", "@workspace/i18n": "workspace:*",
"@workspace/lexical": "workspace:*",
"@workspace/ui": "workspace:*", "@workspace/ui": "workspace:*",
"country-flag-icons": "^1.6.20", "country-flag-icons": "^1.6.20",
"lucide-react": "^1.27.0", "lucide-react": "^1.27.0",
+1 -1
View File
@@ -56,7 +56,7 @@ import {
UserShield01Icon, UserShield01Icon,
Wallet01Icon, Wallet01Icon,
WalletCardsIcon, WalletCardsIcon,
} from "@hugeicons/core-free-icons" } from "@workspace/icons"
import type { NavigationGroup } from "@workspace/blocks/navigation" import type { NavigationGroup } from "@workspace/blocks/navigation"
+1 -1
View File
@@ -3,7 +3,7 @@ import {
Mail01Icon, Mail01Icon,
Message01Icon, Message01Icon,
PackageIcon, PackageIcon,
} from "@hugeicons/core-free-icons" } from "@workspace/icons"
import type { QueryFunctionContext } from "@tanstack/react-query" import type { QueryFunctionContext } from "@tanstack/react-query"
import type { Notification } from "@workspace/blocks/notifications" import type { Notification } from "@workspace/blocks/notifications"
+64 -36
View File
@@ -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 { 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() { export function DashboardPage() {
const [html, setHtml] = React.useState(initialEditorHtml)
return ( return (
<> <div className="flex min-w-0 flex-col gap-6 bg-background">
<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> <div>
<h1 className="font-medium">Project ready!</h1> <h1 className="text-xl font-medium">Lexical editor</h1>
<p>You may now add components and start building.</p> <p className="mt-1 text-sm text-muted-foreground">
<p>We&apos;ve already added the button component for you.</p> Actions declare their controls and editor dependencies in one place.
<Button variant="ghost" className="mt-2"> </p>
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> </div>
<div> <div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(20rem,0.7fr)]">
<span className="mr-2">breakpoint:</span> <section className="min-w-0">
<span className="hidden mobile:inline">mobile</span> <div className="mb-2 flex items-center justify-between gap-3">
<span className="hidden tablet:inline">tablet</span> <h2 className="font-medium">Editor</h2>
<span className="hidden desktop:inline">desktop</span> <code className="text-xs text-muted-foreground">
useDefaults=&quot;full&quot;
</code>
</div> </div>
<div> <LexicalRoot value={html} onChange={setHtml} className="shadow-xs">
<span className="mr-2">collapsed:</span> <LexicalActions useDefaults="full" />
<span className="hidden collapsed:inline">true</span> <LexicalFixedToolbar />
<span className="inline collapsed:hidden">false</span> <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> </div>
<p className="font-mono text-xs text-muted-foreground">
Press <kbd>{formatForDisplay("Mod+D")}</kbd> to toggle dark mode.
</p>
</div> </div>
</>
) )
} }
+1
View File
@@ -1,5 +1,6 @@
@import "@workspace/ui/globals.css"; @import "@workspace/ui/globals.css";
@import "@workspace/blocks/globals.css"; @import "@workspace/blocks/globals.css";
@import "@workspace/lexical/globals.css";
:root { :root {
--ui-content-top-gap: --spacing(2); --ui-content-top-gap: --spacing(2);
+193 -72
View File
@@ -5,9 +5,8 @@
"": { "": {
"name": "simple-react-app-kit", "name": "simple-react-app-kit",
"devDependencies": { "devDependencies": {
"prettier": "^3.8.3", "oxfmt": "^0.61.0",
"prettier-plugin-tailwindcss": "^0.8.0", "oxlint": "^1.76.0",
"turbo": "^2.9.18",
"typescript": "~6", "typescript": "~6",
}, },
}, },
@@ -18,8 +17,6 @@
"@base-ui/react": "^1.6.0", "@base-ui/react": "^1.6.0",
"@base-ui/utils": "^0.3.1", "@base-ui/utils": "^0.3.1",
"@floating-ui/utils": "^0.2.12", "@floating-ui/utils": "^0.2.12",
"@hugeicons/core-free-icons": "^4.2.3",
"@hugeicons/react": "^1.1.9",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@lingui/core": "^6", "@lingui/core": "^6",
"@tanstack/react-form": "^1.33.2", "@tanstack/react-form": "^1.33.2",
@@ -29,6 +26,8 @@
"@tanstack/react-start": "^1.168.32", "@tanstack/react-start": "^1.168.32",
"@workspace/blocks": "workspace:*", "@workspace/blocks": "workspace:*",
"@workspace/i18n": "workspace:*", "@workspace/i18n": "workspace:*",
"@workspace/icons": "workspace:*",
"@workspace/lexical": "workspace:*",
"@workspace/ui": "workspace:*", "@workspace/ui": "workspace:*",
"country-flag-icons": "^1.6.20", "country-flag-icons": "^1.6.20",
"lucide-react": "^1.27.0", "lucide-react": "^1.27.0",
@@ -58,13 +57,13 @@
"@base-ui/react": "^1.6.0", "@base-ui/react": "^1.6.0",
"@base-ui/utils": "^0.3.1", "@base-ui/utils": "^0.3.1",
"@floating-ui/utils": "^0.2.12", "@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-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.18", "@tanstack/react-router": "^1.170.18",
"@workspace/i18n": "workspace:*", "@workspace/i18n": "workspace:*",
"@workspace/icons": "workspace:*",
"@workspace/ui": "workspace:*", "@workspace/ui": "workspace:*",
"cmdk": "^1.1.1",
"lucide-react": "^1.27.0", "lucide-react": "^1.27.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
@@ -108,6 +107,47 @@
"vitest": "^4", "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": { "packages/ui": {
"name": "@workspace/ui", "name": "@workspace/ui",
"version": "0.0.0", "version": "0.0.0",
@@ -135,7 +175,6 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4", "@tailwindcss/vite": "^4",
"@turbo/gen": "^2.9.15",
"@types/node": "^24", "@types/node": "^24",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^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/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/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=="], "@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=="], "@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/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=="], "@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=="], "@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-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=="], "@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=="], "@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/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=="], "@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=="], "@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=="], "@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=="], "@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/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/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=="], "@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/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"], "@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=="], "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=="], "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=="], "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], "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-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=="], "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=="], "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=="], "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-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=="], "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=="], "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": ["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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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": ["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-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=="], "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=="], "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=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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": ["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=="], "zigpty": ["zigpty@0.2.1", "", {}, "sha512-MR9JqJx2wf5f4wz8zpx050AlqrmWeIW+1h0SO5iEyhG3HFRjY5luC3szS2ux2EGuPjE5OU9ZAuiCBeMWBTrqZw=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "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=="], "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/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=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
+10 -7
View File
@@ -3,15 +3,18 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "turbo build", "build": "bun run --filter web build",
"dev": "turbo dev", "check": "bun run format:check && bun run lint && bun run typecheck",
"format": "turbo format", "dev": "bun run --filter web dev",
"typecheck": "turbo typecheck" "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": { "devDependencies": {
"prettier": "^3.8.3", "oxfmt": "^0.61.0",
"prettier-plugin-tailwindcss": "^0.8.0", "oxlint": "^1.76.0",
"turbo": "^2.9.18",
"typescript": "~6" "typescript": "~6"
}, },
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
+9 -4
View File
@@ -4,7 +4,6 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"format": "prettier --write \"**/*.{ts,tsx}\"",
"test": "vitest run", "test": "vitest run",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
@@ -12,13 +11,13 @@
"@base-ui/react": "^1.6.0", "@base-ui/react": "^1.6.0",
"@base-ui/utils": "^0.3.1", "@base-ui/utils": "^0.3.1",
"@floating-ui/utils": "^0.2.12", "@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-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.18", "@tanstack/react-router": "^1.170.18",
"@workspace/icons": "workspace:*",
"@workspace/i18n": "workspace:*", "@workspace/i18n": "workspace:*",
"@workspace/ui": "workspace:*", "@workspace/ui": "workspace:*",
"cmdk": "^1.1.1",
"lucide-react": "^1.27.0", "lucide-react": "^1.27.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6" "react-dom": "^19.2.6"
@@ -45,11 +44,17 @@
"./layout": "./src/blocks/layout/index.ts", "./layout": "./src/blocks/layout/index.ts",
"./layout/locales": "./src/blocks/layout/locales/catalogs.ts", "./layout/locales": "./src/blocks/layout/locales/catalogs.ts",
"./layout/locales/*": "./src/blocks/layout/locales/*.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": "./src/blocks/navigation/index.ts",
"./navigation/locales": "./src/blocks/navigation/locales/catalogs.ts", "./navigation/locales": "./src/blocks/navigation/locales/catalogs.ts",
"./navigation/locales/*": "./src/blocks/navigation/locales/*.ts", "./navigation/locales/*": "./src/blocks/navigation/locales/*.ts",
"./notifications": "./src/blocks/notifications/index.ts", "./notifications": "./src/blocks/notifications/index.ts",
"./notifications/locales": "./src/blocks/notifications/locales/catalogs.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 { useCallback, useState } from "react"
import { useHotkey } from "@tanstack/react-hotkeys" import { useHotkey } from "@tanstack/react-hotkeys"
import { useMessage } from "@workspace/i18n" import { LocalizedText, useMessage } from "@workspace/i18n"
import { useResolvedTheme, useUiState } from "./ui-state" import { useResolvedTheme, useUiState } from "./ui-state"
import { import {
Dialog, Dialog,
@@ -16,7 +16,6 @@ import {
import { appearanceDialogHandle } from "./dialog" import { appearanceDialogHandle } from "./dialog"
import { CompactPreference, ModePreference } from "./preferences" import { CompactPreference, ModePreference } from "./preferences"
import { ThemeConfigPanel } from "./theme-config-panel" import { ThemeConfigPanel } from "./theme-config-panel"
import { LocalizedText } from "../../components/localized-text"
import { appearanceMessages } from "./messages" import { appearanceMessages } from "./messages"
interface AppearanceHotkeysProps { interface AppearanceHotkeysProps {
@@ -30,22 +30,29 @@ export const messages = {
"blocks.appearance.color.violet": "Violett", "blocks.appearance.color.violet": "Violett",
"blocks.appearance.color.yellow": "Gelb", "blocks.appearance.color.yellow": "Gelb",
"blocks.appearance.color.zinc": "Zink", "blocks.appearance.color.zinc": "Zink",
"blocks.appearance.command.description": "Design- und Layout-Einstellungen öffnen", "blocks.appearance.command.description":
"blocks.appearance.compact.description": "Eine kompakte Breite für den Hauptinhalt verwenden", "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.compact.title": "Kompaktes Layout",
"blocks.appearance.description": "Design- und Layout-Einstellungen", "blocks.appearance.description": "Design- und Layout-Einstellungen",
"blocks.appearance.mode.dark": "Dunkel", "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": "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": "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.mode.title": "Darstellungsmodus",
"blocks.appearance.primaryColor": "Primärfarbe", "blocks.appearance.primaryColor": "Primärfarbe",
"blocks.appearance.theme.dark": "Dunkles Design", "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": "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.title": "Darstellung",
"blocks.appearance.toggleTheme": "Design wechseln", "blocks.appearance.toggleTheme": "Design wechseln",
"blocks.appearance.toggleTheme.description": "blocks.appearance.toggleTheme.description":
@@ -30,23 +30,31 @@ export const messages = {
"blocks.appearance.color.violet": "Violeta", "blocks.appearance.color.violet": "Violeta",
"blocks.appearance.color.yellow": "Amarillo", "blocks.appearance.color.yellow": "Amarillo",
"blocks.appearance.color.zinc": "Zinc", "blocks.appearance.color.zinc": "Zinc",
"blocks.appearance.command.description": "Abrir las preferencias de tema y diseño", "blocks.appearance.command.description":
"blocks.appearance.compact.description": "Usar un ancho compacto para el contenido principal", "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.compact.title": "Diseño compacto",
"blocks.appearance.description": "Preferencias de tema y diseño", "blocks.appearance.description": "Preferencias de tema y diseño",
"blocks.appearance.mode.dark": "Oscuro", "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": "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": "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.mode.title": "Modo de tema",
"blocks.appearance.primaryColor": "Color principal", "blocks.appearance.primaryColor": "Color principal",
"blocks.appearance.theme.dark": "Tema oscuro", "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": "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.title": "Apariencia",
"blocks.appearance.toggleTheme": "Cambiar tema", "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 } as const satisfies AppearanceMessageCatalog
@@ -30,23 +30,31 @@ export const messages = {
"blocks.appearance.color.violet": "Violet", "blocks.appearance.color.violet": "Violet",
"blocks.appearance.color.yellow": "Jaune", "blocks.appearance.color.yellow": "Jaune",
"blocks.appearance.color.zinc": "Zinc", "blocks.appearance.color.zinc": "Zinc",
"blocks.appearance.command.description": "Ouvrir les préférences de thème et de mise en page", "blocks.appearance.command.description":
"blocks.appearance.compact.description": "Utiliser une largeur compacte pour le contenu principal", "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.compact.title": "Mise en page compacte",
"blocks.appearance.description": "Préférences de thème et de mise en page", "blocks.appearance.description": "Préférences de thème et de mise en page",
"blocks.appearance.mode.dark": "Sombre", "blocks.appearance.mode.dark": "Sombre",
"blocks.appearance.mode.dark.description": "Linterface utilisera toujours le thème sombre", "blocks.appearance.mode.dark.description":
"Linterface utilisera toujours le thème sombre",
"blocks.appearance.mode.light": "Clair", "blocks.appearance.mode.light": "Clair",
"blocks.appearance.mode.light.description": "Linterface utilisera toujours le thème clair", "blocks.appearance.mode.light.description":
"Linterface utilisera toujours le thème clair",
"blocks.appearance.mode.system": "Système", "blocks.appearance.mode.system": "Système",
"blocks.appearance.mode.system.description": "Le thème de linterface suivra lapparence du système", "blocks.appearance.mode.system.description":
"Le thème de linterface suivra lapparence du système",
"blocks.appearance.mode.title": "Mode du thème", "blocks.appearance.mode.title": "Mode du thème",
"blocks.appearance.primaryColor": "Couleur principale", "blocks.appearance.primaryColor": "Couleur principale",
"blocks.appearance.theme.dark": "Thème sombre", "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": "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.title": "Apparence",
"blocks.appearance.toggleTheme": "Changer de thème", "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 } as const satisfies AppearanceMessageCatalog
@@ -31,7 +31,8 @@ export const messages = {
"blocks.appearance.color.yellow": "イエロー", "blocks.appearance.color.yellow": "イエロー",
"blocks.appearance.color.zinc": "ジンク", "blocks.appearance.color.zinc": "ジンク",
"blocks.appearance.command.description": "テーマとレイアウト設定を開く", "blocks.appearance.command.description": "テーマとレイアウト設定を開く",
"blocks.appearance.compact.description": "メインページのコンテンツをコンパクトな幅で表示します", "blocks.appearance.compact.description":
"メインページのコンテンツをコンパクトな幅で表示します",
"blocks.appearance.compact.title": "コンパクトレイアウト", "blocks.appearance.compact.title": "コンパクトレイアウト",
"blocks.appearance.description": "テーマとレイアウトの設定", "blocks.appearance.description": "テーマとレイアウトの設定",
"blocks.appearance.mode.dark": "ダーク", "blocks.appearance.mode.dark": "ダーク",
@@ -39,14 +40,18 @@ export const messages = {
"blocks.appearance.mode.light": "ライト", "blocks.appearance.mode.light": "ライト",
"blocks.appearance.mode.light.description": "常にライトテーマを使用します", "blocks.appearance.mode.light.description": "常にライトテーマを使用します",
"blocks.appearance.mode.system": "システム", "blocks.appearance.mode.system": "システム",
"blocks.appearance.mode.system.description": "インターフェースのテーマをシステムの外観に合わせます", "blocks.appearance.mode.system.description":
"インターフェースのテーマをシステムの外観に合わせます",
"blocks.appearance.mode.title": "テーマモード", "blocks.appearance.mode.title": "テーマモード",
"blocks.appearance.primaryColor": "プライマリカラー", "blocks.appearance.primaryColor": "プライマリカラー",
"blocks.appearance.theme.dark": "ダークテーマ", "blocks.appearance.theme.dark": "ダークテーマ",
"blocks.appearance.theme.dark.description": "システムがダークモードのときに使用するテーマです", "blocks.appearance.theme.dark.description":
"システムがダークモードのときに使用するテーマです",
"blocks.appearance.theme.light": "ライトテーマ", "blocks.appearance.theme.light": "ライトテーマ",
"blocks.appearance.theme.light.description": "システムがライトモードのときに使用するテーマです", "blocks.appearance.theme.light.description":
"システムがライトモードのときに使用するテーマです",
"blocks.appearance.title": "外観", "blocks.appearance.title": "外観",
"blocks.appearance.toggleTheme": "テーマを切り替える", "blocks.appearance.toggleTheme": "テーマを切り替える",
"blocks.appearance.toggleTheme.description": "ライトテーマとダークテーマを切り替えます", "blocks.appearance.toggleTheme.description":
"ライトテーマとダークテーマを切り替えます",
} as const satisfies AppearanceMessageCatalog } as const satisfies AppearanceMessageCatalog
@@ -31,22 +31,29 @@ export const messages = {
"blocks.appearance.color.yellow": "노란색", "blocks.appearance.color.yellow": "노란색",
"blocks.appearance.color.zinc": "아연색", "blocks.appearance.color.zinc": "아연색",
"blocks.appearance.command.description": "테마 및 레이아웃 환경설정 열기", "blocks.appearance.command.description": "테마 및 레이아웃 환경설정 열기",
"blocks.appearance.compact.description": "기본 페이지 콘텐츠에 좁은 너비를 사용합니다", "blocks.appearance.compact.description":
"기본 페이지 콘텐츠에 좁은 너비를 사용합니다",
"blocks.appearance.compact.title": "컴팩트 레이아웃", "blocks.appearance.compact.title": "컴팩트 레이아웃",
"blocks.appearance.description": "테마 및 레이아웃 환경설정", "blocks.appearance.description": "테마 및 레이아웃 환경설정",
"blocks.appearance.mode.dark": "어둡게", "blocks.appearance.mode.dark": "어둡게",
"blocks.appearance.mode.dark.description": "인터페이스에서 항상 어두운 테마를 사용합니다", "blocks.appearance.mode.dark.description":
"인터페이스에서 항상 어두운 테마를 사용합니다",
"blocks.appearance.mode.light": "밝게", "blocks.appearance.mode.light": "밝게",
"blocks.appearance.mode.light.description": "인터페이스에서 항상 밝은 테마를 사용합니다", "blocks.appearance.mode.light.description":
"인터페이스에서 항상 밝은 테마를 사용합니다",
"blocks.appearance.mode.system": "시스템", "blocks.appearance.mode.system": "시스템",
"blocks.appearance.mode.system.description": "인터페이스 테마가 시스템 화면 모드를 따릅니다", "blocks.appearance.mode.system.description":
"인터페이스 테마가 시스템 화면 모드를 따릅니다",
"blocks.appearance.mode.title": "테마 모드", "blocks.appearance.mode.title": "테마 모드",
"blocks.appearance.primaryColor": "주요 색상", "blocks.appearance.primaryColor": "주요 색상",
"blocks.appearance.theme.dark": "어두운 테마", "blocks.appearance.theme.dark": "어두운 테마",
"blocks.appearance.theme.dark.description": "시스템이 어두운 모드일 때 사용하는 테마입니다", "blocks.appearance.theme.dark.description":
"시스템이 어두운 모드일 때 사용하는 테마입니다",
"blocks.appearance.theme.light": "밝은 테마", "blocks.appearance.theme.light": "밝은 테마",
"blocks.appearance.theme.light.description": "시스템이 밝은 모드일 때 사용하는 테마입니다", "blocks.appearance.theme.light.description":
"시스템이 밝은 모드일 때 사용하는 테마입니다",
"blocks.appearance.title": "화면 모양", "blocks.appearance.title": "화면 모양",
"blocks.appearance.toggleTheme": "테마 전환", "blocks.appearance.toggleTheme": "테마 전환",
"blocks.appearance.toggleTheme.description": "밝은 테마와 어두운 테마 사이를 전환합니다", "blocks.appearance.toggleTheme.description":
"밝은 테마와 어두운 테마 사이를 전환합니다",
} as const satisfies AppearanceMessageCatalog } as const satisfies AppearanceMessageCatalog
@@ -43,9 +43,11 @@ export const messages = {
"blocks.appearance.mode.title": "主题模式", "blocks.appearance.mode.title": "主题模式",
"blocks.appearance.primaryColor": "主要颜色", "blocks.appearance.primaryColor": "主要颜色",
"blocks.appearance.theme.dark": "深色主题", "blocks.appearance.theme.dark": "深色主题",
"blocks.appearance.theme.dark.description": "当系统设置为深色模式时,将使用此主题", "blocks.appearance.theme.dark.description":
"当系统设置为深色模式时,将使用此主题",
"blocks.appearance.theme.light": "浅色主题", "blocks.appearance.theme.light": "浅色主题",
"blocks.appearance.theme.light.description": "当系统设置为浅色模式时,将使用此主题", "blocks.appearance.theme.light.description":
"当系统设置为浅色模式时,将使用此主题",
"blocks.appearance.title": "界面外观", "blocks.appearance.title": "界面外观",
"blocks.appearance.toggleTheme": "切换主题", "blocks.appearance.toggleTheme": "切换主题",
"blocks.appearance.toggleTheme.description": "在浅色与深色主题之间切换", "blocks.appearance.toggleTheme.description": "在浅色与深色主题之间切换",
@@ -1,5 +1,5 @@
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react" import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"
import { useMessage } from "@workspace/i18n" import { LocalizedText, useMessage } from "@workspace/i18n"
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -11,7 +11,6 @@ import { Switch } from "@workspace/ui/components/switch"
import type { ThemeMode } from "./state" import type { ThemeMode } from "./state"
import { useUiState } from "./ui-state" import { useUiState } from "./ui-state"
import { LocalizedText } from "../../components/localized-text"
import { appearanceMessages } from "./messages" import { appearanceMessages } from "./messages"
function ModeIcon({ mode }: { mode: ThemeMode | null }) { function ModeIcon({ mode }: { mode: ThemeMode | null }) {
@@ -1,4 +1,5 @@
import { MoonIcon, SunIcon } from "lucide-react" import { MoonIcon, SunIcon } from "lucide-react"
import { LocalizedText } from "@workspace/i18n"
import { useUiState } from "./ui-state" import { useUiState } from "./ui-state"
import { Badge } from "@workspace/ui/components/badge" import { Badge } from "@workspace/ui/components/badge"
import { import {
@@ -8,7 +9,6 @@ import {
} from "./theme-color-picker" } from "./theme-color-picker"
import { ThemePreview } from "./theme-preview" import { ThemePreview } from "./theme-preview"
import type { ThemeScheme } from "./theme" import type { ThemeScheme } from "./theme"
import { LocalizedText } from "../../components/localized-text"
import { appearanceMessages } from "./messages" import { appearanceMessages } from "./messages"
export function ThemeConfigPanel({ export function ThemeConfigPanel({
@@ -1,5 +1,11 @@
export type BaseColor = export type BaseColor =
"neutral" | "stone" | "zinc" | "mauve" | "olive" | "mist" | "taupe" | "neutral"
| "stone"
| "zinc"
| "mauve"
| "olive"
| "mist"
| "taupe"
export type AccentColor = export type AccentColor =
| "amber" | "amber"
@@ -131,13 +131,14 @@ export function UiStateProvider({
storeRef.current.getSnapshot, storeRef.current.getSnapshot,
storeRef.current.getServerSnapshot storeRef.current.getServerSnapshot
) )
const themeMode = state["theme-mode"]
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
applyUiState(state) applyUiState(state)
}, [state]) }, [state])
React.useEffect(() => { React.useEffect(() => {
if (state["theme-mode"] !== "system") return if (themeMode !== "system") return
const mediaQuery = window.matchMedia(systemThemeQuery) const mediaQuery = window.matchMedia(systemThemeQuery)
const applySystemTheme = () => { const applySystemTheme = () => {
@@ -147,7 +148,7 @@ export function UiStateProvider({
mediaQuery.addEventListener("change", applySystemTheme) mediaQuery.addEventListener("change", applySystemTheme)
return () => mediaQuery.removeEventListener("change", applySystemTheme) return () => mediaQuery.removeEventListener("change", applySystemTheme)
}, [state["theme-mode"]]) }, [themeMode])
const contextValue = React.useMemo( const contextValue = React.useMemo(
() => ({ () => ({
@@ -1,5 +1,6 @@
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import * as React from "react" import * as React from "react"
import { Icon } from "@workspace/icons"
import { useMessage } from "@workspace/i18n" import { useMessage } from "@workspace/i18n"
import { import {
Breadcrumb, Breadcrumb,
@@ -27,7 +28,6 @@ import {
type NavigationInfo, type NavigationInfo,
useNavigationStack, useNavigationStack,
} from "../navigation" } from "../navigation"
import { Icon } from "../../components/icon"
import { layoutMessages } from "./messages" import { layoutMessages } from "./messages"
const MAX_VISIBLE_ENTRIES = 3 const MAX_VISIBLE_ENTRIES = 3
@@ -1,4 +1,10 @@
import * as React from "react" import * as React from "react"
import {
BellIcon,
Icon,
MessageMultiple01Icon,
Search01Icon,
} from "@workspace/icons"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { Kbd } from "@workspace/ui/components/kbd" import { Kbd } from "@workspace/ui/components/kbd"
import { useIsTablet } from "@workspace/ui/hooks/use-breakpoint" 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 { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
import { ChatPopover, ChatPopoverTrigger, type ChatThread } from "../chats" import { ChatPopover, ChatPopoverTrigger, type ChatThread } from "../chats"
import { Icon } from "../../components/icon"
import { import {
MobileNavigationSheet, MobileNavigationSheet,
MobileNavigationSheetTrigger, MobileNavigationSheetTrigger,
@@ -18,12 +23,6 @@ import {
NotificationCenterTrigger, NotificationCenterTrigger,
type UseNotificationsOptions, type UseNotificationsOptions,
} from "../notifications" } from "../notifications"
import {
BellIcon,
MessageMultiple01Icon,
Search01Icon,
} from "@hugeicons/core-free-icons"
import { AppSidebar } from "./app-sidebar" import { AppSidebar } from "./app-sidebar"
import { import {
headerActionIconClassName, headerActionIconClassName,
@@ -1,3 +1,4 @@
import { ReloadIcon } from "@workspace/icons"
import { useMessage } from "@workspace/i18n" import { useMessage } from "@workspace/i18n"
import { useRefresh } from "../../hooks/use-refresh" import { useRefresh } from "../../hooks/use-refresh"
import { import {
@@ -6,7 +7,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@workspace/ui/components/tooltip" } from "@workspace/ui/components/tooltip"
import { HeaderActionButton } from "./header-action-button" import { HeaderActionButton } from "./header-action-button"
import { ReloadIcon } from "@hugeicons/core-free-icons"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { layoutMessages } from "./messages" 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 { Button, type ButtonProps } from "@workspace/ui/components/button"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
@@ -25,7 +25,7 @@ export function SidebarStateProvider({
const contextValue = React.useMemo( const contextValue = React.useMemo(
() => ({ isCollapsed, setCollapsed }), () => ({ isCollapsed, setCollapsed }),
[isCollapsed] [isCollapsed, setCollapsed]
) )
return ( return (
@@ -1,4 +1,12 @@
import { formatForDisplay } from "@tanstack/react-hotkeys" import { formatForDisplay } from "@tanstack/react-hotkeys"
import {
Icon,
type IconData,
KeyboardIcon,
LogoutCircle02Icon,
ShieldKeyIcon,
SwatchIcon,
} from "@workspace/icons"
import { useMessage } from "@workspace/i18n" import { useMessage } from "@workspace/i18n"
import { Button, type ButtonProps } from "@workspace/ui/components/button" import { Button, type ButtonProps } from "@workspace/ui/components/button"
import { import {
@@ -35,13 +43,6 @@ import {
HeaderActionButton, HeaderActionButton,
} from "./header-action-button" } from "./header-action-button"
import { useOnScroll } from "../../hooks/use-on-scroll" 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" import { layoutMessages } from "./messages"
interface UserMenuItem { 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>
)
}
+26
View File
@@ -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>
)
}
+94
View File
@@ -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"
}
+106
View File
@@ -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 { import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
} from "@workspace/ui/components/dropdown-menu" } from "@workspace/ui/components/dropdown-menu"
import { Icon } from "../../components/icon"
import { resolveNavigationItemIcon } from "./item-icon" import { resolveNavigationItemIcon } from "./item-icon"
import { NavigationBadge, NavigationLink } from "./navigation-button" import { NavigationBadge, NavigationLink } from "./navigation-button"
import { NavigationLabel } from "./navigation-label" 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" import type { NavigationItem } from "./types"
@@ -1,4 +1,5 @@
import { useLocation } from "@tanstack/react-router" import { useLocation } from "@tanstack/react-router"
import { Icon, Menu02Icon } from "@workspace/icons"
import { useMessage } from "@workspace/i18n" import { useMessage } from "@workspace/i18n"
import type { ButtonProps } from "@workspace/ui/components/button" import type { ButtonProps } from "@workspace/ui/components/button"
import { Separator } from "@workspace/ui/components/separator" import { Separator } from "@workspace/ui/components/separator"
@@ -31,8 +32,6 @@ import {
type GetNavigationRouteState, type GetNavigationRouteState,
} from "./route-state" } from "./route-state"
import type { NavigationGroup } from "./types" import type { NavigationGroup } from "./types"
import { Icon } from "../../components/icon"
import { Menu02Icon } from "@hugeicons/core-free-icons"
const mobileNavigationSheetHandle = createSheetHandle<void>() 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 { useRender } from "@base-ui/react/use-render"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import * as React from "react" import * as React from "react"
import { Icon, type IconData } from "@workspace/icons"
import { Badge } from "@workspace/ui/components/badge" import { Badge } from "@workspace/ui/components/badge"
import { useRippleRef } from "@workspace/ui/hooks/use-ripple" import { useRippleRef } from "@workspace/ui/hooks/use-ripple"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { ChevronRightIcon } from "lucide-react" import { ChevronRightIcon } from "lucide-react"
import { Icon, type IconData } from "../../components/icon"
import { NavigationLabel, TruncatedNavigationLabel } from "./navigation-label" import { NavigationLabel, TruncatedNavigationLabel } from "./navigation-label"
import type { NavigationLabelValue } from "./types" import type { NavigationLabelValue } from "./types"
@@ -22,7 +22,9 @@ export interface NavigationButtonProps extends Omit<
layout?: "inline" | "stacked" | "responsive" layout?: "inline" | "stacked" | "responsive"
className?: string | (() => string | undefined) | undefined className?: string | (() => string | undefined) | undefined
style?: style?:
React.CSSProperties | (() => React.CSSProperties | undefined) | undefined | React.CSSProperties
| (() => React.CSSProperties | undefined)
| undefined
trailing?: React.ReactNode | (() => React.ReactNode) | undefined trailing?: React.ReactNode | (() => React.ReactNode) | undefined
} }
@@ -1,6 +1,6 @@
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { LocalizedText } from "@workspace/i18n"
import { LocalizedText } from "../../components/localized-text"
import type { NavigationLabelValue } from "./types" import type { NavigationLabelValue } from "./types"
export interface NavigationLabelProps { export interface NavigationLabelProps {
@@ -1,7 +1,6 @@
import type { IconData } from "@workspace/icons"
import type { MessageDescriptor } from "@workspace/i18n" import type { MessageDescriptor } from "@workspace/i18n"
import type { IconData } from "../../components/icon"
export type NavigationLabelValue = string | MessageDescriptor export type NavigationLabelValue = string | MessageDescriptor
export interface NavigationItem { export interface NavigationItem {
@@ -1,7 +1,7 @@
import { useLocation } from "@tanstack/react-router" import { useLocation } from "@tanstack/react-router"
import * as React from "react" import * as React from "react"
import type { IconData } from "@workspace/icons"
import type { IconData } from "../../components/icon"
import { resolveNavigationItemIcon } from "./item-icon" import { resolveNavigationItemIcon } from "./item-icon"
import type { import type {
NavigationGroup, NavigationGroup,
@@ -1,5 +1,6 @@
import * as React from "react" import * as React from "react"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { Icon } from "@workspace/icons"
import { useFormatters, useMessage } from "@workspace/i18n" import { useFormatters, useMessage } from "@workspace/i18n"
import { import {
Avatar, Avatar,
@@ -17,7 +18,6 @@ import {
} from "@workspace/ui/components/item" } from "@workspace/ui/components/item"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { Icon } from "../../components/icon"
import { notificationMessages } from "./messages" import { notificationMessages } from "./messages"
import type { import type {
@@ -1,7 +1,11 @@
import type { IconData } from "../../components/icon" import type { IconData } from "@workspace/icons"
export type NotificationTone = export type NotificationTone =
"destructive" | "info" | "neutral" | "success" | "warning" | "destructive"
| "info"
| "neutral"
| "success"
| "warning"
export interface NotificationAvatarMedia { export interface NotificationAvatarMedia {
alt?: string 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 lhistorique",
"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 lespace 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 lespace 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()]
}
-1
View File
@@ -10,7 +10,6 @@
"workspace-i18n": "./src/cli/index.ts" "workspace-i18n": "./src/cli/index.ts"
}, },
"scripts": { "scripts": {
"format": "prettier --write \"**/*.{css,json,ts,tsx}\"",
"test": "vitest run", "test": "vitest run",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
+1 -5
View File
@@ -2,10 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1.0" />
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<title>Message Studio</title> <title>Message Studio</title>
</head> </head>
@@ -14,4 +11,3 @@
<script type="module" src="/main.tsx"></script> <script type="module" src="/main.tsx"></script>
</body> </body>
</html> </html>
@@ -86,7 +86,8 @@ describe("MessagePanel", () => {
it("keeps the current content visible during a background refresh", async () => { it("keeps the current content visible during a background refresh", async () => {
let resolveRefresh: let resolveRefresh:
((messages: readonly DevtoolMessage[]) => void) | undefined | ((messages: readonly DevtoolMessage[]) => void)
| undefined
const repository: MessageRepository = { const repository: MessageRepository = {
getMessages: vi getMessages: vi
.fn() .fn()
+9 -1
View File
@@ -1,5 +1,5 @@
import * as React from "react" import * as React from "react"
import { useLingui } from "@lingui/react" import { LinguiContext, useLingui } from "@lingui/react"
import { I18nRuntimeContext } from "./context" import { I18nRuntimeContext } from "./context"
import { createIntlFormatters } from "./intl-formatters" 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(descriptor: MessageDescriptor): string
export function useMessage( export function useMessage(
id: MessageId, id: MessageId,
+2
View File
@@ -1,9 +1,11 @@
export { I18nProvider, type I18nProviderProps } from "./i18n-provider" export { I18nProvider, type I18nProviderProps } from "./i18n-provider"
export { LocalizedText, type LocalizedTextProps } from "./localized-text"
export { Translate, type TranslateProps } from "./translate" export { Translate, type TranslateProps } from "./translate"
export { createIntlFormatters } from "./intl-formatters" export { createIntlFormatters } from "./intl-formatters"
export { mergeMessageCatalogs, type MessageCatalogSource } from "./catalogs" export { mergeMessageCatalogs, type MessageCatalogSource } from "./catalogs"
export { export {
useFormatters, useFormatters,
useI18nProvider,
useLocale, useLocale,
useLocaleDefinition, useLocaleDefinition,
useLocales, useLocales,
@@ -1,9 +1,11 @@
import { useMessage, type MessageDescriptor } from "@workspace/i18n" import { useMessage } from "./hooks"
import type { MessageDescriptor } from "./types"
export interface LocalizedTextProps { export interface LocalizedTextProps {
message: MessageDescriptor message: MessageDescriptor
} }
/** Renders a message descriptor as localized text without adding a DOM node. */
export function LocalizedText({ message }: LocalizedTextProps) { export function LocalizedText({ message }: LocalizedTextProps) {
return useMessage(message) return useMessage(message)
} }
@@ -13,6 +13,7 @@ import {
useMessage, useMessage,
useTranslate, useTranslate,
} from "./hooks" } from "./hooks"
import { LocalizedText } from "./localized-text"
import { Translate } from "./translate" import { Translate } from "./translate"
const catalogs = { const catalogs = {
@@ -96,6 +97,18 @@ describe("i18n runtime", () => {
expect(container.innerHTML).toBe("<p>Hello</p>") 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", () => { it("infers right-to-left locale direction", () => {
function DirectionProbe() { function DirectionProbe() {
return <span>{useLocales()[0]?.direction}</span> return <span>{useLocales()[0]?.direction}</span>
+2 -1
View File
@@ -21,7 +21,8 @@ export type MessageCatalog = Record<string, unknown>
export type MessageCatalogs = Record<string, MessageCatalog> export type MessageCatalogs = Record<string, MessageCatalog>
export type MissingTranslationHandler = export type MissingTranslationHandler =
string | ((locale: string, id: string) => string) | string
| ((locale: string, id: string) => string)
export type LocaleDirection = "ltr" | "rtl" export type LocaleDirection = "ltr" | "rtl"
+22
View File
@@ -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`.
+22
View File
@@ -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 { 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 IconData = HugeiconsIconProps["icon"]
export type IconSize = keyof typeof sizeValues
export type IconProps = Omit<HugeiconsIconProps, "icon"> & { export type IconProps = Omit<HugeiconsIconProps, "icon" | "size"> & {
data: IconData data: IconData
size?: "xs" | "sm" | "md" | "lg" | "xl" size?: IconSize
} }
const sizeClasses = { const sizeValues = {
xs: "size-3", xs: 12,
sm: "size-4", sm: 16,
md: "size-5", md: 20,
lg: "size-6", lg: 24,
xl: "size-7", xl: 28,
} }
export function Icon({ data, className, size = "md", ...props }: IconProps) { export function Icon({ data, className, size = "md", ...props }: IconProps) {
return ( return (
<HugeiconsIcon <HugeiconsIcon
icon={data} icon={data}
size={sizeValues[size]}
color="currentColor" color="currentColor"
aria-hidden="true" aria-hidden="true"
strokeWidth={1.75} strokeWidth={1.75}
className={cn(sizeClasses[size], className)} className={className}
{...props} {...props}
/> />
) )
+17
View File
@@ -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"]
}
+198
View File
@@ -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,但不会占据
任何工具栏位置。
+41
View File
@@ -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
}
+35
View File
@@ -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()
})
})
+135
View File
@@ -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>
)}
/>
)
}
+30
View File
@@ -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