diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js deleted file mode 100644 index ef614d2..0000000 --- a/apps/web/eslint.config.js +++ /dev/null @@ -1,22 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - languageOptions: { - globals: globals.browser, - }, - }, -]) diff --git a/apps/web/i18n.config.json b/apps/web/i18n.config.json new file mode 100644 index 0000000..cb9d4d1 --- /dev/null +++ b/apps/web/i18n.config.json @@ -0,0 +1,7 @@ +{ + "sourceLocale": "en", + "locales": ["en", "zh-Hans"], + "catalogPath": "src/locales/{locale}/messages", + "include": ["src", "../../packages/blocks/src", "../../packages/ui/src"], + "exclude": ["**/*.test.{ts,tsx}", "**/node_modules/**"] +} diff --git a/apps/web/index.html b/apps/web/index.html deleted file mode 100644 index de8cc51..0000000 --- a/apps/web/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - vite-monorepo - - -
- - - diff --git a/apps/web/lingui.config.ts b/apps/web/lingui.config.ts new file mode 100644 index 0000000..165c993 --- /dev/null +++ b/apps/web/lingui.config.ts @@ -0,0 +1,4 @@ +import project from "./i18n.config.json" with { type: "json" } +import { createLinguiConfig } from "@workspace/i18n/lingui-config" + +export default createLinguiConfig(project) diff --git a/apps/web/package.json b/apps/web/package.json index 203dbac..679bc40 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,32 +4,48 @@ "type": "module", "private": true, "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint", + "dev": "vite dev", + "build": "vite build && tsc -b", "format": "prettier --write \"**/*.{ts,tsx}\"", - "typecheck": "tsc --noEmit", - "preview": "vite preview" + "i18n": "workspace-i18n", + "i18n:compile": "workspace-i18n compile", + "i18n:extract": "workspace-i18n extract", + "i18n:ui": "workspace-i18n ui", + "typecheck": "tsc -b", + "preview": "vite preview", + "start": "node .output/server/index.mjs" }, "dependencies": { + "@base-ui/react": "^1.6.0", + "@base-ui/utils": "^0.3.1", + "@floating-ui/utils": "^0.2.12", + "@hugeicons/core-free-icons": "^4.2.3", + "@hugeicons/react": "^1.1.9", + "@iconify/react": "^6.0.2", + "@lingui/core": "^6", + "@tanstack/react-form": "^1.33.2", + "@tanstack/react-hotkeys": "^0.10.0", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-router": "^1.170.18", + "@tanstack/react-start": "^1.168.32", + "@workspace/blocks": "workspace:*", + "@workspace/i18n": "workspace:*", "@workspace/ui": "workspace:*", "lucide-react": "^1.27.0", "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "sonner": "^2.0.7" }, "devDependencies": { - "@eslint/js": "^10", "@tailwindcss/vite": "^4", + "@testing-library/react": "^16.3.2", "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6", - "eslint": "^10", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17", + "nitro": "^3.0.260610-beta", "typescript": "~6", - "typescript-eslint": "^8", - "vite": "^8" + "vite": "^8", + "vitest": "^4.1.10" } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 55a08a9..1855548 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,19 +1,24 @@ -import { Button } from "@workspace/ui/components/button" +import { AppLayout } from "@workspace/blocks/layout" + +import { chatThreads } from "./data/chats" +import { primaryNavigationGroups } from "./data/navigation" +import { + fetchMockNotifications, + notificationsQueryKey, +} from "./data/notifications" +import { DashboardPage } from "./pages/dashboard-page" export function App() { return ( -
-
-
-

Project ready!

-

You may now add components and start building.

-

We've already added the button component for you.

- -
-
- (Press d to toggle dark mode) -
-
-
+ + + ) } diff --git a/apps/web/src/components/theme-provider.tsx b/apps/web/src/components/theme-provider.tsx deleted file mode 100644 index 1349a0c..0000000 --- a/apps/web/src/components/theme-provider.tsx +++ /dev/null @@ -1,230 +0,0 @@ -/* eslint-disable react-refresh/only-export-components */ -import * as React from "react" - -type Theme = "dark" | "light" | "system" -type ResolvedTheme = "dark" | "light" - -type ThemeProviderProps = { - children: React.ReactNode - defaultTheme?: Theme - storageKey?: string - disableTransitionOnChange?: boolean -} - -type ThemeProviderState = { - theme: Theme - setTheme: (theme: Theme) => void -} - -const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)" -const THEME_VALUES: Theme[] = ["dark", "light", "system"] - -const ThemeProviderContext = React.createContext< - ThemeProviderState | undefined ->(undefined) - -function isTheme(value: string | null): value is Theme { - if (value === null) { - return false - } - - return THEME_VALUES.includes(value as Theme) -} - -function getSystemTheme(): ResolvedTheme { - if (window.matchMedia(COLOR_SCHEME_QUERY).matches) { - return "dark" - } - - return "light" -} - -function disableTransitionsTemporarily() { - const style = document.createElement("style") - style.appendChild( - document.createTextNode( - "*,*::before,*::after{-webkit-transition:none!important;transition:none!important}" - ) - ) - document.head.appendChild(style) - - return () => { - window.getComputedStyle(document.body) - requestAnimationFrame(() => { - requestAnimationFrame(() => { - style.remove() - }) - }) - } -} - -function isEditableTarget(target: EventTarget | null) { - if (!(target instanceof HTMLElement)) { - return false - } - - if (target.isContentEditable) { - return true - } - - const editableParent = target.closest( - "input, textarea, select, [contenteditable='true']" - ) - if (editableParent) { - return true - } - - return false -} - -export function ThemeProvider({ - children, - defaultTheme = "system", - storageKey = "theme", - disableTransitionOnChange = true, - ...props -}: ThemeProviderProps) { - const [theme, setThemeState] = React.useState(() => { - const storedTheme = localStorage.getItem(storageKey) - if (isTheme(storedTheme)) { - return storedTheme - } - - return defaultTheme - }) - - const setTheme = React.useCallback( - (nextTheme: Theme) => { - localStorage.setItem(storageKey, nextTheme) - setThemeState(nextTheme) - }, - [storageKey] - ) - - const applyTheme = React.useCallback( - (nextTheme: Theme) => { - const root = document.documentElement - const resolvedTheme = - nextTheme === "system" ? getSystemTheme() : nextTheme - const restoreTransitions = disableTransitionOnChange - ? disableTransitionsTemporarily() - : null - - root.classList.remove("light", "dark") - root.classList.add(resolvedTheme) - - if (restoreTransitions) { - restoreTransitions() - } - }, - [disableTransitionOnChange] - ) - - React.useEffect(() => { - applyTheme(theme) - - if (theme !== "system") { - return undefined - } - - const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY) - const handleChange = () => { - applyTheme("system") - } - - mediaQuery.addEventListener("change", handleChange) - - return () => { - mediaQuery.removeEventListener("change", handleChange) - } - }, [theme, applyTheme]) - - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.repeat) { - return - } - - if (event.metaKey || event.ctrlKey || event.altKey) { - return - } - - if (isEditableTarget(event.target)) { - return - } - - if (event.key.toLowerCase() !== "d") { - return - } - - setThemeState((currentTheme) => { - const nextTheme = - currentTheme === "dark" - ? "light" - : currentTheme === "light" - ? "dark" - : getSystemTheme() === "dark" - ? "light" - : "dark" - - localStorage.setItem(storageKey, nextTheme) - return nextTheme - }) - } - - window.addEventListener("keydown", handleKeyDown) - - return () => { - window.removeEventListener("keydown", handleKeyDown) - } - }, [storageKey]) - - React.useEffect(() => { - const handleStorageChange = (event: StorageEvent) => { - if (event.storageArea !== localStorage) { - return - } - - if (event.key !== storageKey) { - return - } - - if (isTheme(event.newValue)) { - setThemeState(event.newValue) - return - } - - setThemeState(defaultTheme) - } - - window.addEventListener("storage", handleStorageChange) - - return () => { - window.removeEventListener("storage", handleStorageChange) - } - }, [defaultTheme, storageKey]) - - const value = React.useMemo( - () => ({ - theme, - setTheme, - }), - [theme, setTheme] - ) - - return ( - - {children} - - ) -} - -export const useTheme = () => { - const context = React.useContext(ThemeProviderContext) - - if (context === undefined) { - throw new Error("useTheme must be used within a ThemeProvider") - } - - return context -} diff --git a/apps/web/src/components/toaster.tsx b/apps/web/src/components/toaster.tsx new file mode 100644 index 0000000..920e777 --- /dev/null +++ b/apps/web/src/components/toaster.tsx @@ -0,0 +1,45 @@ +"use client" + +import { useUiState } from "@workspace/blocks/appearance" +import { Toaster as Sonner, type ToasterProps } from "sonner" +import { + CircleCheckIcon, + InfoIcon, + TriangleAlertIcon, + OctagonXIcon, + Loader2Icon, +} from "lucide-react" + +const Toaster = ({ ...props }: ToasterProps) => { + const [themeMode] = useUiState("theme-mode") + + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ) +} + +export { Toaster } diff --git a/apps/web/src/data/chats.ts b/apps/web/src/data/chats.ts new file mode 100644 index 0000000..6833f5a --- /dev/null +++ b/apps/web/src/data/chats.ts @@ -0,0 +1,46 @@ +import type { ChatThread } from "@workspace/blocks/chats" + +export const chatThreads: readonly ChatThread[] = [ + { + avatarUrl: + "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-1.webp", + name: "Jayvion Simon", + presence: "offline", + }, + { + avatarUrl: + "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-2.webp", + name: "Lucian Obrien", + presence: "online", + }, + { + avatarUrl: + "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-3.webp", + name: "Deja Brady", + }, + { + avatarUrl: + "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-4.webp", + name: "Harrison Stein", + }, + { + avatarUrl: + "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-5.webp", + name: "Reece Chung", + }, + { + avatarUrl: + "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-6.webp", + name: "Cristopher Cardenas", + }, + { + avatarUrl: + "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-8.webp", + name: "Melanie Noble", + }, + { + avatarUrl: + "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-9.webp", + name: "Chase Day", + }, +] diff --git a/apps/web/src/data/navigation.ts b/apps/web/src/data/navigation.ts new file mode 100644 index 0000000..d7f217e --- /dev/null +++ b/apps/web/src/data/navigation.ts @@ -0,0 +1,594 @@ +import { + Alert02Icon, + Analytics01Icon, + AnalyticsUpIcon, + AppWindowIcon, + ArrowDataTransferHorizontalIcon, + BubbleChatIcon, + Calculator01Icon, + CallMissed01Icon, + CancelCircleIcon, + Chart01Icon, + ChartIncreaseIcon, + CheckmarkBadge01Icon, + CheckmarkCircle01Icon, + ClipboardListIcon, + Clock01Icon, + CodeSquareIcon, + Compass01Icon, + CreditCardIcon, + CrownIcon, + Delete02Icon, + DeliveryTruck01Icon, + Discount01Icon, + File01Icon, + GalleryHorizontalIcon, + GiftIcon, + HistoryIcon, + IdentityCardIcon, + InboxDownloadIcon, + InboxUploadIcon, + Invoice03Icon, + Key01Icon, + LinkCircleIcon, + Location01Icon, + LockKeyIcon, + Medal01Icon, + MoneySendSquareIcon, + Note01Icon, + Package02Icon, + PackageIcon, + Refresh01Icon, + RoadLocation01Icon, + SecurityWarningIcon, + Settings01Icon, + Settings03Icon, + Share01Icon, + ShoppingBag01Icon, + ShoppingCart01Icon, + StarCircleIcon, + StarIcon, + Store01Icon, + Structure01Icon, + Task01Icon, + Ticket01Icon, + UserGroupIcon, + UserShield01Icon, + Wallet01Icon, + WalletCardsIcon, +} from "@hugeicons/core-free-icons" + +import type { NavigationGroup } from "@workspace/blocks/navigation" + +export const primaryNavigationGroups: readonly NavigationGroup[] = [ + { + id: "overview", + label: "概览", + items: [ + { + id: "overview-dashboard", + to: "/", + label: "经营总览", + icon: Analytics01Icon, + }, + { + id: "overview-realtime", + to: "/analytics/realtime", + label: "实时监控", + icon: AnalyticsUpIcon, + }, + { + id: "overview-tasks", + to: "/workspace/tasks", + label: "待办中心", + icon: Task01Icon, + }, + ], + }, + { + id: "catalog-and-inventory", + label: "商品与库存", + items: [ + { + id: "catalog-products", + label: "商品中心", + icon: ShoppingBag01Icon, + items: [ + { + id: "catalog-products-list", + to: "/catalog/products/list", + label: "商品列表", + icon: ShoppingBag01Icon, + }, + { + id: "catalog-products-drafts", + to: "/catalog/products/drafts", + label: "商品草稿", + icon: File01Icon, + }, + { + id: "catalog-products-bundles", + to: "/catalog/products/bundles", + label: "组合商品", + icon: Package02Icon, + }, + { + id: "catalog-products-attributes", + label: "商品属性", + icon: Settings03Icon, + items: [ + { + id: "catalog-product-specifications", + to: "/catalog/products/attributes/specifications", + label: "规格模板", + icon: ClipboardListIcon, + }, + { + id: "catalog-product-brands", + to: "/catalog/products/attributes/brands", + label: "品牌资料", + icon: StarIcon, + }, + ], + }, + ], + }, + { + id: "catalog-categories", + to: "/catalog/categories", + label: "分类目录", + icon: ClipboardListIcon, + items: [ + { + id: "catalog-category-tree", + to: "/catalog/categories/tree", + label: "分类树", + icon: Structure01Icon, + }, + { + id: "catalog-category-navigation", + to: "/catalog/categories/navigation", + label: "导航分类", + icon: Compass01Icon, + }, + ], + }, + { + id: "inventory-center", + to: "/inventory", + label: "库存中心", + icon: PackageIcon, + items: [ + { + id: "inventory-overview", + to: "/inventory/overview", + label: "库存总览", + icon: Chart01Icon, + }, + { + id: "inventory-inbound", + to: "/inventory/inbound", + label: "入库单", + icon: InboxDownloadIcon, + }, + { + id: "inventory-outbound", + to: "/inventory/outbound", + label: "出库单", + icon: InboxUploadIcon, + }, + { + id: "inventory-transfers", + to: "/inventory/transfers", + label: "调拨单", + icon: ArrowDataTransferHorizontalIcon, + }, + { + id: "inventory-alerts", + to: "/inventory/alerts", + label: "库存预警", + icon: Alert02Icon, + }, + ], + }, + { + id: "content-assets", + to: "/content", + label: "内容素材", + icon: File01Icon, + items: [ + { + id: "content-articles", + to: "/content/articles", + label: "内容文章", + icon: File01Icon, + }, + { + id: "content-media", + to: "/content/media", + label: "媒体资源", + icon: GalleryHorizontalIcon, + }, + { + id: "content-pages", + to: "/content/pages", + label: "自定义页面", + icon: AppWindowIcon, + }, + ], + }, + ], + }, + { + id: "trade-and-fulfillment", + label: "交易与履约", + items: [ + { + id: "trade-orders", + to: "/trade/orders", + label: "订单中心", + icon: ShoppingCart01Icon, + items: [ + { + id: "trade-orders-awaiting-payment", + to: "/trade/orders/awaiting-payment", + label: "待付款订单", + icon: WalletCardsIcon, + }, + { + id: "trade-orders-awaiting-shipment", + to: "/trade/orders/awaiting-shipment", + label: "待发货订单", + icon: PackageIcon, + }, + { + id: "trade-orders-shipped", + to: "/trade/orders/shipped", + label: "配送中订单", + icon: DeliveryTruck01Icon, + }, + { + id: "trade-orders-completed", + to: "/trade/orders/completed", + label: "已完成订单", + icon: CheckmarkCircle01Icon, + }, + { + id: "trade-orders-canceled", + to: "/trade/orders/canceled", + label: "已取消订单", + icon: CancelCircleIcon, + }, + ], + }, + { + id: "fulfillment-center", + to: "/fulfillment", + label: "配送履约", + icon: DeliveryTruck01Icon, + items: [ + { + id: "fulfillment-deliveries", + to: "/fulfillment/deliveries", + label: "配送任务", + icon: Location01Icon, + }, + { + id: "fulfillment-pickups", + to: "/fulfillment/pickups", + label: "自提核销", + icon: Store01Icon, + }, + { + id: "fulfillment-freight-rules", + to: "/fulfillment/freight-rules", + label: "运费规则", + icon: RoadLocation01Icon, + }, + ], + }, + { + id: "after-sales-center", + to: "/after-sales", + label: "售后中心", + icon: CallMissed01Icon, + items: [ + { + id: "after-sales-returns", + to: "/after-sales/returns", + label: "退货申请", + icon: Refresh01Icon, + }, + { + id: "after-sales-refunds", + to: "/after-sales/refunds", + label: "退款审核", + icon: MoneySendSquareIcon, + }, + { + id: "after-sales-disputes", + to: "/after-sales/disputes", + label: "交易纠纷", + icon: SecurityWarningIcon, + }, + ], + }, + ], + }, + { + id: "customers-and-marketing", + label: "客户与营销", + items: [ + { + id: "customer-center", + to: "/customers", + label: "客户中心", + icon: IdentityCardIcon, + items: [ + { + id: "customer-profiles", + to: "/customers/profiles", + label: "客户档案", + icon: IdentityCardIcon, + }, + { + id: "customer-segments", + to: "/customers/segments", + label: "客户分群", + icon: UserGroupIcon, + }, + { + id: "customer-tiers", + to: "/customers/tiers", + label: "客户等级", + icon: CrownIcon, + }, + { + id: "customer-risk-list", + to: "/customers/risk-list", + label: "风险名单", + icon: SecurityWarningIcon, + }, + ], + }, + { + id: "marketing-campaigns", + to: "/marketing/campaigns", + label: "营销活动", + icon: Discount01Icon, + items: [ + { + id: "marketing-coupons", + to: "/marketing/coupons", + label: "优惠券管理", + icon: Ticket01Icon, + }, + { + id: "marketing-flash-sales", + to: "/marketing/flash-sales", + label: "限时折扣", + icon: Clock01Icon, + }, + { + id: "marketing-gift-promotions", + to: "/marketing/gift-promotions", + label: "满赠活动", + icon: GiftIcon, + }, + { + id: "marketing-referrals", + to: "/marketing/referrals", + label: "邀请有礼", + icon: Share01Icon, + }, + ], + }, + { + id: "loyalty-center", + to: "/loyalty", + label: "会员权益", + icon: Medal01Icon, + items: [ + { + id: "loyalty-points", + to: "/loyalty/points", + label: "积分规则", + icon: StarCircleIcon, + }, + { + id: "loyalty-stored-value", + to: "/loyalty/stored-value", + label: "储值账户", + icon: Wallet01Icon, + }, + { + id: "loyalty-benefits", + to: "/loyalty/benefits", + label: "权益配置", + icon: CheckmarkBadge01Icon, + }, + ], + }, + ], + }, + { + id: "data-and-finance", + label: "数据与财务", + items: [ + { + id: "report-center", + to: "/reports", + label: "数据报表", + icon: Chart01Icon, + items: [ + { + id: "report-sales", + to: "/reports/sales", + label: "销售分析", + icon: AnalyticsUpIcon, + }, + { + id: "report-products", + to: "/reports/products", + label: "商品分析", + icon: ShoppingBag01Icon, + }, + { + id: "report-customers", + to: "/reports/customers", + label: "客群分析", + icon: UserGroupIcon, + }, + { + id: "report-channels", + to: "/reports/channels", + label: "渠道分析", + icon: ChartIncreaseIcon, + }, + ], + }, + { + id: "finance-center", + to: "/finance", + label: "财务中心", + icon: WalletCardsIcon, + items: [ + { + id: "finance-settlements", + to: "/finance/settlements", + label: "结算账单", + icon: Invoice03Icon, + }, + { + id: "finance-reconciliation", + to: "/finance/reconciliation", + label: "对账管理", + icon: Calculator01Icon, + }, + { + id: "finance-invoices", + to: "/finance/invoices", + label: "发票管理", + icon: File01Icon, + }, + { + id: "finance-payouts", + to: "/finance/payouts", + label: "打款记录", + icon: MoneySendSquareIcon, + }, + ], + }, + ], + }, + { + id: "system-management", + label: "系统管理", + items: [ + { + id: "system-members", + to: "/system/members", + label: "组织成员", + icon: UserShield01Icon, + items: [ + { + id: "system-members-admins", + to: "/system/members/admins", + label: "管理员账号", + icon: UserShield01Icon, + }, + { + id: "system-members-roles", + to: "/system/members/roles", + label: "角色管理", + icon: UserGroupIcon, + }, + { + id: "system-members-permissions", + to: "/system/members/permissions", + label: "权限策略", + icon: LockKeyIcon, + }, + ], + }, + { + id: "system-notifications", + to: "/system/notifications", + label: "消息中心", + icon: BubbleChatIcon, + items: [ + { + id: "system-notification-templates", + to: "/system/notifications/templates", + label: "通知模板", + icon: File01Icon, + }, + { + id: "system-notification-logs", + to: "/system/notifications/logs", + label: "发送记录", + icon: HistoryIcon, + }, + ], + }, + { + id: "system-audit", + to: "/system/audit", + label: "操作审计", + icon: Note01Icon, + }, + { + id: "system-integrations", + to: "/system/integrations", + label: "开放集成", + icon: CodeSquareIcon, + items: [ + { + id: "system-integration-api-keys", + to: "/system/integrations/api-keys", + label: "API 密钥", + icon: Key01Icon, + }, + { + id: "system-integration-webhooks", + to: "/system/integrations/webhooks", + label: "Webhook 配置", + icon: LinkCircleIcon, + }, + ], + }, + { + id: "system-settings", + to: "/system-configs", + label: "系统配置", + icon: Settings01Icon, + items: [ + { + id: "system-settings-store", + to: "/system-configs/store", + label: "门店资料", + icon: Store01Icon, + }, + { + id: "system-settings-payments", + to: "/system-configs/payments", + label: "支付渠道", + icon: CreditCardIcon, + }, + { + id: "system-settings-shipping", + to: "/system-configs/shipping", + label: "配送设置", + icon: DeliveryTruck01Icon, + }, + ], + }, + { + id: "system-recycle-bin", + to: "/system/recycle-bin", + label: "回收站", + icon: Delete02Icon, + }, + ], + }, +] diff --git a/apps/web/src/data/notifications.ts b/apps/web/src/data/notifications.ts new file mode 100644 index 0000000..8a9b972 --- /dev/null +++ b/apps/web/src/data/notifications.ts @@ -0,0 +1,226 @@ +import { + DeliveryTruck01Icon, + Mail01Icon, + Message01Icon, + PackageIcon, +} from "@hugeicons/core-free-icons" +import type { QueryFunctionContext } from "@tanstack/react-query" + +import type { Notification } from "@workspace/blocks/notifications" + +const RANDOM_USER_API_URL = + "https://randomuser.me/api/1.4/?results=4&inc=name,email,picture&nat=au,ca,de,fr,gb,nl,nz,us&noinfo" + +interface RandomUser { + email: string + name: { + first: string + last: string + } + picture: { + medium: string + } +} + +const HOUR_IN_MILLISECONDS = 60 * 60 * 1000 + +function getCreatedAt(hoursAgo: number) { + return new Date(Date.now() - hoursAgo * HOUR_IN_MILLISECONDS).toISOString() +} + +export const fallbackNotifications: readonly Notification[] = [ + { + id: "friend-request-deja-brady", + title: "Deja Brady sent you a friend request", + createdAt: getCreatedAt(11), + category: "Communication", + isRead: false, + media: { + kind: "avatar", + src: "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-2.webp", + alt: "Deja Brady", + }, + actions: [ + { id: "accept", label: "Accept" }, + { id: "decline", label: "Decline", intent: "neutral" }, + ], + }, + { + id: "mention-minimal-ui", + title: "Jayvon Hull mentioned you in Minimal UI", + createdAt: getCreatedAt(24), + category: "Project UI", + to: "/workspace/tasks", + isRead: false, + media: { + kind: "avatar", + src: "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-3.webp", + alt: "Jayvon Hull", + }, + actions: [{ id: "view", label: "View", intent: "neutral" }], + }, + { + id: "order-placed", + title: "Your order is placed", + description: "The order is confirmed and waiting for shipping.", + createdAt: getCreatedAt(24 * 6), + category: "Order", + isRead: true, + media: { + kind: "icon", + icon: PackageIcon, + tone: "warning", + }, + }, + { + id: "order-shipping", + title: "Delivery in progress", + description: "Your order has left the warehouse and is being shipped.", + createdAt: getCreatedAt(24 * 7), + category: "Order", + isRead: true, + media: { + kind: "icon", + icon: DeliveryTruck01Icon, + tone: "success", + }, + }, + { + id: "unread-messages", + title: "You have 5 unread messages", + createdAt: getCreatedAt(24 * 8), + category: "Communication", + to: "/workspace/tasks", + isRead: true, + media: { + kind: "icon", + icon: Message01Icon, + tone: "info", + }, + }, + { + id: "new-mail", + title: "You have new mail", + createdAt: getCreatedAt(24 * 9), + category: "Communication", + isRead: true, + media: { + kind: "icon", + icon: Mail01Icon, + tone: "destructive", + }, + }, +] + +export const notificationsQueryKey = ["notifications"] as const + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function isRandomUser(value: unknown): value is RandomUser { + if (!isRecord(value) || !isRecord(value.name) || !isRecord(value.picture)) { + return false + } + + return ( + typeof value.email === "string" && + typeof value.name.first === "string" && + typeof value.name.last === "string" && + typeof value.picture.medium === "string" + ) +} + +function getUserName(user: RandomUser) { + return `${user.name.first} ${user.name.last}` +} + +function getAvatarMedia(user: RandomUser) { + return { + alt: getUserName(user), + kind: "avatar" as const, + src: user.picture.medium, + } +} + +function createRandomUserNotifications( + users: readonly RandomUser[] +): readonly Notification[] { + const [friend, mention, commenter, collaborator] = users + + if (!friend || !mention || !commenter || !collaborator) { + throw new Error("Random User API returned too few users") + } + + return [ + { + id: `friend-request:${friend.email}`, + title: `${getUserName(friend)} sent you a friend request`, + createdAt: getCreatedAt(11), + category: "Communication", + isRead: false, + media: getAvatarMedia(friend), + actions: [ + { id: "accept", label: "Accept" }, + { id: "decline", label: "Decline", intent: "neutral" }, + ], + }, + { + id: `mention:${mention.email}`, + title: `${getUserName(mention)} mentioned you in Minimal UI`, + createdAt: getCreatedAt(24), + category: "Project UI", + to: "/workspace/tasks", + isRead: false, + media: getAvatarMedia(mention), + actions: [{ id: "view", label: "View", intent: "neutral" }], + }, + { + id: `comment:${commenter.email}`, + title: `${getUserName(commenter)} commented on your task`, + description: "Can you review the latest changes?", + createdAt: getCreatedAt(24 * 2), + category: "Project UI", + to: "/workspace/tasks", + isRead: false, + media: getAvatarMedia(commenter), + }, + { + id: `collaborator:${collaborator.email}`, + title: `${getUserName(collaborator)} joined your workspace`, + createdAt: getCreatedAt(24 * 4), + category: "Team", + isRead: true, + media: getAvatarMedia(collaborator), + }, + ...fallbackNotifications.slice(2), + ] +} + +export async function fetchMockNotifications({ + signal, +}: QueryFunctionContext): Promise { + try { + const response = await fetch(RANDOM_USER_API_URL, { signal }) + + if (!response.ok) { + throw new Error(`Random User API responded with ${response.status}`) + } + + const payload: unknown = await response.json() + + if (!isRecord(payload) || !Array.isArray(payload.results)) { + throw new Error("Random User API returned an invalid response") + } + + const users = payload.results.filter(isRandomUser) + + return createRandomUserNotifications(users) + } catch (error) { + if (signal.aborted) { + throw error + } + + return fallbackNotifications + } +} diff --git a/apps/web/src/i18n/app-i18n-provider.tsx b/apps/web/src/i18n/app-i18n-provider.tsx new file mode 100644 index 0000000..d3f2755 --- /dev/null +++ b/apps/web/src/i18n/app-i18n-provider.tsx @@ -0,0 +1,51 @@ +import { lazy, Suspense, type PropsWithChildren } from "react" +import { I18nProvider, type LocaleDefinition } from "@workspace/i18n" + +import { messages as englishMessages } from "@/locales/en/messages" +import { messages as simplifiedChineseMessages } from "@/locales/zh-Hans/messages" + +const I18nDevtool = import.meta.env.DEV + ? lazy(() => + import("@workspace/i18n/devtool").then((module) => ({ + 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 { + locale?: AppLocale +} + +export function AppI18nProvider({ + children, + locale = "zh-Hans", +}: AppI18nProviderProps) { + return ( + + {children} + {I18nDevtool && ( + + + + )} + + ) +} diff --git a/apps/web/src/lib/query-client.ts b/apps/web/src/lib/query-client.ts new file mode 100644 index 0000000..f0cf6df --- /dev/null +++ b/apps/web/src/lib/query-client.ts @@ -0,0 +1,12 @@ +import { QueryClient } from "@tanstack/react-query" + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}) diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po new file mode 100644 index 0000000..ed86065 --- /dev/null +++ b/apps/web/src/locales/en/messages.po @@ -0,0 +1,14 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: en\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" diff --git a/apps/web/src/locales/en/messages.ts b/apps/web/src/locales/en/messages.ts new file mode 100644 index 0000000..c754d7a --- /dev/null +++ b/apps/web/src/locales/en/messages.ts @@ -0,0 +1,2 @@ +/*eslint-disable*/ import type { Messages } from "@lingui/core" +export const messages = JSON.parse("{}") as Messages diff --git a/apps/web/src/locales/zh-Hans/messages.po b/apps/web/src/locales/zh-Hans/messages.po new file mode 100644 index 0000000..0e231a8 --- /dev/null +++ b/apps/web/src/locales/zh-Hans/messages.po @@ -0,0 +1,14 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: zh-Hans\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" diff --git a/apps/web/src/locales/zh-Hans/messages.ts b/apps/web/src/locales/zh-Hans/messages.ts new file mode 100644 index 0000000..c754d7a --- /dev/null +++ b/apps/web/src/locales/zh-Hans/messages.ts @@ -0,0 +1,2 @@ +/*eslint-disable*/ import type { Messages } from "@lingui/core" +export const messages = JSON.parse("{}") as Messages diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx deleted file mode 100644 index 242ad82..0000000 --- a/apps/web/src/main.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { StrictMode } from "react" -import { createRoot } from "react-dom/client" - -import "@workspace/ui/globals.css" -import { App } from "./App.tsx" -import { ThemeProvider } from "@/components/theme-provider.tsx" - -createRoot(document.getElementById("root")!).render( - - - - - -) diff --git a/apps/web/src/pages/dashboard-page.tsx b/apps/web/src/pages/dashboard-page.tsx new file mode 100644 index 0000000..5bc367e --- /dev/null +++ b/apps/web/src/pages/dashboard-page.tsx @@ -0,0 +1,48 @@ +import { Button } from "@workspace/ui/components/button" +import { Slider } from "@workspace/ui/components/slider" +import { Switch } from "@workspace/ui/components/switch" + +export function DashboardPage() { + return ( + <> + +
+
+

