commit f771369e97bc990a201afc91fccb13e40623f364 Author: liuliu Date: Mon Dec 15 12:45:54 2025 +0800 init repo diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7f8bacd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.git +.gitignore +*.md +.gemini diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2555a06 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Your domain name for Traefik +DOMAIN_NAME=wengyeyulu.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fca1b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +*.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d808886 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM oven/bun:1 AS base +WORKDIR /app + +# Install dependencies +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile --production + +# Copy source code +COPY . . + +# Expose port +EXPOSE 3000 + +# Start the application +CMD ["bun", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed6241e --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# 翁爷语录 (WengyeYulu) - 聊天记录归档查看器 + +这是一个基于 **Bun** + **Elysia** + **React** 构建的全栈聊天记录归档与查看应用。它演示了如何使用现代高性能 TypeScript 运行时 Bun 构建端到端类型安全(End-to-End Type Safety)的 Web 应用。 + +## 🛠 技术栈 + +本项目采用以下前沿技术构建: + +### 核心运行时 +- **[Bun](https://bun.sh)**: 极速的 TypeScript 运行时、打包器和包管理器。 + +### 后端 (Backend) +- **[ElysiaJS](https://elysiajs.com)**: 基于 Bun 的高性能 Web 框架。 +- **[Bun:sqlite](https://bun.sh/docs/api/sqlite)**: Bun 内置的高性能 SQLite 驱动。 +- **TypeScript**: 全后端类型支持。 + +### 前端 (Frontend) +- **[React 19](https://react.dev)**: 用于构建用户界面。 +- **[@elysiajs/eden](https://elysiajs.com/eden/overview.html)**: 实现了**端到端类型安全**。前端直接调用后端 API 类型,无需手动编写 Fetch 代码或类型定义。 +- **[TailwindCSS](https://tailwindcss.com)**: 实用优先的 CSS 框架。 +- **[Lucide React](https://lucide.dev)**: 漂亮的图标库。 + +### 部署 (Deployment) +- **Docker**: 容器化部署。 +- **Docker Compose**: 服务编排。 +- **Traefik**: 支持并预配置了 Traefik 标签,方便作为反向代理接入。 + +## ✨ 功能特性 + +- **端到端类型安全**: 后端修改 API 定义,前端类型自动更新,开发体验极佳。 +- **微信风格 UI**: 仿微信 PC 端界面,简洁熟悉。 +- **高性能**: 利用 Bun 和 SQLite 的高性能特性,秒级加载大量聊天记录。 +- **全文搜索**: 支持实时搜索聊天内容。 +- **数据导入**: 包含管理员后台,支持导入 JSON 格式的聊天记录。 + +## 🚀 快速开始 (开发模式) + +### 前置要求 +请确保已安装 [Bun](https://bun.sh/docs/installation)。 + +### 1. 安装依赖 +```bash +bun install +``` + +### 2. 启动开发服务器 +```bash +bun dev +``` +服务器将在 `http://localhost:3000` 启动。 +* Bun 会自动处理前端资源的 HMR(热更新)。 +* 后端 API 变更也会自动重启。 + +## 🐳 Docker 部署 (生产环境) + +本项目提供了完整的 Docker 支持,包含 Traefik 标签配置,适合在生产环境中部署。 + +### 1. 配置环境变量 +复制示例配置文件并设置你的域名: + +```bash +cp .env.example .env +``` +打开 `.env` 文件,修改 `DOMAIN_NAME` 为你的实际域名(例如 `chat.example.com`)。如果不修改,Traefik 将默认监听 `example.com`。 + +### 2. 启动服务 +使用 Docker Compose 一键启动: + +```bash +docker-compose up -d --build +``` + +### 3. 数据持久化 +所有数据(SQLite 数据库文件)将保存在项目根目录下的 `./data` 文件夹中,容器重启或删除后数据不会丢失。 + +## 📂 项目结构 + +``` +src/ +├── backend/ # 后端代码 +│ ├── api.ts # Elysia API 定义 +│ ├── db.ts # 数据库连接与 Schema +│ └── index.ts # 服务端入口 +└── frontend/ # 前端代码 + ├── client.ts # Eden 客户端 (API 类型推断) + ├── components/ # React 组件 + ├── App.tsx # 主应用逻辑 + └── index.tsx # 前端入口 +``` + +## 📝 许可证 + +MIT License diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..f3c5cd4 --- /dev/null +++ b/build.ts @@ -0,0 +1,149 @@ +#!/usr/bin/env bun +import plugin from "bun-plugin-tailwind"; +import { existsSync } from "fs"; +import { rm } from "fs/promises"; +import path from "path"; + +if (process.argv.includes("--help") || process.argv.includes("-h")) { + console.log(` +🏗️ Bun Build Script + +Usage: bun run build.ts [options] + +Common Options: + --outdir Output directory (default: "dist") + --minify Enable minification (or --minify.whitespace, --minify.syntax, etc) + --sourcemap Sourcemap type: none|linked|inline|external + --target Build target: browser|bun|node + --format Output format: esm|cjs|iife + --splitting Enable code splitting + --packages Package handling: bundle|external + --public-path Public path for assets + --env Environment handling: inline|disable|prefix* + --conditions Package.json export conditions (comma separated) + --external External packages (comma separated) + --banner Add banner text to output + --footer Add footer text to output + --define Define global constants (e.g. --define.VERSION=1.0.0) + --help, -h Show this help message + +Example: + bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom +`); + process.exit(0); +} + +const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase()); + +const parseValue = (value: string): any => { + if (value === "true") return true; + if (value === "false") return false; + + if (/^\d+$/.test(value)) return parseInt(value, 10); + if (/^\d*\.\d+$/.test(value)) return parseFloat(value); + + if (value.includes(",")) return value.split(",").map(v => v.trim()); + + return value; +}; + +function parseArgs(): Partial { + const config: Partial = {}; + const args = process.argv.slice(2); + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === undefined) continue; + if (!arg.startsWith("--")) continue; + + if (arg.startsWith("--no-")) { + const key = toCamelCase(arg.slice(5)); + config[key] = false; + continue; + } + + if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) { + const key = toCamelCase(arg.slice(2)); + config[key] = true; + continue; + } + + let key: string; + let value: string; + + if (arg.includes("=")) { + [key, value] = arg.slice(2).split("=", 2) as [string, string]; + } else { + key = arg.slice(2); + value = args[++i] ?? ""; + } + + key = toCamelCase(key); + + if (key.includes(".")) { + const [parentKey, childKey] = key.split("."); + config[parentKey] = config[parentKey] || {}; + config[parentKey][childKey] = parseValue(value); + } else { + config[key] = parseValue(value); + } + } + + return config; +} + +const formatFileSize = (bytes: number): string => { + const units = ["B", "KB", "MB", "GB"]; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; +}; + +console.log("\n🚀 Starting build process...\n"); + +const cliConfig = parseArgs(); +const outdir = cliConfig.outdir || path.join(process.cwd(), "dist"); + +if (existsSync(outdir)) { + console.log(`🗑️ Cleaning previous build at ${outdir}`); + await rm(outdir, { recursive: true, force: true }); +} + +const start = performance.now(); + +const entrypoints = [...new Bun.Glob("**.html").scanSync("src")] + .map(a => path.resolve("src", a)) + .filter(dir => !dir.includes("node_modules")); +console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`); + +const result = await Bun.build({ + entrypoints, + outdir, + plugins: [plugin], + minify: true, + target: "browser", + sourcemap: "linked", + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, + ...cliConfig, +}); + +const end = performance.now(); + +const outputTable = result.outputs.map(output => ({ + File: path.relative(process.cwd(), output.path), + Type: output.kind, + Size: formatFileSize(output.size), +})); + +console.table(outputTable); +const buildTime = (end - start).toFixed(2); + +console.log(`\n✅ Build completed in ${buildTime}ms\n`); diff --git a/bun-env.d.ts b/bun-env.d.ts new file mode 100644 index 0000000..72f1c26 --- /dev/null +++ b/bun-env.d.ts @@ -0,0 +1,17 @@ +// Generated by `bun init` + +declare module "*.svg" { + /** + * A path to the SVG file + */ + const path: `${string}.svg`; + export = path; +} + +declare module "*.module.css" { + /** + * A record of class names to their corresponding CSS module classes + */ + const classes: { readonly [key: string]: string }; + export = classes; +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..179496a --- /dev/null +++ b/bun.lock @@ -0,0 +1,116 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-react-template", + "dependencies": { + "@elysiajs/eden": "^1.4.5", + "bun-plugin-tailwind": "^0.1.2", + "clsx": "^2.1.1", + "elysia": "^1.4.19", + "lucide-react": "^0.561.0", + "react": "^19", + "react-dom": "^19", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.11", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/react": "^19", + "@types/react-dom": "^19", + }, + }, + }, + "packages": { + "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "https://mirrors.cloud.tencent.com/npm/@borewit/text-codec/-/text-codec-0.1.1.tgz", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], + + "@elysiajs/eden": ["@elysiajs/eden@1.4.5", "https://mirrors.cloud.tencent.com/npm/@elysiajs/eden/-/eden-1.4.5.tgz", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-hIOeH+S5NU/84A7+t8yB1JjxqjmzRkBF9fnLn6y+AH8EcF39KumOAnciMhIOkhhThVZvXZ3d+GsizRc+Fxoi8g=="], + + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.4.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-2Ie4jDGvNGuPSD+pyyBKL8dJmX+bZfDNYEalwgROImVtwB1XYAatJK20dMaRlPA7jOhjvS9Io+4IZAJu7Js0AA=="], + + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.4.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-4/BJojT8hk5g6Gecjn5yI7y96/+9Mtzsvdp9+2dcy9sTMdlV7jBvDzswqyJPZyQqw0F3HV3Vu9XuMubZwKd9lA=="], + + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.4.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-ZYxzIOCDqylTMsnWYERjKMMuK2b4an4qbloBmUZTwLHmVzos00yrhtpitZhJBgH6yB/l4Q5eoJ2W98UKtFFeiQ=="], + + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-8DUIlanftMdFxLGq2FxwKwfrp8O4ZofF/8Oc6lxCyEFmg2hixbHhL04+fPfJIi5D4hZloynxZdwTeDbGv/Kc4A=="], + + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-6UtmM4wXgRKz+gnLZEfddfsuBSVQpJr09K12e5pbdnLzeWgXYlBT5FG8S7SVn1t6cbgBMnigEsFjWwfTuMNoCw=="], + + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-linux-x64/-/bun-linux-x64-1.3.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-03iSDMqdrmIFAsvsRptq+A7EGNjkg20dNzPnqxAlXHk5rc1PeIRWIP0eIn0i3nI6mmdj33mimf9AGr0+d0lKMg=="], + + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ZMGPbFPqmG/VYJv61D+Y1V7T23jPK57vYl7yYLakmkTRjG6vcJ0Akhb2qR1iW94rHvfEBjeuVDAZBp8Qp9oyWA=="], + + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-xUXPuJHndGhk4K3Cx1FgTyTgDZOn+ki3eWvdXYqKdfi0EaNA9KpUq+/vUtpJbZRjzpHs9L+OJcdDILq5H0LX4g=="], + + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-qsGSSlNsxiX8lAayK2uYCfMLtqu776F0nn7qoyzg9Ti7mElM3woNh7RtGClTwQ6qsp5/UvgqT9g4pLaDHmqJFg=="], + + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-windows-x64/-/bun-windows-x64-1.3.4.tgz", { "os": "win32", "cpu": "x64" }, "sha512-nswsuN6+HZPim6x4tFpDFpMa/qpTKfywbGvCkzxwrbJO9MtpuW/54NA1nFbHhpV14OLU0xuxyBj2PK4FHq4MlA=="], + + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.4.tgz", { "os": "win32", "cpu": "x64" }, "sha512-ZQiSDFfSUdOrPTiL2GvkxlC/kMED4fsJwdZnwJK6S9ylXnk9xY/9ZXfe1615SFLQl2LsVRzJAtjQLeM0BifIKQ=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "https://mirrors.cloud.tencent.com/npm/@sinclair/typebox/-/typebox-0.34.41.tgz", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "https://mirrors.cloud.tencent.com/npm/@tokenizer/inflate/-/inflate-0.4.1.tgz", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "https://mirrors.cloud.tencent.com/npm/@tokenizer/token/-/token-0.3.0.tgz", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/bun": ["@types/bun@1.3.4", "https://mirrors.cloud.tencent.com/npm/@types/bun/-/bun-1.3.4.tgz", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], + + "@types/node": ["@types/node@25.0.1", "https://mirrors.cloud.tencent.com/npm/@types/node/-/node-25.0.1.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg=="], + + "@types/react": ["@types/react@19.2.7", "https://mirrors.cloud.tencent.com/npm/@types/react/-/react-19.2.7.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "https://mirrors.cloud.tencent.com/npm/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "bun": ["bun@1.3.4", "https://mirrors.cloud.tencent.com/npm/bun/-/bun-1.3.4.tgz", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.4", "@oven/bun-darwin-x64": "1.3.4", "@oven/bun-darwin-x64-baseline": "1.3.4", "@oven/bun-linux-aarch64": "1.3.4", "@oven/bun-linux-aarch64-musl": "1.3.4", "@oven/bun-linux-x64": "1.3.4", "@oven/bun-linux-x64-baseline": "1.3.4", "@oven/bun-linux-x64-musl": "1.3.4", "@oven/bun-linux-x64-musl-baseline": "1.3.4", "@oven/bun-windows-x64": "1.3.4", "@oven/bun-windows-x64-baseline": "1.3.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-xV6KgD5ImquuKsoghzbWmYzeCXmmSgN6yJGz444hri2W+NGKNRFUNrEhy9+/rRXbvNA2qF0K0jAwqFNy1/GhBg=="], + + "bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "https://mirrors.cloud.tencent.com/npm/bun-plugin-tailwind/-/bun-plugin-tailwind-0.1.2.tgz", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], + + "bun-types": ["bun-types@1.3.4", "https://mirrors.cloud.tencent.com/npm/bun-types/-/bun-types-1.3.4.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + + "clsx": ["clsx@2.1.1", "https://mirrors.cloud.tencent.com/npm/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cookie": ["cookie@1.1.1", "https://mirrors.cloud.tencent.com/npm/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "csstype": ["csstype@3.2.3", "https://mirrors.cloud.tencent.com/npm/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "https://mirrors.cloud.tencent.com/npm/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "elysia": ["elysia@1.4.19", "https://mirrors.cloud.tencent.com/npm/elysia/-/elysia-1.4.19.tgz", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "0.2.5", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-DZb9y8FnWyX5IuqY44SvqAV0DjJ15NeCWHrLdgXrKgTPDPsl3VNwWHqrEr9bmnOCpg1vh6QUvAX/tcxNj88jLA=="], + + "exact-mirror": ["exact-mirror@0.2.5", "https://mirrors.cloud.tencent.com/npm/exact-mirror/-/exact-mirror-0.2.5.tgz", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-u8Wu2lO8nio5lKSJubOydsdNtQmH8ENba5m0nbQYmTvsjksXKYIS1nSShdDlO8Uem+kbo+N6eD5I03cpZ+QsRQ=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "https://mirrors.cloud.tencent.com/npm/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "file-type": ["file-type@21.1.1", "https://mirrors.cloud.tencent.com/npm/file-type/-/file-type-21.1.1.tgz", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg=="], + + "ieee754": ["ieee754@1.2.1", "https://mirrors.cloud.tencent.com/npm/ieee754/-/ieee754-1.2.1.tgz", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "lucide-react": ["lucide-react@0.561.0", "https://mirrors.cloud.tencent.com/npm/lucide-react/-/lucide-react-0.561.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A=="], + + "memoirist": ["memoirist@0.4.0", "https://mirrors.cloud.tencent.com/npm/memoirist/-/memoirist-0.4.0.tgz", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + + "ms": ["ms@2.1.3", "https://mirrors.cloud.tencent.com/npm/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "openapi-types": ["openapi-types@12.1.3", "https://mirrors.cloud.tencent.com/npm/openapi-types/-/openapi-types-12.1.3.tgz", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "react": ["react@19.2.3", "https://mirrors.cloud.tencent.com/npm/react/-/react-19.2.3.tgz", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "react-dom": ["react-dom@19.2.3", "https://mirrors.cloud.tencent.com/npm/react-dom/-/react-dom-19.2.3.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + + "scheduler": ["scheduler@0.27.0", "https://mirrors.cloud.tencent.com/npm/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "strtok3": ["strtok3@10.3.4", "https://mirrors.cloud.tencent.com/npm/strtok3/-/strtok3-10.3.4.tgz", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], + + "tailwind-merge": ["tailwind-merge@3.4.0", "https://mirrors.cloud.tencent.com/npm/tailwind-merge/-/tailwind-merge-3.4.0.tgz", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + + "tailwindcss": ["tailwindcss@4.1.18", "https://mirrors.cloud.tencent.com/npm/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + + "token-types": ["token-types@6.1.1", "https://mirrors.cloud.tencent.com/npm/token-types/-/token-types-6.1.1.tgz", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "https://mirrors.cloud.tencent.com/npm/uint8array-extras/-/uint8array-extras-1.5.0.tgz", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "undici-types": ["undici-types@7.16.0", "https://mirrors.cloud.tencent.com/npm/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..8877354 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ + +[serve.static] +plugins = ["bun-plugin-tailwind"] +env = "BUN_PUBLIC_*" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7e0d299 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + app: + build: . + ports: + - "3000:3000" + volumes: + - ./data:/app/data + environment: + - NODE_ENV=production + labels: + - "traefik.enable=true" + - "traefik.http.routers.wengyeyulu.rule=Host(`${DOMAIN_NAME:-example.com}`)" + - "traefik.http.routers.wengyeyulu.entrypoints=web" + - "traefik.http.services.wengyeyulu.loadbalancer.server.port=3000" diff --git a/package.json b/package.json new file mode 100644 index 0000000..cf4ae41 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "bun-react-template", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --hot src/backend/index.ts", + "start": "NODE_ENV=production bun src/backend/index.ts", + "build": "bun run build.ts" + }, + "dependencies": { + "@elysiajs/eden": "^1.4.5", + "bun-plugin-tailwind": "^0.1.2", + "clsx": "^2.1.1", + "elysia": "^1.4.19", + "lucide-react": "^0.561.0", + "react": "^19", + "react-dom": "^19", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.11" + }, + "devDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/bun": "latest" + } +} \ No newline at end of file diff --git a/src/backend/api.ts b/src/backend/api.ts new file mode 100644 index 0000000..b2c8695 --- /dev/null +++ b/src/backend/api.ts @@ -0,0 +1,106 @@ +import { Elysia } from "elysia"; +import { db } from "./db"; + +// Define the API app +export const api = new Elysia({ prefix: "/api" }) + .get("/conversations", () => { + const conversations = db.query(` + SELECT + c.id, + c.unread_count as unreadCount, + u.id as userId, + u.name as userName, + u.avatar as userAvatar + FROM conversations c + JOIN users u ON c.user_id = u.id + `).all() as any[]; + + const result = conversations.map(conv => { + const lastMsg = db.query(`SELECT * FROM messages WHERE conversation_id = $cid ORDER BY timestamp DESC LIMIT 1`).get({ $cid: conv.id }) as any; + const messages = lastMsg ? [{ + id: lastMsg.id, + content: lastMsg.content, + senderId: lastMsg.sender_id, + timestamp: lastMsg.timestamp, + type: lastMsg.type + }] : []; + + return { + id: conv.id, + unreadCount: conv.unreadCount, + user: { + id: conv.userId, + name: conv.userName, + avatar: conv.userAvatar + }, + messages: messages + }; + }); + + return result; + }) + .get("/messages/:conversationId", ({ params, query }) => { + const cid = params.conversationId; + const limit = query.limit ? parseInt(query.limit as string) : 10; + const before = query.before; + const queryParam = query.q; + const startDate = query.startDate; + const endDate = query.endDate; + + let sql = ` + SELECT m.*, u.name as senderName, u.avatar as senderAvatar + FROM messages m + LEFT JOIN users u ON m.sender_id = u.id + WHERE conversation_id = $cid + `; + let sqlParams: any = { $cid: cid, $limit: limit }; + + if (before) { + sql += ` AND timestamp < $before`; + sqlParams.$before = parseInt(before as string); + } + + if (queryParam) { + sql += ` AND content LIKE $q`; + sqlParams.$q = `%${queryParam as string}%`; + } + + if (startDate) { + sql += ` AND timestamp >= $startDate`; + sqlParams.$startDate = parseInt(startDate as string); + } + + if (endDate) { + sql += ` AND timestamp <= $endDate`; + sqlParams.$endDate = parseInt(endDate as string); + } + + sql += ` ORDER BY timestamp DESC LIMIT $limit`; + + const messages = db.query(sql).all(sqlParams) as any[]; + messages.reverse(); + + return messages.map(m => ({ + id: m.id, + content: m.content, + senderId: m.sender_id, + timestamp: m.timestamp, + type: m.type, + senderName: m.senderName, + senderAvatar: m.senderAvatar + })); + }) + .post("/import", async ({ body }) => { + try { + const data = body; + const { importChatData } = await import("./db"); + // @ts-ignore + const count = importChatData(data); + return { success: true, messageCount: count }; + } catch (err: any) { + console.error('Import failed:', err); + throw new Error(err.message); + } + }); + +export type App = typeof api; diff --git a/src/backend/db.ts b/src/backend/db.ts new file mode 100644 index 0000000..ea1f267 --- /dev/null +++ b/src/backend/db.ts @@ -0,0 +1,155 @@ +import { Database } from "bun:sqlite"; + +import { mkdirSync } from "fs"; + +// Ensure data directory exists +try { + mkdirSync("data"); +} catch (e) { } + +export const db = new Database("data/chat.db"); + +export function initDB() { + // Users + db.run(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + avatar TEXT NOT NULL + ) + `); + + // Conversations + db.run(` + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + unread_count INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `); + + // Messages + db.run(` + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + content TEXT NOT NULL, + sender_id TEXT NOT NULL, + timestamp INTEGER NOT NULL, + type TEXT DEFAULT 'text', + FOREIGN KEY (conversation_id) REFERENCES conversations(id) + ) + `); + + // Seed data if empty + const userCount = db.query("SELECT count(*) as count FROM users").get() as { count: number }; + if (userCount.count === 0) { + console.log("🌱 Seeding database..."); + + // Insert Users + const insertUser = db.prepare("INSERT INTO users (id, name, avatar) VALUES ($id, $name, $avatar)"); + const users = [ + { $id: 'me', $name: '我', $avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix' }, + { $id: 'u1', $name: '老王', $avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Jack' }, + { $id: 'u2', $name: '李安', $avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka' }, + { $id: 'u3', $name: '产品经理', $avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Trouble' }, + ]; + users.forEach(u => insertUser.run(u)); + + // Insert Conversations + const insertConv = db.prepare("INSERT INTO conversations (id, user_id, unread_count) VALUES ($id, $userId, $unread)"); + insertConv.run({ $id: 'c1', $userId: 'u1', $unread: 2 }); + insertConv.run({ $id: 'c2', $userId: 'u2', $unread: 0 }); + insertConv.run({ $id: 'c3', $userId: 'u3', $unread: 5 }); + + // Insert Messages + const insertMsg = db.prepare("INSERT INTO messages (id, conversation_id, content, sender_id, timestamp, type) VALUES ($id, $cid, $content, $sender, $ts, $type)"); + const now = Date.now(); + + // c1 messages + insertMsg.run({ $id: 'm1', $cid: 'c1', $content: '周末有空去钓鱼吗?', $sender: 'u1', $ts: now - 1000 * 60 * 60 * 2, $type: 'text' }); + insertMsg.run({ $id: 'm2', $cid: 'c1', $content: '上次那个水库不错。', $sender: 'u1', $ts: now - 1000 * 60 * 60 * 2 + 5000, $type: 'text' }); + insertMsg.run({ $id: 'm3', $cid: 'c1', $content: '可以啊,几点出发?', $sender: 'me', $ts: now - 1000 * 60 * 30, $type: 'text' }); + insertMsg.run({ $id: 'm4', $cid: 'c1', $content: '早上6点吧,老地方见。', $sender: 'u1', $ts: now - 1000 * 60 * 5, $type: 'text' }); + + // c2 messages + insertMsg.run({ $id: 'm21', $cid: 'c2', $content: '方案我已经发你邮箱了,记得看一下。', $sender: 'u2', $ts: now - 1000 * 60 * 60 * 24, $type: 'text' }); + insertMsg.run({ $id: 'm22', $cid: 'c2', $content: '好的,我晚上回去看。', $sender: 'me', $ts: now - 1000 * 60 * 60 * 23, $type: 'text' }); + + // c3 messages + insertMsg.run({ $id: 'm31', $cid: 'c3', $content: '这个需求还要再改一下。', $sender: 'u3', $ts: now - 1000 * 60 * 10, $type: 'text' }); + insertMsg.run({ $id: 'm32', $cid: 'c3', $content: '老板说要五彩斑斓的黑。', $sender: 'u3', $ts: now - 1000 * 60 * 9, $type: 'text' }); + } +} + +export function importChatData(data: any) { + const { session, messages } = data; + + // 1. Ensure "Group" User exists (The chat itself is treated as a user for the list) + const upsertUser = db.prepare(` + INSERT INTO users (id, name, avatar) VALUES ($id, $name, $avatar) + ON CONFLICT(id) DO UPDATE SET name=excluded.name + `); + + const groupAvatar = 'https://api.dicebear.com/7.x/identicon/svg?seed=' + session.wxid; + upsertUser.run({ $id: session.wxid, $name: session.displayName || session.nickname, $avatar: groupAvatar }); + + // 2. Upsert Conversation + const upsertConv = db.prepare(` + INSERT INTO conversations (id, user_id, unread_count) VALUES ($id, $userId, 0) + ON CONFLICT(id) DO NOTHING + `); + upsertConv.run({ $id: session.wxid, $userId: session.wxid }); + + // 3. Process Messages Transaction + const insertMessage = db.prepare(` + INSERT INTO messages (id, conversation_id, content, sender_id, timestamp, type) + VALUES ($id, $cid, $content, $sender, $ts, $type) + ON CONFLICT(id) DO UPDATE SET + content=excluded.content, + sender_id=excluded.sender_id, + timestamp=excluded.timestamp, + type=excluded.type + `); + + let importedCount = 0; + const transaction = db.transaction((msgs: any[]) => { + for (const msg of msgs) { + // Filter: Only import if sender is 'pincman' AND content contains '@所有人' + const isPincman = msg.senderDisplayName === 'pincman' || msg.senderUsername === 'pincman'; + const hasAtAll = msg.content && msg.content.includes('@所有人'); + + if (!isPincman || !hasAtAll) { + continue; + } + + importedCount++; + + // Ensure sender exists as a user + // If nickname is missing, fallback to ID, or generic name + const senderName = msg.senderDisplayName || msg.senderUsername || 'Unknown'; + const senderAvatar = 'https://api.dicebear.com/7.x/avataaars/svg?seed=' + (msg.senderUsername || 'unknown'); + + // We only insert sender if they have a username/id and are NOT the session owner (group itself) + if (msg.senderUsername && msg.senderUsername !== session.wxid) { + upsertUser.run({ $id: msg.senderUsername, $name: senderName, $avatar: senderAvatar }); + } + + const uniqueId = `${session.wxid}_${msg.localId}`; // Composite ID to prevent collisions across chats + + insertMessage.run({ + $id: uniqueId, + $cid: session.wxid, + $content: msg.content, + $sender: msg.senderUsername || 'system', + $ts: msg.createTime * 1000, + $type: msg.localType === 1 ? 'text' : 'unknown' + }); + } + }); + + transaction(messages); + + return importedCount; +} diff --git a/src/backend/index.ts b/src/backend/index.ts new file mode 100644 index 0000000..0310fcc --- /dev/null +++ b/src/backend/index.ts @@ -0,0 +1,27 @@ +import { serve } from "bun"; +import index from "../frontend/index.html"; // Updated relative import +import { initDB } from "./db"; +import { api } from "./api"; // Elysia app + +// Initialize database +initDB(); + +const server = serve({ + routes: { + // API requests handled by Elysia fetch + "/api/*": api.fetch, + + // Frontend handled by Bun's native HMR/transpiler/asset serving + "/*": index, + }, + + // Increase global request body size limit for Bun server (default is 128MB) + maxRequestBodySize: 1024 * 1024 * 512, // 512MB + + development: process.env.NODE_ENV !== "production" && { + hmr: true, + console: true, + }, +}); + +console.log(`🚀 Server running at ${server.url}`); diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx new file mode 100644 index 0000000..39c4dba --- /dev/null +++ b/src/frontend/App.tsx @@ -0,0 +1,57 @@ +import { useState, useEffect } from 'react'; +import { Sidebar } from './components/Sidebar'; +import { ChatList } from './components/ChatList'; +import { ChatWindow } from './components/ChatWindow'; +import { Admin } from './components/Admin'; +import type { Conversation } from './data/mock'; +import "./index.css"; +import { client } from './client'; + +export function App() { + const [conversations, setConversations] = useState([]); + const [activeId, setActiveId] = useState(null); + + // Simple "routing" + const isAdmin = window.location.pathname === '/admin'; + + if (isAdmin) { + return ; + } + + useEffect(() => { + const fetchConversations = async () => { + const { data, error } = await client.api.conversations.get(); + if (data && !error) { + // @ts-ignore + setConversations(data); + if (data.length > 0 && !activeId) { + // @ts-ignore + setActiveId(data[0].id); + } + } else { + console.error('Failed to fetch conversations:', error); + } + }; + fetchConversations(); + }, []); + + const activeConversation = conversations.find(c => c.id === activeId); + + return ( +
+

