feat(web): migrate application shell to TanStack Start

- replace the legacy client-only Vite bootstrap with TanStack Router routes, Start SSR, and Nitro output

- compose the reusable layout, navigation, appearance, chats, and notifications blocks with application-owned data

- persist UI preferences through server functions and initialize theme, sidebar, and breakpoint state before hydration

- add React Query, hotkey, toaster, and responsive dashboard integration

- add application-owned Lingui configuration and catalogs, mount the runtime provider, and lazily enable I18nDevtool in development

- remove superseded HTML, main entry, theme provider, and application ESLint configuration
This commit is contained in:
Maofeng
2026-07-29 21:23:17 +08:00
parent 9e3f1d6b59
commit a9480ad9aa
32 changed files with 1555 additions and 309 deletions
-22
View File
@@ -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,
},
},
])
+7
View File
@@ -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/**"]
}
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-monorepo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
import project from "./i18n.config.json" with { type: "json" }
import { createLinguiConfig } from "@workspace/i18n/lingui-config"
export default createLinguiConfig(project)
+29 -13
View File
@@ -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"
}
}
+19 -14
View File
@@ -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 (
<div className="flex min-h-svh p-6">
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
<div>
<h1 className="font-medium">Project ready!</h1>
<p>You may now add components and start building.</p>
<p>We&apos;ve already added the button component for you.</p>
<Button className="mt-2">Button</Button>
</div>
<div className="text-muted-foreground font-mono text-xs">
(Press <kbd>d</kbd> to toggle dark mode)
</div>
</div>
</div>
<AppLayout
chatThreads={chatThreads}
navigationGroups={primaryNavigationGroups}
notifications={{
queryFn: fetchMockNotifications,
queryKey: notificationsQueryKey,
}}
>
<DashboardPage />
</AppLayout>
)
}
-230
View File
@@ -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<Theme>(() => {
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 (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = React.useContext(ThemeProviderContext)
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider")
}
return context
}
+45
View File
@@ -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 (
<Sonner
theme={themeMode}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
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 }
+46
View File
@@ -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",
},
]
+594
View File
@@ -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,
},
],
},
]
+226
View File
@@ -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<string, unknown> {
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<readonly Notification[]> {
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
}
}
+51
View File
@@ -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 (
<I18nProvider catalogs={appCatalogs} locale={locale} locales={appLocales}>
{children}
{I18nDevtool && (
<Suspense fallback={null}>
<I18nDevtool dark={"ui\\:dark"} />
</Suspense>
)}
</I18nProvider>
)
}
+12
View File
@@ -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,
},
},
})
+14
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
/*eslint-disable*/ import type { Messages } from "@lingui/core"
export const messages = JSON.parse("{}") as Messages
+14
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
/*eslint-disable*/ import type { Messages } from "@lingui/core"
export const messages = JSON.parse("{}") as Messages
-14
View File
@@ -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(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>
)
+48
View File
@@ -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 (
<>
<img
className="h-auto w-full"
src="https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/background/background-3-blur.webp"
/>
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
<div>
<h1 className="font-medium">Project ready!</h1>
<p>You may now add components and start building.</p>
<p>We&apos;ve already added the button component for you.</p>
<Button variant="ghost" className="mt-2">
Button
</Button>
</div>
<div>
<Switch />
<Slider
defaultValue={[75]}
max={100}
step={1}
className="mx-auto w-full max-w-xs"
/>
</div>
<div className="font-mono text-xs text-muted-foreground">
(Press <kbd>Mod+D</kbd> to toggle dark mode)
</div>
<div>
<span className="mr-2">breakpoint:</span>
<span className="hidden mobile:inline">mobile</span>
<span className="hidden tablet:inline">tablet</span>
<span className="hidden desktop:inline">desktop</span>
</div>
<div>
<span className="mr-2">collapsed:</span>
<span className="hidden collapsed:inline">true</span>
<span className="inline collapsed:hidden">false</span>
</div>
</div>
</>
)
}
+109
View File
@@ -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<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+11
View File
@@ -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,
})
}
+146
View File
@@ -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 (
<RootDocument initialState={initialState}>
<AppI18nProvider>
<BreakpointReactor />
<UiStateProvider
initialState={initialState}
onStateChange={(update) => setUiState({ data: update })}
>
<SidebarStateProvider>
<QueryClientProvider client={queryClient}>
<HotkeysProvider>
<TooltipProvider>
<AppearanceController />
<Outlet />
</TooltipProvider>
</HotkeysProvider>
</QueryClientProvider>
</SidebarStateProvider>
</UiStateProvider>
</AppI18nProvider>
</RootDocument>
)
}
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 (
<html
lang="zh-CN"
suppressHydrationWarning
className={className || undefined}
style={themeMode === "system" ? undefined : { colorScheme: themeMode }}
>
<head>
<script
dangerouslySetInnerHTML={{
__html: getBreakpointInitializationScript(),
}}
/>
<script
dangerouslySetInnerHTML={{
__html: getUiInitializationScript(initialState),
}}
/>
<HeadContent />
<style
id={THEME_STYLE_ELEMENT_ID}
dangerouslySetInnerHTML={{
__html: buildThemeStyleSheet(
initialState["theme-light"],
initialState["theme-dark"]
),
}}
/>
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"
import { DashboardPage } from "@/pages/dashboard-page"
export const Route = createFileRoute("/_app/$")({
component: DashboardPage,
})
+7
View File
@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"
import { DashboardPage } from "@/pages/dashboard-page"
export const Route = createFileRoute("/_app/")({
component: DashboardPage,
})
+29
View File
@@ -0,0 +1,29 @@
import { Outlet, createFileRoute } from "@tanstack/react-router"
import { AppLayout } from "@workspace/blocks/layout"
import { chatThreads } from "@/data/chats"
import { primaryNavigationGroups } from "@/data/navigation"
import {
fetchMockNotifications,
notificationsQueryKey,
} from "@/data/notifications"
export const Route = createFileRoute("/_app")({
component: ApplicationLayoutRoute,
})
function ApplicationLayoutRoute() {
return (
<AppLayout
chatThreads={chatThreads}
navigationGroups={primaryNavigationGroups}
notifications={{
queryFn: fetchMockNotifications,
queryKey: notificationsQueryKey,
}}
>
<Outlet />
</AppLayout>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { createServerFn } from "@tanstack/react-start"
import { isUiStateUpdate } from "@workspace/blocks/appearance"
import { readUiState, writeUiState } from "./ui-state.server"
export const getUiState = createServerFn({ method: "GET" }).handler(() => {
return readUiState()
})
export const setUiState = createServerFn({ method: "POST" })
.validator((value: unknown) => {
if (!isUiStateUpdate(value)) {
throw new Error("Invalid UI state update")
}
return value
})
.handler(({ data }) => {
writeUiState(data.key, data.value)
})
+39
View File
@@ -0,0 +1,39 @@
import "@tanstack/react-start/server-only"
import { getCookie, setCookie } from "@tanstack/react-start/server"
import {
type UiState,
type UiStateKey,
uiStateDefinitions,
} from "@workspace/blocks/appearance"
const LEGACY_THEME_COOKIE = "simple-shop-admin-theme"
export function readUiState(): UiState {
return {
"main-compacted": uiStateDefinitions["main-compacted"].parse(
getCookie(uiStateDefinitions["main-compacted"].cookie)
),
"sidebar-collapsed": uiStateDefinitions["sidebar-collapsed"].parse(
getCookie(uiStateDefinitions["sidebar-collapsed"].cookie)
),
"theme-mode": uiStateDefinitions["theme-mode"].parse(
getCookie(uiStateDefinitions["theme-mode"].cookie) ??
getCookie(LEGACY_THEME_COOKIE)
),
"theme-light": uiStateDefinitions["theme-light"].parse(
getCookie(uiStateDefinitions["theme-light"].cookie)
),
"theme-dark": uiStateDefinitions["theme-dark"].parse(
getCookie(uiStateDefinitions["theme-dark"].cookie)
),
}
}
export function writeUiState<K extends UiStateKey>(key: K, value: UiState[K]) {
const definition = uiStateDefinitions[key]
setCookie(definition.cookie, definition.serialize(value), {
path: "/",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 365,
})
}
+48
View File
@@ -0,0 +1,48 @@
@import "@workspace/ui/globals.css";
@import "@workspace/blocks/globals.css";
:root {
--ui-content-top-gap: --spacing(2);
--ui-header-height: --spacing(18);
--ui-sidebar-collapsed-width: --spacing(22);
--ui-sidebar-width: --spacing(74);
}
.ui\:mobile {
--ui-header-height: --spacing(16);
--ui-sidebar-width: 0px;
}
.ui\:tablet,
.ui\:collapsed:not(.ui\:mobile) {
--ui-sidebar-width: var(--ui-sidebar-collapsed-width);
}
.ui\:theme-changing *,
.ui\:theme-changing *::before,
.ui\:theme-changing *::after {
-webkit-transition: none !important;
transition: none !important;
}
@keyframes ui-show-scroll-divider {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@utility scroll-top-divider {
opacity: 0;
}
@supports (animation-timeline: scroll()) {
.scroll-top-divider {
animation: ui-show-scroll-divider 1s steps(1, end) both;
animation-range: 0px var(--ui-content-top-gap, 1px);
animation-timeline: scroll(root block);
}
}
+2 -1
View File
@@ -10,7 +10,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"verbatimModuleSyntax": false,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
@@ -23,6 +23,7 @@
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"],
"@workspace/blocks/*": ["../../packages/blocks/src/*"],
"@workspace/ui/*": ["../../packages/ui/src/*"]
}
},
+1
View File
@@ -7,6 +7,7 @@
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@workspace/blocks/*": ["../../packages/blocks/src/*"],
"@workspace/ui/*": ["../../packages/ui/src/*"]
}
}
+2 -1
View File
@@ -10,6 +10,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
@@ -20,5 +21,5 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "lingui.config.ts"]
}
+23 -1
View File
@@ -1,14 +1,36 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import { tanstackStart } from "@tanstack/react-start/plugin/vite"
import { i18n } from "@workspace/i18n/vite"
import react from "@vitejs/plugin-react"
import { nitro } from "nitro/vite"
import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
tsconfigPaths: true,
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
optimizeDeps: {
include: ["use-sync-external-store/shim/with-selector"],
},
plugins: [
i18n(),
tailwindcss(),
tanstackStart({
router: {
generatedRouteTree: "route-tree.gen.ts",
},
srcDirectory: "src",
}),
react(),
nitro(),
],
server: {
host: "0.0.0.0",
port: 31573,
},
})