Compare commits

..

15 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
Maofeng 8cf3a1f03a feat(web): integrate localized application catalogs
- compose app, UI, and independently imported block catalogs from JSON configuration\n- persist the selected locale on the server and activate it without hydration flicker\n- add a flag-based language switcher and localized navigation descriptors\n- mount the redesigned i18n devtool only in development\n- regenerate English and Simplified Chinese catalogs and add switcher coverage
2026-07-30 14:48:25 +08:00
Maofeng 6af4d7b798 feat(blocks): localize reusable application shell blocks
- move block messages beside appearance, chats, layout, navigation, and notification features\n- publish independently tree-shakeable catalogs for eight locales per block\n- localize controllers and leaf labels without rerendering surrounding interactive components\n- support descriptor-backed navigation labels, translated breadcrumbs, and compact-label truncation\n- add catalog composition helpers and coverage for reusable block consumers
2026-07-30 14:48:06 +08:00
Maofeng ae1798f6f4 feat(ui): add framework-neutral localization support
- add a lightweight UI i18n provider with translation and locale contexts\n- localize accessibility labels across breadcrumb, carousel, dialog, pagination, sheet, sidebar, spinner, and toast components\n- integrate calendar locale data without coupling UI components to Lingui\n- publish tree-shakeable catalogs for eight common locales
2026-07-30 14:47:51 +08:00
Maofeng 0768e8fa29 feat(i18n): expand catalog runtime and developer tooling
- add Intl-backed locale definitions, formatters, catalog loaders, and runtime hooks\n- compile hashed production catalogs from package-scoped catalog sources\n- drive Lingui extraction and compilation directly from project JSON config\n- redesign the devtool with localized controls, draggable persistence, settings, navigation, copy actions, ANSI output, and fullscreen support\n- extend repository APIs, CLI coverage, documentation, and runtime tests
2026-07-30 14:47:42 +08:00
Maofeng 0d0aad1d47 chore(deps): add localization support packages
- add ANSI rendering and icon dependencies for the i18n devtool\n- add flag, calendar, and jsdom dependencies for the web locale experience\n- connect reusable blocks to the shared i18n runtime\n- refresh the Bun lockfile
2026-07-30 14:47:26 +08:00
327 changed files with 24856 additions and 1174 deletions
+2 -2
View File
@@ -16,8 +16,8 @@ dist-ssr
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
# turbo # generated i18n build schema
.turbo .i18n
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
+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"]
}
+11
View File
@@ -2,6 +2,17 @@
"sourceLocale": "en", "sourceLocale": "en",
"locales": ["en", "zh-Hans"], "locales": ["en", "zh-Hans"],
"catalogPath": "src/locales/{locale}/messages", "catalogPath": "src/locales/{locale}/messages",
"catalogSources": [
"@workspace/ui/locales/{locale}",
"@workspace/blocks/appearance/locales/{locale}",
"@workspace/blocks/chats/locales/{locale}",
"@workspace/blocks/layout/locales/{locale}",
"@workspace/blocks/media/locales/{locale}",
"@workspace/blocks/navigation/locales/{locale}",
"@workspace/blocks/notifications/locales/{locale}",
"@workspace/blocks/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/**"]
} }
-4
View File
@@ -1,4 +0,0 @@
import project from "./i18n.config.json" with { type: "json" }
import { createLinguiConfig } from "@workspace/i18n/lingui-config"
export default createLinguiConfig(project)
+6 -3
View File
@@ -6,11 +6,11 @@
"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",
"i18n:ui": "workspace-i18n ui", "i18n:ui": "workspace-i18n ui",
"test": "vitest run",
"typecheck": "tsc -b", "typecheck": "tsc -b",
"preview": "vite preview", "preview": "vite preview",
"start": "node .output/server/index.mjs" "start": "node .output/server/index.mjs"
@@ -19,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",
@@ -29,10 +27,14 @@
"@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",
"lucide-react": "^1.27.0", "lucide-react": "^1.27.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"sonner": "^2.0.7" "sonner": "^2.0.7"
}, },
@@ -43,6 +45,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react": "^6", "@vitejs/plugin-react": "^6",
"jsdom": "^30.0.1",
"nitro": "^3.0.260610-beta", "nitro": "^3.0.260610-beta",
"typescript": "~6", "typescript": "~6",
"vite": "^8", "vite": "^8",
+336
View File
@@ -0,0 +1,336 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const navigationMessages = {
overview: /* i18n */ {
id: "app.navigation.overview",
message: "Overview",
},
"overview-dashboard": /* i18n */ {
id: "app.navigation.overview-dashboard",
message: "Business overview",
},
"overview-realtime": /* i18n */ {
id: "app.navigation.overview-realtime",
message: "Real-time monitoring",
},
"overview-tasks": /* i18n */ {
id: "app.navigation.overview-tasks",
message: "Tasks",
},
"catalog-and-inventory": /* i18n */ {
id: "app.navigation.catalog-and-inventory",
message: "Products and inventory",
},
"catalog-products": /* i18n */ {
id: "app.navigation.catalog-products",
message: "Products",
},
"catalog-products-list": /* i18n */ {
id: "app.navigation.catalog-products-list",
message: "Product list",
},
"catalog-products-drafts": /* i18n */ {
id: "app.navigation.catalog-products-drafts",
message: "Product drafts",
},
"catalog-products-bundles": /* i18n */ {
id: "app.navigation.catalog-products-bundles",
message: "Product bundles",
},
"catalog-products-attributes": /* i18n */ {
id: "app.navigation.catalog-products-attributes",
message: "Product attributes",
},
"catalog-product-specifications": /* i18n */ {
id: "app.navigation.catalog-product-specifications",
message: "Specification templates",
},
"catalog-product-brands": /* i18n */ {
id: "app.navigation.catalog-product-brands",
message: "Brands",
},
"catalog-categories": /* i18n */ {
id: "app.navigation.catalog-categories",
message: "Categories",
},
"catalog-category-tree": /* i18n */ {
id: "app.navigation.catalog-category-tree",
message: "Category tree",
},
"catalog-category-navigation": /* i18n */ {
id: "app.navigation.catalog-category-navigation",
message: "Navigation categories",
},
"inventory-center": /* i18n */ {
id: "app.navigation.inventory-center",
message: "Inventory",
},
"inventory-overview": /* i18n */ {
id: "app.navigation.inventory-overview",
message: "Inventory overview",
},
"inventory-inbound": /* i18n */ {
id: "app.navigation.inventory-inbound",
message: "Inbound orders",
},
"inventory-outbound": /* i18n */ {
id: "app.navigation.inventory-outbound",
message: "Outbound orders",
},
"inventory-transfers": /* i18n */ {
id: "app.navigation.inventory-transfers",
message: "Stock transfers",
},
"inventory-alerts": /* i18n */ {
id: "app.navigation.inventory-alerts",
message: "Inventory alerts",
},
"content-assets": /* i18n */ {
id: "app.navigation.content-assets",
message: "Content",
},
"content-articles": /* i18n */ {
id: "app.navigation.content-articles",
message: "Articles",
},
"content-media": /* i18n */ {
id: "app.navigation.content-media",
message: "Media",
},
"content-pages": /* i18n */ {
id: "app.navigation.content-pages",
message: "Custom pages",
},
"trade-and-fulfillment": /* i18n */ {
id: "app.navigation.trade-and-fulfillment",
message: "Orders and fulfillment",
},
"trade-orders": /* i18n */ {
id: "app.navigation.trade-orders",
message: "Orders",
},
"trade-orders-awaiting-payment": /* i18n */ {
id: "app.navigation.trade-orders-awaiting-payment",
message: "Awaiting payment",
},
"trade-orders-awaiting-shipment": /* i18n */ {
id: "app.navigation.trade-orders-awaiting-shipment",
message: "Awaiting shipment",
},
"trade-orders-shipped": /* i18n */ {
id: "app.navigation.trade-orders-shipped",
message: "In transit",
},
"trade-orders-completed": /* i18n */ {
id: "app.navigation.trade-orders-completed",
message: "Completed orders",
},
"trade-orders-canceled": /* i18n */ {
id: "app.navigation.trade-orders-canceled",
message: "Canceled orders",
},
"fulfillment-center": /* i18n */ {
id: "app.navigation.fulfillment-center",
message: "Fulfillment",
},
"fulfillment-deliveries": /* i18n */ {
id: "app.navigation.fulfillment-deliveries",
message: "Delivery tasks",
},
"fulfillment-pickups": /* i18n */ {
id: "app.navigation.fulfillment-pickups",
message: "Pickup verification",
},
"fulfillment-freight-rules": /* i18n */ {
id: "app.navigation.fulfillment-freight-rules",
message: "Shipping rules",
},
"after-sales-center": /* i18n */ {
id: "app.navigation.after-sales-center",
message: "After-sales",
},
"after-sales-returns": /* i18n */ {
id: "app.navigation.after-sales-returns",
message: "Return requests",
},
"after-sales-refunds": /* i18n */ {
id: "app.navigation.after-sales-refunds",
message: "Refund review",
},
"after-sales-disputes": /* i18n */ {
id: "app.navigation.after-sales-disputes",
message: "Transaction disputes",
},
"customers-and-marketing": /* i18n */ {
id: "app.navigation.customers-and-marketing",
message: "Customers and marketing",
},
"customer-center": /* i18n */ {
id: "app.navigation.customer-center",
message: "Customers",
},
"customer-profiles": /* i18n */ {
id: "app.navigation.customer-profiles",
message: "Customer profiles",
},
"customer-segments": /* i18n */ {
id: "app.navigation.customer-segments",
message: "Customer segments",
},
"customer-tiers": /* i18n */ {
id: "app.navigation.customer-tiers",
message: "Customer tiers",
},
"customer-risk-list": /* i18n */ {
id: "app.navigation.customer-risk-list",
message: "Risk list",
},
"marketing-campaigns": /* i18n */ {
id: "app.navigation.marketing-campaigns",
message: "Marketing campaigns",
},
"marketing-coupons": /* i18n */ {
id: "app.navigation.marketing-coupons",
message: "Coupons",
},
"marketing-flash-sales": /* i18n */ {
id: "app.navigation.marketing-flash-sales",
message: "Flash sales",
},
"marketing-gift-promotions": /* i18n */ {
id: "app.navigation.marketing-gift-promotions",
message: "Gift promotions",
},
"marketing-referrals": /* i18n */ {
id: "app.navigation.marketing-referrals",
message: "Referral rewards",
},
"loyalty-center": /* i18n */ {
id: "app.navigation.loyalty-center",
message: "Membership benefits",
},
"loyalty-points": /* i18n */ {
id: "app.navigation.loyalty-points",
message: "Points rules",
},
"loyalty-stored-value": /* i18n */ {
id: "app.navigation.loyalty-stored-value",
message: "Stored-value accounts",
},
"loyalty-benefits": /* i18n */ {
id: "app.navigation.loyalty-benefits",
message: "Benefits configuration",
},
"data-and-finance": /* i18n */ {
id: "app.navigation.data-and-finance",
message: "Data and finance",
},
"report-center": /* i18n */ {
id: "app.navigation.report-center",
message: "Reports",
},
"report-sales": /* i18n */ {
id: "app.navigation.report-sales",
message: "Sales analysis",
},
"report-products": /* i18n */ {
id: "app.navigation.report-products",
message: "Product analysis",
},
"report-customers": /* i18n */ {
id: "app.navigation.report-customers",
message: "Customer analysis",
},
"report-channels": /* i18n */ {
id: "app.navigation.report-channels",
message: "Channel analysis",
},
"finance-center": /* i18n */ {
id: "app.navigation.finance-center",
message: "Finance",
},
"finance-settlements": /* i18n */ {
id: "app.navigation.finance-settlements",
message: "Settlement statements",
},
"finance-reconciliation": /* i18n */ {
id: "app.navigation.finance-reconciliation",
message: "Reconciliation",
},
"finance-invoices": /* i18n */ {
id: "app.navigation.finance-invoices",
message: "Invoices",
},
"finance-payouts": /* i18n */ {
id: "app.navigation.finance-payouts",
message: "Payout history",
},
"system-management": /* i18n */ {
id: "app.navigation.system-management",
message: "System management",
},
"system-members": /* i18n */ {
id: "app.navigation.system-members",
message: "Organization members",
},
"system-members-admins": /* i18n */ {
id: "app.navigation.system-members-admins",
message: "Administrator accounts",
},
"system-members-roles": /* i18n */ {
id: "app.navigation.system-members-roles",
message: "Roles",
},
"system-members-permissions": /* i18n */ {
id: "app.navigation.system-members-permissions",
message: "Permission policies",
},
"system-notifications": /* i18n */ {
id: "app.navigation.system-notifications",
message: "Notifications",
},
"system-notification-templates": /* i18n */ {
id: "app.navigation.system-notification-templates",
message: "Notification templates",
},
"system-notification-logs": /* i18n */ {
id: "app.navigation.system-notification-logs",
message: "Delivery logs",
},
"system-audit": /* i18n */ {
id: "app.navigation.system-audit",
message: "Audit log",
},
"system-integrations": /* i18n */ {
id: "app.navigation.system-integrations",
message: "Integrations",
},
"system-integration-api-keys": /* i18n */ {
id: "app.navigation.system-integration-api-keys",
message: "API keys",
},
"system-integration-webhooks": /* i18n */ {
id: "app.navigation.system-integration-webhooks",
message: "Webhook configuration",
},
"system-settings": /* i18n */ {
id: "app.navigation.system-settings",
message: "System settings",
},
"system-settings-store": /* i18n */ {
id: "app.navigation.system-settings-store",
message: "Store information",
},
"system-settings-payments": /* i18n */ {
id: "app.navigation.system-settings-payments",
message: "Payment channels",
},
"system-settings-shipping": /* i18n */ {
id: "app.navigation.system-settings-shipping",
message: "Shipping settings",
},
"system-recycle-bin": /* i18n */ {
id: "app.navigation.system-recycle-bin",
message: "Recycle bin",
},
} as const satisfies Record<string, MessageDescriptor>
+86 -84
View File
@@ -56,77 +56,79 @@ 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"
import { navigationMessages } from "./navigation-messages"
export const primaryNavigationGroups: readonly NavigationGroup[] = [ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "overview", id: "overview",
label: "概览", label: navigationMessages["overview"],
items: [ items: [
{ {
id: "overview-dashboard", id: "overview-dashboard",
to: "/", to: "/",
label: "经营总览", label: navigationMessages["overview-dashboard"],
icon: Analytics01Icon, icon: Analytics01Icon,
}, },
{ {
id: "overview-realtime", id: "overview-realtime",
to: "/analytics/realtime", to: "/analytics/realtime",
label: "实时监控", label: navigationMessages["overview-realtime"],
icon: AnalyticsUpIcon, icon: AnalyticsUpIcon,
}, },
{ {
id: "overview-tasks", id: "overview-tasks",
to: "/workspace/tasks", to: "/workspace/tasks",
label: "待办中心", label: navigationMessages["overview-tasks"],
icon: Task01Icon, icon: Task01Icon,
}, },
], ],
}, },
{ {
id: "catalog-and-inventory", id: "catalog-and-inventory",
label: "商品与库存", label: navigationMessages["catalog-and-inventory"],
items: [ items: [
{ {
id: "catalog-products", id: "catalog-products",
label: "商品中心", label: navigationMessages["catalog-products"],
icon: ShoppingBag01Icon, icon: ShoppingBag01Icon,
items: [ items: [
{ {
id: "catalog-products-list", id: "catalog-products-list",
to: "/catalog/products/list", to: "/catalog/products/list",
label: "商品列表", label: navigationMessages["catalog-products-list"],
icon: ShoppingBag01Icon, icon: ShoppingBag01Icon,
}, },
{ {
id: "catalog-products-drafts", id: "catalog-products-drafts",
to: "/catalog/products/drafts", to: "/catalog/products/drafts",
label: "商品草稿", label: navigationMessages["catalog-products-drafts"],
icon: File01Icon, icon: File01Icon,
}, },
{ {
id: "catalog-products-bundles", id: "catalog-products-bundles",
to: "/catalog/products/bundles", to: "/catalog/products/bundles",
label: "组合商品", label: navigationMessages["catalog-products-bundles"],
icon: Package02Icon, icon: Package02Icon,
}, },
{ {
id: "catalog-products-attributes", id: "catalog-products-attributes",
label: "商品属性", label: navigationMessages["catalog-products-attributes"],
icon: Settings03Icon, icon: Settings03Icon,
items: [ items: [
{ {
id: "catalog-product-specifications", id: "catalog-product-specifications",
to: "/catalog/products/attributes/specifications", to: "/catalog/products/attributes/specifications",
label: "规格模板", label: navigationMessages["catalog-product-specifications"],
icon: ClipboardListIcon, icon: ClipboardListIcon,
}, },
{ {
id: "catalog-product-brands", id: "catalog-product-brands",
to: "/catalog/products/attributes/brands", to: "/catalog/products/attributes/brands",
label: "品牌资料", label: navigationMessages["catalog-product-brands"],
icon: StarIcon, icon: StarIcon,
}, },
], ],
@@ -136,19 +138,19 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "catalog-categories", id: "catalog-categories",
to: "/catalog/categories", to: "/catalog/categories",
label: "分类目录", label: navigationMessages["catalog-categories"],
icon: ClipboardListIcon, icon: ClipboardListIcon,
items: [ items: [
{ {
id: "catalog-category-tree", id: "catalog-category-tree",
to: "/catalog/categories/tree", to: "/catalog/categories/tree",
label: "分类树", label: navigationMessages["catalog-category-tree"],
icon: Structure01Icon, icon: Structure01Icon,
}, },
{ {
id: "catalog-category-navigation", id: "catalog-category-navigation",
to: "/catalog/categories/navigation", to: "/catalog/categories/navigation",
label: "导航分类", label: navigationMessages["catalog-category-navigation"],
icon: Compass01Icon, icon: Compass01Icon,
}, },
], ],
@@ -156,37 +158,37 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "inventory-center", id: "inventory-center",
to: "/inventory", to: "/inventory",
label: "库存中心", label: navigationMessages["inventory-center"],
icon: PackageIcon, icon: PackageIcon,
items: [ items: [
{ {
id: "inventory-overview", id: "inventory-overview",
to: "/inventory/overview", to: "/inventory/overview",
label: "库存总览", label: navigationMessages["inventory-overview"],
icon: Chart01Icon, icon: Chart01Icon,
}, },
{ {
id: "inventory-inbound", id: "inventory-inbound",
to: "/inventory/inbound", to: "/inventory/inbound",
label: "入库单", label: navigationMessages["inventory-inbound"],
icon: InboxDownloadIcon, icon: InboxDownloadIcon,
}, },
{ {
id: "inventory-outbound", id: "inventory-outbound",
to: "/inventory/outbound", to: "/inventory/outbound",
label: "出库单", label: navigationMessages["inventory-outbound"],
icon: InboxUploadIcon, icon: InboxUploadIcon,
}, },
{ {
id: "inventory-transfers", id: "inventory-transfers",
to: "/inventory/transfers", to: "/inventory/transfers",
label: "调拨单", label: navigationMessages["inventory-transfers"],
icon: ArrowDataTransferHorizontalIcon, icon: ArrowDataTransferHorizontalIcon,
}, },
{ {
id: "inventory-alerts", id: "inventory-alerts",
to: "/inventory/alerts", to: "/inventory/alerts",
label: "库存预警", label: navigationMessages["inventory-alerts"],
icon: Alert02Icon, icon: Alert02Icon,
}, },
], ],
@@ -194,25 +196,25 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "content-assets", id: "content-assets",
to: "/content", to: "/content",
label: "内容素材", label: navigationMessages["content-assets"],
icon: File01Icon, icon: File01Icon,
items: [ items: [
{ {
id: "content-articles", id: "content-articles",
to: "/content/articles", to: "/content/articles",
label: "内容文章", label: navigationMessages["content-articles"],
icon: File01Icon, icon: File01Icon,
}, },
{ {
id: "content-media", id: "content-media",
to: "/content/media", to: "/content/media",
label: "媒体资源", label: navigationMessages["content-media"],
icon: GalleryHorizontalIcon, icon: GalleryHorizontalIcon,
}, },
{ {
id: "content-pages", id: "content-pages",
to: "/content/pages", to: "/content/pages",
label: "自定义页面", label: navigationMessages["content-pages"],
icon: AppWindowIcon, icon: AppWindowIcon,
}, },
], ],
@@ -221,42 +223,42 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
}, },
{ {
id: "trade-and-fulfillment", id: "trade-and-fulfillment",
label: "交易与履约", label: navigationMessages["trade-and-fulfillment"],
items: [ items: [
{ {
id: "trade-orders", id: "trade-orders",
to: "/trade/orders", to: "/trade/orders",
label: "订单中心", label: navigationMessages["trade-orders"],
icon: ShoppingCart01Icon, icon: ShoppingCart01Icon,
items: [ items: [
{ {
id: "trade-orders-awaiting-payment", id: "trade-orders-awaiting-payment",
to: "/trade/orders/awaiting-payment", to: "/trade/orders/awaiting-payment",
label: "待付款订单", label: navigationMessages["trade-orders-awaiting-payment"],
icon: WalletCardsIcon, icon: WalletCardsIcon,
}, },
{ {
id: "trade-orders-awaiting-shipment", id: "trade-orders-awaiting-shipment",
to: "/trade/orders/awaiting-shipment", to: "/trade/orders/awaiting-shipment",
label: "待发货订单", label: navigationMessages["trade-orders-awaiting-shipment"],
icon: PackageIcon, icon: PackageIcon,
}, },
{ {
id: "trade-orders-shipped", id: "trade-orders-shipped",
to: "/trade/orders/shipped", to: "/trade/orders/shipped",
label: "配送中订单", label: navigationMessages["trade-orders-shipped"],
icon: DeliveryTruck01Icon, icon: DeliveryTruck01Icon,
}, },
{ {
id: "trade-orders-completed", id: "trade-orders-completed",
to: "/trade/orders/completed", to: "/trade/orders/completed",
label: "已完成订单", label: navigationMessages["trade-orders-completed"],
icon: CheckmarkCircle01Icon, icon: CheckmarkCircle01Icon,
}, },
{ {
id: "trade-orders-canceled", id: "trade-orders-canceled",
to: "/trade/orders/canceled", to: "/trade/orders/canceled",
label: "已取消订单", label: navigationMessages["trade-orders-canceled"],
icon: CancelCircleIcon, icon: CancelCircleIcon,
}, },
], ],
@@ -264,25 +266,25 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "fulfillment-center", id: "fulfillment-center",
to: "/fulfillment", to: "/fulfillment",
label: "配送履约", label: navigationMessages["fulfillment-center"],
icon: DeliveryTruck01Icon, icon: DeliveryTruck01Icon,
items: [ items: [
{ {
id: "fulfillment-deliveries", id: "fulfillment-deliveries",
to: "/fulfillment/deliveries", to: "/fulfillment/deliveries",
label: "配送任务", label: navigationMessages["fulfillment-deliveries"],
icon: Location01Icon, icon: Location01Icon,
}, },
{ {
id: "fulfillment-pickups", id: "fulfillment-pickups",
to: "/fulfillment/pickups", to: "/fulfillment/pickups",
label: "自提核销", label: navigationMessages["fulfillment-pickups"],
icon: Store01Icon, icon: Store01Icon,
}, },
{ {
id: "fulfillment-freight-rules", id: "fulfillment-freight-rules",
to: "/fulfillment/freight-rules", to: "/fulfillment/freight-rules",
label: "运费规则", label: navigationMessages["fulfillment-freight-rules"],
icon: RoadLocation01Icon, icon: RoadLocation01Icon,
}, },
], ],
@@ -290,25 +292,25 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "after-sales-center", id: "after-sales-center",
to: "/after-sales", to: "/after-sales",
label: "售后中心", label: navigationMessages["after-sales-center"],
icon: CallMissed01Icon, icon: CallMissed01Icon,
items: [ items: [
{ {
id: "after-sales-returns", id: "after-sales-returns",
to: "/after-sales/returns", to: "/after-sales/returns",
label: "退货申请", label: navigationMessages["after-sales-returns"],
icon: Refresh01Icon, icon: Refresh01Icon,
}, },
{ {
id: "after-sales-refunds", id: "after-sales-refunds",
to: "/after-sales/refunds", to: "/after-sales/refunds",
label: "退款审核", label: navigationMessages["after-sales-refunds"],
icon: MoneySendSquareIcon, icon: MoneySendSquareIcon,
}, },
{ {
id: "after-sales-disputes", id: "after-sales-disputes",
to: "/after-sales/disputes", to: "/after-sales/disputes",
label: "交易纠纷", label: navigationMessages["after-sales-disputes"],
icon: SecurityWarningIcon, icon: SecurityWarningIcon,
}, },
], ],
@@ -317,36 +319,36 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
}, },
{ {
id: "customers-and-marketing", id: "customers-and-marketing",
label: "客户与营销", label: navigationMessages["customers-and-marketing"],
items: [ items: [
{ {
id: "customer-center", id: "customer-center",
to: "/customers", to: "/customers",
label: "客户中心", label: navigationMessages["customer-center"],
icon: IdentityCardIcon, icon: IdentityCardIcon,
items: [ items: [
{ {
id: "customer-profiles", id: "customer-profiles",
to: "/customers/profiles", to: "/customers/profiles",
label: "客户档案", label: navigationMessages["customer-profiles"],
icon: IdentityCardIcon, icon: IdentityCardIcon,
}, },
{ {
id: "customer-segments", id: "customer-segments",
to: "/customers/segments", to: "/customers/segments",
label: "客户分群", label: navigationMessages["customer-segments"],
icon: UserGroupIcon, icon: UserGroupIcon,
}, },
{ {
id: "customer-tiers", id: "customer-tiers",
to: "/customers/tiers", to: "/customers/tiers",
label: "客户等级", label: navigationMessages["customer-tiers"],
icon: CrownIcon, icon: CrownIcon,
}, },
{ {
id: "customer-risk-list", id: "customer-risk-list",
to: "/customers/risk-list", to: "/customers/risk-list",
label: "风险名单", label: navigationMessages["customer-risk-list"],
icon: SecurityWarningIcon, icon: SecurityWarningIcon,
}, },
], ],
@@ -354,31 +356,31 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "marketing-campaigns", id: "marketing-campaigns",
to: "/marketing/campaigns", to: "/marketing/campaigns",
label: "营销活动", label: navigationMessages["marketing-campaigns"],
icon: Discount01Icon, icon: Discount01Icon,
items: [ items: [
{ {
id: "marketing-coupons", id: "marketing-coupons",
to: "/marketing/coupons", to: "/marketing/coupons",
label: "优惠券管理", label: navigationMessages["marketing-coupons"],
icon: Ticket01Icon, icon: Ticket01Icon,
}, },
{ {
id: "marketing-flash-sales", id: "marketing-flash-sales",
to: "/marketing/flash-sales", to: "/marketing/flash-sales",
label: "限时折扣", label: navigationMessages["marketing-flash-sales"],
icon: Clock01Icon, icon: Clock01Icon,
}, },
{ {
id: "marketing-gift-promotions", id: "marketing-gift-promotions",
to: "/marketing/gift-promotions", to: "/marketing/gift-promotions",
label: "满赠活动", label: navigationMessages["marketing-gift-promotions"],
icon: GiftIcon, icon: GiftIcon,
}, },
{ {
id: "marketing-referrals", id: "marketing-referrals",
to: "/marketing/referrals", to: "/marketing/referrals",
label: "邀请有礼", label: navigationMessages["marketing-referrals"],
icon: Share01Icon, icon: Share01Icon,
}, },
], ],
@@ -386,25 +388,25 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "loyalty-center", id: "loyalty-center",
to: "/loyalty", to: "/loyalty",
label: "会员权益", label: navigationMessages["loyalty-center"],
icon: Medal01Icon, icon: Medal01Icon,
items: [ items: [
{ {
id: "loyalty-points", id: "loyalty-points",
to: "/loyalty/points", to: "/loyalty/points",
label: "积分规则", label: navigationMessages["loyalty-points"],
icon: StarCircleIcon, icon: StarCircleIcon,
}, },
{ {
id: "loyalty-stored-value", id: "loyalty-stored-value",
to: "/loyalty/stored-value", to: "/loyalty/stored-value",
label: "储值账户", label: navigationMessages["loyalty-stored-value"],
icon: Wallet01Icon, icon: Wallet01Icon,
}, },
{ {
id: "loyalty-benefits", id: "loyalty-benefits",
to: "/loyalty/benefits", to: "/loyalty/benefits",
label: "权益配置", label: navigationMessages["loyalty-benefits"],
icon: CheckmarkBadge01Icon, icon: CheckmarkBadge01Icon,
}, },
], ],
@@ -413,36 +415,36 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
}, },
{ {
id: "data-and-finance", id: "data-and-finance",
label: "数据与财务", label: navigationMessages["data-and-finance"],
items: [ items: [
{ {
id: "report-center", id: "report-center",
to: "/reports", to: "/reports",
label: "数据报表", label: navigationMessages["report-center"],
icon: Chart01Icon, icon: Chart01Icon,
items: [ items: [
{ {
id: "report-sales", id: "report-sales",
to: "/reports/sales", to: "/reports/sales",
label: "销售分析", label: navigationMessages["report-sales"],
icon: AnalyticsUpIcon, icon: AnalyticsUpIcon,
}, },
{ {
id: "report-products", id: "report-products",
to: "/reports/products", to: "/reports/products",
label: "商品分析", label: navigationMessages["report-products"],
icon: ShoppingBag01Icon, icon: ShoppingBag01Icon,
}, },
{ {
id: "report-customers", id: "report-customers",
to: "/reports/customers", to: "/reports/customers",
label: "客群分析", label: navigationMessages["report-customers"],
icon: UserGroupIcon, icon: UserGroupIcon,
}, },
{ {
id: "report-channels", id: "report-channels",
to: "/reports/channels", to: "/reports/channels",
label: "渠道分析", label: navigationMessages["report-channels"],
icon: ChartIncreaseIcon, icon: ChartIncreaseIcon,
}, },
], ],
@@ -450,31 +452,31 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "finance-center", id: "finance-center",
to: "/finance", to: "/finance",
label: "财务中心", label: navigationMessages["finance-center"],
icon: WalletCardsIcon, icon: WalletCardsIcon,
items: [ items: [
{ {
id: "finance-settlements", id: "finance-settlements",
to: "/finance/settlements", to: "/finance/settlements",
label: "结算账单", label: navigationMessages["finance-settlements"],
icon: Invoice03Icon, icon: Invoice03Icon,
}, },
{ {
id: "finance-reconciliation", id: "finance-reconciliation",
to: "/finance/reconciliation", to: "/finance/reconciliation",
label: "对账管理", label: navigationMessages["finance-reconciliation"],
icon: Calculator01Icon, icon: Calculator01Icon,
}, },
{ {
id: "finance-invoices", id: "finance-invoices",
to: "/finance/invoices", to: "/finance/invoices",
label: "发票管理", label: navigationMessages["finance-invoices"],
icon: File01Icon, icon: File01Icon,
}, },
{ {
id: "finance-payouts", id: "finance-payouts",
to: "/finance/payouts", to: "/finance/payouts",
label: "打款记录", label: navigationMessages["finance-payouts"],
icon: MoneySendSquareIcon, icon: MoneySendSquareIcon,
}, },
], ],
@@ -483,30 +485,30 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
}, },
{ {
id: "system-management", id: "system-management",
label: "系统管理", label: navigationMessages["system-management"],
items: [ items: [
{ {
id: "system-members", id: "system-members",
to: "/system/members", to: "/system/members",
label: "组织成员", label: navigationMessages["system-members"],
icon: UserShield01Icon, icon: UserShield01Icon,
items: [ items: [
{ {
id: "system-members-admins", id: "system-members-admins",
to: "/system/members/admins", to: "/system/members/admins",
label: "管理员账号", label: navigationMessages["system-members-admins"],
icon: UserShield01Icon, icon: UserShield01Icon,
}, },
{ {
id: "system-members-roles", id: "system-members-roles",
to: "/system/members/roles", to: "/system/members/roles",
label: "角色管理", label: navigationMessages["system-members-roles"],
icon: UserGroupIcon, icon: UserGroupIcon,
}, },
{ {
id: "system-members-permissions", id: "system-members-permissions",
to: "/system/members/permissions", to: "/system/members/permissions",
label: "权限策略", label: navigationMessages["system-members-permissions"],
icon: LockKeyIcon, icon: LockKeyIcon,
}, },
], ],
@@ -514,19 +516,19 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "system-notifications", id: "system-notifications",
to: "/system/notifications", to: "/system/notifications",
label: "消息中心", label: navigationMessages["system-notifications"],
icon: BubbleChatIcon, icon: BubbleChatIcon,
items: [ items: [
{ {
id: "system-notification-templates", id: "system-notification-templates",
to: "/system/notifications/templates", to: "/system/notifications/templates",
label: "通知模板", label: navigationMessages["system-notification-templates"],
icon: File01Icon, icon: File01Icon,
}, },
{ {
id: "system-notification-logs", id: "system-notification-logs",
to: "/system/notifications/logs", to: "/system/notifications/logs",
label: "发送记录", label: navigationMessages["system-notification-logs"],
icon: HistoryIcon, icon: HistoryIcon,
}, },
], ],
@@ -534,25 +536,25 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "system-audit", id: "system-audit",
to: "/system/audit", to: "/system/audit",
label: "操作审计", label: navigationMessages["system-audit"],
icon: Note01Icon, icon: Note01Icon,
}, },
{ {
id: "system-integrations", id: "system-integrations",
to: "/system/integrations", to: "/system/integrations",
label: "开放集成", label: navigationMessages["system-integrations"],
icon: CodeSquareIcon, icon: CodeSquareIcon,
items: [ items: [
{ {
id: "system-integration-api-keys", id: "system-integration-api-keys",
to: "/system/integrations/api-keys", to: "/system/integrations/api-keys",
label: "API 密钥", label: navigationMessages["system-integration-api-keys"],
icon: Key01Icon, icon: Key01Icon,
}, },
{ {
id: "system-integration-webhooks", id: "system-integration-webhooks",
to: "/system/integrations/webhooks", to: "/system/integrations/webhooks",
label: "Webhook 配置", label: navigationMessages["system-integration-webhooks"],
icon: LinkCircleIcon, icon: LinkCircleIcon,
}, },
], ],
@@ -560,25 +562,25 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "system-settings", id: "system-settings",
to: "/system-configs", to: "/system-configs",
label: "系统配置", label: navigationMessages["system-settings"],
icon: Settings01Icon, icon: Settings01Icon,
items: [ items: [
{ {
id: "system-settings-store", id: "system-settings-store",
to: "/system-configs/store", to: "/system-configs/store",
label: "门店资料", label: navigationMessages["system-settings-store"],
icon: Store01Icon, icon: Store01Icon,
}, },
{ {
id: "system-settings-payments", id: "system-settings-payments",
to: "/system-configs/payments", to: "/system-configs/payments",
label: "支付渠道", label: navigationMessages["system-settings-payments"],
icon: CreditCardIcon, icon: CreditCardIcon,
}, },
{ {
id: "system-settings-shipping", id: "system-settings-shipping",
to: "/system-configs/shipping", to: "/system-configs/shipping",
label: "配送设置", label: navigationMessages["system-settings-shipping"],
icon: DeliveryTruck01Icon, icon: DeliveryTruck01Icon,
}, },
], ],
@@ -586,7 +588,7 @@ export const primaryNavigationGroups: readonly NavigationGroup[] = [
{ {
id: "system-recycle-bin", id: "system-recycle-bin",
to: "/system/recycle-bin", to: "/system/recycle-bin",
label: "回收站", label: navigationMessages["system-recycle-bin"],
icon: Delete02Icon, icon: Delete02Icon,
}, },
], ],
+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"
+21
View File
@@ -0,0 +1,21 @@
import { lazy, Suspense } from "react"
const LazyI18nDevtool = import.meta.env.DEV
? lazy(() =>
import("@workspace/i18n/devtool").then((module) => ({
default: module.I18nDevtool,
}))
)
: null
export function AppI18nDevtool() {
if (!LazyI18nDevtool) {
return null
}
return (
<Suspense fallback={null}>
<LazyI18nDevtool dark={"ui\\:dark"} />
</Suspense>
)
}
+90 -36
View File
@@ -1,51 +1,105 @@
import { lazy, Suspense, type PropsWithChildren } from "react" import {
import { I18nProvider, type LocaleDefinition } from "@workspace/i18n" createContext,
startTransition,
use,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from "react"
import {
I18nProvider,
useLocaleDefinition,
useTranslate,
} from "@workspace/i18n"
import { loadMessageCatalog } from "@workspace/i18n/catalogs"
import {
I18nProvider as UiI18nProvider,
type CalendarLocale,
} from "@workspace/ui/components/i18n"
import { calendarLocale as englishCalendarLocale } from "@workspace/ui/locales/en"
import { calendarLocale as simplifiedChineseCalendarLocale } from "@workspace/ui/locales/zh-Hans"
import { messages as englishMessages } from "@/locales/en/messages" import { appLocales, DEFAULT_APP_LOCALE, type AppLocale } from "./app-locales"
import { messages as simplifiedChineseMessages } from "@/locales/zh-Hans/messages" import { AppI18nDevtool } from "./app-i18n-devtool"
import { setAppLocale as persistAppLocale } from "@/server/app-locale.functions"
const I18nDevtool = import.meta.env.DEV const appCalendarLocales: Partial<Record<AppLocale, CalendarLocale>> = {
? lazy(() => en: englishCalendarLocale,
import("@workspace/i18n/devtool").then((module) => ({ "zh-Hans": simplifiedChineseCalendarLocale,
default: module.I18nDevtool,
}))
)
: null
export const appLocales = [
{
label: "English",
locale: "en",
},
{
label: "简体中文",
locale: "zh-Hans",
},
] as const satisfies readonly LocaleDefinition[]
export type AppLocale = (typeof appLocales)[number]["locale"]
const appCatalogs = {
en: englishMessages,
"zh-Hans": simplifiedChineseMessages,
} }
export interface AppI18nProviderProps extends PropsWithChildren { export interface AppI18nProviderProps extends PropsWithChildren {
locale?: AppLocale locale?: AppLocale
} }
interface AppLocaleContextValue {
locale: AppLocale
setLocale: (locale: AppLocale) => void
}
const AppLocaleContext = createContext<AppLocaleContextValue | null>(null)
export function AppI18nProvider({ export function AppI18nProvider({
children, children,
locale = "zh-Hans", locale: initialLocale = DEFAULT_APP_LOCALE,
}: AppI18nProviderProps) { }: AppI18nProviderProps) {
const [locale, setLocaleState] = useState(initialLocale)
const messages = use(loadMessageCatalog(locale))
const catalogs = useMemo(() => ({ [locale]: messages }), [locale, messages])
const setLocale = useCallback((nextLocale: AppLocale) => {
startTransition(() => {
setLocaleState(nextLocale)
})
void persistAppLocale({ data: nextLocale })
}, [])
const localeContext = useMemo(
() => ({ locale, setLocale }),
[locale, setLocale]
)
useEffect(() => {
const definition = appLocales.find(
(candidate) => candidate.locale === locale
)
document.documentElement.lang = definition?.languageTag ?? locale
document.documentElement.dir = definition?.direction ?? "ltr"
}, [locale])
return ( return (
<I18nProvider catalogs={appCatalogs} locale={locale} locales={appLocales}> <AppLocaleContext.Provider value={localeContext}>
{children} <I18nProvider catalogs={catalogs} locale={locale} locales={appLocales}>
{I18nDevtool && ( <AppUiI18nProvider>{children}</AppUiI18nProvider>
<Suspense fallback={null}> <AppI18nDevtool />
<I18nDevtool dark={"ui\\:dark"} />
</Suspense>
)}
</I18nProvider> </I18nProvider>
</AppLocaleContext.Provider>
)
}
export function useAppLocale() {
const context = useContext(AppLocaleContext)
if (!context) {
throw new Error("useAppLocale must be used within AppI18nProvider")
}
return context
}
function AppUiI18nProvider({ children }: PropsWithChildren) {
const locale = useLocaleDefinition()
const translate = useTranslate()
return (
<UiI18nProvider
calendarLocale={appCalendarLocales[locale.locale as AppLocale]}
locale={locale.languageTag ?? locale.locale}
t={translate}
>
{children}
</UiI18nProvider>
) )
} }
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
import { AppLanguageSwitcher } from "./app-language-switcher"
vi.mock("./app-i18n-provider", () => ({
useAppLocale: () => ({
locale: "en",
setLocale: vi.fn(),
}),
}))
vi.mock("country-flag-icons/react/1x1", () => ({
CN: () => <svg />,
US: () => <svg />,
}))
beforeAll(() => {
vi.stubGlobal(
"ResizeObserver",
class {
disconnect() {}
observe() {}
unobserve() {}
}
)
})
afterEach(cleanup)
describe("AppLanguageSwitcher", () => {
it("opens its grouped locale choices", async () => {
render(<AppLanguageSwitcher />)
fireEvent.click(
screen.getByRole("button", {
name: "Change language, currently English",
})
)
expect(await screen.findByText("简体中文")).not.toBeNull()
})
})
@@ -0,0 +1,94 @@
import { CN, US } from "country-flag-icons/react/1x1"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu"
import { cn } from "@workspace/ui/lib/utils"
import { HeaderActionButton } from "@workspace/blocks/layout"
import { appLocales, isAppLocale, type AppLocaleCountry } from "./app-locales"
import { useAppLocale } from "./app-i18n-provider"
const localeFlags = {
CN,
US,
} satisfies Record<AppLocaleCountry, typeof CN>
function LocaleFlag({
country,
className,
}: {
className?: string
country: AppLocaleCountry
}) {
const Flag = localeFlags[country]
return (
<span
aria-hidden="true"
className={cn(
"inline-flex shrink-0 overflow-hidden rounded-full ring-1 ring-foreground/15",
className
)}
>
<Flag className="size-full" />
</span>
)
}
export function AppLanguageSwitcher() {
const { locale, setLocale } = useAppLocale()
const activeLocale =
appLocales.find((definition) => definition.locale === locale) ??
appLocales[0]
const menuLabel = locale === "zh-Hans" ? "语言" : "Language"
const triggerLabel =
locale === "zh-Hans"
? `切换语言,当前为${activeLocale.label}`
: `Change language, currently ${activeLocale.label}`
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<HeaderActionButton
aria-label={triggerLabel}
className="rounded-full"
/>
}
>
<LocaleFlag country={activeLocale.country} className="size-6" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-44"
showArrow={{ cornerRadius: "lg", tailSize: "sm" }}
>
<DropdownMenuRadioGroup
value={locale}
onValueChange={(value) => {
if (isAppLocale(value)) {
setLocale(value)
}
}}
>
<DropdownMenuLabel>{menuLabel}</DropdownMenuLabel>
{appLocales.map((definition) => (
<DropdownMenuRadioItem
key={definition.locale}
className="py-2"
value={definition.locale}
>
<LocaleFlag country={definition.country} className="size-5" />
<span lang={definition.languageTag}>{definition.label}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
+32
View File
@@ -0,0 +1,32 @@
import type { LocaleDefinition } from "@workspace/i18n"
export type AppLocale = "en" | "zh-Hans"
export type AppLocaleCountry = "CN" | "US"
export interface AppLocaleDefinition extends LocaleDefinition {
country: AppLocaleCountry
locale: AppLocale
}
export const DEFAULT_APP_LOCALE: AppLocale = "zh-Hans"
export const appLocales = [
{
country: "US",
direction: "ltr",
languageTag: "en-US",
label: "English",
locale: "en",
},
{
country: "CN",
direction: "ltr",
languageTag: "zh-CN",
label: "简体中文",
locale: "zh-Hans",
},
] as const satisfies readonly AppLocaleDefinition[]
export function isAppLocale(value: unknown): value is AppLocale {
return appLocales.some((definition) => definition.locale === value)
}
+840
View File
@@ -12,3 +12,843 @@ msgstr ""
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
"Plural-Forms: \n" "Plural-Forms: \n"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:4
msgid "blocks.userMenu.accountPassword"
msgstr "Account password"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:4
msgid "blocks.appearance.active"
msgstr "Active"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:276
msgid "app.navigation.system-members-admins"
msgstr "Administrator accounts"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:148
msgid "app.navigation.after-sales-center"
msgstr "After-sales"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:91
msgid "blocks.appearance.color.amber"
msgstr "Amber"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:308
msgid "app.navigation.system-integration-api-keys"
msgstr "API keys"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:8
msgid "blocks.userMenu.appearance"
msgstr "Appearance"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:8
msgid "blocks.appearance.title"
msgstr "Appearance"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:92
msgid "app.navigation.content-articles"
msgstr "Articles"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:300
msgid "app.navigation.system-audit"
msgstr "Audit log"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:112
msgid "app.navigation.trade-orders-awaiting-payment"
msgstr "Awaiting payment"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:116
msgid "app.navigation.trade-orders-awaiting-shipment"
msgstr "Awaiting shipment"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:16
msgid "blocks.appearance.baseColor"
msgstr "Base color"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:220
msgid "app.navigation.loyalty-benefits"
msgstr "Benefits configuration"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:95
msgid "blocks.appearance.color.blue"
msgstr "Blue"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:48
msgid "app.navigation.catalog-product-brands"
msgstr "Brands"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:25
msgid "ui.breadcrumb.label"
msgstr "Breadcrumb"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:8
msgid "app.navigation.overview-dashboard"
msgstr "Business overview"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:128
msgid "app.navigation.trade-orders-canceled"
msgstr "Canceled orders"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:52
msgid "app.navigation.catalog-categories"
msgstr "Categories"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:56
msgid "app.navigation.catalog-category-tree"
msgstr "Category tree"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:244
msgid "app.navigation.report-channels"
msgstr "Channel analysis"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/chats/messages.ts:4
msgid "blocks.chats.title"
msgstr "Chats"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:16
msgid "blocks.notifications.error.description"
msgstr "Check your network connection and try again."
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:29
msgid "ui.common.close"
msgstr "Close"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:33
msgid "ui.toast.close"
msgstr "Close notification"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:24
msgid "blocks.appearance.compact.title"
msgstr "Compact layout"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:124
msgid "app.navigation.trade-orders-completed"
msgstr "Completed orders"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:88
msgid "app.navigation.content-assets"
msgstr "Content"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:192
msgid "app.navigation.marketing-coupons"
msgstr "Coupons"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:100
msgid "app.navigation.content-pages"
msgstr "Custom pages"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:240
msgid "app.navigation.report-customers"
msgstr "Customer analysis"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:172
msgid "app.navigation.customer-profiles"
msgstr "Customer profiles"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:176
msgid "app.navigation.customer-segments"
msgstr "Customer segments"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:180
msgid "app.navigation.customer-tiers"
msgstr "Customer tiers"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:168
msgid "app.navigation.customer-center"
msgstr "Customers"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:164
msgid "app.navigation.customers-and-marketing"
msgstr "Customers and marketing"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:99
msgid "blocks.appearance.color.cyan"
msgstr "Cyan"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:28
msgid "blocks.appearance.mode.dark"
msgstr "Dark"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:36
msgid "blocks.appearance.theme.dark"
msgstr "Dark theme"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:224
msgid "app.navigation.data-and-finance"
msgstr "Data and finance"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:296
msgid "app.navigation.system-notification-logs"
msgstr "Delivery logs"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:136
msgid "app.navigation.fulfillment-deliveries"
msgstr "Delivery tasks"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:81
msgid "ui.sidebar.description"
msgstr "Displays the mobile sidebar."
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:103
msgid "blocks.appearance.color.emerald"
msgstr "Emerald"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:248
msgid "app.navigation.finance-center"
msgstr "Finance"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:196
msgid "app.navigation.marketing-flash-sales"
msgstr "Flash sales"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:107
msgid "blocks.appearance.color.fuchsia"
msgstr "Fuchsia"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:132
msgid "app.navigation.fulfillment-center"
msgstr "Fulfillment"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:200
msgid "app.navigation.marketing-gift-promotions"
msgstr "Gift promotions"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:53
msgid "ui.pagination.nextPage"
msgstr "Go to next page"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:69
msgid "ui.pagination.previousPage"
msgstr "Go to previous page"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:111
msgid "blocks.appearance.color.green"
msgstr "Green"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:120
msgid "app.navigation.trade-orders-shipped"
msgstr "In transit"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:72
msgid "app.navigation.inventory-inbound"
msgstr "Inbound orders"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:115
msgid "blocks.appearance.color.indigo"
msgstr "Indigo"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:304
msgid "app.navigation.system-integrations"
msgstr "Integrations"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:64
msgid "app.navigation.inventory-center"
msgstr "Inventory"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:84
msgid "app.navigation.inventory-alerts"
msgstr "Inventory alerts"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:68
msgid "app.navigation.inventory-overview"
msgstr "Inventory overview"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:260
msgid "app.navigation.finance-invoices"
msgstr "Invoices"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:24
msgid "blocks.notifications.justNow"
msgstr "just now"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:16
msgid "blocks.userMenu.keybindings"
msgstr "Keybindings"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:52
msgid "blocks.appearance.mode.light"
msgstr "Light"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:60
msgid "blocks.appearance.theme.light"
msgstr "Light theme"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:119
msgid "blocks.appearance.color.lime"
msgstr "Lime"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:37
msgid "ui.common.loading"
msgstr "Loading"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:28
msgid "blocks.notifications.loading"
msgstr "Loading notifications…"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:20
msgid "blocks.userMenu.logout"
msgstr "Log out"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:32
msgid "blocks.notifications.markAllAsRead"
msgstr "Mark all as read"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:188
msgid "app.navigation.marketing-campaigns"
msgstr "Marketing campaigns"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:123
msgid "blocks.appearance.color.mauve"
msgstr "Mauve"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:96
msgid "app.navigation.content-media"
msgstr "Media"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:208
msgid "app.navigation.loyalty-center"
msgstr "Membership benefits"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:127
msgid "blocks.appearance.color.mist"
msgstr "Mist"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:41
msgid "ui.common.more"
msgstr "More"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:45
msgid "ui.pagination.more"
msgstr "More pages"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/navigation/messages.ts:4
msgid "blocks.navigation.description"
msgstr "Navigate through the app."
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/navigation/messages.ts:12
msgid "blocks.navigation.title"
msgstr "Navigation"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:60
msgid "app.navigation.catalog-category-navigation"
msgstr "Navigation categories"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:131
msgid "blocks.appearance.color.neutral"
msgstr "Neutral"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:8
msgid "blocks.notifications.empty.description"
msgstr "New activity will appear here."
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:49
msgid "ui.pagination.next"
msgstr "Next"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:57
msgid "ui.carousel.nextSlide"
msgstr "Next slide"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:12
msgid "blocks.notifications.empty.title"
msgstr "No notifications"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:292
msgid "app.navigation.system-notification-templates"
msgstr "Notification templates"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:40
msgid "blocks.notifications.title"
msgstr "Notifications"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:288
msgid "app.navigation.system-notifications"
msgstr "Notifications"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:135
msgid "blocks.appearance.color.olive"
msgstr "Olive"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:12
msgid "blocks.breadcrumb.menuLabel"
msgstr "Open breadcrumb menu"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/navigation/messages.ts:8
msgid "blocks.navigation.open"
msgstr "Open navigation"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:12
msgid "blocks.appearance.command.description"
msgstr "Open theme and layout preferences"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:24
msgid "blocks.userMenu.open"
msgstr "Open user menu"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:139
msgid "blocks.appearance.color.orange"
msgstr "Orange"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:108
msgid "app.navigation.trade-orders"
msgstr "Orders"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:104
msgid "app.navigation.trade-and-fulfillment"
msgstr "Orders and fulfillment"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:272
msgid "app.navigation.system-members"
msgstr "Organization members"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:76
msgid "app.navigation.inventory-outbound"
msgstr "Outbound orders"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:4
msgid "app.navigation.overview"
msgstr "Overview"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:61
msgid "ui.pagination.label"
msgstr "Pagination"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:324
msgid "app.navigation.system-settings-payments"
msgstr "Payment channels"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:264
msgid "app.navigation.finance-payouts"
msgstr "Payout history"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:284
msgid "app.navigation.system-members-permissions"
msgstr "Permission policies"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:140
msgid "app.navigation.fulfillment-pickups"
msgstr "Pickup verification"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:143
msgid "blocks.appearance.color.pink"
msgstr "Pink"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:212
msgid "app.navigation.loyalty-points"
msgstr "Points rules"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:65
msgid "ui.pagination.previous"
msgstr "Previous"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:73
msgid "ui.carousel.previousSlide"
msgstr "Previous slide"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:68
msgid "blocks.appearance.primaryColor"
msgstr "Primary color"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:236
msgid "app.navigation.report-products"
msgstr "Product analysis"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:40
msgid "app.navigation.catalog-products-attributes"
msgstr "Product attributes"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:36
msgid "app.navigation.catalog-products-bundles"
msgstr "Product bundles"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:32
msgid "app.navigation.catalog-products-drafts"
msgstr "Product drafts"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:28
msgid "app.navigation.catalog-products-list"
msgstr "Product list"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:24
msgid "app.navigation.catalog-products"
msgstr "Products"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:20
msgid "app.navigation.catalog-and-inventory"
msgstr "Products and inventory"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:147
msgid "blocks.appearance.color.purple"
msgstr "Purple"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:12
msgid "app.navigation.overview-realtime"
msgstr "Real-time monitoring"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:256
msgid "app.navigation.finance-reconciliation"
msgstr "Reconciliation"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:332
msgid "app.navigation.system-recycle-bin"
msgstr "Recycle bin"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:151
msgid "blocks.appearance.color.red"
msgstr "Red"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:204
msgid "app.navigation.marketing-referrals"
msgstr "Referral rewards"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:32
msgid "blocks.refresh.label"
msgstr "Refresh current page data"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:28
msgid "blocks.refresh.description"
msgstr "Refresh the current page data"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:156
msgid "app.navigation.after-sales-refunds"
msgstr "Refund review"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:228
msgid "app.navigation.report-center"
msgstr "Reports"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:152
msgid "app.navigation.after-sales-returns"
msgstr "Return requests"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:184
msgid "app.navigation.customer-risk-list"
msgstr "Risk list"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:280
msgid "app.navigation.system-members-roles"
msgstr "Roles"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:155
msgid "blocks.appearance.color.rose"
msgstr "Rose"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:232
msgid "app.navigation.report-sales"
msgstr "Sales analysis"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:252
msgid "app.navigation.finance-settlements"
msgstr "Settlement statements"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:144
msgid "app.navigation.fulfillment-freight-rules"
msgstr "Shipping rules"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:328
msgid "app.navigation.system-settings-shipping"
msgstr "Shipping settings"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:77
msgid "ui.sidebar.title"
msgstr "Sidebar"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:159
msgid "blocks.appearance.color.sky"
msgstr "Sky"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:44
msgid "app.navigation.catalog-product-specifications"
msgstr "Specification templates"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:80
msgid "app.navigation.inventory-transfers"
msgstr "Stock transfers"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:163
msgid "blocks.appearance.color.stone"
msgstr "Stone"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:320
msgid "app.navigation.system-settings-store"
msgstr "Store information"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:216
msgid "app.navigation.loyalty-stored-value"
msgstr "Stored-value accounts"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:84
msgid "blocks.appearance.toggleTheme.description"
msgstr "Switch between light and dark themes"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:44
msgid "blocks.appearance.mode.system"
msgstr "System"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:268
msgid "app.navigation.system-management"
msgstr "System management"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:316
msgid "app.navigation.system-settings"
msgstr "System settings"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:16
msgid "app.navigation.overview-tasks"
msgstr "Tasks"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:167
msgid "blocks.appearance.color.taupe"
msgstr "Taupe"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:171
msgid "blocks.appearance.color.teal"
msgstr "Teal"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:48
msgid "blocks.appearance.mode.system.description"
msgstr "The interface theme will follow the system appearance"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:32
msgid "blocks.appearance.mode.dark.description"
msgstr "The interface will always use the dark theme"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:56
msgid "blocks.appearance.mode.light.description"
msgstr "The interface will always use the light theme"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:72
msgid "blocks.appearance.description"
msgstr "Theme and layout preferences"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:76
msgid "blocks.appearance.mode.title"
msgstr "Theme mode"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:40
msgid "blocks.appearance.theme.dark.description"
msgstr "This theme is used when the system is in dark mode"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:64
msgid "blocks.appearance.theme.light.description"
msgstr "This theme is used when the system is in light mode"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:85
msgid "ui.sidebar.toggle"
msgstr "Toggle sidebar"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:80
msgid "blocks.appearance.toggleTheme"
msgstr "Toggle theme"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:160
msgid "app.navigation.after-sales-disputes"
msgstr "Transaction disputes"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:36
msgid "blocks.notifications.retry"
msgstr "Try again"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:20
msgid "blocks.notifications.error.title"
msgstr "Unable to load notifications"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:20
msgid "blocks.appearance.compact.description"
msgstr "Use a compact width for the main page content"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:36
msgid "blocks.userMenu.title"
msgstr "User menu"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:44
msgid "blocks.notifications.viewAll"
msgstr "View all"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:4
msgid "blocks.notifications.description"
msgstr "View and manage notifications"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:175
msgid "blocks.appearance.color.violet"
msgstr "Violet"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:312
msgid "app.navigation.system-integration-webhooks"
msgstr "Webhook configuration"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:179
msgid "blocks.appearance.color.yellow"
msgstr "Yellow"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:183
msgid "blocks.appearance.color.zinc"
msgstr "Zinc"
File diff suppressed because one or more lines are too long
+840
View File
@@ -12,3 +12,843 @@ msgstr ""
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
"Plural-Forms: \n" "Plural-Forms: \n"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:4
msgid "blocks.userMenu.accountPassword"
msgstr "账号密码"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:4
msgid "blocks.appearance.active"
msgstr "使用中"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:276
msgid "app.navigation.system-members-admins"
msgstr "管理员账号"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:148
msgid "app.navigation.after-sales-center"
msgstr "售后中心"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:91
msgid "blocks.appearance.color.amber"
msgstr "琥珀色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:308
msgid "app.navigation.system-integration-api-keys"
msgstr "API 密钥"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:8
msgid "blocks.userMenu.appearance"
msgstr "界面外观"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:8
msgid "blocks.appearance.title"
msgstr "界面外观"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:92
msgid "app.navigation.content-articles"
msgstr "内容文章"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:300
msgid "app.navigation.system-audit"
msgstr "操作审计"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:112
msgid "app.navigation.trade-orders-awaiting-payment"
msgstr "待付款订单"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:116
msgid "app.navigation.trade-orders-awaiting-shipment"
msgstr "待发货订单"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:16
msgid "blocks.appearance.baseColor"
msgstr "基础颜色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:220
msgid "app.navigation.loyalty-benefits"
msgstr "权益配置"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:95
msgid "blocks.appearance.color.blue"
msgstr "蓝色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:48
msgid "app.navigation.catalog-product-brands"
msgstr "品牌资料"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:25
msgid "ui.breadcrumb.label"
msgstr "面包屑导航"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:8
msgid "app.navigation.overview-dashboard"
msgstr "经营总览"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:128
msgid "app.navigation.trade-orders-canceled"
msgstr "已取消订单"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:52
msgid "app.navigation.catalog-categories"
msgstr "分类目录"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:56
msgid "app.navigation.catalog-category-tree"
msgstr "分类树"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:244
msgid "app.navigation.report-channels"
msgstr "渠道分析"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/chats/messages.ts:4
msgid "blocks.chats.title"
msgstr "聊天"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:16
msgid "blocks.notifications.error.description"
msgstr "请检查网络连接后重试。"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:29
msgid "ui.common.close"
msgstr "关闭"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:33
msgid "ui.toast.close"
msgstr "关闭通知"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:24
msgid "blocks.appearance.compact.title"
msgstr "紧凑布局"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:124
msgid "app.navigation.trade-orders-completed"
msgstr "已完成订单"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:88
msgid "app.navigation.content-assets"
msgstr "内容素材"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:192
msgid "app.navigation.marketing-coupons"
msgstr "优惠券管理"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:100
msgid "app.navigation.content-pages"
msgstr "自定义页面"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:240
msgid "app.navigation.report-customers"
msgstr "客群分析"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:172
msgid "app.navigation.customer-profiles"
msgstr "客户档案"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:176
msgid "app.navigation.customer-segments"
msgstr "客户分群"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:180
msgid "app.navigation.customer-tiers"
msgstr "客户等级"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:168
msgid "app.navigation.customer-center"
msgstr "客户中心"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:164
msgid "app.navigation.customers-and-marketing"
msgstr "客户与营销"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:99
msgid "blocks.appearance.color.cyan"
msgstr "青色"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:28
msgid "blocks.appearance.mode.dark"
msgstr "深色"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:36
msgid "blocks.appearance.theme.dark"
msgstr "深色主题"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:224
msgid "app.navigation.data-and-finance"
msgstr "数据与财务"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:296
msgid "app.navigation.system-notification-logs"
msgstr "发送记录"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:136
msgid "app.navigation.fulfillment-deliveries"
msgstr "配送任务"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:81
msgid "ui.sidebar.description"
msgstr "显示移动端侧栏。"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:103
msgid "blocks.appearance.color.emerald"
msgstr "翠绿色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:248
msgid "app.navigation.finance-center"
msgstr "财务中心"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:196
msgid "app.navigation.marketing-flash-sales"
msgstr "限时折扣"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:107
msgid "blocks.appearance.color.fuchsia"
msgstr "紫红色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:132
msgid "app.navigation.fulfillment-center"
msgstr "配送履约"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:200
msgid "app.navigation.marketing-gift-promotions"
msgstr "满赠活动"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:53
msgid "ui.pagination.nextPage"
msgstr "前往下一页"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:69
msgid "ui.pagination.previousPage"
msgstr "前往上一页"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:111
msgid "blocks.appearance.color.green"
msgstr "绿色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:120
msgid "app.navigation.trade-orders-shipped"
msgstr "配送中订单"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:72
msgid "app.navigation.inventory-inbound"
msgstr "入库单"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:115
msgid "blocks.appearance.color.indigo"
msgstr "靛蓝色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:304
msgid "app.navigation.system-integrations"
msgstr "开放集成"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:64
msgid "app.navigation.inventory-center"
msgstr "库存中心"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:84
msgid "app.navigation.inventory-alerts"
msgstr "库存预警"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:68
msgid "app.navigation.inventory-overview"
msgstr "库存总览"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:260
msgid "app.navigation.finance-invoices"
msgstr "发票管理"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:24
msgid "blocks.notifications.justNow"
msgstr "刚刚"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:16
msgid "blocks.userMenu.keybindings"
msgstr "按键映射"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:52
msgid "blocks.appearance.mode.light"
msgstr "浅色"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:60
msgid "blocks.appearance.theme.light"
msgstr "浅色主题"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:119
msgid "blocks.appearance.color.lime"
msgstr "青柠色"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:37
msgid "ui.common.loading"
msgstr "正在加载"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:28
msgid "blocks.notifications.loading"
msgstr "正在加载通知…"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:20
msgid "blocks.userMenu.logout"
msgstr "退出登录"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:32
msgid "blocks.notifications.markAllAsRead"
msgstr "全部标记为已读"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:188
msgid "app.navigation.marketing-campaigns"
msgstr "营销活动"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:123
msgid "blocks.appearance.color.mauve"
msgstr "紫灰"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:96
msgid "app.navigation.content-media"
msgstr "媒体资源"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:208
msgid "app.navigation.loyalty-center"
msgstr "会员权益"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:127
msgid "blocks.appearance.color.mist"
msgstr "雾灰"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:41
msgid "ui.common.more"
msgstr "更多"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:45
msgid "ui.pagination.more"
msgstr "更多页面"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/navigation/messages.ts:4
msgid "blocks.navigation.description"
msgstr "浏览应用中的页面。"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/navigation/messages.ts:12
msgid "blocks.navigation.title"
msgstr "导航"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:60
msgid "app.navigation.catalog-category-navigation"
msgstr "导航分类"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:131
msgid "blocks.appearance.color.neutral"
msgstr "中性灰"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:8
msgid "blocks.notifications.empty.description"
msgstr "新的动态会显示在这里。"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:49
msgid "ui.pagination.next"
msgstr "下一页"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:57
msgid "ui.carousel.nextSlide"
msgstr "下一张"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:12
msgid "blocks.notifications.empty.title"
msgstr "暂无通知"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:292
msgid "app.navigation.system-notification-templates"
msgstr "通知模板"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:40
msgid "blocks.notifications.title"
msgstr "通知"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:288
msgid "app.navigation.system-notifications"
msgstr "消息中心"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:135
msgid "blocks.appearance.color.olive"
msgstr "橄榄灰"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:12
msgid "blocks.breadcrumb.menuLabel"
msgstr "打开面包屑菜单"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/navigation/messages.ts:8
msgid "blocks.navigation.open"
msgstr "打开导航"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:12
msgid "blocks.appearance.command.description"
msgstr "打开主题与布局偏好"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:24
msgid "blocks.userMenu.open"
msgstr "打开用户菜单"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:139
msgid "blocks.appearance.color.orange"
msgstr "橙色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:108
msgid "app.navigation.trade-orders"
msgstr "订单中心"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:104
msgid "app.navigation.trade-and-fulfillment"
msgstr "交易与履约"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:272
msgid "app.navigation.system-members"
msgstr "组织成员"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:76
msgid "app.navigation.inventory-outbound"
msgstr "出库单"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:4
msgid "app.navigation.overview"
msgstr "概览"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:61
msgid "ui.pagination.label"
msgstr "分页导航"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:324
msgid "app.navigation.system-settings-payments"
msgstr "支付渠道"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:264
msgid "app.navigation.finance-payouts"
msgstr "打款记录"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:284
msgid "app.navigation.system-members-permissions"
msgstr "权限策略"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:140
msgid "app.navigation.fulfillment-pickups"
msgstr "自提核销"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:143
msgid "blocks.appearance.color.pink"
msgstr "粉色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:212
msgid "app.navigation.loyalty-points"
msgstr "积分规则"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:65
msgid "ui.pagination.previous"
msgstr "上一页"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:73
msgid "ui.carousel.previousSlide"
msgstr "上一张"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:68
msgid "blocks.appearance.primaryColor"
msgstr "主要颜色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:236
msgid "app.navigation.report-products"
msgstr "商品分析"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:40
msgid "app.navigation.catalog-products-attributes"
msgstr "商品属性"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:36
msgid "app.navigation.catalog-products-bundles"
msgstr "组合商品"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:32
msgid "app.navigation.catalog-products-drafts"
msgstr "商品草稿"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:28
msgid "app.navigation.catalog-products-list"
msgstr "商品列表"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:24
msgid "app.navigation.catalog-products"
msgstr "商品中心"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:20
msgid "app.navigation.catalog-and-inventory"
msgstr "商品与库存"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:147
msgid "blocks.appearance.color.purple"
msgstr "紫色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:12
msgid "app.navigation.overview-realtime"
msgstr "实时监控"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:256
msgid "app.navigation.finance-reconciliation"
msgstr "对账管理"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:332
msgid "app.navigation.system-recycle-bin"
msgstr "回收站"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:151
msgid "blocks.appearance.color.red"
msgstr "红色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:204
msgid "app.navigation.marketing-referrals"
msgstr "邀请有礼"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:32
msgid "blocks.refresh.label"
msgstr "刷新当前页面数据"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:28
msgid "blocks.refresh.description"
msgstr "刷新当前页面的数据"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:156
msgid "app.navigation.after-sales-refunds"
msgstr "退款审核"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:228
msgid "app.navigation.report-center"
msgstr "数据报表"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:152
msgid "app.navigation.after-sales-returns"
msgstr "退货申请"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:184
msgid "app.navigation.customer-risk-list"
msgstr "风险名单"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:280
msgid "app.navigation.system-members-roles"
msgstr "角色管理"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:155
msgid "blocks.appearance.color.rose"
msgstr "玫红色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:232
msgid "app.navigation.report-sales"
msgstr "销售分析"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:252
msgid "app.navigation.finance-settlements"
msgstr "结算账单"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:144
msgid "app.navigation.fulfillment-freight-rules"
msgstr "运费规则"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:328
msgid "app.navigation.system-settings-shipping"
msgstr "配送设置"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:77
msgid "ui.sidebar.title"
msgstr "侧栏"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:159
msgid "blocks.appearance.color.sky"
msgstr "天蓝色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:44
msgid "app.navigation.catalog-product-specifications"
msgstr "规格模板"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:80
msgid "app.navigation.inventory-transfers"
msgstr "调拨单"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:163
msgid "blocks.appearance.color.stone"
msgstr "石灰"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:320
msgid "app.navigation.system-settings-store"
msgstr "门店资料"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:216
msgid "app.navigation.loyalty-stored-value"
msgstr "储值账户"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:84
msgid "blocks.appearance.toggleTheme.description"
msgstr "在浅色与深色主题之间切换"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:44
msgid "blocks.appearance.mode.system"
msgstr "系统"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:268
msgid "app.navigation.system-management"
msgstr "系统管理"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:316
msgid "app.navigation.system-settings"
msgstr "系统配置"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:16
msgid "app.navigation.overview-tasks"
msgstr "待办中心"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:167
msgid "blocks.appearance.color.taupe"
msgstr "灰褐"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:171
msgid "blocks.appearance.color.teal"
msgstr "蓝绿色"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:48
msgid "blocks.appearance.mode.system.description"
msgstr "界面主题将跟随系统外观设置"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:32
msgid "blocks.appearance.mode.dark.description"
msgstr "界面将始终使用深色主题"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:56
msgid "blocks.appearance.mode.light.description"
msgstr "界面将始终使用浅色主题"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:72
msgid "blocks.appearance.description"
msgstr "主题与布局偏好"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:76
msgid "blocks.appearance.mode.title"
msgstr "主题模式"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:40
msgid "blocks.appearance.theme.dark.description"
msgstr "当系统设置为深色模式时,将使用此主题"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:64
msgid "blocks.appearance.theme.light.description"
msgstr "当系统设置为浅色模式时,将使用此主题"
#. js-lingui-explicit-id
#: ../../packages/ui/src/components/i18n.tsx:85
msgid "ui.sidebar.toggle"
msgstr "切换侧栏"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:80
msgid "blocks.appearance.toggleTheme"
msgstr "切换主题"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:160
msgid "app.navigation.after-sales-disputes"
msgstr "交易纠纷"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:36
msgid "blocks.notifications.retry"
msgstr "重新加载"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:20
msgid "blocks.notifications.error.title"
msgstr "无法加载通知"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:20
msgid "blocks.appearance.compact.description"
msgstr "启用后,页面主内容将使用紧凑宽度"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/layout/messages.ts:36
msgid "blocks.userMenu.title"
msgstr "用户菜单"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:44
msgid "blocks.notifications.viewAll"
msgstr "查看全部"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/notifications/messages.ts:4
msgid "blocks.notifications.description"
msgstr "查看并处理通知"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:175
msgid "blocks.appearance.color.violet"
msgstr "紫罗兰色"
#. js-lingui-explicit-id
#: src/data/navigation-messages.ts:312
msgid "app.navigation.system-integration-webhooks"
msgstr "Webhook 配置"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:179
msgid "blocks.appearance.color.yellow"
msgstr "黄色"
#. js-lingui-explicit-id
#: ../../packages/blocks/src/blocks/appearance/messages.ts:183
msgid "blocks.appearance.color.zinc"
msgstr "锌灰"
File diff suppressed because one or more lines are too long
+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>
</>
) )
} }
+16 -5
View File
@@ -24,10 +24,19 @@ import { HotkeysProvider } from "@tanstack/react-hotkeys"
import { TooltipProvider } from "@workspace/ui/components/tooltip" import { TooltipProvider } from "@workspace/ui/components/tooltip"
import { QueryClientProvider } from "@tanstack/react-query" import { QueryClientProvider } from "@tanstack/react-query"
import { AppI18nProvider } from "@/i18n/app-i18n-provider" import { AppI18nProvider } from "@/i18n/app-i18n-provider"
import { getAppLocale } from "@/server/app-locale.functions"
import type { AppLocale } from "@/i18n/app-locales"
import { queryClient } from "@/lib/query-client" import { queryClient } from "@/lib/query-client"
export const Route = createRootRoute({ export const Route = createRootRoute({
loader: () => getUiState(), loader: async () => {
const [initialState, initialLocale] = await Promise.all([
getUiState(),
getAppLocale(),
])
return { initialLocale, initialState }
},
head: () => ({ head: () => ({
meta: [ meta: [
{ charSet: "utf-8" }, { charSet: "utf-8" },
@@ -48,11 +57,11 @@ export const Route = createRootRoute({
}) })
function RootComponent() { function RootComponent() {
const initialState = Route.useLoaderData() const { initialLocale, initialState } = Route.useLoaderData()
return ( return (
<RootDocument initialState={initialState}> <RootDocument initialLocale={initialLocale} initialState={initialState}>
<AppI18nProvider> <AppI18nProvider locale={initialLocale}>
<BreakpointReactor /> <BreakpointReactor />
<UiStateProvider <UiStateProvider
initialState={initialState} initialState={initialState}
@@ -94,9 +103,11 @@ function getUiInitializationScript(state: UiState) {
function RootDocument({ function RootDocument({
children, children,
initialLocale,
initialState, initialState,
}: Readonly<{ }: Readonly<{
children: ReactNode children: ReactNode
initialLocale: AppLocale
initialState: UiState initialState: UiState
}>) { }>) {
const themeMode = initialState["theme-mode"] const themeMode = initialState["theme-mode"]
@@ -110,7 +121,7 @@ function RootDocument({
return ( return (
<html <html
lang="zh-CN" lang={initialLocale === "zh-Hans" ? "zh-CN" : "en-US"}
suppressHydrationWarning suppressHydrationWarning
className={className || undefined} className={className || undefined}
style={themeMode === "system" ? undefined : { colorScheme: themeMode }} style={themeMode === "system" ? undefined : { colorScheme: themeMode }}
+2
View File
@@ -8,6 +8,7 @@ import {
fetchMockNotifications, fetchMockNotifications,
notificationsQueryKey, notificationsQueryKey,
} from "@/data/notifications" } from "@/data/notifications"
import { AppLanguageSwitcher } from "@/i18n/app-language-switcher"
export const Route = createFileRoute("/_app")({ export const Route = createFileRoute("/_app")({
component: ApplicationLayoutRoute, component: ApplicationLayoutRoute,
@@ -17,6 +18,7 @@ function ApplicationLayoutRoute() {
return ( return (
<AppLayout <AppLayout
chatThreads={chatThreads} chatThreads={chatThreads}
headerActions={<AppLanguageSwitcher />}
navigationGroups={primaryNavigationGroups} navigationGroups={primaryNavigationGroups}
notifications={{ notifications={{
queryFn: fetchMockNotifications, queryFn: fetchMockNotifications,
@@ -0,0 +1,20 @@
import { createServerFn } from "@tanstack/react-start"
import { isAppLocale } from "@/i18n/app-locales"
import { readAppLocale, writeAppLocale } from "./app-locale.server"
export const getAppLocale = createServerFn({ method: "GET" }).handler(() => {
return readAppLocale()
})
export const setAppLocale = createServerFn({ method: "POST" })
.validator((value: unknown) => {
if (!isAppLocale(value)) {
throw new Error("Invalid app locale")
}
return value
})
.handler(({ data }) => {
writeAppLocale(data)
})
+25
View File
@@ -0,0 +1,25 @@
import "@tanstack/react-start/server-only"
import { getCookie, setCookie } from "@tanstack/react-start/server"
import {
DEFAULT_APP_LOCALE,
isAppLocale,
type AppLocale,
} from "@/i18n/app-locales"
const APP_LOCALE_COOKIE = "ui-locale"
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365
export function readAppLocale(): AppLocale {
const locale = getCookie(APP_LOCALE_COOKIE)
return isAppLocale(locale) ? locale : DEFAULT_APP_LOCALE
}
export function writeAppLocale(locale: AppLocale) {
setCookie(APP_LOCALE_COOKIE, locale, {
maxAge: COOKIE_MAX_AGE,
path: "/",
sameSite: "lax",
})
}
+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);
+1 -2
View File
@@ -10,7 +10,6 @@
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
@@ -21,5 +20,5 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["vite.config.ts", "lingui.config.ts"] "include": ["vite.config.ts"]
} }
+16
View File
@@ -0,0 +1,16 @@
import path from "node:path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vitest/config"
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
tsconfigPaths: true,
},
test: {
environment: "jsdom",
},
})
+211 -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,9 +26,13 @@
"@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",
"lucide-react": "^1.27.0", "lucide-react": "^1.27.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"sonner": "^2.0.7", "sonner": "^2.0.7",
}, },
@@ -42,6 +43,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react": "^6", "@vitejs/plugin-react": "^6",
"jsdom": "^30.0.1",
"nitro": "^3.0.260610-beta", "nitro": "^3.0.260610-beta",
"typescript": "~6", "typescript": "~6",
"vite": "^8", "vite": "^8",
@@ -55,12 +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/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",
@@ -86,7 +89,9 @@
"@lingui/core": "^6", "@lingui/core": "^6",
"@lingui/react": "^6", "@lingui/react": "^6",
"@vitejs/plugin-react": "^6", "@vitejs/plugin-react": "^6",
"ansi-to-react": "^6.2.6",
"cac": "^7.0.0", "cac": "^7.0.0",
"lucide-react": "^1.27.0",
"open": "^11.0.0", "open": "^11.0.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
@@ -102,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",
@@ -129,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",
@@ -301,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=="],
@@ -317,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=="],
@@ -365,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=="],
@@ -405,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=="],
@@ -571,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=="],
@@ -625,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=="],
@@ -677,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=="],
@@ -687,12 +818,16 @@
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"anser": ["anser@2.3.5", "", {}, "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"ansi-to-react": ["ansi-to-react@6.2.6", "", { "dependencies": { "anser": "^2.3.2", "escape-carriage": "^1.3.1", "linkify-it": "^3.0.3" }, "peerDependencies": { "react": "^16.3.2 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.3.2 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Eqi0iaMK5OZ3jsVFxWvU2B74UZBnGuHlkflKMX6wTOeH+luy9KE2O0gUkc2PxhIP1R4IO0xohv62UMFInQOSeg=="],
"ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -745,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=="],
@@ -759,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=="],
@@ -795,6 +926,8 @@
"cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="],
"country-flag-icons": ["country-flag-icons@1.6.20", "", {}, "sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"crossws": ["crossws@0.4.10", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-pz3oubH/dt12KjqsUB0IuXW4nwRDQ583iDsP4555Cpdqx0NoU7pGlWBcayyFI8f/l/idRpgjMEfwuOxSWJYlIA=="], "crossws": ["crossws@0.4.10", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-pz3oubH/dt12KjqsUB0IuXW4nwRDQ583iDsP4555Cpdqx0NoU7pGlWBcayyFI8f/l/idRpgjMEfwuOxSWJYlIA=="],
@@ -913,6 +1046,8 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-carriage": ["escape-carriage@1.3.1", "", {}, "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
@@ -1067,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=="],
@@ -1099,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=="],
@@ -1127,6 +1268,8 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"linkify-it": ["linkify-it@3.0.3", "", { "dependencies": { "uc.micro": "^1.0.1" } }, "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ=="],
"local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="], "local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="],
"locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
@@ -1175,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=="],
@@ -1219,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=="],
@@ -1267,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=="],
@@ -1409,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=="],
@@ -1445,14 +1590,14 @@
"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=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"uc.micro": ["uc.micro@1.0.6", "", {}, "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="],
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"undici": ["undici@8.9.0", "", {}, "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA=="], "undici": ["undici@8.9.0", "", {}, "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA=="],
@@ -1513,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=="],
@@ -1527,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=="],
@@ -1629,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",
+20 -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,12 +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/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"
@@ -33,12 +33,28 @@
"exports": { "exports": {
"./globals.css": "./src/styles/globals.css", "./globals.css": "./src/styles/globals.css",
"./appearance": "./src/blocks/appearance/index.ts", "./appearance": "./src/blocks/appearance/index.ts",
"./appearance/locales": "./src/blocks/appearance/locales/catalogs.ts",
"./appearance/locales/*": "./src/blocks/appearance/locales/*.ts",
"./chats": "./src/blocks/chats/index.ts", "./chats": "./src/blocks/chats/index.ts",
"./chats/locales": "./src/blocks/chats/locales/catalogs.ts",
"./chats/locales/*": "./src/blocks/chats/locales/*.ts",
"./components/*": "./src/components/*.tsx", "./components/*": "./src/components/*.tsx",
"./hooks/*": "./src/hooks/*.ts", "./hooks/*": "./src/hooks/*.ts",
"./lib/*": "./src/lib/*.tsx", "./lib/*": "./src/lib/*.tsx",
"./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/*.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",
"./notifications": "./src/blocks/notifications/index.ts" "./navigation/locales": "./src/blocks/navigation/locales/catalogs.ts",
"./navigation/locales/*": "./src/blocks/navigation/locales/*.ts",
"./notifications": "./src/blocks/notifications/index.ts",
"./notifications/locales": "./src/blocks/notifications/locales/catalogs.ts",
"./notifications/locales/*": "./src/blocks/notifications/locales/*.ts",
"./search": "./src/blocks/search/index.ts",
"./search/locales": "./src/blocks/search/locales/catalogs.ts",
"./search/locales/*": "./src/blocks/search/locales/*.ts"
} }
} }
@@ -1,5 +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 { LocalizedText, useMessage } from "@workspace/i18n"
import { useResolvedTheme, useUiState } from "./ui-state" import { useResolvedTheme, useUiState } from "./ui-state"
import { import {
Dialog, Dialog,
@@ -15,6 +16,61 @@ 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 { appearanceMessages } from "./messages"
interface AppearanceHotkeysProps {
onToggleDialog: VoidFunction
onToggleTheme: VoidFunction
}
function AppearanceHotkeys({
onToggleDialog,
onToggleTheme,
}: AppearanceHotkeysProps) {
const appearanceTitle = useMessage(appearanceMessages.appearance)
const appearanceDescription = useMessage(
appearanceMessages.appearanceCommandDescription
)
const toggleThemeTitle = useMessage(appearanceMessages.toggleTheme)
const toggleThemeDescription = useMessage(
appearanceMessages.toggleThemeDescription
)
useHotkey("Mod+,", onToggleDialog, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: appearanceTitle,
description: appearanceDescription,
},
})
useHotkey("Mod+D", onToggleTheme, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: toggleThemeTitle,
description: toggleThemeDescription,
},
})
return null
}
function AppearanceDialogHeader() {
return (
<DialogHeader>
<DialogTitle>
<LocalizedText message={appearanceMessages.appearance} />
</DialogTitle>
<DialogDescription>
<LocalizedText message={appearanceMessages.themeAndLayoutPreferences} />
</DialogDescription>
</DialogHeader>
)
}
export function AppearanceController() { export function AppearanceController() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
@@ -33,37 +89,18 @@ export function AppearanceController() {
setThemeMode(resolvedTheme === "dark" ? "light" : "dark") setThemeMode(resolvedTheme === "dark" ? "light" : "dark")
}, [resolvedTheme, setThemeMode]) }, [resolvedTheme, setThemeMode])
useHotkey("Mod+,", toggleDialog, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: "界面外观",
description: "打开主题与布局偏好",
},
})
useHotkey("Mod+D", toggleTheme, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: "切换主题",
description: "在浅色与深色主题之间切换",
},
})
return ( return (
<Dialog handle={appearanceDialogHandle} onOpenChange={setIsOpen}> <Dialog handle={appearanceDialogHandle} onOpenChange={setIsOpen}>
<AppearanceHotkeys
onToggleDialog={toggleDialog}
onToggleTheme={toggleTheme}
/>
<NavigationActionTarget <NavigationActionTarget
action={navigationActions.appearance} action={navigationActions.appearance}
onInvoke={() => appearanceDialogHandle.open(null)} onInvoke={() => appearanceDialogHandle.open(null)}
/> />
<DialogContent className="sm:max-w-max"> <DialogContent className="sm:max-w-max">
<DialogHeader> <AppearanceDialogHeader />
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="-mx-6 max-h-[75vh] space-y-10 overflow-y-auto px-6"> <div className="-mx-6 max-h-[75vh] space-y-10 overflow-y-auto px-6">
<div className="flex flex-col gap-6 lg:flex-row"> <div className="flex flex-col gap-6 lg:flex-row">
<ModePreference /> <ModePreference />
@@ -73,13 +110,8 @@ export function AppearanceController() {
<ThemeConfigPanel <ThemeConfigPanel
scheme="light" scheme="light"
active={resolvedTheme === "light"} active={resolvedTheme === "light"}
description="当系统设置为浅色模式时,将使用此主题"
/>
<ThemeConfigPanel
scheme="dark"
active={resolvedTheme === "dark"}
description="当系统设置为深色模式时,将使用此主题"
/> />
<ThemeConfigPanel scheme="dark" active={resolvedTheme === "dark"} />
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
@@ -0,0 +1,35 @@
import { describe, it } from "vitest"
import { expectCompleteCatalogs } from "../../../i18n/test-catalogs"
import { appearanceMessages, themeColorMessages } from "../messages"
import { appearanceCatalogLocales } 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("appearance locale catalogs", () => {
it("ship every appearance message in every built-in locale", () => {
expectCompleteCatalogs({
catalogs: {
de,
en,
es,
fr,
ja,
ko,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
},
locales: appearanceCatalogLocales,
messageIds: [
...Object.values(appearanceMessages),
...Object.values(themeColorMessages),
].map((descriptor) => descriptor.id),
})
})
})
@@ -0,0 +1,12 @@
import type { appearanceMessages, themeColorMessages } from "../messages"
import {
blockCatalogLocales,
type BlockCatalogLocale,
type BlockMessageCatalog,
} from "../../../i18n/catalogs"
export { blockCatalogLocales as appearanceCatalogLocales }
export type AppearanceCatalogLocale = BlockCatalogLocale
export type AppearanceMessageCatalog = BlockMessageCatalog<
typeof appearanceMessages & typeof themeColorMessages
>
@@ -0,0 +1,60 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "de"
export const languageTag = "de-DE"
export const messages = {
"blocks.appearance.active": "Aktiv",
"blocks.appearance.baseColor": "Basisfarbe",
"blocks.appearance.color.amber": "Bernstein",
"blocks.appearance.color.blue": "Blau",
"blocks.appearance.color.cyan": "Cyan",
"blocks.appearance.color.emerald": "Smaragd",
"blocks.appearance.color.fuchsia": "Fuchsia",
"blocks.appearance.color.green": "Grün",
"blocks.appearance.color.indigo": "Indigo",
"blocks.appearance.color.lime": "Limette",
"blocks.appearance.color.mauve": "Malve",
"blocks.appearance.color.mist": "Nebel",
"blocks.appearance.color.neutral": "Neutral",
"blocks.appearance.color.olive": "Oliv",
"blocks.appearance.color.orange": "Orange",
"blocks.appearance.color.pink": "Rosa",
"blocks.appearance.color.purple": "Lila",
"blocks.appearance.color.red": "Rot",
"blocks.appearance.color.rose": "Rose",
"blocks.appearance.color.sky": "Himmelblau",
"blocks.appearance.color.stone": "Stein",
"blocks.appearance.color.taupe": "Taupe",
"blocks.appearance.color.teal": "Blaugrün",
"blocks.appearance.color.violet": "Violett",
"blocks.appearance.color.yellow": "Gelb",
"blocks.appearance.color.zinc": "Zink",
"blocks.appearance.command.description":
"Design- und Layout-Einstellungen öffnen",
"blocks.appearance.compact.description":
"Eine kompakte Breite für den Hauptinhalt verwenden",
"blocks.appearance.compact.title": "Kompaktes Layout",
"blocks.appearance.description": "Design- und Layout-Einstellungen",
"blocks.appearance.mode.dark": "Dunkel",
"blocks.appearance.mode.dark.description":
"Die Oberfläche verwendet immer das dunkle Design",
"blocks.appearance.mode.light": "Hell",
"blocks.appearance.mode.light.description":
"Die Oberfläche verwendet immer das helle Design",
"blocks.appearance.mode.system": "System",
"blocks.appearance.mode.system.description":
"Das Design der Oberfläche folgt der Systemeinstellung",
"blocks.appearance.mode.title": "Darstellungsmodus",
"blocks.appearance.primaryColor": "Primärfarbe",
"blocks.appearance.theme.dark": "Dunkles Design",
"blocks.appearance.theme.dark.description":
"Dieses Design wird im dunklen Systemmodus verwendet",
"blocks.appearance.theme.light": "Helles Design",
"blocks.appearance.theme.light.description":
"Dieses Design wird im hellen Systemmodus verwendet",
"blocks.appearance.title": "Darstellung",
"blocks.appearance.toggleTheme": "Design wechseln",
"blocks.appearance.toggleTheme.description":
"Zwischen hellem und dunklem Design wechseln",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,25 @@
import type { MessageDescriptor } from "@workspace/i18n"
import { appearanceMessages, themeColorMessages } from "../messages.ts"
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "en"
export const languageTag = "en-US"
function createSourceCatalog(
...groups: readonly Record<string, MessageDescriptor>[]
): AppearanceMessageCatalog {
return Object.fromEntries(
groups.flatMap((group) =>
Object.values(group).map((descriptor) => [
descriptor.id,
descriptor.message,
])
)
) as AppearanceMessageCatalog
}
export const messages = createSourceCatalog(
appearanceMessages,
themeColorMessages
) satisfies AppearanceMessageCatalog
@@ -0,0 +1,60 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "es"
export const languageTag = "es-ES"
export const messages = {
"blocks.appearance.active": "Activo",
"blocks.appearance.baseColor": "Color base",
"blocks.appearance.color.amber": "Ámbar",
"blocks.appearance.color.blue": "Azul",
"blocks.appearance.color.cyan": "Cian",
"blocks.appearance.color.emerald": "Esmeralda",
"blocks.appearance.color.fuchsia": "Fucsia",
"blocks.appearance.color.green": "Verde",
"blocks.appearance.color.indigo": "Índigo",
"blocks.appearance.color.lime": "Lima",
"blocks.appearance.color.mauve": "Malva",
"blocks.appearance.color.mist": "Niebla",
"blocks.appearance.color.neutral": "Neutro",
"blocks.appearance.color.olive": "Oliva",
"blocks.appearance.color.orange": "Naranja",
"blocks.appearance.color.pink": "Rosa",
"blocks.appearance.color.purple": "Morado",
"blocks.appearance.color.red": "Rojo",
"blocks.appearance.color.rose": "Rosa intenso",
"blocks.appearance.color.sky": "Celeste",
"blocks.appearance.color.stone": "Piedra",
"blocks.appearance.color.taupe": "Topo",
"blocks.appearance.color.teal": "Verde azulado",
"blocks.appearance.color.violet": "Violeta",
"blocks.appearance.color.yellow": "Amarillo",
"blocks.appearance.color.zinc": "Zinc",
"blocks.appearance.command.description":
"Abrir las preferencias de tema y diseño",
"blocks.appearance.compact.description":
"Usar un ancho compacto para el contenido principal",
"blocks.appearance.compact.title": "Diseño compacto",
"blocks.appearance.description": "Preferencias de tema y diseño",
"blocks.appearance.mode.dark": "Oscuro",
"blocks.appearance.mode.dark.description":
"La interfaz siempre usará el tema oscuro",
"blocks.appearance.mode.light": "Claro",
"blocks.appearance.mode.light.description":
"La interfaz siempre usará el tema claro",
"blocks.appearance.mode.system": "Sistema",
"blocks.appearance.mode.system.description":
"El tema de la interfaz seguirá la apariencia del sistema",
"blocks.appearance.mode.title": "Modo de tema",
"blocks.appearance.primaryColor": "Color principal",
"blocks.appearance.theme.dark": "Tema oscuro",
"blocks.appearance.theme.dark.description":
"Este tema se usa cuando el sistema está en modo oscuro",
"blocks.appearance.theme.light": "Tema claro",
"blocks.appearance.theme.light.description":
"Este tema se usa cuando el sistema está en modo claro",
"blocks.appearance.title": "Apariencia",
"blocks.appearance.toggleTheme": "Cambiar tema",
"blocks.appearance.toggleTheme.description":
"Cambiar entre los temas claro y oscuro",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,60 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "fr"
export const languageTag = "fr-FR"
export const messages = {
"blocks.appearance.active": "Actif",
"blocks.appearance.baseColor": "Couleur de base",
"blocks.appearance.color.amber": "Ambre",
"blocks.appearance.color.blue": "Bleu",
"blocks.appearance.color.cyan": "Cyan",
"blocks.appearance.color.emerald": "Émeraude",
"blocks.appearance.color.fuchsia": "Fuchsia",
"blocks.appearance.color.green": "Vert",
"blocks.appearance.color.indigo": "Indigo",
"blocks.appearance.color.lime": "Citron vert",
"blocks.appearance.color.mauve": "Mauve",
"blocks.appearance.color.mist": "Brume",
"blocks.appearance.color.neutral": "Neutre",
"blocks.appearance.color.olive": "Olive",
"blocks.appearance.color.orange": "Orange",
"blocks.appearance.color.pink": "Rose",
"blocks.appearance.color.purple": "Pourpre",
"blocks.appearance.color.red": "Rouge",
"blocks.appearance.color.rose": "Rose soutenu",
"blocks.appearance.color.sky": "Bleu ciel",
"blocks.appearance.color.stone": "Pierre",
"blocks.appearance.color.taupe": "Taupe",
"blocks.appearance.color.teal": "Sarcelle",
"blocks.appearance.color.violet": "Violet",
"blocks.appearance.color.yellow": "Jaune",
"blocks.appearance.color.zinc": "Zinc",
"blocks.appearance.command.description":
"Ouvrir les préférences de thème et de mise en page",
"blocks.appearance.compact.description":
"Utiliser une largeur compacte pour le contenu principal",
"blocks.appearance.compact.title": "Mise en page compacte",
"blocks.appearance.description": "Préférences de thème et de mise en page",
"blocks.appearance.mode.dark": "Sombre",
"blocks.appearance.mode.dark.description":
"Linterface utilisera toujours le thème sombre",
"blocks.appearance.mode.light": "Clair",
"blocks.appearance.mode.light.description":
"Linterface utilisera toujours le thème clair",
"blocks.appearance.mode.system": "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.primaryColor": "Couleur principale",
"blocks.appearance.theme.dark": "Thème sombre",
"blocks.appearance.theme.dark.description":
"Ce thème est utilisé lorsque le système est en mode sombre",
"blocks.appearance.theme.light": "Thème clair",
"blocks.appearance.theme.light.description":
"Ce thème est utilisé lorsque le système est en mode clair",
"blocks.appearance.title": "Apparence",
"blocks.appearance.toggleTheme": "Changer de thème",
"blocks.appearance.toggleTheme.description":
"Basculer entre les thèmes clair et sombre",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,57 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "ja"
export const languageTag = "ja-JP"
export const messages = {
"blocks.appearance.active": "使用中",
"blocks.appearance.baseColor": "ベースカラー",
"blocks.appearance.color.amber": "アンバー",
"blocks.appearance.color.blue": "ブルー",
"blocks.appearance.color.cyan": "シアン",
"blocks.appearance.color.emerald": "エメラルド",
"blocks.appearance.color.fuchsia": "フクシア",
"blocks.appearance.color.green": "グリーン",
"blocks.appearance.color.indigo": "インディゴ",
"blocks.appearance.color.lime": "ライム",
"blocks.appearance.color.mauve": "モーブ",
"blocks.appearance.color.mist": "ミスト",
"blocks.appearance.color.neutral": "ニュートラル",
"blocks.appearance.color.olive": "オリーブ",
"blocks.appearance.color.orange": "オレンジ",
"blocks.appearance.color.pink": "ピンク",
"blocks.appearance.color.purple": "パープル",
"blocks.appearance.color.red": "レッド",
"blocks.appearance.color.rose": "ローズ",
"blocks.appearance.color.sky": "スカイ",
"blocks.appearance.color.stone": "ストーン",
"blocks.appearance.color.taupe": "トープ",
"blocks.appearance.color.teal": "ティール",
"blocks.appearance.color.violet": "バイオレット",
"blocks.appearance.color.yellow": "イエロー",
"blocks.appearance.color.zinc": "ジンク",
"blocks.appearance.command.description": "テーマとレイアウト設定を開く",
"blocks.appearance.compact.description":
"メインページのコンテンツをコンパクトな幅で表示します",
"blocks.appearance.compact.title": "コンパクトレイアウト",
"blocks.appearance.description": "テーマとレイアウトの設定",
"blocks.appearance.mode.dark": "ダーク",
"blocks.appearance.mode.dark.description": "常にダークテーマを使用します",
"blocks.appearance.mode.light": "ライト",
"blocks.appearance.mode.light.description": "常にライトテーマを使用します",
"blocks.appearance.mode.system": "システム",
"blocks.appearance.mode.system.description":
"インターフェースのテーマをシステムの外観に合わせます",
"blocks.appearance.mode.title": "テーマモード",
"blocks.appearance.primaryColor": "プライマリカラー",
"blocks.appearance.theme.dark": "ダークテーマ",
"blocks.appearance.theme.dark.description":
"システムがダークモードのときに使用するテーマです",
"blocks.appearance.theme.light": "ライトテーマ",
"blocks.appearance.theme.light.description":
"システムがライトモードのときに使用するテーマです",
"blocks.appearance.title": "外観",
"blocks.appearance.toggleTheme": "テーマを切り替える",
"blocks.appearance.toggleTheme.description":
"ライトテーマとダークテーマを切り替えます",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,59 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "ko"
export const languageTag = "ko-KR"
export const messages = {
"blocks.appearance.active": "사용 중",
"blocks.appearance.baseColor": "기본 색상",
"blocks.appearance.color.amber": "호박색",
"blocks.appearance.color.blue": "파란색",
"blocks.appearance.color.cyan": "청록색",
"blocks.appearance.color.emerald": "에메랄드",
"blocks.appearance.color.fuchsia": "자홍색",
"blocks.appearance.color.green": "초록색",
"blocks.appearance.color.indigo": "남색",
"blocks.appearance.color.lime": "라임",
"blocks.appearance.color.mauve": "연보라",
"blocks.appearance.color.mist": "안개색",
"blocks.appearance.color.neutral": "중성색",
"blocks.appearance.color.olive": "올리브",
"blocks.appearance.color.orange": "주황색",
"blocks.appearance.color.pink": "분홍색",
"blocks.appearance.color.purple": "보라색",
"blocks.appearance.color.red": "빨간색",
"blocks.appearance.color.rose": "장미색",
"blocks.appearance.color.sky": "하늘색",
"blocks.appearance.color.stone": "돌색",
"blocks.appearance.color.taupe": "회갈색",
"blocks.appearance.color.teal": "틸",
"blocks.appearance.color.violet": "제비꽃색",
"blocks.appearance.color.yellow": "노란색",
"blocks.appearance.color.zinc": "아연색",
"blocks.appearance.command.description": "테마 및 레이아웃 환경설정 열기",
"blocks.appearance.compact.description":
"기본 페이지 콘텐츠에 좁은 너비를 사용합니다",
"blocks.appearance.compact.title": "컴팩트 레이아웃",
"blocks.appearance.description": "테마 및 레이아웃 환경설정",
"blocks.appearance.mode.dark": "어둡게",
"blocks.appearance.mode.dark.description":
"인터페이스에서 항상 어두운 테마를 사용합니다",
"blocks.appearance.mode.light": "밝게",
"blocks.appearance.mode.light.description":
"인터페이스에서 항상 밝은 테마를 사용합니다",
"blocks.appearance.mode.system": "시스템",
"blocks.appearance.mode.system.description":
"인터페이스 테마가 시스템 화면 모드를 따릅니다",
"blocks.appearance.mode.title": "테마 모드",
"blocks.appearance.primaryColor": "주요 색상",
"blocks.appearance.theme.dark": "어두운 테마",
"blocks.appearance.theme.dark.description":
"시스템이 어두운 모드일 때 사용하는 테마입니다",
"blocks.appearance.theme.light": "밝은 테마",
"blocks.appearance.theme.light.description":
"시스템이 밝은 모드일 때 사용하는 테마입니다",
"blocks.appearance.title": "화면 모양",
"blocks.appearance.toggleTheme": "테마 전환",
"blocks.appearance.toggleTheme.description":
"밝은 테마와 어두운 테마 사이를 전환합니다",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,54 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"blocks.appearance.active": "使用中",
"blocks.appearance.baseColor": "基础颜色",
"blocks.appearance.color.amber": "琥珀色",
"blocks.appearance.color.blue": "蓝色",
"blocks.appearance.color.cyan": "青色",
"blocks.appearance.color.emerald": "翠绿色",
"blocks.appearance.color.fuchsia": "紫红色",
"blocks.appearance.color.green": "绿色",
"blocks.appearance.color.indigo": "靛蓝色",
"blocks.appearance.color.lime": "青柠色",
"blocks.appearance.color.mauve": "紫灰",
"blocks.appearance.color.mist": "雾灰",
"blocks.appearance.color.neutral": "中性灰",
"blocks.appearance.color.olive": "橄榄灰",
"blocks.appearance.color.orange": "橙色",
"blocks.appearance.color.pink": "粉色",
"blocks.appearance.color.purple": "紫色",
"blocks.appearance.color.red": "红色",
"blocks.appearance.color.rose": "玫红色",
"blocks.appearance.color.sky": "天蓝色",
"blocks.appearance.color.stone": "石灰",
"blocks.appearance.color.taupe": "灰褐",
"blocks.appearance.color.teal": "蓝绿色",
"blocks.appearance.color.violet": "紫罗兰色",
"blocks.appearance.color.yellow": "黄色",
"blocks.appearance.color.zinc": "锌灰",
"blocks.appearance.command.description": "打开主题与布局偏好",
"blocks.appearance.compact.description": "启用后,页面主内容将使用紧凑宽度",
"blocks.appearance.compact.title": "紧凑布局",
"blocks.appearance.description": "主题与布局偏好",
"blocks.appearance.mode.dark": "深色",
"blocks.appearance.mode.dark.description": "界面将始终使用深色主题",
"blocks.appearance.mode.light": "浅色",
"blocks.appearance.mode.light.description": "界面将始终使用浅色主题",
"blocks.appearance.mode.system": "系统",
"blocks.appearance.mode.system.description": "界面主题将跟随系统外观设置",
"blocks.appearance.mode.title": "主题模式",
"blocks.appearance.primaryColor": "主要颜色",
"blocks.appearance.theme.dark": "深色主题",
"blocks.appearance.theme.dark.description":
"当系统设置为深色模式时,将使用此主题",
"blocks.appearance.theme.light": "浅色主题",
"blocks.appearance.theme.light.description":
"当系统设置为浅色模式时,将使用此主题",
"blocks.appearance.title": "界面外观",
"blocks.appearance.toggleTheme": "切换主题",
"blocks.appearance.toggleTheme.description": "在浅色与深色主题之间切换",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,52 @@
import type { AppearanceMessageCatalog } from "./catalogs"
export const locale = "zh-Hant"
export const languageTag = "zh-TW"
export const messages = {
"blocks.appearance.active": "使用中",
"blocks.appearance.baseColor": "基礎顏色",
"blocks.appearance.color.amber": "琥珀色",
"blocks.appearance.color.blue": "藍色",
"blocks.appearance.color.cyan": "青色",
"blocks.appearance.color.emerald": "翠綠色",
"blocks.appearance.color.fuchsia": "紫紅色",
"blocks.appearance.color.green": "綠色",
"blocks.appearance.color.indigo": "靛藍色",
"blocks.appearance.color.lime": "萊姆色",
"blocks.appearance.color.mauve": "淡紫色",
"blocks.appearance.color.mist": "霧灰色",
"blocks.appearance.color.neutral": "中性灰",
"blocks.appearance.color.olive": "橄欖色",
"blocks.appearance.color.orange": "橙色",
"blocks.appearance.color.pink": "粉紅色",
"blocks.appearance.color.purple": "紫色",
"blocks.appearance.color.red": "紅色",
"blocks.appearance.color.rose": "玫瑰色",
"blocks.appearance.color.sky": "天藍色",
"blocks.appearance.color.stone": "石灰色",
"blocks.appearance.color.taupe": "灰褐色",
"blocks.appearance.color.teal": "藍綠色",
"blocks.appearance.color.violet": "紫羅蘭色",
"blocks.appearance.color.yellow": "黃色",
"blocks.appearance.color.zinc": "鋅灰色",
"blocks.appearance.command.description": "開啟主題與版面配置偏好",
"blocks.appearance.compact.description": "使用較緊湊的主頁內容寬度",
"blocks.appearance.compact.title": "緊湊版面配置",
"blocks.appearance.description": "主題與版面配置偏好",
"blocks.appearance.mode.dark": "深色",
"blocks.appearance.mode.dark.description": "介面將一律使用深色主題",
"blocks.appearance.mode.light": "淺色",
"blocks.appearance.mode.light.description": "介面將一律使用淺色主題",
"blocks.appearance.mode.system": "系統",
"blocks.appearance.mode.system.description": "介面主題將跟隨系統外觀設定",
"blocks.appearance.mode.title": "主題模式",
"blocks.appearance.primaryColor": "主要顏色",
"blocks.appearance.theme.dark": "深色主題",
"blocks.appearance.theme.dark.description": "系統使用深色模式時套用此主題",
"blocks.appearance.theme.light": "淺色主題",
"blocks.appearance.theme.light.description": "系統使用淺色模式時套用此主題",
"blocks.appearance.title": "介面外觀",
"blocks.appearance.toggleTheme": "切換主題",
"blocks.appearance.toggleTheme.description": "在淺色與深色主題之間切換",
} as const satisfies AppearanceMessageCatalog
@@ -0,0 +1,187 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const appearanceMessages = {
active: /* i18n */ {
id: "blocks.appearance.active",
message: "Active",
},
appearance: /* i18n */ {
id: "blocks.appearance.title",
message: "Appearance",
},
appearanceCommandDescription: /* i18n */ {
id: "blocks.appearance.command.description",
message: "Open theme and layout preferences",
},
baseColor: /* i18n */ {
id: "blocks.appearance.baseColor",
message: "Base color",
},
compactDescription: /* i18n */ {
id: "blocks.appearance.compact.description",
message: "Use a compact width for the main page content",
},
compactLayout: /* i18n */ {
id: "blocks.appearance.compact.title",
message: "Compact layout",
},
darkMode: /* i18n */ {
id: "blocks.appearance.mode.dark",
message: "Dark",
},
darkModeDescription: /* i18n */ {
id: "blocks.appearance.mode.dark.description",
message: "The interface will always use the dark theme",
},
darkTheme: /* i18n */ {
id: "blocks.appearance.theme.dark",
message: "Dark theme",
},
darkThemeDescription: /* i18n */ {
id: "blocks.appearance.theme.dark.description",
message: "This theme is used when the system is in dark mode",
},
followSystem: /* i18n */ {
id: "blocks.appearance.mode.system",
message: "System",
},
followSystemDescription: /* i18n */ {
id: "blocks.appearance.mode.system.description",
message: "The interface theme will follow the system appearance",
},
lightMode: /* i18n */ {
id: "blocks.appearance.mode.light",
message: "Light",
},
lightModeDescription: /* i18n */ {
id: "blocks.appearance.mode.light.description",
message: "The interface will always use the light theme",
},
lightTheme: /* i18n */ {
id: "blocks.appearance.theme.light",
message: "Light theme",
},
lightThemeDescription: /* i18n */ {
id: "blocks.appearance.theme.light.description",
message: "This theme is used when the system is in light mode",
},
primaryColor: /* i18n */ {
id: "blocks.appearance.primaryColor",
message: "Primary color",
},
themeAndLayoutPreferences: /* i18n */ {
id: "blocks.appearance.description",
message: "Theme and layout preferences",
},
themeMode: /* i18n */ {
id: "blocks.appearance.mode.title",
message: "Theme mode",
},
toggleTheme: /* i18n */ {
id: "blocks.appearance.toggleTheme",
message: "Toggle theme",
},
toggleThemeDescription: /* i18n */ {
id: "blocks.appearance.toggleTheme.description",
message: "Switch between light and dark themes",
},
} as const satisfies Record<string, MessageDescriptor>
export const themeColorMessages = {
amber: /* i18n */ {
id: "blocks.appearance.color.amber",
message: "Amber",
},
blue: /* i18n */ {
id: "blocks.appearance.color.blue",
message: "Blue",
},
cyan: /* i18n */ {
id: "blocks.appearance.color.cyan",
message: "Cyan",
},
emerald: /* i18n */ {
id: "blocks.appearance.color.emerald",
message: "Emerald",
},
fuchsia: /* i18n */ {
id: "blocks.appearance.color.fuchsia",
message: "Fuchsia",
},
green: /* i18n */ {
id: "blocks.appearance.color.green",
message: "Green",
},
indigo: /* i18n */ {
id: "blocks.appearance.color.indigo",
message: "Indigo",
},
lime: /* i18n */ {
id: "blocks.appearance.color.lime",
message: "Lime",
},
mauve: /* i18n */ {
id: "blocks.appearance.color.mauve",
message: "Mauve",
},
mist: /* i18n */ {
id: "blocks.appearance.color.mist",
message: "Mist",
},
neutral: /* i18n */ {
id: "blocks.appearance.color.neutral",
message: "Neutral",
},
olive: /* i18n */ {
id: "blocks.appearance.color.olive",
message: "Olive",
},
orange: /* i18n */ {
id: "blocks.appearance.color.orange",
message: "Orange",
},
pink: /* i18n */ {
id: "blocks.appearance.color.pink",
message: "Pink",
},
purple: /* i18n */ {
id: "blocks.appearance.color.purple",
message: "Purple",
},
red: /* i18n */ {
id: "blocks.appearance.color.red",
message: "Red",
},
rose: /* i18n */ {
id: "blocks.appearance.color.rose",
message: "Rose",
},
sky: /* i18n */ {
id: "blocks.appearance.color.sky",
message: "Sky",
},
stone: /* i18n */ {
id: "blocks.appearance.color.stone",
message: "Stone",
},
taupe: /* i18n */ {
id: "blocks.appearance.color.taupe",
message: "Taupe",
},
teal: /* i18n */ {
id: "blocks.appearance.color.teal",
message: "Teal",
},
violet: /* i18n */ {
id: "blocks.appearance.color.violet",
message: "Violet",
},
yellow: /* i18n */ {
id: "blocks.appearance.color.yellow",
message: "Yellow",
},
zinc: /* i18n */ {
id: "blocks.appearance.color.zinc",
message: "Zinc",
},
} as const satisfies Record<string, MessageDescriptor>
@@ -1,4 +1,5 @@
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react" import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"
import { LocalizedText, useMessage } from "@workspace/i18n"
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -10,12 +11,7 @@ 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 { appearanceMessages } from "./messages"
const modeItems: ReadonlyArray<{ label: string; value: ThemeMode }> = [
{ label: "使用浅色", value: "light" },
{ label: "使用深色", value: "dark" },
{ label: "跟随系统", value: "system" },
]
function ModeIcon({ mode }: { mode: ThemeMode | null }) { function ModeIcon({ mode }: { mode: ThemeMode | null }) {
if (mode === "light") return <SunIcon /> if (mode === "light") return <SunIcon />
@@ -25,15 +21,29 @@ function ModeIcon({ mode }: { mode: ThemeMode | null }) {
export function ModePreference() { export function ModePreference() {
const [themeMode, setThemeMode] = useUiState("theme-mode") const [themeMode, setThemeMode] = useUiState("theme-mode")
const lightLabel = useMessage(appearanceMessages.lightMode)
const darkLabel = useMessage(appearanceMessages.darkMode)
const systemLabel = useMessage(appearanceMessages.followSystem)
const lightDescription = useMessage(appearanceMessages.lightModeDescription)
const darkDescription = useMessage(appearanceMessages.darkModeDescription)
const systemDescription = useMessage(
appearanceMessages.followSystemDescription
)
const title = useMessage(appearanceMessages.themeMode)
const modeItems: ReadonlyArray<{ label: string; value: ThemeMode }> = [
{ label: lightLabel, value: "light" },
{ label: darkLabel, value: "dark" },
{ label: systemLabel, value: "system" },
]
const description = { const description = {
light: "界面将始终使用浅色主题", light: lightDescription,
dark: "界面将始终使用深色主题", dark: darkDescription,
system: "界面主题将跟随系统外观设置", system: systemDescription,
}[themeMode] }[themeMode]
return ( return (
<div className="lg:flex-1"> <div className="lg:flex-1">
<h2 className="font-semibold"></h2> <h2 className="font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p> <p className="mt-1 text-sm text-muted-foreground">{description}</p>
<Select <Select
items={modeItems} items={modeItems}
@@ -47,9 +57,7 @@ export function ModePreference() {
{(value: ThemeMode | null) => ( {(value: ThemeMode | null) => (
<> <>
<ModeIcon mode={value} /> <ModeIcon mode={value} />
{modeItems {modeItems.find((item) => item.value === value)?.label}
.find((item) => item.value === value)
?.label.slice(-2)}
</> </>
)} )}
</SelectValue> </SelectValue>
@@ -72,9 +80,11 @@ export function CompactPreference() {
return ( return (
<div className="lg:flex-1"> <div className="lg:flex-1">
<h2 className="font-semibold"></h2> <h2 className="font-semibold">
<LocalizedText message={appearanceMessages.compactLayout} />
</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
使 <LocalizedText message={appearanceMessages.compactDescription} />
</p> </p>
<Switch <Switch
className="mt-2" className="mt-2"
@@ -1,5 +1,7 @@
import type { CSSProperties } from "react" import type { CSSProperties, ReactNode } from "react"
import { useMessage } from "@workspace/i18n"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { themeColorMessages } from "./messages"
import { import {
accentColors, accentColors,
baseColors, baseColors,
@@ -12,7 +14,6 @@ type ThemeColor = BaseColor | AccentColor
type ThemeColorSwatch<T extends ThemeColor> = { type ThemeColorSwatch<T extends ThemeColor> = {
name: T name: T
title: string
light: { light: {
background: string background: string
foreground: string foreground: string
@@ -31,7 +32,6 @@ function createSwatches<T extends ThemeColor>(
return { return {
name, name,
title: theme.title,
light: { light: {
background: theme.cssVars.light.primary, background: theme.cssVars.light.primary,
foreground: theme.cssVars.light["primary-foreground"], foreground: theme.cssVars.light["primary-foreground"],
@@ -54,7 +54,7 @@ export function ThemeColorPicker<T extends ThemeColor>({
onChange, onChange,
}: { }: {
swatches: readonly ThemeColorSwatch<T>[] swatches: readonly ThemeColorSwatch<T>[]
title: string title: ReactNode
value: T value: T
onChange: (color: T) => void onChange: (color: T) => void
}) { }) {
@@ -86,6 +86,7 @@ function ThemeColorButton({
active: boolean active: boolean
onClick: () => void onClick: () => void
}) { }) {
const swatchTitle = useMessage(themeColorMessages[swatch.name])
const style = { const style = {
"--light-bg": swatch.light.background, "--light-bg": swatch.light.background,
"--light-fg": swatch.light.foreground, "--light-fg": swatch.light.foreground,
@@ -96,7 +97,7 @@ function ThemeColorButton({
return ( return (
<button <button
type="button" type="button"
aria-label={swatch.title} aria-label={swatchTitle}
aria-pressed={active} aria-pressed={active}
className={cn( className={cn(
"h-8 min-w-8 appearance-none truncate overflow-hidden rounded-4xl border-2 px-2 text-xs whitespace-nowrap ring-popover ring-inset", "h-8 min-w-8 appearance-none truncate overflow-hidden rounded-4xl border-2 px-2 text-xs whitespace-nowrap ring-popover ring-inset",
@@ -108,7 +109,7 @@ function ThemeColorButton({
style={style} style={style}
onClick={onClick} onClick={onClick}
> >
{swatch.title} {swatchTitle}
</button> </button>
) )
} }
@@ -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,18 +9,25 @@ 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 { appearanceMessages } from "./messages"
export function ThemeConfigPanel({ export function ThemeConfigPanel({
scheme, scheme,
active, active,
description,
}: { }: {
scheme: ThemeScheme scheme: ThemeScheme
active: boolean active: boolean
description: string
}) { }) {
const stateKey = scheme === "light" ? "theme-light" : "theme-dark" const stateKey = scheme === "light" ? "theme-light" : "theme-dark"
const [config, setConfig] = useUiState(stateKey) const [config, setConfig] = useUiState(stateKey)
const titleMessage =
scheme === "light"
? appearanceMessages.lightTheme
: appearanceMessages.darkTheme
const descriptionMessage =
scheme === "light"
? appearanceMessages.lightThemeDescription
: appearanceMessages.darkThemeDescription
return ( return (
<div className="w-md rounded-xl border"> <div className="w-md rounded-xl border">
@@ -29,15 +37,23 @@ export function ThemeConfigPanel({
) : ( ) : (
<MoonIcon className="size-5" /> <MoonIcon className="size-5" />
)} )}
<span>{scheme === "light" ? "浅色主题" : "深色主题"}</span> <span>
{active && <Badge className="ml-auto">使</Badge>} <LocalizedText message={titleMessage} />
</span>
{active && (
<Badge className="ml-auto">
<LocalizedText message={appearanceMessages.active} />
</Badge>
)}
</div> </div>
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
<p>{description}</p> <p>
<LocalizedText message={descriptionMessage} />
</p>
<ThemePreview scheme={scheme} config={config} /> <ThemePreview scheme={scheme} config={config} />
<ThemeColorPicker <ThemeColorPicker
swatches={baseColorSwatches} swatches={baseColorSwatches}
title="基础颜色" title={<LocalizedText message={appearanceMessages.baseColor} />}
value={config.baseColor} value={config.baseColor}
onChange={(baseColor) => onChange={(baseColor) =>
setConfig((current) => ({ ...current, baseColor })) setConfig((current) => ({ ...current, baseColor }))
@@ -45,7 +61,7 @@ export function ThemeConfigPanel({
/> />
<ThemeColorPicker <ThemeColorPicker
swatches={accentColorSwatches} swatches={accentColorSwatches}
title="主要颜色" title={<LocalizedText message={appearanceMessages.primaryColor} />}
value={config.accentColor} value={config.accentColor}
onChange={(accentColor) => onChange={(accentColor) =>
setConfig((current) => ({ ...current, accentColor })) setConfig((current) => ({ ...current, accentColor }))
@@ -14,6 +14,7 @@ describe("appearance themes", () => {
expect(themes.map((theme) => theme.name)).toEqual(selectableColors) expect(themes.map((theme) => theme.name)).toEqual(selectableColors)
expect(new Set(selectableColors).size).toBe(selectableColors.length) expect(new Set(selectableColors).size).toBe(selectableColors.length)
expect(new Set(themes.map((theme) => theme.name)).size).toBe(themes.length)
expect(themes.every((theme) => theme.title.length > 0)).toBe(true) expect(themes.every((theme) => theme.title.length > 0)).toBe(true)
}) })
+31 -25
View File
@@ -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"
@@ -69,7 +75,7 @@ export type Theme = {
export const themes: Theme[] = [ export const themes: Theme[] = [
{ {
name: "neutral", name: "neutral",
title: "中性灰", title: "Neutral",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -142,7 +148,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "stone", name: "stone",
title: "石灰", title: "Stone",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -215,7 +221,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "zinc", name: "zinc",
title: "锌灰", title: "Zinc",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -288,7 +294,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "mauve", name: "mauve",
title: "紫灰", title: "Mauve",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -361,7 +367,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "olive", name: "olive",
title: "橄榄灰", title: "Olive",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -434,7 +440,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "mist", name: "mist",
title: "雾灰", title: "Mist",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -507,7 +513,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "taupe", name: "taupe",
title: "灰褐", title: "Taupe",
cssVars: { cssVars: {
light: { light: {
background: "oklch(1 0 0)", background: "oklch(1 0 0)",
@@ -580,7 +586,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "amber", name: "amber",
title: "琥珀色", title: "Amber",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.555 0.163 48.998)", primary: "oklch(0.555 0.163 48.998)",
@@ -612,7 +618,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "blue", name: "blue",
title: "蓝色", title: "Blue",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.488 0.243 264.376)", primary: "oklch(0.488 0.243 264.376)",
@@ -644,7 +650,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "cyan", name: "cyan",
title: "青色", title: "Cyan",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.52 0.105 223.128)", primary: "oklch(0.52 0.105 223.128)",
@@ -676,7 +682,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "emerald", name: "emerald",
title: "翠绿色", title: "Emerald",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.508 0.118 165.612)", primary: "oklch(0.508 0.118 165.612)",
@@ -708,7 +714,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "fuchsia", name: "fuchsia",
title: "紫红色", title: "Fuchsia",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.518 0.253 323.949)", primary: "oklch(0.518 0.253 323.949)",
@@ -740,7 +746,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "green", name: "green",
title: "绿色", title: "Green",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.527 0.154 150.069)", primary: "oklch(0.527 0.154 150.069)",
@@ -772,7 +778,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "indigo", name: "indigo",
title: "靛蓝色", title: "Indigo",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.457 0.24 277.023)", primary: "oklch(0.457 0.24 277.023)",
@@ -804,7 +810,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "lime", name: "lime",
title: "青柠色", title: "Lime",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.841 0.238 128.85)", primary: "oklch(0.841 0.238 128.85)",
@@ -836,7 +842,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "orange", name: "orange",
title: "橙色", title: "Orange",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.553 0.195 38.402)", primary: "oklch(0.553 0.195 38.402)",
@@ -868,7 +874,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "pink", name: "pink",
title: "粉色", title: "Pink",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.525 0.223 3.958)", primary: "oklch(0.525 0.223 3.958)",
@@ -900,7 +906,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "purple", name: "purple",
title: "紫色", title: "Purple",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.496 0.265 301.924)", primary: "oklch(0.496 0.265 301.924)",
@@ -932,7 +938,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "red", name: "red",
title: "红色", title: "Red",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.505 0.213 27.518)", primary: "oklch(0.505 0.213 27.518)",
@@ -964,7 +970,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "rose", name: "rose",
title: "玫红色", title: "Rose",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.514 0.222 16.935)", primary: "oklch(0.514 0.222 16.935)",
@@ -997,7 +1003,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "sky", name: "sky",
title: "天蓝色", title: "Sky",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.5 0.134 242.749)", primary: "oklch(0.5 0.134 242.749)",
@@ -1029,7 +1035,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "teal", name: "teal",
title: "蓝绿色", title: "Teal",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.511 0.096 186.391)", primary: "oklch(0.511 0.096 186.391)",
@@ -1061,7 +1067,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "violet", name: "violet",
title: "紫罗兰色", title: "Violet",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.491 0.27 292.581)", primary: "oklch(0.491 0.27 292.581)",
@@ -1093,7 +1099,7 @@ export const themes: Theme[] = [
}, },
{ {
name: "yellow", name: "yellow",
title: "黄色", title: "Yellow",
cssVars: { cssVars: {
light: { light: {
primary: "oklch(0.852 0.199 91.936)", primary: "oklch(0.852 0.199 91.936)",
@@ -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,3 +1,4 @@
import { useMessage } from "@workspace/i18n"
import { ItemGroup } from "@workspace/ui/components/item" import { ItemGroup } from "@workspace/ui/components/item"
import { import {
Popover, Popover,
@@ -8,6 +9,7 @@ import {
} from "@workspace/ui/components/popover" } from "@workspace/ui/components/popover"
import { useOnScroll } from "../../hooks/use-on-scroll" import { useOnScroll } from "../../hooks/use-on-scroll"
import { chatMessages } from "./messages"
import { chatPopoverHandle } from "./chat-popover-handle" import { chatPopoverHandle } from "./chat-popover-handle"
import { ChatThreadItem } from "./chat-thread-item" import { ChatThreadItem } from "./chat-thread-item"
@@ -18,7 +20,9 @@ export interface ChatPopoverProps {
title?: string title?: string
} }
export function ChatPopover({ threads, title = "Chats" }: ChatPopoverProps) { export function ChatPopover({ threads, title }: ChatPopoverProps) {
const defaultTitle = useMessage(chatMessages.title)
useOnScroll(() => { useOnScroll(() => {
chatPopoverHandle.close() chatPopoverHandle.close()
}) })
@@ -28,7 +32,7 @@ export function ChatPopover({ threads, title = "Chats" }: ChatPopoverProps) {
<PopoverContent className="h-120 p-2" alignOffset={-50} showArrow> <PopoverContent className="h-120 p-2" alignOffset={-50} showArrow>
<PopoverHeader className="px-4 pt-4"> <PopoverHeader className="px-4 pt-4">
<PopoverTitle className="text-lg"> <PopoverTitle className="text-lg">
{title}({threads.length}) {title ?? defaultTitle}({threads.length})
</PopoverTitle> </PopoverTitle>
<PopoverDescription /> <PopoverDescription />
</PopoverHeader> </PopoverHeader>
@@ -0,0 +1,34 @@
import { describe, it } from "vitest"
import { expectCompleteCatalogs } from "../../../i18n/test-catalogs"
import { chatMessages } from "../messages"
import { chatCatalogLocales } 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("chat locale catalogs", () => {
it("ship every chat message in every built-in locale", () => {
expectCompleteCatalogs({
catalogs: {
de,
en,
es,
fr,
ja,
ko,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
},
locales: chatCatalogLocales,
messageIds: Object.values(chatMessages).map(
(descriptor) => descriptor.id
),
})
})
})
@@ -0,0 +1,10 @@
import type { chatMessages } from "../messages"
import {
blockCatalogLocales,
type BlockCatalogLocale,
type BlockMessageCatalog,
} from "../../../i18n/catalogs"
export { blockCatalogLocales as chatCatalogLocales }
export type ChatCatalogLocale = BlockCatalogLocale
export type ChatMessageCatalog = BlockMessageCatalog<typeof chatMessages>
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "de"
export const languageTag = "de-DE"
export const messages = {
"blocks.chats.title": "Chats",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "en"
export const languageTag = "en-US"
export const messages = {
"blocks.chats.title": "Chats",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "es"
export const languageTag = "es-ES"
export const messages = {
"blocks.chats.title": "Chats",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "fr"
export const languageTag = "fr-FR"
export const messages = {
"blocks.chats.title": "Discussions",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "ja"
export const languageTag = "ja-JP"
export const messages = {
"blocks.chats.title": "チャット",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "ko"
export const languageTag = "ko-KR"
export const messages = {
"blocks.chats.title": "채팅",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"blocks.chats.title": "聊天",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,7 @@
import type { ChatMessageCatalog } from "./catalogs"
export const locale = "zh-Hant"
export const languageTag = "zh-TW"
export const messages = {
"blocks.chats.title": "聊天",
} as const satisfies ChatMessageCatalog
@@ -0,0 +1,8 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const chatMessages = {
title: /* i18n */ {
id: "blocks.chats.title",
message: "Chats",
},
} as const satisfies Record<string, MessageDescriptor>
@@ -1,5 +1,7 @@
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 { import {
Breadcrumb, Breadcrumb,
BreadcrumbEllipsis, BreadcrumbEllipsis,
@@ -21,8 +23,12 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu" } from "@workspace/ui/components/dropdown-menu"
import { type NavigationInfo, useNavigationStack } from "../navigation" import {
import { Icon } from "../../components/icon" NavigationLabel,
type NavigationInfo,
useNavigationStack,
} from "../navigation"
import { layoutMessages } from "./messages"
const MAX_VISIBLE_ENTRIES = 3 const MAX_VISIBLE_ENTRIES = 3
@@ -185,7 +191,7 @@ function BreadcrumbEntryContent({ entry }: { entry: BreadcrumbEntry }) {
return ( return (
<> <>
{entry.icon && <Icon data={entry.icon} className="size-4 shrink-0" />} {entry.icon && <Icon data={entry.icon} className="size-4 shrink-0" />}
{entry.label} <NavigationLabel label={entry.label} />
</> </>
) )
} }
@@ -303,11 +309,13 @@ function BreadcrumbRouteLink({
} }
function BreadcrumbMenu({ entries }: { entries: readonly BreadcrumbEntry[] }) { function BreadcrumbMenu({ entries }: { entries: readonly BreadcrumbEntry[] }) {
const menuLabel = useMessage(layoutMessages.breadcrumbMenuLabel)
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button aria-label="打开面包屑菜单" size="icon-sm" variant="ghost" /> <Button aria-label={menuLabel} size="icon-sm" variant="ghost" />
} }
> >
<BreadcrumbEllipsis /> <BreadcrumbEllipsis />
@@ -320,7 +328,7 @@ function BreadcrumbMenu({ entries }: { entries: readonly BreadcrumbEntry[] }) {
{entry.icon && ( {entry.icon && (
<Icon data={entry.icon} className="size-4 shrink-0" /> <Icon data={entry.icon} className="size-4 shrink-0" />
)} )}
{entry.label} <NavigationLabel label={entry.label} />
</> </>
) )
@@ -405,7 +413,9 @@ export function AppBreadcrumb() {
className="size-4 shrink-0" className="size-4 shrink-0"
/> />
)} )}
<span className="truncate">{item.entry.label}</span> <span className="truncate">
<NavigationLabel label={item.entry.label} />
</span>
</BreadcrumbPage> </BreadcrumbPage>
) : ( ) : (
<BreadcrumbRouteLink <BreadcrumbRouteLink
@@ -432,7 +442,7 @@ export function AppBreadcrumb() {
{entry.icon && ( {entry.icon && (
<Icon data={entry.icon} className="size-4 shrink-0" /> <Icon data={entry.icon} className="size-4 shrink-0" />
)} )}
{entry.label} <NavigationLabel label={entry.label} />
</span> </span>
))} ))}
<span data-breadcrumb-measure-menu> <span data-breadcrumb-measure-menu>
@@ -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,
@@ -38,6 +37,7 @@ import { AppQueryIndicator } from "./app-query-indicator"
export interface AppLayoutProps { export interface AppLayoutProps {
chatThreads: readonly ChatThread[] chatThreads: readonly ChatThread[]
children: React.ReactNode children: React.ReactNode
headerActions?: React.ReactNode
navigationGroups: readonly NavigationGroup[] navigationGroups: readonly NavigationGroup[]
notifications: UseNotificationsOptions notifications: UseNotificationsOptions
} }
@@ -45,6 +45,7 @@ export interface AppLayoutProps {
export function AppLayout({ export function AppLayout({
chatThreads, chatThreads,
children, children,
headerActions,
navigationGroups, navigationGroups,
notifications, notifications,
}: AppLayoutProps) { }: AppLayoutProps) {
@@ -55,7 +56,7 @@ export function AppLayout({
<NotificationCenterSheet {...notifications} /> <NotificationCenterSheet {...notifications} />
<ChatPopover threads={chatThreads} /> <ChatPopover threads={chatThreads} />
<div className="isolate min-h-svh w-svw"> <div className="isolate min-h-svh w-svw">
<AppHeader /> <AppHeader actions={headerActions} />
<div className="relative z-1 flex min-h-svh w-svw items-start"> <div className="relative z-1 flex min-h-svh w-svw items-start">
<AppHeaderBackground /> <AppHeaderBackground />
<AppSidebar groups={navigationGroups} /> <AppSidebar groups={navigationGroups} />
@@ -66,7 +67,7 @@ export function AppLayout({
) )
} }
function AppHeader() { function AppHeader({ actions }: { actions?: React.ReactNode }) {
return ( return (
<header className="sticky top-0 z-3 h-0"> <header className="sticky top-0 z-3 h-0">
<div className="flex h-(--ui-header-height) items-center"> <div className="flex h-(--ui-header-height) items-center">
@@ -157,6 +158,7 @@ function AppHeader() {
</div> </div>
<div className="ml-auto flex shrink-0 items-center gap-2"> <div className="ml-auto flex shrink-0 items-center gap-2">
<GlobalSearchTrigger /> <GlobalSearchTrigger />
{actions}
<NotificationCenterTrigger <NotificationCenterTrigger
render={<HeaderActionButton icon={BellIcon} showIndicator />} render={<HeaderActionButton icon={BellIcon} showIndicator />}
/> />
@@ -1,3 +1,5 @@
import { ReloadIcon } from "@workspace/icons"
import { useMessage } from "@workspace/i18n"
import { useRefresh } from "../../hooks/use-refresh" import { useRefresh } from "../../hooks/use-refresh"
import { import {
Tooltip, Tooltip,
@@ -5,11 +7,13 @@ 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"
export function AppQueryIndicator({ className }: { className?: string }) { export function AppQueryIndicator({ className }: { className?: string }) {
const { canRefresh, isRefreshing, refresh } = useRefresh() const { canRefresh, isRefreshing, refresh } = useRefresh()
const refreshLabel = useMessage(layoutMessages.refreshLabel)
const refreshDescription = useMessage(layoutMessages.refreshDescription)
if (isRefreshing) { if (isRefreshing) {
return ( return (
@@ -29,7 +33,7 @@ export function AppQueryIndicator({ className }: { className?: string }) {
<TooltipTrigger <TooltipTrigger
render={ render={
<HeaderActionButton <HeaderActionButton
aria-label="刷新当前页面数据" aria-label={refreshLabel}
className={cn("[&_svg]:size-4", className)} className={cn("[&_svg]:size-4", className)}
disabled={!canRefresh} disabled={!canRefresh}
icon={ReloadIcon} icon={ReloadIcon}
@@ -37,11 +41,7 @@ export function AppQueryIndicator({ className }: { className?: string }) {
/> />
} }
/> />
<TooltipContent showArrow> <TooltipContent showArrow>{refreshDescription}</TooltipContent>
<br />
</TooltipContent>
</Tooltip> </Tooltip>
) )
} }
@@ -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"
@@ -0,0 +1,34 @@
import { describe, it } from "vitest"
import { expectCompleteCatalogs } from "../../../i18n/test-catalogs"
import { layoutMessages } from "../messages"
import { layoutCatalogLocales } 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("layout locale catalogs", () => {
it("ship every layout message in every built-in locale", () => {
expectCompleteCatalogs({
catalogs: {
de,
en,
es,
fr,
ja,
ko,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
},
locales: layoutCatalogLocales,
messageIds: Object.values(layoutMessages).map(
(descriptor) => descriptor.id
),
})
})
})
@@ -0,0 +1,10 @@
import type { layoutMessages } from "../messages"
import {
blockCatalogLocales,
type BlockCatalogLocale,
type BlockMessageCatalog,
} from "../../../i18n/catalogs"
export { blockCatalogLocales as layoutCatalogLocales }
export type LayoutCatalogLocale = BlockCatalogLocale
export type LayoutMessageCatalog = BlockMessageCatalog<typeof layoutMessages>
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "de"
export const languageTag = "de-DE"
export const messages = {
"blocks.breadcrumb.menuLabel": "Breadcrumb-Menü öffnen",
"blocks.refresh.description": "Daten der aktuellen Seite aktualisieren",
"blocks.refresh.label": "Aktuelle Seitendaten aktualisieren",
"blocks.userMenu.accountPassword": "Kontopasswort",
"blocks.userMenu.appearance": "Darstellung",
"blocks.userMenu.keybindings": "Tastenkürzel",
"blocks.userMenu.logout": "Abmelden",
"blocks.userMenu.open": "Benutzermenü öffnen",
"blocks.userMenu.title": "Benutzermenü",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "en"
export const languageTag = "en-US"
export const messages = {
"blocks.breadcrumb.menuLabel": "Open breadcrumb menu",
"blocks.refresh.description": "Refresh the current page data",
"blocks.refresh.label": "Refresh current page data",
"blocks.userMenu.accountPassword": "Account password",
"blocks.userMenu.appearance": "Appearance",
"blocks.userMenu.keybindings": "Keybindings",
"blocks.userMenu.logout": "Log out",
"blocks.userMenu.open": "Open user menu",
"blocks.userMenu.title": "User menu",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "es"
export const languageTag = "es-ES"
export const messages = {
"blocks.breadcrumb.menuLabel": "Abrir el menú de ruta de navegación",
"blocks.refresh.description": "Actualizar los datos de la página actual",
"blocks.refresh.label": "Actualizar datos de la página actual",
"blocks.userMenu.accountPassword": "Contraseña de la cuenta",
"blocks.userMenu.appearance": "Apariencia",
"blocks.userMenu.keybindings": "Atajos de teclado",
"blocks.userMenu.logout": "Cerrar sesión",
"blocks.userMenu.open": "Abrir menú de usuario",
"blocks.userMenu.title": "Menú de usuario",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "fr"
export const languageTag = "fr-FR"
export const messages = {
"blocks.breadcrumb.menuLabel": "Ouvrir le menu du fil dAriane",
"blocks.refresh.description": "Actualiser les données de la page actuelle",
"blocks.refresh.label": "Actualiser les données de la page",
"blocks.userMenu.accountPassword": "Mot de passe du compte",
"blocks.userMenu.appearance": "Apparence",
"blocks.userMenu.keybindings": "Raccourcis clavier",
"blocks.userMenu.logout": "Se déconnecter",
"blocks.userMenu.open": "Ouvrir le menu utilisateur",
"blocks.userMenu.title": "Menu utilisateur",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "ja"
export const languageTag = "ja-JP"
export const messages = {
"blocks.breadcrumb.menuLabel": "パンくずメニューを開く",
"blocks.refresh.description": "現在のページのデータを更新します",
"blocks.refresh.label": "現在のページを更新",
"blocks.userMenu.accountPassword": "アカウントのパスワード",
"blocks.userMenu.appearance": "外観",
"blocks.userMenu.keybindings": "キーバインド",
"blocks.userMenu.logout": "ログアウト",
"blocks.userMenu.open": "ユーザーメニューを開く",
"blocks.userMenu.title": "ユーザーメニュー",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "ko"
export const languageTag = "ko-KR"
export const messages = {
"blocks.breadcrumb.menuLabel": "이동 경로 메뉴 열기",
"blocks.refresh.description": "현재 페이지 데이터를 새로 고칩니다",
"blocks.refresh.label": "현재 페이지 데이터 새로 고침",
"blocks.userMenu.accountPassword": "계정 비밀번호",
"blocks.userMenu.appearance": "화면 모양",
"blocks.userMenu.keybindings": "키 바인딩",
"blocks.userMenu.logout": "로그아웃",
"blocks.userMenu.open": "사용자 메뉴 열기",
"blocks.userMenu.title": "사용자 메뉴",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"blocks.breadcrumb.menuLabel": "打开面包屑菜单",
"blocks.refresh.description": "刷新当前页面的数据",
"blocks.refresh.label": "刷新当前页面数据",
"blocks.userMenu.accountPassword": "账号密码",
"blocks.userMenu.appearance": "界面外观",
"blocks.userMenu.keybindings": "按键映射",
"blocks.userMenu.logout": "退出登录",
"blocks.userMenu.open": "打开用户菜单",
"blocks.userMenu.title": "用户菜单",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,15 @@
import type { LayoutMessageCatalog } from "./catalogs"
export const locale = "zh-Hant"
export const languageTag = "zh-TW"
export const messages = {
"blocks.breadcrumb.menuLabel": "開啟麵包屑選單",
"blocks.refresh.description": "重新整理目前頁面的資料",
"blocks.refresh.label": "重新整理目前頁面資料",
"blocks.userMenu.accountPassword": "帳號密碼",
"blocks.userMenu.appearance": "介面外觀",
"blocks.userMenu.keybindings": "按鍵綁定",
"blocks.userMenu.logout": "登出",
"blocks.userMenu.open": "開啟使用者選單",
"blocks.userMenu.title": "使用者選單",
} as const satisfies LayoutMessageCatalog
@@ -0,0 +1,40 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const layoutMessages = {
accountPassword: /* i18n */ {
id: "blocks.userMenu.accountPassword",
message: "Account password",
},
appearance: /* i18n */ {
id: "blocks.userMenu.appearance",
message: "Appearance",
},
breadcrumbMenuLabel: /* i18n */ {
id: "blocks.breadcrumb.menuLabel",
message: "Open breadcrumb menu",
},
keybindings: /* i18n */ {
id: "blocks.userMenu.keybindings",
message: "Keybindings",
},
logout: /* i18n */ {
id: "blocks.userMenu.logout",
message: "Log out",
},
openUserMenu: /* i18n */ {
id: "blocks.userMenu.open",
message: "Open user menu",
},
refreshDescription: /* i18n */ {
id: "blocks.refresh.description",
message: "Refresh the current page data",
},
refreshLabel: /* i18n */ {
id: "blocks.refresh.label",
message: "Refresh current page data",
},
userMenuTitle: /* i18n */ {
id: "blocks.userMenu.title",
message: "User menu",
},
} as const satisfies Record<string, MessageDescriptor>
@@ -25,7 +25,7 @@ export function SidebarStateProvider({
const contextValue = React.useMemo( const contextValue = React.useMemo(
() => ({ isCollapsed, setCollapsed }), () => ({ isCollapsed, setCollapsed }),
[isCollapsed] [isCollapsed, setCollapsed]
) )
return ( return (
+45 -29
View File
@@ -1,4 +1,13 @@
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 { Button, type ButtonProps } from "@workspace/ui/components/button" import { Button, type ButtonProps } from "@workspace/ui/components/button"
import { import {
createDialogHandle, createDialogHandle,
@@ -34,15 +43,10 @@ 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 { layoutMessages } from "./messages"
import {
KeyboardIcon,
LogoutCircle02Icon,
ShieldKeyIcon,
SwatchIcon,
} from "@hugeicons/core-free-icons"
interface UserMenuItem { interface UserMenuItem {
id: string
action?: NavigationAction action?: NavigationAction
shortcut?: string shortcut?: string
icon: IconData icon: IconData
@@ -53,24 +57,6 @@ interface UserMenuTriggerProps {
className?: string className?: string
} }
const userMenuItems: readonly UserMenuItem[] = [
{
action: navigationActions.appearance,
icon: SwatchIcon,
label: "界面外观",
shortcut: "Mod+,",
},
{
icon: KeyboardIcon,
label: "按键映射",
},
{
icon: ShieldKeyIcon,
label: "账号密码",
shortcut: "Mod+P",
},
]
let userMenuHandles: let userMenuHandles:
| { | {
dialog: DialogHandle<void> dialog: DialogHandle<void>
@@ -123,8 +109,11 @@ function UserMenuPopoverTrigger({ className }: UserMenuTriggerProps) {
} }
function UserMenuTriggerButton({ className, ...props }: ButtonProps) { function UserMenuTriggerButton({ className, ...props }: ButtonProps) {
const openUserMenuLabel = useMessage(layoutMessages.openUserMenu)
return ( return (
<HeaderActionButton <HeaderActionButton
aria-label={openUserMenuLabel}
className={cn( className={cn(
"overflow-hidden after:pointer-events-none after:absolute after:inset-0 after:z-999 after:rounded-md after:border after:border-foreground/35", "overflow-hidden after:pointer-events-none after:absolute after:inset-0 after:z-999 after:rounded-md after:border after:border-foreground/35",
className className
@@ -140,6 +129,31 @@ function UserMenuTriggerButton({ className, ...props }: ButtonProps) {
} }
function UserMenuContent({ onItemSelect }: { onItemSelect: VoidFunction }) { function UserMenuContent({ onItemSelect }: { onItemSelect: VoidFunction }) {
const appearanceLabel = useMessage(layoutMessages.appearance)
const keybindingsLabel = useMessage(layoutMessages.keybindings)
const accountPasswordLabel = useMessage(layoutMessages.accountPassword)
const logoutLabel = useMessage(layoutMessages.logout)
const userMenuItems: readonly UserMenuItem[] = [
{
id: "appearance",
action: navigationActions.appearance,
icon: SwatchIcon,
label: appearanceLabel,
shortcut: "Mod+,",
},
{
id: "keybindings",
icon: KeyboardIcon,
label: keybindingsLabel,
},
{
id: "account-password",
icon: ShieldKeyIcon,
label: accountPasswordLabel,
shortcut: "Mod+P",
},
]
return ( return (
<div className="w-72"> <div className="w-72">
<div className="flex flex-col items-center pt-10 pb-6"> <div className="flex flex-col items-center pt-10 pb-6">
@@ -153,7 +167,7 @@ function UserMenuContent({ onItemSelect }: { onItemSelect: VoidFunction }) {
<div className="border-t border-dashed border-border p-2"> <div className="border-t border-dashed border-border p-2">
<ul className="space-y"> <ul className="space-y">
{userMenuItems.map((item) => ( {userMenuItems.map((item) => (
<li key={item.label}> <li key={item.id}>
<Button <Button
className="w-full justify-start font-normal" className="w-full justify-start font-normal"
size="lg" size="lg"
@@ -176,7 +190,7 @@ function UserMenuContent({ onItemSelect }: { onItemSelect: VoidFunction }) {
<div className="rounded-b-md border-t p-2"> <div className="rounded-b-md border-t p-2">
<Button className="w-full font-normal" variant="destructive" size="lg"> <Button className="w-full font-normal" variant="destructive" size="lg">
<Icon data={LogoutCircle02Icon} size="sm" /> <Icon data={LogoutCircle02Icon} size="sm" />
退 {logoutLabel}
<MenuShortcutSequence shortcuts={["Q", "Q", "Q"]} /> <MenuShortcutSequence shortcuts={["Q", "Q", "Q"]} />
</Button> </Button>
</div> </div>
@@ -213,12 +227,14 @@ function UserMenuDialogTrigger({ className }: UserMenuTriggerProps) {
function UserMenuDialog() { function UserMenuDialog() {
const { dialog } = getOrCreateUserMenuHandles() const { dialog } = getOrCreateUserMenuHandles()
const title = useMessage(layoutMessages.userMenuTitle)
return ( return (
<Dialog handle={dialog}> <Dialog handle={dialog}>
<DialogContent className="w-fit p-0"> <DialogContent className="w-fit p-0">
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle></DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription></DialogDescription> <DialogDescription>{title}</DialogDescription>
</DialogHeader> </DialogHeader>
<UserMenuContent onItemSelect={() => dialog.close()} /> <UserMenuContent onItemSelect={() => dialog.close()} />
</DialogContent> </DialogContent>
@@ -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,14 +1,13 @@
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 type { GetNavigationRouteState } from "./route-state" import type { GetNavigationRouteState } from "./route-state"
import type { NavigationItem } from "./types" import type { NavigationItem } from "./types"
@@ -37,7 +36,9 @@ export function SidebarFlyoutMenuItems({
className="h-9 gap-3 px-3 py-1 data-route-active:bg-primary/10 data-route-active:text-primary! data-route-active:**:text-primary!" className="h-9 gap-3 px-3 py-1 data-route-active:bg-primary/10 data-route-active:text-primary! data-route-active:**:text-primary!"
> >
{iconElement} {iconElement}
<span className="min-w-0 flex-1 truncate">{item.label}</span> <span className="min-w-0 flex-1 truncate">
<NavigationLabel label={item.label} />
</span>
<NavigationBadge className="ms-auto" itemId={item.id} /> <NavigationBadge className="ms-auto" itemId={item.id} />
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent className="grid w-fit max-w-(--available-width) min-w-44 gap-0.5"> <DropdownMenuSubContent className="grid w-fit max-w-(--available-width) min-w-44 gap-0.5">
@@ -59,7 +60,9 @@ export function SidebarFlyoutMenuItems({
render={item.to ? <NavigationLink to={item.to} /> : undefined} render={item.to ? <NavigationLink to={item.to} /> : undefined}
> >
{iconElement} {iconElement}
<span className="min-w-0 flex-1 truncate">{item.label}</span> <span className="min-w-0 flex-1 truncate">
<NavigationLabel label={item.label} />
</span>
<NavigationBadge className="ms-auto" itemId={item.id} /> <NavigationBadge className="ms-auto" itemId={item.id} />
</DropdownMenuItem> </DropdownMenuItem>
) )
@@ -4,6 +4,7 @@ export {
} from "./mobile-navigation" } from "./mobile-navigation"
export { resolveNavigationItemIcon } from "./item-icon" export { resolveNavigationItemIcon } from "./item-icon"
export { PrimaryNavigation } from "./primary-navigation" export { PrimaryNavigation } from "./primary-navigation"
export { NavigationLabel, TruncatedNavigationLabel } from "./navigation-label"
export { resolveNavigationRouteState } from "./route-state" export { resolveNavigationRouteState } from "./route-state"
export { export {
NavigationProvider, NavigationProvider,
@@ -16,4 +17,8 @@ export type {
NavigationRouteState, NavigationRouteState,
} from "./route-state" } from "./route-state"
export type { NavigationInfo, NavigationKey } from "./use-navigation" export type { NavigationInfo, NavigationKey } from "./use-navigation"
export type { NavigationGroup, NavigationItem } from "./types" export type {
NavigationGroup,
NavigationItem,
NavigationLabelValue,
} from "./types"
@@ -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"
@@ -0,0 +1,34 @@
import { describe, it } from "vitest"
import { expectCompleteCatalogs } from "../../../i18n/test-catalogs"
import { navigationMessages } from "../messages"
import { navigationCatalogLocales } 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("navigation locale catalogs", () => {
it("ship every navigation message in every built-in locale", () => {
expectCompleteCatalogs({
catalogs: {
de,
en,
es,
fr,
ja,
ko,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
},
locales: navigationCatalogLocales,
messageIds: Object.values(navigationMessages).map(
(descriptor) => descriptor.id
),
})
})
})
@@ -0,0 +1,12 @@
import type { navigationMessages } from "../messages"
import {
blockCatalogLocales,
type BlockCatalogLocale,
type BlockMessageCatalog,
} from "../../../i18n/catalogs"
export { blockCatalogLocales as navigationCatalogLocales }
export type NavigationCatalogLocale = BlockCatalogLocale
export type NavigationMessageCatalog = BlockMessageCatalog<
typeof navigationMessages
>

Some files were not shown because too many files have changed in this diff Show More