翁爷语录

+
+ + + +
+
+ ); +} + +export default App; + diff --git a/src/frontend/client.ts b/src/frontend/client.ts new file mode 100644 index 0000000..1e5aa35 --- /dev/null +++ b/src/frontend/client.ts @@ -0,0 +1,5 @@ +import { treaty } from '@elysiajs/eden'; +import type { App } from '../backend/api'; + +// Create a single client instance +export const client = treaty('localhost:3000'); diff --git a/src/frontend/components/Admin.tsx b/src/frontend/components/Admin.tsx new file mode 100644 index 0000000..77e5139 --- /dev/null +++ b/src/frontend/components/Admin.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import { Upload, FileText, CheckCircle, AlertCircle } from 'lucide-react'; +import { clsx } from 'clsx'; +import { client } from '../client'; + +export function Admin() { + const [file, setFile] = useState(null); + const [status, setStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle'); + const [message, setMessage] = useState(''); + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + setFile(e.target.files[0]); + setStatus('idle'); + setMessage(''); + } + }; + + const handleUpload = async () => { + if (!file) return; + + setStatus('uploading'); + + try { + const text = await file.text(); + let json; + try { + json = JSON.parse(text); + } catch (e) { + throw new Error('Invalid JSON file'); + } + + const { data, error } = await client.api.import.post(json); + + if (error) { + throw new Error('Import failed'); + } + setStatus('success'); + setMessage(`Imported ${data.messageCount} messages successfully!`); + setFile(null); + } catch (err: any) { + setStatus('error'); + setMessage(err.message || 'Something went wrong'); + } + }; + + return ( +
+
+