Project ready!

+

You may now add components and start building.

+

We've already added the button component for you.

+ +
+
+ + +
+
+ (Press Mod+D to toggle dark mode) +
+ +
+ breakpoint: + mobile + tablet + desktop +
+
+ collapsed: + true + false +
+
+ + ) +} diff --git a/apps/web/src/route-tree.gen.ts b/apps/web/src/route-tree.gen.ts new file mode 100644 index 0000000..e0e762c --- /dev/null +++ b/apps/web/src/route-tree.gen.ts @@ -0,0 +1,109 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AppRouteImport } from './routes/_app' +import { Route as AppIndexRouteImport } from './routes/_app.index' +import { Route as AppSplatRouteImport } from './routes/_app.$' + +const AppRoute = AppRouteImport.update({ + id: '/_app', + getParentRoute: () => rootRouteImport, +} as any) +const AppIndexRoute = AppIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AppRoute, +} as any) +const AppSplatRoute = AppSplatRouteImport.update({ + id: '/$', + path: '/$', + getParentRoute: () => AppRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof AppIndexRoute + '/$': typeof AppSplatRoute +} +export interface FileRoutesByTo { + '/$': typeof AppSplatRoute + '/': typeof AppIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/_app': typeof AppRouteWithChildren + '/_app/$': typeof AppSplatRoute + '/_app/': typeof AppIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/$' + fileRoutesByTo: FileRoutesByTo + to: '/$' | '/' + id: '__root__' | '/_app' | '/_app/$' | '/_app/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + AppRoute: typeof AppRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/_app': { + id: '/_app' + path: '' + fullPath: '/' + preLoaderRoute: typeof AppRouteImport + parentRoute: typeof rootRouteImport + } + '/_app/': { + id: '/_app/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof AppIndexRouteImport + parentRoute: typeof AppRoute + } + '/_app/$': { + id: '/_app/$' + path: '/$' + fullPath: '/$' + preLoaderRoute: typeof AppSplatRouteImport + parentRoute: typeof AppRoute + } + } +} + +interface AppRouteChildren { + AppSplatRoute: typeof AppSplatRoute + AppIndexRoute: typeof AppIndexRoute +} + +const AppRouteChildren: AppRouteChildren = { + AppSplatRoute: AppSplatRoute, + AppIndexRoute: AppIndexRoute, +} + +const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + AppRoute: AppRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx new file mode 100644 index 0000000..aa1b4fc --- /dev/null +++ b/apps/web/src/router.tsx @@ -0,0 +1,11 @@ +import { createRouter } from "@tanstack/react-router" + +import { routeTree } from "./route-tree.gen" + +export function getRouter() { + return createRouter({ + routeTree, + defaultPreload: "intent", + scrollRestoration: true, + }) +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx new file mode 100644 index 0000000..65b8520 --- /dev/null +++ b/apps/web/src/routes/__root.tsx @@ -0,0 +1,146 @@ +import type { ReactNode } from "react" +import { + HeadContent, + Outlet, + Scripts, + createRootRoute, +} from "@tanstack/react-router" +import { + BreakpointReactor, + getBreakpointInitializationScript, +} from "@workspace/ui/hooks/use-breakpoint" + +import appCss from "@/styles/globals.css?url" +import { + AppearanceController, + buildThemeStyleSheet, + THEME_STYLE_ELEMENT_ID, + type UiState, + UiStateProvider, +} from "@workspace/blocks/appearance" +import { SidebarStateProvider } from "@workspace/blocks/layout" +import { getUiState, setUiState } from "@/server/ui-state.functions" +import { HotkeysProvider } from "@tanstack/react-hotkeys" +import { TooltipProvider } from "@workspace/ui/components/tooltip" +import { QueryClientProvider } from "@tanstack/react-query" +import { AppI18nProvider } from "@/i18n/app-i18n-provider" +import { queryClient } from "@/lib/query-client" + +export const Route = createRootRoute({ + loader: () => getUiState(), + head: () => ({ + meta: [ + { charSet: "utf-8" }, + { + name: "viewport", + content: "width=device-width, initial-scale=1", + }, + { title: "Simple React App Kit" }, + ], + links: [ + { + rel: "stylesheet", + href: appCss, + }, + ], + }), + component: RootComponent, +}) + +function RootComponent() { + const initialState = Route.useLoaderData() + + return ( + + + + setUiState({ data: update })} + > + + + + + + + + + + + + + + ) +} + +function getUiInitializationScript(state: UiState) { + return `(() => { + const theme = ${JSON.stringify(state["theme-mode"])}; + const resolvedTheme = + theme === "system" + ? window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : theme; + const root = document.documentElement; + root.classList.remove("ui:light", "ui:dark"); + root.classList.add("ui:" + resolvedTheme); + root.classList.toggle("ui:compacted", ${state["main-compacted"]}); + root.classList.toggle("ui:collapsed", ${state["sidebar-collapsed"]}); + root.style.colorScheme = resolvedTheme; + })();` +} + +function RootDocument({ + children, + initialState, +}: Readonly<{ + children: ReactNode + initialState: UiState +}>) { + const themeMode = initialState["theme-mode"] + const className = [ + themeMode === "system" ? null : `ui:${themeMode}`, + initialState["main-compacted"] ? "ui:compacted" : null, + initialState["sidebar-collapsed"] ? "ui:collapsed" : null, + ] + .filter(Boolean) + .join(" ") + + return ( + + +