新分支:Vite+React19+TS+Tailwind v4 重新搭建,先跑通四级响应式布局壳
不再沿用旧 im_webapp 的技术栈(webpack + class组件 + 15000行全局CSS + 手搓History路由), 按业务重新选型:Vite/React Router/Zustand预留/TanStack Query/保留tinode-sdk真实依赖 (协议客户端本身没有重写的业务理由)。新代码放在 web/ 子目录,旧代码原样保留在仓库根目录, 两者共存到重写完成。 这一步先验证布局壳本身:NavRail(消息/通讯录/收藏/文件助手/通话记录/设置)+ 会话列表 + 主区域 + 详情栏,按设计稿的四档断点(<640/640-959/960-1279/≥1280)用真实浏览器宽度逐档验证 过——两列及以下"列表/主区域二选一+返回键"、三列"详情栏抽屉化"、四列"详情栏常驻"均已用 真实点击/返回验证正确,不是只看了截图。会话列表和聊天内容目前是占位数据,业务数据接入和 Tinode连接是下一步。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4e8f45d7d8
commit
8bcd9f3a5b
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" />
|
||||
<title>MeetingIM</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2211
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"zustand": "^5.0.9",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"tinode-sdk": "^0.25.3",
|
||||
"react-intl": "^7.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^8.2.2",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"@tailwindcss/vite": "^4.1.16"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,40 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from '@/app/AppShell';
|
||||
import { SectionPlaceholder } from '@/app/SectionPlaceholder';
|
||||
import { ConversationView } from '@/features/messages/ConversationView';
|
||||
import { EmptyMain } from '@/features/messages/EmptyMain';
|
||||
import { MessagesLayout } from '@/features/messages/MessagesLayout';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/messages" replace /> },
|
||||
{
|
||||
path: 'messages',
|
||||
element: <MessagesLayout />,
|
||||
children: [
|
||||
{ index: true, element: <EmptyMain /> },
|
||||
{ path: ':topicId', element: <ConversationView /> },
|
||||
],
|
||||
},
|
||||
{ path: 'contacts', element: <SectionPlaceholder title="通讯录" /> },
|
||||
{ path: 'favorites', element: <SectionPlaceholder title="我的收藏" /> },
|
||||
{ path: 'files', element: <SectionPlaceholder title="文件助手" /> },
|
||||
{ path: 'calls', element: <SectionPlaceholder title="通话记录" /> },
|
||||
{ path: 'settings', element: <SectionPlaceholder title="设置" /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { BottomTabBar } from './BottomTabBar';
|
||||
import { NavRail } from './NavRail';
|
||||
|
||||
/**
|
||||
* Top-level chrome: persistent icon rail (>=640px) or bottom tab bar (<640px), wrapping whichever
|
||||
* feature section is active. Each feature section owns its own list/main/detail responsive split
|
||||
* internally (see e.g. features/messages) -- this shell only decides "rail vs tabs", nothing about
|
||||
* columns within a section.
|
||||
*/
|
||||
export function AppShell() {
|
||||
return (
|
||||
<div className="flex h-dvh w-full overflow-hidden bg-surface-page">
|
||||
<div className="hidden sm:flex sm:h-full">
|
||||
<NavRail />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
<div className="sm:hidden">
|
||||
<BottomTabBar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/messages', icon: 'chat_bubble', label: '消息' },
|
||||
{ to: '/contacts', icon: 'contacts', label: '通讯录' },
|
||||
{ to: '/settings', icon: 'person', label: '我的' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Replaces the old app's message-center-view.jsx-embedded 2-tab (消息/工作台) bar: this one is
|
||||
* top-level (visible regardless of which section is open, not just inside the message list) and
|
||||
* has 3 tabs per the new design, since 工作台 no longer exists.
|
||||
*/
|
||||
export function BottomTabBar() {
|
||||
return (
|
||||
<nav className="grid h-[82px] flex-none grid-cols-3 border-t border-border-panel bg-white pb-4.5">
|
||||
{TABS.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center justify-center gap-0.5 text-[11px] no-underline ${
|
||||
isActive ? 'text-accent' : 'text-text-tertiary-3'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon name={tab.icon} className="!text-2xl" />
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html). */
|
||||
export function Icon({ name, className = '' }: { name: string; className?: string }) {
|
||||
return <i className={`material-icons select-none ${className}`}>{name}</i>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface NavRailItem {
|
||||
to: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const TOP_ITEMS: NavRailItem[] = [
|
||||
{ to: '/messages', icon: 'chat_bubble', label: '消息' },
|
||||
{ to: '/contacts', icon: 'contacts', label: '通讯录' },
|
||||
{ to: '/favorites', icon: 'inventory_2', label: '收藏' },
|
||||
{ to: '/files', icon: 'folder', label: '文件助手' },
|
||||
];
|
||||
|
||||
const BOTTOM_ITEMS: NavRailItem[] = [
|
||||
{ to: '/calls', icon: 'phone', label: '通话记录' },
|
||||
{ to: '/settings', icon: 'settings', label: '设置' },
|
||||
];
|
||||
|
||||
function NavRailButton({ item }: { item: NavRailItem }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
title={item.label}
|
||||
className={({ isActive }) =>
|
||||
`relative grid h-11 w-11 place-items-center rounded-[10px] no-underline ${
|
||||
isActive ? 'bg-accent-soft-bg text-accent' : 'text-icon-default'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon name={item.icon} className="!text-2xl" />
|
||||
{item.badge ? (
|
||||
<b className="absolute top-1 right-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-unread px-1 text-[10px] leading-none font-medium text-white">
|
||||
{item.badge}
|
||||
</b>
|
||||
) : null}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
/** Persistent 64px icon rail, visible at >=640px (see App.tsx for the <640 bottom-tab fallback). */
|
||||
export function NavRail({ selfInitial = '我', unreadMessages = 0 }: { selfInitial?: string; unreadMessages?: number }) {
|
||||
return (
|
||||
<nav className="flex h-full w-16 flex-none flex-col items-center border-r border-border-panel bg-surface-rail py-3.5">
|
||||
<div className="mb-5 grid h-[38px] w-[38px] place-items-center rounded-[10px] bg-accent text-base font-medium text-white">
|
||||
{selfInitial}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center gap-1.5">
|
||||
{TOP_ITEMS.map((item) => (
|
||||
<NavRailButton key={item.to} item={item.to === '/messages' ? { ...item, badge: unreadMessages } : item} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
{BOTTOM_ITEMS.map((item) => (
|
||||
<NavRailButton key={item.to} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function SectionPlaceholder({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
|
||||
{title}(后续阶段实现)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Link, useOutletContext, useParams } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
|
||||
interface OutletCtx {
|
||||
onToggleDetail: () => void;
|
||||
}
|
||||
|
||||
/** Placeholder chat surface -- real header/bubbles/composer land in Phase 1. */
|
||||
export function ConversationView() {
|
||||
const { topicId } = useParams();
|
||||
const { onToggleDetail } = useOutletContext<OutletCtx>();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-surface-chat">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Only the <960 tiers hide the list column, so only there do we need our own way back. */}
|
||||
<Link to="/messages" className="grid h-9.5 w-9.5 place-items-center text-text-heading md:hidden">
|
||||
<Icon name="arrow_back_ios_new" />
|
||||
</Link>
|
||||
<strong className="text-base font-semibold text-text-heading">{topicId}</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleDetail}
|
||||
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1 md:bg-accent-soft-bg md:text-accent lg:bg-transparent lg:text-text-tertiary-1"
|
||||
>
|
||||
<Icon name="info" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-text-tertiary-4">
|
||||
消息流占位(阶段1实现)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Icon } from '@/app/Icon';
|
||||
|
||||
export function EmptyMain() {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-text-tertiary-4">
|
||||
<Icon name="forum" className="!text-5xl" />
|
||||
<span className="text-sm">选择一个会话开始聊天</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Outlet, useParams } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
|
||||
const PLACEHOLDER_CHATS = [
|
||||
{ id: 'grp1', name: '项目协作群', summary: '陈明远:@我 明天上午十点开项目会,请准时参加', time: '10:24', unread: 5 },
|
||||
{ id: 'p1', name: '接语彤', summary: '我:收到,我会提前准备材料。', time: '10:12', unread: 0 },
|
||||
{ id: 'grp2', name: '产品评审群', summary: '[图片]', time: '09:47', unread: 2 },
|
||||
];
|
||||
|
||||
function ChatListColumn() {
|
||||
const { topicId } = useParams();
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
|
||||
<header className="flex h-15 flex-none items-center justify-between px-5">
|
||||
<h1 className="m-0 text-xl font-semibold text-text-heading">消息</h1>
|
||||
<Icon name="add_comment" className="text-text-heading" />
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{PLACEHOLDER_CHATS.map((chat) => (
|
||||
<a
|
||||
key={chat.id}
|
||||
href={`/messages/${chat.id}`}
|
||||
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item px-4 py-2.5 no-underline ${
|
||||
topicId === chat.id ? 'bg-accent-selected-bg' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<span className="grid h-11 w-11 place-items-center rounded-lg bg-avatar-1 text-base font-medium text-white">
|
||||
{chat.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<strong className="truncate text-[15px] font-medium text-[#272b31]">{chat.name}</strong>
|
||||
<small className="truncate text-[13px] text-text-tertiary-2">{chat.summary}</small>
|
||||
</span>
|
||||
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
|
||||
<time className="text-xs text-text-tertiary-3">{chat.time}</time>
|
||||
{chat.unread ? (
|
||||
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
|
||||
{chat.unread}
|
||||
</b>
|
||||
) : null}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailColumn({ onClose }: { onClose?: () => void }) {
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[300px]">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold">聊天设置</strong>
|
||||
{onClose ? (
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="p-5 text-sm text-text-secondary">详情栏内容占位(阶段1实现)</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The 4-tier responsive split described in the design handoff:
|
||||
* - <640 and 640-959: list OR main, whichever the route implies (topicId present -> main; the
|
||||
* conversation header then needs its own back-to-list affordance, since NavRail alone doesn't
|
||||
* provide one at this tier -- see ConversationView).
|
||||
* - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon.
|
||||
* - >=1280: list + main + detail all docked side by side.
|
||||
*/
|
||||
export function MessagesLayout() {
|
||||
const { topicId } = useParams();
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const showListOnNarrow = !topicId;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className={`h-full w-full md:w-auto md:shrink-0 ${showListOnNarrow ? '' : 'hidden md:flex'}`}>
|
||||
<ChatListColumn />
|
||||
</div>
|
||||
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
|
||||
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v) }} />
|
||||
</div>
|
||||
<div className="hidden h-full lg:block">
|
||||
<DetailColumn />
|
||||
</div>
|
||||
{detailOpen ? (
|
||||
<div className="absolute inset-0 hidden md:block lg:hidden">
|
||||
<div className="absolute inset-0 bg-[#22262d]/25" onClick={() => setDetailOpen(false)} />
|
||||
<div className="absolute inset-y-0 right-0 shadow-[-8px_0_32px_rgba(0,0,0,0.12)]">
|
||||
<DetailColumn onClose={() => setDetailOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Design tokens ported 1:1 from docs/design_handoff_meetingim_web/README.md's "Design Tokens"
|
||||
* section. Values are exact hex/px from the design handoff, not semantic guesses -- keep them in
|
||||
* sync with that doc, not with whatever the previous im_webapp's css/base.css happened to use.
|
||||
*
|
||||
* Breakpoints are intentionally remapped onto Tailwind's own sm/md/lg so every component can just
|
||||
* use the standard `sm:`/`md:`/`lg:` prefixes (min-width, mobile-first) instead of inventing custom
|
||||
* variants: <640 base styles = single column mobile, sm: (>=640) = two column, md: (>=960) = three
|
||||
* column, lg: (>=1280) = four column docked detail panel.
|
||||
*/
|
||||
@theme {
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 960px;
|
||||
--breakpoint-lg: 1280px;
|
||||
|
||||
--font-sans: Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
|
||||
/* Primary / accent */
|
||||
--color-accent: #1677ff;
|
||||
--color-accent-soft-bg: #e3eefe;
|
||||
--color-accent-selected-bg: #e8f1fe;
|
||||
--color-focus-border: #4091ff;
|
||||
|
||||
--color-btn: #368bfa;
|
||||
--color-btn-hover: #1677e8;
|
||||
--color-btn-disabled: #b8d5fa;
|
||||
|
||||
/* Sent bubble */
|
||||
--color-bubble-mine: #b9eb8f;
|
||||
|
||||
/* Surfaces */
|
||||
--color-surface-chat: #eef1f4;
|
||||
--color-surface-content: #f6f7f9;
|
||||
--color-surface-page: #f7f9fc;
|
||||
--color-surface-rail: #f2f4f7;
|
||||
--color-surface-input: #f2f4f7;
|
||||
--color-surface-search: #f1f2f4;
|
||||
|
||||
/* Borders */
|
||||
--color-border-panel: #eceef1;
|
||||
--color-border-row: #f1f2f4;
|
||||
--color-border-list-item: #f4f5f7;
|
||||
|
||||
/* Text */
|
||||
--color-text-title: #14171c;
|
||||
--color-text-heading: #22262d;
|
||||
--color-text-body: #252931;
|
||||
--color-text-secondary: #656b74;
|
||||
--color-text-tertiary-1: #4a4f57;
|
||||
--color-text-tertiary-2: #969ca5;
|
||||
--color-text-tertiary-3: #9ca2aa;
|
||||
--color-text-tertiary-4: #8d949d;
|
||||
--color-text-placeholder-1: #b2b7bf;
|
||||
--color-text-placeholder-2: #a5abb4;
|
||||
|
||||
--color-icon-default: #555c65;
|
||||
--color-icon-arrow: #c2c7ce;
|
||||
|
||||
/* Status */
|
||||
--color-unread: #f34b46;
|
||||
--color-danger-text: #d94b4b;
|
||||
--color-danger-border: #cf3f3f;
|
||||
--color-danger-border-soft: #f3c4c4;
|
||||
--color-online: #1fb36a;
|
||||
--color-offline: #aeb4bd;
|
||||
|
||||
/* Orange (system messages / group owner / warning) */
|
||||
--color-warn: #ff951c;
|
||||
--color-warn-bg: #fff5e5;
|
||||
--color-announce: #f5a623;
|
||||
--color-announce-bg: #fff8e6;
|
||||
--color-announce-text: #6d4c41;
|
||||
|
||||
/* Green (file assistant / success) */
|
||||
--color-success: #16ae54;
|
||||
--color-success-bg: #eefaf3;
|
||||
|
||||
/* Alert strips */
|
||||
--color-error-strip-bg: #ffeef0;
|
||||
--color-error-strip-text: #b51c1c;
|
||||
--color-info-strip-bg: #e8f1fe;
|
||||
--color-info-strip-text: #1a4f9c;
|
||||
|
||||
/* Call surfaces (dark) */
|
||||
--color-call-bg-1: #1b2231;
|
||||
--color-call-bg-2: #1f2a3d;
|
||||
--color-call-bg-3: #3b4a63;
|
||||
|
||||
/* Avatar palette (deterministic per-contact hashing picks from this list) */
|
||||
--color-avatar-1: #3c8df6;
|
||||
--color-avatar-2: #7c5cf6;
|
||||
--color-avatar-3: #2bb673;
|
||||
--color-avatar-4: #f28c3b;
|
||||
--color-avatar-5: #e2537a;
|
||||
--color-avatar-6: #0097a8;
|
||||
--color-avatar-7: #8d6e63;
|
||||
--color-avatar-8: #5c6bc0;
|
||||
--color-avatar-9: #26a69a;
|
||||
--color-avatar-10: #ef6c00;
|
||||
--color-avatar-11: #9ca2aa;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: 0 8px 32px rgba(35, 74, 125, 0.10);
|
||||
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.18);
|
||||
--shadow-menu: 0 12px 36px rgba(0, 0, 0, 0.16);
|
||||
--shadow-bubble: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-btn: 0 4px 12px rgba(54, 139, 250, 0.2);
|
||||
--shadow-focus-ring: 0 0 0 3px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-surface-page);
|
||||
color: var(--color-text-body);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Ported from the previous im_webapp's src/config.js. These values encode real constraints that
|
||||
// must match the backend (server/*.go) -- they are not stylistic choices, so the numbers/strings
|
||||
// themselves carry over unchanged even though this is a from-scratch rewrite.
|
||||
|
||||
/** Version-independent product name (Firebase app name, youth-mode copy, etc.). */
|
||||
export const BASE_APP_NAME = 'MeetingIM';
|
||||
|
||||
/** Name used in the Tinode client's User-Agent string. */
|
||||
export const APP_NAME = `${BASE_APP_NAME}/${__APP_VERSION__}`;
|
||||
|
||||
/**
|
||||
* Tinode API key for the WS handshake ({hi} packet) and every REST call's X-MeetingIM-APIKey
|
||||
* header. Generated with https://github.com/tinode/chat/tree/master/keygen against this
|
||||
* deployment's own API_KEY_SALT -- the fallback below only works against a backend still using
|
||||
* the upstream sample salt, same caveat as the old app.
|
||||
*/
|
||||
export const API_KEY = import.meta.env.VITE_TINODE_API_KEY || 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
|
||||
|
||||
export const KNOWN_HOSTS = { hosted: 'web.118im.com', local: 'localhost:6060' } as const;
|
||||
export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
|
||||
|
||||
export const LOGGING_ENABLED = true;
|
||||
|
||||
/** Minimum time between two keypress notifications, ms. */
|
||||
export const KEYPRESS_DELAY = 3_000;
|
||||
/** Delay before sending a read notification, ms. */
|
||||
export const READ_DELAY = 1_500;
|
||||
|
||||
export const MIN_TAG_LENGTH = 2;
|
||||
export const MAX_TAG_LENGTH = 96;
|
||||
export const MAX_TAG_COUNT = 16;
|
||||
|
||||
export const DEFAULT_P2P_ACCESS_MODE = 'JRWPS';
|
||||
export const NEW_GRP_ACCESS_MODE = 'JRWPSAO';
|
||||
export const CHANNEL_ACCESS_MODE = 'JR';
|
||||
export const NO_ACCESS_MODE = 'N';
|
||||
|
||||
/** Design breakpoints, px. Four tiers per the new design handoff (was a single 640 cut before). */
|
||||
export const BREAKPOINTS = { sm: 640, md: 960, lg: 1280 } as const;
|
||||
|
||||
export const AVATAR_SIZE = 384;
|
||||
export const MAX_AVATAR_BYTES = 4096;
|
||||
export const BROKEN_IMAGE_SIZE = 32;
|
||||
export const NO_DIMENSIONS_VIDEO = 128;
|
||||
|
||||
export const MESSAGES_PAGE = 24;
|
||||
|
||||
export const MAX_INBAND_ATTACHMENT_SIZE = 262_144;
|
||||
export const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23;
|
||||
export const MAX_IMAGE_DIM = 1024;
|
||||
export const IMAGE_PREVIEW_DIM = 64;
|
||||
export const VIDEO_PREVIEW_DIM = 96;
|
||||
export const IMAGE_THUMBNAIL_DIM = 36;
|
||||
export const VIDEO_THUMBNAIL_WIDTH = 48;
|
||||
|
||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
||||
export const MAX_TITLE_LENGTH = 60;
|
||||
export const MAX_TOPIC_DESCRIPTION_LENGTH = 360;
|
||||
export const MAX_PEER_TITLE_LENGTH = 24;
|
||||
|
||||
export const MESSAGE_PREVIEW_LENGTH = 80;
|
||||
export const QUOTED_REPLY_LENGTH = 30;
|
||||
export const FORWARDED_PREVIEW_LENGTH = 84;
|
||||
export const EDIT_PREVIEW_LENGTH = 48;
|
||||
|
||||
export const MIN_DURATION = 2_000;
|
||||
export const MAX_DURATION = 600_000;
|
||||
|
||||
export const IMMEDIATE_P2P_SUBSCRIPTION = false;
|
||||
|
||||
export const CLICKABLE_URL_SCHEMES = ['http', 'https', 'ftp', 'ftps'];
|
||||
|
||||
export const QRCODE_SIZE = 128;
|
||||
|
||||
export const WAKE_UP_TIMEOUT = 80_000;
|
||||
export const WAKE_UP_TICK = 1_000;
|
||||
|
||||
export const TOAST_DURATION = 3_000;
|
||||
Vendored
+133
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* `tinode-sdk` ships as a plain UMD JS bundle with no bundled TypeScript types. This is a
|
||||
* deliberately partial ambient declaration covering only the surface this app actually calls --
|
||||
* grown incrementally as later phases touch more of the SDK, not an attempt at a full port of the
|
||||
* upstream JS. Method/property names and shapes below are verified against the installed
|
||||
* `node_modules/tinode-sdk/umd/tinode.dev.js` (v0.25.3), not guessed from the upstream docs, since
|
||||
* this project's own history has repeatedly found the two disagree (e.g. this deployment's REST
|
||||
* layer uses `X-MeetingIM-APIKey`, not the upstream `X-Tinode-APIKey`).
|
||||
*/
|
||||
declare module 'tinode-sdk' {
|
||||
export interface TinodeOptions {
|
||||
appName: string;
|
||||
host: string;
|
||||
apiKey: string;
|
||||
transport?: 'ws' | 'lp' | null;
|
||||
secure?: boolean;
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
export type AuthLevel = 'anon' | 'auth' | 'root';
|
||||
|
||||
export class AccessMode {
|
||||
mode: string;
|
||||
given: string;
|
||||
want: string;
|
||||
isOwner(): boolean;
|
||||
isAdmin(): boolean;
|
||||
isSharer(): boolean;
|
||||
isWriter(): boolean;
|
||||
isReader(): boolean;
|
||||
isMuted(): boolean;
|
||||
isDeleter(): boolean;
|
||||
isJoiner(): boolean;
|
||||
isPresencer(): boolean;
|
||||
}
|
||||
|
||||
export interface TopicDescPublic {
|
||||
fn?: string;
|
||||
name?: string;
|
||||
photo?: { data?: string; type?: string; ref?: string };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TopicDescPrivate {
|
||||
comment?: string;
|
||||
pinned?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** A row from `MeTopic.contacts()` -- a live, mutable contact/subscription snapshot. */
|
||||
export interface Contact {
|
||||
topic: string;
|
||||
user?: string;
|
||||
updated?: Date;
|
||||
touched?: Date;
|
||||
online?: boolean;
|
||||
acs?: AccessMode;
|
||||
seq?: number;
|
||||
read?: number;
|
||||
recv?: number;
|
||||
unread?: number;
|
||||
public?: TopicDescPublic | null;
|
||||
private?: TopicDescPrivate | null;
|
||||
latestMessage?: () => StoredMessage | undefined;
|
||||
}
|
||||
|
||||
export interface StoredMessage {
|
||||
seq: number;
|
||||
from?: string;
|
||||
ts?: Date;
|
||||
content?: unknown;
|
||||
head?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class Topic {
|
||||
name: string;
|
||||
subscribe(): Promise<unknown>;
|
||||
leave(unsub?: boolean): Promise<unknown>;
|
||||
getMeta(query: unknown): Promise<unknown>;
|
||||
publish(data: unknown, head?: Record<string, unknown>): Promise<unknown>;
|
||||
isSubscribed(): boolean;
|
||||
}
|
||||
|
||||
export class MeTopic extends Topic {
|
||||
contacts(callback: (contact: Contact) => void): void;
|
||||
pinnedTopicRank(topic: string): number;
|
||||
getMeta(query: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
export class Tinode {
|
||||
constructor(options: TinodeOptions, onCompletion?: () => void);
|
||||
|
||||
_connection?: { sendText: (msg: string) => unknown; __tenantPatched?: boolean };
|
||||
|
||||
setHumanLanguage(locale: string): void;
|
||||
enableLogging(enabled: boolean, verbose?: boolean): void;
|
||||
|
||||
connect(host?: string): Promise<void>;
|
||||
disconnect(): void;
|
||||
isConnected(): boolean;
|
||||
isAuthenticated(): boolean;
|
||||
getAuthenticatedUserID(): string | undefined;
|
||||
|
||||
login(scheme: string, secret: string, cred?: unknown): Promise<{ ctrl: { code: number; params?: Record<string, unknown> } }>;
|
||||
loginToken(token: string, cred?: unknown): Promise<{ ctrl: { code: number } }>;
|
||||
getAuthToken(): { token: string; expires: Date } | null;
|
||||
|
||||
getMeTopic(): MeTopic;
|
||||
getTopic(topicName: string): Topic;
|
||||
|
||||
account(userId: string, scheme: string, secret: string, login: boolean, desc?: unknown): Promise<unknown>;
|
||||
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: (err?: Error) => void;
|
||||
onLogin?: (code: number, text: string) => void;
|
||||
onNetworkProbe?: () => void;
|
||||
onAutoreconnectIteration?: (seconds: number, promise?: Promise<unknown>) => void;
|
||||
}
|
||||
|
||||
export const Drafty: {
|
||||
init(text: string): unknown;
|
||||
parse(text: string): unknown;
|
||||
toPlainText(content: unknown): string;
|
||||
preview(content: unknown, length: number, cutFirst?: boolean): unknown;
|
||||
videoCall(audioOnly: boolean): unknown;
|
||||
isValid(content: unknown): boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export const TheCard: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** Injected by vite.config.ts's `define`, from package.json's version field. */
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_TINODE_API_KEY?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
import pkg from './package.json' with { type: 'json' }
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
server: {
|
||||
port: 8094,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user