+ + Admin Import +

+ +
+ + + {file ? ( +
+ + {file.name} +
+ ) : ( +
+ + Drop JSON file or click to browse +
+ )} +
+ + {status === 'idle' && file && ( + + )} + + {status === 'uploading' && ( +
+
+ Processing... +
+ )} + + {status === 'success' && ( +
+ + {message} +
+ )} + + {status === 'error' && ( +
+ + {message} +
+ )} +
+
+ ); +} diff --git a/src/frontend/components/ChatList.tsx b/src/frontend/components/ChatList.tsx new file mode 100644 index 0000000..77c4c3a --- /dev/null +++ b/src/frontend/components/ChatList.tsx @@ -0,0 +1,76 @@ +import { Search, Plus } from 'lucide-react'; +import { clsx } from 'clsx'; +import type { Conversation } from '../data/mock'; + +interface ChatListProps { + conversations: Conversation[]; + activeId: string | null; + onSelect: (id: string) => void; +} + +export function ChatList({ conversations, activeId, onSelect }: ChatListProps) { + const formatTime = (timestamp: number) => { + const date = new Date(timestamp); + const now = new Date(); + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }); + }; + + return ( +
+ {/* Search Header */} +
+
+ + +
+
+ +
+
+ + {/* List */} +
+ {conversations.map((conv) => ( +
onSelect(conv.id)} + className={clsx( + "h-16 flex items-center px-3 gap-3 cursor-pointer transition-colors relative", + activeId === conv.id ? "bg-[#c5c5c6]" : "hover:bg-[#d9d8d8]" + )} + > +
+ + {conv.unreadCount > 0 && ( +
+ {conv.unreadCount} +
+ )} +
+ +
+
+ {conv.user.name} + + {conv.messages.length > 0 && formatTime(conv.messages[conv.messages.length - 1]!.timestamp)} + +
+
+ {conv.messages.length > 0 + ? conv.messages[conv.messages.length - 1]?.content + : No messages} +
+
+
+ ))} +
+
+ ); +} diff --git a/src/frontend/components/ChatWindow.tsx b/src/frontend/components/ChatWindow.tsx new file mode 100644 index 0000000..83a5370 --- /dev/null +++ b/src/frontend/components/ChatWindow.tsx @@ -0,0 +1,267 @@ +import { MoreHorizontal, Smile, FolderOpen, Scissors, Clipboard, Search, X } from 'lucide-react'; +import { client } from '../client'; +import type { Conversation } from '../data/mock'; +import { MessageBubble } from './MessageBubble'; +import { clsx } from 'clsx'; +import { useEffect, useRef, useState } from 'react'; + +interface ChatWindowProps { + conversation: Conversation | undefined; +} + +export function ChatWindow({ conversation }: ChatWindowProps) { + const scrollRef = useRef(null); + const [inputValue, setInputValue] = useState(''); + const [messages, setMessages] = useState(conversation?.messages || []); + const [hasMore, setHasMore] = useState(true); + const [loading, setLoading] = useState(false); + + // Search state + const [isSearching, setIsSearching] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + const fetchMessages = async (isLoadMore = false, query = '', start = '', end = '') => { + if (!conversation?.id || loading) return; + + setLoading(true); + try { + let limit = 10; + const beforeTimestamp = isLoadMore && messages.length > 0 ? messages[0]!.timestamp : undefined; + + const { data, error } = await client.api.messages({ conversationId: conversation.id }).get({ + query: { + limit: limit.toString(), + before: beforeTimestamp ? beforeTimestamp.toString() : undefined, + q: query || undefined, + startDate: start ? new Date(start).getTime().toString() : undefined, + endDate: end ? new Date(end).setHours(23, 59, 59, 999).toString() : undefined + } + }); + + if (data && !error) { + // @ts-ignore + const newMessages = data; + + if (newMessages.length < limit) { + setHasMore(false); + } else { + setHasMore(true); + } + + if (isLoadMore) { + // Maintain scroll position + if (scrollRef.current) { + const oldHeight = scrollRef.current.scrollHeight; + setMessages(prev => [...newMessages, ...prev]); + // We need to wait for render to adjust scroll, useEffect layout effect is better but setTimeout works for simple case + requestAnimationFrame(() => { + if (scrollRef.current) { + const newHeight = scrollRef.current.scrollHeight; + scrollRef.current.scrollTop = newHeight - oldHeight; + } + }); + } + } else { + setMessages(newMessages); + // Scroll to bottom on initial load + setTimeout(() => { + requestAnimationFrame(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }); + }, 10); + } + } else { + console.error(error); + } + + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + // Trigger search + useEffect(() => { + if (isSearching) { + const handler = setTimeout(() => { + setMessages([]); + setHasMore(true); + fetchMessages(false, searchQuery, startDate, endDate); + }, 300); // Debounce + return () => clearTimeout(handler); + } else { + if (searchQuery !== '') { + setSearchQuery(''); + // fetchMessages handled by conversation change or we should reset? + // If we close search, we probably want to see latest messages effectively resetting view + setMessages([]); + setHasMore(true); + fetchMessages(false, '', '', ''); + } + } + }, [searchQuery, isSearching, startDate, endDate]); // Be careful with dependency loops, but this seems okay + + // Initial load when conversation changes + useEffect(() => { + setMessages([]); + setHasMore(true); + setIsSearching(false); + setSearchQuery(''); + setStartDate(''); + setEndDate(''); + fetchMessages(false); + }, [conversation?.id]); + + const handleScroll = () => { + if (scrollRef.current && scrollRef.current.scrollTop === 0 && hasMore && !loading) { + fetchMessages(true, isSearching ? searchQuery : '', startDate, endDate); + } + }; + + if (!conversation) { + return ( +
+
WeChat
+
+ ); + } + + const handleSend = () => { + if (!inputValue.trim()) return; + // Mock send - in real app would call API + console.log('Sending:', inputValue); + setInputValue(''); + }; + + return ( +
+ {/* Header */} +
+ {isSearching ? ( +
+ + setSearchQuery(e.target.value)} + autoFocus + /> + setStartDate(e.target.value)} + title="Start Date" + /> + - + setEndDate(e.target.value)} + title="End Date" + /> + { + setIsSearching(false); + setSearchQuery(''); + setStartDate(''); + setEndDate(''); + }} + /> +
+ ) : ( +
+ {conversation.user.name} +
+ )} + +
+ {!isSearching && ( + setIsSearching(true)} + /> + )} + +
+
+ + {/* Messages */} +
+ {loading && messages.length > 0 && ( +
Loading...
+ )} + {messages.map((msg, index) => { + const prevMsg = messages[index - 1]; + const showTime = !prevMsg || (msg.timestamp - prevMsg.timestamp > 5 * 60 * 1000); // 5 mins + + return ( +
+ {showTime && ( +
+ {new Date(msg.timestamp).toLocaleString([], { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +
+ )} + +
+ ); + })} +
+ + {/* Input Area */} +
+ {/* Toolbar */} +
+ + + {/* Fake scissors icon using a character or placeholder if icon missing, but lucide has Scissors usually. Using generic placeholders if not sure */} +
+ + {/* Text Area */} +