Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87ff5aab44 | |||
| e4ca70d758 | |||
| c791c0820e | |||
| b81e39ac65 | |||
| 7e781631bb | |||
| 7a1ab18657 | |||
| 29897bea87 | |||
| 131d1e12f5 | |||
| 362a805c9b | |||
| 2012412bc1 | |||
| b1b663e1ce | |||
| d72f3d3860 | |||
| 529024eed7 | |||
| c0826c7d34 | |||
| b364ef3c16 | |||
| c845399856 | |||
| 0ccefbd0c1 | |||
| b6193b662f | |||
| 6c93b1d995 | |||
| cf529eed64 | |||
| 57004b3a1f | |||
| 8f6f0a9266 | |||
| 83b835f19c | |||
| 0e2cf068c8 | |||
| ae06c45324 | |||
| fa8ca13283 | |||
| 201b36ed19 | |||
| 927e227c29 | |||
| fd70fa35ca | |||
| de6199bbf2 | |||
| 3ce1ca58ea | |||
| 0dfe627e25 | |||
| 2b24dbef0e | |||
| e0c31957cc | |||
| 5c8df1867c | |||
| 1a0467b439 | |||
| bb90d0c0fd | |||
| 4328cd0a1c | |||
| 0b82fc2d47 | |||
| 49e20edd80 | |||
| 30d7a6c27b | |||
| 52bb56501e | |||
| cb9797d0e8 | |||
| 7a027a27b8 | |||
| dceaae8efa | |||
| 0d830114cd | |||
| 761921684c | |||
| 621d65bdfd | |||
| a0befd06f4 | |||
| 27375c1a4d | |||
| 17a43ec181 | |||
| 723a1ea086 | |||
| 10f236dc8d | |||
| 89a38936b6 | |||
| 18f6703c6b | |||
| 415adfe638 | |||
| 0c4397bf46 | |||
| 2840da2f34 | |||
| 2f5ed92188 | |||
| 3d222fd316 | |||
| b636953770 | |||
| 9d9b8036c4 | |||
| 30635864da | |||
| 47beed3e7f | |||
| b2aa493e2d | |||
| 5ca89d71ad | |||
| 0d308515a7 | |||
| 0621fb27db | |||
| 99f264fcd0 | |||
| ad6fc85df1 |
@@ -16,6 +16,8 @@ coverage/
|
|||||||
.next/
|
.next/
|
||||||
# Ignore out/ directories in all subdirectories
|
# Ignore out/ directories in all subdirectories
|
||||||
out/
|
out/
|
||||||
|
out.zip
|
||||||
|
deploy.config*
|
||||||
|
|
||||||
# production
|
# production
|
||||||
# Ignore build/ directories in all subdirectories
|
# Ignore build/ directories in all subdirectories
|
||||||
@@ -69,3 +71,5 @@ logs/
|
|||||||
# Temporary files
|
# Temporary files
|
||||||
.temp/
|
.temp/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
|
||||||
|
AGENTS.md
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# AGENTS — PrivyDrop Repository Rules (Short)
|
||||||
|
|
||||||
|
## First Principles
|
||||||
|
|
||||||
|
- Communicate in Chinese: Always use Simplified Chinese when communicating with the project owner/maintainers. Use English for code comments, naming, commit messages, and PR titles/descriptions.
|
||||||
|
- Best practices, aligned with the existing stack: Prefer proven approaches consistent with what the repo already uses; iterate in small steps and keep changes easy to roll back.
|
||||||
|
- Plan first: Before implementing anything, propose a change plan and get approval (goals, scope/files, approach, risks, acceptance, rollback, docs updates, validation). Template: `docs/ai-playbook/collab-rules.md` (or `docs/ai-playbook/collab-rules.zh-CN.md`).
|
||||||
|
- One change, one purpose: Each change should solve one clear goal; avoid “while I’m here” fixes; keep it minimal and reversible.
|
||||||
|
- Privacy & architecture red line: The backend is for signaling and room coordination only. Do not relay, store, or upload any user file data to the server or third parties in any form.
|
||||||
|
- Transport guardrails: Keep established chunking/backpressure/retry parameters and mechanisms; any breaking change or parameter-level change must be approved first.
|
||||||
|
- Dependencies & infrastructure: Do not add new dependencies/component libraries/infrastructure or do large refactors without approval.
|
||||||
|
- Docs must stay in sync: If a change affects flows, interfaces, or entry file paths, update `docs/ai-playbook/flows.zh-CN.md` and `docs/ai-playbook/code-map.zh-CN.md` in the same PR.
|
||||||
|
- Verification required: Frontend must build (`next build`); list key manual test cases and regression points.
|
||||||
|
|
||||||
|
## Priority & Conflicts
|
||||||
|
|
||||||
|
- Explicit user instructions override this file; if there’s a conflict, call it out in the plan and get approval.
|
||||||
|
- For detailed rules, examples, and checklists, follow `docs/ai-playbook/collab-rules.md` (this file only keeps the highest-level principles).
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# AGENTS — PrivyDrop Repository Rules(简版)
|
||||||
|
|
||||||
|
最重要的原则
|
||||||
|
|
||||||
|
- 用中文沟通:与项目负责人沟通一律使用中文(简体)。代码注释、命名、提交信息、PR 标题与描述统一使用英文。
|
||||||
|
- 方案与代码遵循最佳实践:优先选择与现有技术栈一致、被验证过的实现;小步迭代、易回滚。
|
||||||
|
- 计划先行:任何实现前须提交并获批变更计划(目标、范围/文件、方案、风险、验收、回滚、需更新文档、验证方式)。模板见 docs/ai-playbook/collab-rules.zh-CN.md。
|
||||||
|
- 单一主题:每次改动只解决一个明确目标,避免“顺手修复”无关问题,保持最小可回滚。
|
||||||
|
- 隐私与架构红线:后端仅做信令与房间管理;严禁任何形式的文件数据中转、存储或上报至第三方。
|
||||||
|
- 传输护栏:保持既定分片/背压/重试等关键参数与机制;任何破坏性或参数层变更需先获批。
|
||||||
|
- 依赖与基建:未经批准不得新增依赖/组件库/基础设施或进行大规模重构。
|
||||||
|
- 文档同步:涉及流程、接口或入口文件路径的改动,必须在同一 PR 内同步更新 docs/ai-playbook/flows.zh-CN.md 与 docs/ai-playbook/code-map.zh-CN.md。
|
||||||
|
- 验证要求:前端需构建通过(next build);列出关键手测用例与回归点。
|
||||||
|
|
||||||
|
优先级与冲突
|
||||||
|
|
||||||
|
- 显式用户指令优先于本文件;如有冲突需在计划中说明并征得同意。
|
||||||
|
- 更多细则、示例与校验清单以 docs/ai-playbook/collab-rules.zh-CN.md 为准(本文件仅保留最原则条款)。
|
||||||
@@ -21,6 +21,8 @@ We believe everyone should have control over their own data. PrivyDrop was creat
|
|||||||
## ✨ Key Features
|
## ✨ Key Features
|
||||||
|
|
||||||
- 🔒 **End-to-End Encryption**: Leverages P2P direct connections via WebRTC. All files and text are transferred directly between browsers without passing through any central server.
|
- 🔒 **End-to-End Encryption**: Leverages P2P direct connections via WebRTC. All files and text are transferred directly between browsers without passing through any central server.
|
||||||
|
- 🔄 **Unlimited File Transfer** - Support files of any size through Chrome's direct-to-disk streaming (Need to set the save directory)
|
||||||
|
- 👥 **Multi-receiver Support** - A single room can have multiple receivers simultaneously downloading files/text, and newcomers can join without disrupting ongoing transfers.
|
||||||
- 📂 **File & Folder Transfer**: Supports transferring multiple files and entire folders.
|
- 📂 **File & Folder Transfer**: Supports transferring multiple files and entire folders.
|
||||||
- ⏸️ **Resume Transfer**: Resume file transfer from the point of interruption. Simply set the save directory to enable this feature, ensuring your large files are delivered safely even with unstable networks. If interrupted, you currently need to refresh both the sender and receiver web pages to restart the transfer.
|
- ⏸️ **Resume Transfer**: Resume file transfer from the point of interruption. Simply set the save directory to enable this feature, ensuring your large files are delivered safely even with unstable networks. If interrupted, you currently need to refresh both the sender and receiver web pages to restart the transfer.
|
||||||
- ⚡ **Real-time & Efficient**: Displays real-time transfer progress and automatically calculates transfer speed.
|
- ⚡ **Real-time & Efficient**: Displays real-time transfer progress and automatically calculates transfer speed.
|
||||||
@@ -28,6 +30,7 @@ We believe everyone should have control over their own data. PrivyDrop was creat
|
|||||||
- 🔗 **Convenient Sharing**: Easily share a room and establish a connection via a link or QR code.
|
- 🔗 **Convenient Sharing**: Easily share a room and establish a connection via a link or QR code.
|
||||||
- 📱 **Multi-Device Support**: Responsive design supports both desktop and mobile browsers.
|
- 📱 **Multi-Device Support**: Responsive design supports both desktop and mobile browsers.
|
||||||
- 🌐 **Internationalization**: Supports multiple languages, including English and Chinese.
|
- 🌐 **Internationalization**: Supports multiple languages, including English and Chinese.
|
||||||
|
- 🧭 **In-App Navigation Persistence**: For in-app navigation within the same browser tab (Next.js App Router page switches), ongoing transfers are not interrupted, and selected-to-send items and already-displayed received content are preserved. Powered by a singleton app state (Zustand Store) and a singleton connection service (webrtcService).
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
@@ -35,7 +38,7 @@ We believe everyone should have control over their own data. PrivyDrop was creat
|
|||||||
- **Backend**: Node.js, Express.js, TypeScript
|
- **Backend**: Node.js, Express.js, TypeScript
|
||||||
- **Real-time Communication**: WebRTC, Socket.IO
|
- **Real-time Communication**: WebRTC, Socket.IO
|
||||||
- **Data Storage**: Redis
|
- **Data Storage**: Redis
|
||||||
- **Deployment**: PM2, Nginx, Docker
|
- **Deployment**: Docker (Docker Compose)
|
||||||
|
|
||||||
## 🐳 Docker One-Click Deployment (Recommended)
|
## 🐳 Docker One-Click Deployment (Recommended)
|
||||||
|
|
||||||
@@ -61,6 +64,7 @@ bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn -
|
|||||||
See [Docker Deployment Guide](./docs/DEPLOYMENT_docker.md) (Modes Overview, LAN TLS limitations, Let’s Encrypt auto-issue/renew)
|
See [Docker Deployment Guide](./docs/DEPLOYMENT_docker.md) (Modes Overview, LAN TLS limitations, Let’s Encrypt auto-issue/renew)
|
||||||
|
|
||||||
Heads-up (LAN TLS, self-signed)
|
Heads-up (LAN TLS, self-signed)
|
||||||
|
|
||||||
- Import the CA certificate into your browser (or system trust store) on first use: `docker/ssl/ca-cert.pem`. Otherwise the browser shows “certificate not valid/untrusted”.
|
- Import the CA certificate into your browser (or system trust store) on first use: `docker/ssl/ca-cert.pem`. Otherwise the browser shows “certificate not valid/untrusted”.
|
||||||
- Access endpoints (by default):
|
- Access endpoints (by default):
|
||||||
- Nginx: `http://localhost`
|
- Nginx: `http://localhost`
|
||||||
@@ -121,7 +125,8 @@ We provide detailed documentation to help you dive deeper into the project's des
|
|||||||
- [**Overall Project Architecture**](./docs/ARCHITECTURE.md): Understand how all components of the PrivyDrop system work together.
|
- [**Overall Project Architecture**](./docs/ARCHITECTURE.md): Understand how all components of the PrivyDrop system work together.
|
||||||
- [**Frontend Architecture Deep Dive**](./docs/FRONTEND_ARCHITECTURE.md): Explore the frontend's modern, layered architecture, state management with Zustand, and the decoupled service-based approach to WebRTC.
|
- [**Frontend Architecture Deep Dive**](./docs/FRONTEND_ARCHITECTURE.md): Explore the frontend's modern, layered architecture, state management with Zustand, and the decoupled service-based approach to WebRTC.
|
||||||
- [**Backend Architecture Deep Dive**](./docs/BACKEND_ARCHITECTURE.md): Dive into the backend's code structure, signaling flow, and Redis design.
|
- [**Backend Architecture Deep Dive**](./docs/BACKEND_ARCHITECTURE.md): Dive into the backend's code structure, signaling flow, and Redis design.
|
||||||
- [**Deployment Guide**](./docs/DEPLOYMENT.md): Learn how to deploy the complete PrivyDrop application in a production environment.
|
- [**Docker Deployment Guide**](./docs/DEPLOYMENT_docker.md): One-click deployment (LAN/Public/Full), HTTPS automation, TURN, and troubleshooting.
|
||||||
|
- [AI Playbook (zh-CN)](./docs/ai-playbook/index.zh-CN.md) · [Collaboration Rules (zh-CN)](./docs/ai-playbook/collab-rules.zh-CN.md)
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -21,6 +21,8 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
|
|||||||
## ✨ 主要特性
|
## ✨ 主要特性
|
||||||
|
|
||||||
- 🔒 **端到端加密**: 基于 WebRTC 的 P2P 直连技术,所有文件和文本在浏览器间直接传输,不经过任何中央服务器。
|
- 🔒 **端到端加密**: 基于 WebRTC 的 P2P 直连技术,所有文件和文本在浏览器间直接传输,不经过任何中央服务器。
|
||||||
|
- 🔄 **无限制文件传输** - 支持任意大小文件传输,通过 Chrome 的流式保存到磁盘功能实现(需设置保存目录)
|
||||||
|
- 👥 **多接收端支持** - 单个房间可同时让多个接收端并行获取文件/文本,加入房间不会中断正在进行的传输。
|
||||||
- 📂 **文件与文件夹传输**: 支持多文件和整个文件夹的传输。
|
- 📂 **文件与文件夹传输**: 支持多文件和整个文件夹的传输。
|
||||||
- ⏸️ **断点续传**: 自动从中断处恢复文件传输。只需设置保存目录即可启用此功能,确保即使在网络不稳定的情况下,您的大文件也能安全送达。如果中断,目前需要同时刷新发送端和接收端网页,重新开始传输即可。
|
- ⏸️ **断点续传**: 自动从中断处恢复文件传输。只需设置保存目录即可启用此功能,确保即使在网络不稳定的情况下,您的大文件也能安全送达。如果中断,目前需要同时刷新发送端和接收端网页,重新开始传输即可。
|
||||||
- ⚡ **实时高效**: 实时显示传输进度、自动计算传输速度。
|
- ⚡ **实时高效**: 实时显示传输进度、自动计算传输速度。
|
||||||
@@ -28,6 +30,7 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
|
|||||||
- 🔗 **便捷分享**: 通过链接或二维码轻松分享房间,建立连接。
|
- 🔗 **便捷分享**: 通过链接或二维码轻松分享房间,建立连接。
|
||||||
- 📱 **多端支持**: 响应式设计,支持桌面和移动端浏览器。
|
- 📱 **多端支持**: 响应式设计,支持桌面和移动端浏览器。
|
||||||
- 🌐 **国际化**: 支持中文、英文等多个语言。
|
- 🌐 **国际化**: 支持中文、英文等多个语言。
|
||||||
|
- 🧭 **站内导航不中断/状态保持**: 在同一标签页的站内跳转(Next.js App Router 页面切换)时,进行中的传输不会中断,已选择的待发送内容与接收页已展示的文本/文件清单也不会丢失。该能力依赖前端的单例状态(Zustand Store)与单例连接服务(webrtcService)。
|
||||||
|
|
||||||
## 🛠️ 技术栈
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
@@ -35,7 +38,7 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
|
|||||||
- **后端**: Node.js, Express.js, TypeScript
|
- **后端**: Node.js, Express.js, TypeScript
|
||||||
- **实时通信**: WebRTC, Socket.IO
|
- **实时通信**: WebRTC, Socket.IO
|
||||||
- **数据存储**: Redis
|
- **数据存储**: Redis
|
||||||
- **部署**: PM2, Nginx, Docker
|
- **部署**: Docker(Docker Compose)
|
||||||
|
|
||||||
## 🚀 快速上手
|
## 🚀 快速上手
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn -
|
|||||||
完整说明见: docs/DEPLOYMENT_docker.zh-CN.md(模式一览、LAN TLS、自签限制、Let’s Encrypt 自动签发/续期)
|
完整说明见: docs/DEPLOYMENT_docker.zh-CN.md(模式一览、LAN TLS、自签限制、Let’s Encrypt 自动签发/续期)
|
||||||
|
|
||||||
提示(lan-tls 自签 HTTPS)
|
提示(lan-tls 自签 HTTPS)
|
||||||
|
|
||||||
- 首次访问需导入 CA 证书:`docker/ssl/ca-cert.pem` 到浏览器(或系统信任),否则浏览器会提示“证书无效/不受信任”。
|
- 首次访问需导入 CA 证书:`docker/ssl/ca-cert.pem` 到浏览器(或系统信任),否则浏览器会提示“证书无效/不受信任”。
|
||||||
- 访问方式(默认):
|
- 访问方式(默认):
|
||||||
- Nginx: `http://localhost`
|
- Nginx: `http://localhost`
|
||||||
@@ -132,7 +136,8 @@ bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn -
|
|||||||
- [**项目整体架构**](./docs/ARCHITECTURE.zh-CN.md): 了解 PrivyDrop 系统各个组件如何协同工作。
|
- [**项目整体架构**](./docs/ARCHITECTURE.zh-CN.md): 了解 PrivyDrop 系统各个组件如何协同工作。
|
||||||
- [**前端架构详解**](./docs/FRONTEND_ARCHITECTURE.zh-CN.md): 深入探索前端的现代化分层架构、基于 Zustand 的状态管理,以及解耦的服务化 WebRTC 实现。
|
- [**前端架构详解**](./docs/FRONTEND_ARCHITECTURE.zh-CN.md): 深入探索前端的现代化分层架构、基于 Zustand 的状态管理,以及解耦的服务化 WebRTC 实现。
|
||||||
- [**后端架构详解**](./docs/BACKEND_ARCHITECTURE.zh-CN.md): 深入探索后端的代码结构、信令流程和 Redis 设计。
|
- [**后端架构详解**](./docs/BACKEND_ARCHITECTURE.zh-CN.md): 深入探索后端的代码结构、信令流程和 Redis 设计。
|
||||||
- [**部署指南**](./docs/DEPLOYMENT.zh-CN.md): 学习如何在生产环境部署完整的 PrivyDrop 应用。
|
- [**Docker 部署指南**](./docs/DEPLOYMENT_docker.zh-CN.md): 一键部署(内网/公网/full)、HTTPS 自动化、TURN 与排错。
|
||||||
|
- [AI Playbook 索引](./docs/ai-playbook/index.zh-CN.md) · [协作规则](./docs/ai-playbook/collab-rules.zh-CN.md)
|
||||||
|
|
||||||
## 🤝 参与贡献
|
## 🤝 参与贡献
|
||||||
|
|
||||||
|
|||||||
+46
-52
@@ -1,73 +1,67 @@
|
|||||||
# PrivyDrop Project Roadmap
|
# PrivyDrop Roadmap
|
||||||
|
|
||||||
Welcome to the official roadmap for PrivyDrop! This document outlines our vision for the future, detailing the planned features and improvements that will shape the project. Our goal is to build the most secure, private, and user-friendly P2P file sharing solution.
|
## Overview
|
||||||
|
|
||||||
This roadmap is a living document. We welcome community feedback and contributions. If you have an idea or want to help build the future of PrivyDrop, please open an [Issue](https://github.com/david-bai00/PrivyDrop/issues) or a [Pull Request](https://github.com/david-bai00/PrivyDrop/pulls)!
|
- Vision: keep file/text transfer lightweight, smooth, reliable, and easy to self‑host.
|
||||||
|
- Current snapshot: resumable transfer, chunking + backpressure, Safari/Firefox support, Docker one‑click deploy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Completed
|
## Scope
|
||||||
|
|
||||||
### Architecture optimization
|
- Scope: file/text transfer only (one‑to‑many), room‑based sessions.
|
||||||
|
|
||||||
- **Core Architecture Refactor (Q3 2025)**: Successfully refactored the entire frontend codebase to a modern, layered architecture.
|
|
||||||
- Implemented a framework-agnostic **Service Layer** (`webrtcService`) to encapsulate all WebRTC and business logic.
|
|
||||||
- Introduced **Zustand** for centralized, predictable state management (`fileTransferStore`).
|
|
||||||
- Decoupled UI components from business logic, establishing a clear, unidirectional data flow.
|
|
||||||
- **Resumable File Transfers (Q3 2025):** Implemented robust logic for resuming transfers from the point of interruption. This is enabled by setting a save directory, which allows the receiver to check for partially downloaded files and request only the missing chunks.
|
|
||||||
|
|
||||||
### Deployment and Operation
|
|
||||||
|
|
||||||
- Docker one-click deployment (Q4 20252)
|
|
||||||
- Unified container health checks (node health-check.js)
|
|
||||||
- Let’s Encrypt automation (webroot) with zero-downtime renewals and deploy-hook
|
|
||||||
- TURN improvements (env port range; default 49152-49252)
|
|
||||||
- SNI 443 multiplexing (turns:443 via Nginx stream; enabled by default in full+domain)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Short-Term Goals (Next 1-3 Months)
|
## Near‑Term Roadmap (by priority, no dates)
|
||||||
|
|
||||||
This phase focuses on perfecting the current feature set and enhancing reliability to build an even stronger foundation.
|
- P0 Code Optimization & Slimming
|
||||||
|
|
||||||
- **Enhanced Connection Stability:** The current implementation supports automatic reconnection for a short period (e.g., 15 minutes) in default 4-digit rooms. This will be extended to support custom-named rooms with a longer reconnection window (e.g., 1 hour).
|
- Architecture convergence & clear boundaries: transport (send/receive), WebRTC wrapper, state, and UI separated; split oversized files; centralize shared types/constants.
|
||||||
- **Detailed Transfer Error-Handling:** Provide users with clearer, more specific feedback when a transfer fails (e.g., "Peer disconnected," "Browser storage full," "Network interrupted").
|
- Redundancy cleanup: remove dead code/unused exports; merge duplicate utilities and logic (keep a single authority for packet encode/decode).
|
||||||
|
- Unified config & naming: chunk/batch/backpressure thresholds from a single source; unify naming; do not change behavior.
|
||||||
|
- State management coherence: Zustand as the single source of truth; custom hooks only subscribe/dispatch intent, no business logic.
|
||||||
|
- Async & error path simplification: unify Promise/event patterns and return values; centralize error types and boundaries.
|
||||||
|
- Logging & debug (key runtime item): unified logger with levels (error/warn/info/debug) and toggle; default low‑noise in production; replace scattered console/postLog; consistent IDs by room/session/file.
|
||||||
|
- Type & build health: gradually tighten TS, reduce any/implicit any; keep lint/format consistent.
|
||||||
|
|
||||||
|
- P0 Minimal Test Set
|
||||||
|
|
||||||
|
- Unit tests: chunk read/slice, embedded packet parse, sequenced disk writer handling of out‑of‑order/duplicate/tail chunks.
|
||||||
|
- Lightweight integration: headless/fake data channel to verify send→receive→persist, covering backpressure wait and resume path.
|
||||||
|
- Backend minimal tests: room and rate‑limit core contracts.
|
||||||
|
|
||||||
|
- P1 Error UX & Read‑only Network Check
|
||||||
|
|
||||||
|
- Clear, actionable errors with retry suggestions; visible send/receive states and failure summaries.
|
||||||
|
- Read‑only panel: connection state, data channel state, send buffer, current/avg rate, recent errors. Display only; no complex probing.
|
||||||
|
|
||||||
|
- P1 Docs & Deployment Consistency
|
||||||
|
- Aligned quickstart and Docker self‑hosting; FAQ and troubleshooting; consistent screenshots and terminology.
|
||||||
|
- Frontend architecture docs synced (Zustand + custom hooks).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mid-Term Goals (Next 3-9 Months)
|
## Definition of Done
|
||||||
|
|
||||||
This phase introduces powerful new features that expand PrivyDrop's use cases beyond one-to-one file transfers.
|
- P0 Code Optimization & Slimming
|
||||||
|
|
||||||
- **[Major Feature] P2P Group Chat:** While multiple peers can already join a room, this feature will add a simple, host-based group chat. The room creator will act as a hub to relay encrypted text and files to all other participants, enabling basic group collaboration.
|
- Clear module boundaries; unified directory/naming; duplicates merged; dead code removed.
|
||||||
- **Self-Destructing Messages & Files:** Allow users to send files or text messages that are automatically deleted from the recipient's view after being read or after a set time.
|
- Single source for chunk/batch/backpressure config, with behavior unchanged.
|
||||||
- **Clipboard Synchronization:** Add a dedicated mode to sync the clipboard content (text and images) in real-time between connected devices.
|
- Zustand as the only state source; components free of business side‑effects; custom hooks roles are clear.
|
||||||
- **Official Docker Support:** Provide and maintain official `Dockerfile` and `docker-compose.yml` configurations for easy, one-command self-hosting of the entire stack.
|
- Logger levels and toggle in place; production low‑noise; no stray debug output.
|
||||||
|
- Build and lint pass; TypeScript warnings significantly reduced.
|
||||||
|
|
||||||
### Performance and deployment
|
- P0 Minimal Test Set
|
||||||
|
- Core edge cases covered by unit tests; at least one minimal integration path completes send→receive→persist.
|
||||||
- **Official Docker support:** Provide and maintain the official `Dockerfile` and `docker-compose.yml` configurations to achieve one-click deployment of the entire technology stack (frontend, backend, Redis, Coturn), which greatly facilitates self-hosted users.
|
|
||||||
- **Package size optimization:** Regularly use `@next/bundle-analyzer` to analyze the frontend package size, optimize through code splitting and other means, and keep the application lightweight.
|
|
||||||
|
|
||||||
### User Experience (UX)
|
|
||||||
|
|
||||||
- **To be defined**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Future & Community-Driven Ideas
|
## Terminology
|
||||||
|
|
||||||
This section is for features that are not on the immediate roadmap but represent great opportunities for community contributions.
|
- Sender/Receiver
|
||||||
|
- Room
|
||||||
- **Comprehensive Testing:** The recent architectural refactor has made the codebase significantly more testable. We now plan to introduce a testing framework (like Vitest) to add unit tests for the core `webrtcService` and Zustand store, improving code quality and making community contributions safer. We welcome contributions in this area.
|
- Chunk / Backpressure
|
||||||
- **Your Ideas Here:** Have a great idea for a feature, like screen sharing or P2P media streaming? Open an issue and let's discuss it! We believe the best ideas can come from the community.
|
- Resume
|
||||||
|
- DataChannel
|
||||||
## How to Contribute
|
- Persist to disk (OPFS/disk write)
|
||||||
|
|
||||||
Your contributions are vital to making this roadmap a reality!
|
|
||||||
|
|
||||||
1. **Pick an Issue:** Look for issues tagged with `help wanted` or `good first issue`.
|
|
||||||
2. **Start a Discussion:** If you're interested in a roadmap item, start a discussion to share your ideas.
|
|
||||||
3. **Submit a PR:** Fork the repo, create a feature branch, and submit a Pull Request.
|
|
||||||
|
|
||||||
Thank you for being part of the PrivyDrop community! Let's build the future of private sharing, together.
|
|
||||||
|
|||||||
+47
-52
@@ -1,74 +1,69 @@
|
|||||||
# PrivyDrop 项目发展路线图
|
# PrivyDrop 项目路线图
|
||||||
|
|
||||||
欢迎来到 PrivyDrop 的官方路线图!本文档将详细阐述我们对未来的愿景,以及计划中的功能和改进。我们的目标是构建最安全、最私密、最友好的 P2P 文件分享解决方案。
|
## 简介
|
||||||
|
|
||||||
这份路线图是一份动态文档,我们真诚地欢迎社区的反馈和贡献。如果你有任何想法,或希望帮助我们共创 PrivyDrop 的未来,请随时开启一个 [Issue](https://github.com/david-bai00/PrivyDrop/issues) 或提交一个 [Pull Request](https://github.com/david-bai00/PrivyDrop/pulls)!
|
- 目标:把文件/文本传输这件事做得轻盈、顺滑、可靠,且易于自托管。
|
||||||
|
- 现状快照:断点续传、分块与背压、跨浏览器(含 Safari/Firefox)、Docker 一键部署。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ 已完成
|
## 范围说明
|
||||||
|
|
||||||
### 架构优化
|
- 范围:仅文件/文本传输(点对多),以房间为单位进行会话。
|
||||||
|
|
||||||
- **核心架构重构 (2025 年 Q3)**: 成功地将整个前端代码库重构为现代化的分层架构。
|
|
||||||
- 实现了一个与框架无关的**服务层** (`webrtcService`),用于封装所有 WebRTC 和业务逻辑。
|
|
||||||
- 引入 **Zustand** (`fileTransferStore`) 进行中心化的、可预测的状态管理。
|
|
||||||
- 将 UI 组件与业务逻辑解耦,建立了清晰的单向数据流。
|
|
||||||
- **文件断点续传 (2025 年 Q3):** 实现了稳健的断点续传逻辑。通过设置保存目录,接收方能够检查已部分下载的文件,并仅请求缺失的数据块,极大地提升了大文件和不稳定网络下的传输成功率。
|
|
||||||
|
|
||||||
### 部署与运维
|
|
||||||
|
|
||||||
- Docker 一键部署(2025 年 Q4)
|
|
||||||
- 容器健康检查统一(node health-check.js)
|
|
||||||
- Let’s Encrypt(webroot)自动化与续期 deploy-hook(无停机)
|
|
||||||
- TURN 端口段变量化与默认缩小(49152-49252)
|
|
||||||
- SNI 443 分流(Nginx stream;full+domain 默认开启)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 短期目标 (未来 1-3 个月)
|
## 路线图
|
||||||
|
|
||||||
本阶段将专注于完善现有功能、提升核心体验的可靠性,为项目打下更坚实的基础。
|
- P0 代码优化与瘦身
|
||||||
|
|
||||||
- **连接稳定性增强:** 目前,使用默认 4 位数字 ID 的房间已支持短时间(例如 15 分钟)内断线重连。未来将此特性扩展到自定义名称的房间,并提供更长的重连窗口(例如 1 小时)。
|
- 架构收敛与边界清晰:传输(发送/接收)、WebRTC 封装、状态管理与 UI 分离;拆分过大文件,公共类型/常量下沉共享。
|
||||||
- **精细化传输错误处理:** 当传输失败时,向用户提供更清晰、具体的反馈(例如:"对方已断开连接"、"浏览器存储空间不足"、"网络中断")。
|
- 冗余清理与精简:移除死代码与未用导出;合并重复工具与重复逻辑(封包/解包保留权威实现)。
|
||||||
|
- 配置与命名统一:分块大小/批大小/背压阈值来自单一配置源,仅统一来源,不改既有行为。
|
||||||
|
- 状态管理收敛:以 Zustand 为唯一状态源;自定义 hooks 负责订阅与意图触发,不承载业务逻辑。
|
||||||
|
- 异步与错误路径简化:统一 Promise/事件用法与返回值;集中错误类型与边界。
|
||||||
|
- 日志与调试(本批重点):统一 logger(error/warn/info/debug + 开关),生产默认低噪;替换散落的 console/postLog,并在房间/会话/文件维度埋点一致。
|
||||||
|
- 类型与构建健康度:收紧 TypeScript(小步),减少 any/隐式 any;保持 Lint/格式化一致。
|
||||||
|
|
||||||
|
- P0 最小测试集
|
||||||
|
|
||||||
|
- 单元测试:分块读取/切片、嵌包解析、顺序落盘器的乱序/重复/尾块处理等核心边界。
|
||||||
|
- 轻量集成:伪造数据通道验证“发送 → 接收 → 落盘”的最小闭环,覆盖背压等待与断点恢复路径。
|
||||||
|
- 后端最小单测:房间与速率限制的关键契约。
|
||||||
|
|
||||||
|
- P1 错误体验与只读网络体检
|
||||||
|
|
||||||
|
- 错误提示:用语清晰、可重试建议;显示发送/接收状态与失败简述。
|
||||||
|
- 只读体检:展示连接状态、数据通道状态、发送缓冲、当前/平均速率、最近错误;仅展示,不做复杂探测。
|
||||||
|
|
||||||
|
- P1 文档与部署一致性
|
||||||
|
- 快速上手与 Docker 自托管流程对齐;常见问题与排错路径补充;截图与术语口径统一。
|
||||||
|
- 前端架构文档与实现同步(Zustand + 自定义 Hooks)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 中期目标 (未来 3-9 个月)
|
## 完成定义(达成即止)
|
||||||
|
|
||||||
本阶段将引入强大的新功能,将 PrivyDrop 的应用场景从一对一的文件传输拓展到更广阔的领域。
|
- P0 代码优化与瘦身
|
||||||
|
|
||||||
- **[重大功能] P2P 群组聊天:** 虽然目前已支持多人加入同一房间,但此功能将增加一个简洁的、基于主持人的群聊系统。房间创建者将作为中心节点,负责将加密的文本和文件转发给所有其他参与者,实现基础的群组协作。
|
- 模块边界清晰、目录与命名统一;重复实现合并,死代码清理完毕。
|
||||||
- **阅后即焚的消息与文件:** 允许用户发送在被读取或设定时间后自动销毁的文件或文本消息。
|
- 分块/批/背压等配置项有单一来源,保持现有行为不变。
|
||||||
- **剪贴板同步:** 增加一个专门的模式,用于在连接的设备之间实时同步剪贴板内容(文本和图片)。
|
- Zustand 统一为状态源;组件无业务副作用;自定义 hooks 角色明确。
|
||||||
- **官方 Docker 支持:** 提供并维护官方的 `Dockerfile` 和 `docker-compose.yml` 配置,实现一键部署整个技术栈(前端、后端、Redis、Coturn),极大地方便自托管用户。
|
- 日志体系可按等级与开关控制,生产默认低噪;无散落调试输出。
|
||||||
- **包体积优化:** 定期使用 `@next/bundle-analyzer` 分析前端打包体积,通过代码分割等手段进行优化,保持应用的轻量化。
|
- 构建与 Lint 通过;类型告警明显减少。
|
||||||
|
|
||||||
### 性能与部署
|
- P0 最小测试集
|
||||||
|
- 关键模块单测覆盖核心边界;存在一个最小集成用例完成“发送 → 接收 → 落盘”。
|
||||||
- **官方 Docker 支持:** 提供并维护官方的 `Dockerfile` 和 `docker-compose.yml` 配置,实现一键部署整个技术栈(前端、后端、Redis、Coturn),极大地方便自托管用户。
|
|
||||||
- **包体积优化:** 定期使用 `@next/bundle-analyzer` 分析前端打包体积,通过代码分割等手段进行优化,保持应用的轻量化。
|
|
||||||
|
|
||||||
### 用户体验 (UX)
|
|
||||||
|
|
||||||
- to be define
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 未来探索与社区驱动
|
## 术语口径
|
||||||
|
|
||||||
本部分用于记录那些不在当前核心规划中,但对社区贡献开放的绝佳想法。
|
- 发送/接收(Sender/Receiver)
|
||||||
|
- 房间(Room)
|
||||||
|
- 分块(Chunk)与背压(Backpressure)
|
||||||
|
- 断点续传(Resume)
|
||||||
|
- 数据通道(DataChannel)
|
||||||
|
- 落盘(OPFS/磁盘写入)
|
||||||
|
|
||||||
- **代码测试覆盖:** 近期的架构重构使得代码库的可测试性大大增强。我们现在计划引入测试框架(如 Vitest),为核心的 `webrtcService` 和 Zustand store 添加单元测试,以提升代码质量、保障社区贡献的安全性。我们非常欢迎社区在此方面做出贡献。
|
感谢关注与贡献!
|
||||||
- **你的想法:** 你是否对浏览器扩展、屏幕共享、P2P 媒体流等高级功能有很棒的想法?欢迎通过 Issue 发起讨论!我们相信最好的创意来自社区。
|
|
||||||
|
|
||||||
## 如何贡献
|
|
||||||
|
|
||||||
你的贡献对于将这份路线图变为现实至关重要!
|
|
||||||
|
|
||||||
1. **认领任务:** 寻找被标记为 `help wanted` 或 `good first issue` 的 Issue。
|
|
||||||
2. **发起讨论:** 如果你对路线图中某个项目感兴趣,欢迎发起一个讨论来分享你的想法。
|
|
||||||
3. **提交代码:** Fork 仓库,创建你的功能分支,然后提交 Pull Request。
|
|
||||||
|
|
||||||
感谢你成为 PrivyDrop 社区的一员!让我们一起共创私人分享的未来。
|
|
||||||
|
|||||||
+1
-2
@@ -16,7 +16,6 @@ This is the backend server for PrivyDrop. It is built with Node.js, Express, and
|
|||||||
- **Language**: TypeScript
|
- **Language**: TypeScript
|
||||||
- **Real-time Communication**: Socket.IO
|
- **Real-time Communication**: Socket.IO
|
||||||
- **Database**: Redis (using the ioredis client)
|
- **Database**: Redis (using the ioredis client)
|
||||||
- **Process Management**: PM2
|
|
||||||
|
|
||||||
## 🚀 Getting Started (Local Development)
|
## 🚀 Getting Started (Local Development)
|
||||||
|
|
||||||
@@ -56,4 +55,4 @@ This service provides a set of API endpoints and Socket.IO events to support the
|
|||||||
|
|
||||||
- To understand the backend's code structure, module design, and Redis data model in depth, please read the [**Backend Architecture Deep Dive**](../docs/BACKEND_ARCHITECTURE.md).
|
- To understand the backend's code structure, module design, and Redis data model in depth, please read the [**Backend Architecture Deep Dive**](../docs/BACKEND_ARCHITECTURE.md).
|
||||||
- To learn about how the frontend and backend collaborate, refer to the [**Overall Project Architecture**](../docs/ARCHITECTURE.md).
|
- To learn about how the frontend and backend collaborate, refer to the [**Overall Project Architecture**](../docs/ARCHITECTURE.md).
|
||||||
- For instructions on deploying in a production environment, please see the [**Deployment Guide**](../docs/DEPLOYMENT.md).
|
- For production deployment, see the [**Docker Deployment Guide**](../docs/DEPLOYMENT_docker.md).
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
- **语言**: TypeScript
|
- **语言**: TypeScript
|
||||||
- **实时通信**: Socket.IO
|
- **实时通信**: Socket.IO
|
||||||
- **数据库**: Redis (使用 ioredis 客户端)
|
- **数据库**: Redis (使用 ioredis 客户端)
|
||||||
- **进程管理**: PM2
|
|
||||||
|
|
||||||
## 🚀 入门 (本地开发)
|
## 🚀 入门 (本地开发)
|
||||||
|
|
||||||
@@ -56,4 +55,4 @@
|
|||||||
|
|
||||||
- 要深入理解后端的代码结构、模块设计和 Redis 数据模型,请阅读 [**后端架构详解**](../docs/BACKEND_ARCHITECTURE.zh-CN.md)。
|
- 要深入理解后端的代码结构、模块设计和 Redis 数据模型,请阅读 [**后端架构详解**](../docs/BACKEND_ARCHITECTURE.zh-CN.md)。
|
||||||
- 要了解项目前后端的整体协作方式,请参阅 [**项目整体架构**](../docs/ARCHITECTURE.zh-CN.md)。
|
- 要了解项目前后端的整体协作方式,请参阅 [**项目整体架构**](../docs/ARCHITECTURE.zh-CN.md)。
|
||||||
- 有关生产环境的部署方法,请参考 [**部署指南**](../docs/DEPLOYMENT.zh-CN.md)。
|
- 有关生产环境部署,请参考 [**Docker 部署指南**](../docs/DEPLOYMENT_docker.zh-CN.md)。
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
# Use Ubuntu 20.04 image as base
|
|
||||||
FROM ubuntu:20.04
|
|
||||||
|
|
||||||
# Set environment variables to avoid interactive installation
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
# Set Tsinghua University software source
|
|
||||||
RUN sed -i 's/archive.ubuntu.com/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y tzdata
|
|
||||||
|
|
||||||
# Set Shanghai time zone
|
|
||||||
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
|
||||||
|
|
||||||
# Install certbot nginx
|
|
||||||
RUN apt install -y certbot python3-certbot-nginx ssl-cert
|
|
||||||
|
|
||||||
# TURN server
|
|
||||||
RUN apt-get install -y vim coturn
|
|
||||||
|
|
||||||
# redis service
|
|
||||||
RUN apt-get install -y redis-server
|
|
||||||
|
|
||||||
# Install nodejs 20
|
|
||||||
RUN apt-get install -y curl lsb-release
|
|
||||||
|
|
||||||
# node.js
|
|
||||||
## Import repository GPG key
|
|
||||||
RUN apt install -y ca-certificates gnupg && mkdir -p /etc/apt/keyrings
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
|
||||||
## Add Node.JS 20 LTS APT repository.
|
|
||||||
ENV NODE_MAJOR=20
|
|
||||||
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
|
|
||||||
## Update package index.
|
|
||||||
RUN apt-get update
|
|
||||||
## Install Node.js, npm, pnpm
|
|
||||||
RUN apt install -y nodejs
|
|
||||||
RUN npm install -g pnpm pm2
|
|
||||||
## node -v -> v20.18.1;npm -v -> 10.8.2;pnpm -v -> 9.14.4
|
|
||||||
## install Yarn package manager
|
|
||||||
#curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /usr/share/keyrings/yarnkey.gpg >/dev/null
|
|
||||||
#echo "deb [signed-by=/usr/share/keyrings/yarnkey.gpg] https://dl.yarnpkg.com/debian stable main" | tee /etc/apt/sources.list.d/yarn.list
|
|
||||||
#apt update && apt-get install yarn -y
|
|
||||||
|
|
||||||
## Install Nginx
|
|
||||||
RUN curl -fsSL https://nginx.org/keys/nginx_signing.key | apt-key add - && \
|
|
||||||
echo "deb https://nginx.org/packages/ubuntu/ $(lsb_release -cs) nginx" | tee /etc/apt/sources.list.d/nginx.list && \
|
|
||||||
apt update && apt install -y nginx
|
|
||||||
|
|
||||||
#clean up
|
|
||||||
RUN apt-get clean autoclean
|
|
||||||
RUN apt-get autoremove --yes
|
|
||||||
RUN rm -rf /var/lib/{apt,cache,log}/ && rm -rf /tmp/*
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Define required environment variables
|
|
||||||
declare -A required_vars=(
|
|
||||||
["NGINX_SERVER_NAME"]="Nginx server domain"
|
|
||||||
["NGINX_FRONTEND_ROOT"]="Frontend build file path"
|
|
||||||
["BACKEND_PORT"]="Backend service port"
|
|
||||||
["TURN_REALM"]="TURN server domain name"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate environment variables
|
|
||||||
validate_env_vars() {
|
|
||||||
local missing_vars=()
|
|
||||||
local env_file=$1
|
|
||||||
|
|
||||||
echo "Verifying Nginx environment variable configuration..."
|
|
||||||
|
|
||||||
# Load environment variables
|
|
||||||
source "$env_file"
|
|
||||||
|
|
||||||
# Check required variables
|
|
||||||
for var in "${!required_vars[@]}"; do
|
|
||||||
if [ -z "${!var}" ]; then
|
|
||||||
missing_vars+=("$var (${required_vars[$var]})")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# If there are missing variables, display an error message and exit
|
|
||||||
if [ ${#missing_vars[@]} -ne 0 ]; then
|
|
||||||
echo "Error: The following required Nginx variables are not set:"
|
|
||||||
printf '%s\n' "${missing_vars[@]}" | sed 's/^/ - /'
|
|
||||||
echo "Please set these variables in $env_file and try again."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Nginx production environment variables verified successfully!"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check parameters
|
|
||||||
if [ -z "$1" ]; then
|
|
||||||
echo "Usage: $0 <env_file_path>"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
ENV_FILE=$1
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
echo "Nginx path: $SCRIPT_DIR"
|
|
||||||
|
|
||||||
# Check if the environment variable file exists
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
|
||||||
echo "Error: Environment file $ENV_FILE not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Validate environment variables
|
|
||||||
validate_env_vars "$ENV_FILE"
|
|
||||||
|
|
||||||
# Read environment variables
|
|
||||||
source "$ENV_FILE"
|
|
||||||
|
|
||||||
# Configure Nginx
|
|
||||||
configure_nginx() {
|
|
||||||
echo "Configuring Nginx..."
|
|
||||||
|
|
||||||
NGINX_TEMPLATE="$SCRIPT_DIR/default"
|
|
||||||
echo "reading $NGINX_TEMPLATE ..."
|
|
||||||
TEMP_NGINX=$(mktemp)
|
|
||||||
|
|
||||||
# Use sed for more robust replacement
|
|
||||||
sed -e "s/www\.YourDomain/www.$NGINX_SERVER_NAME/g" \
|
|
||||||
-e "s/YourDomain/$NGINX_SERVER_NAME/g" \
|
|
||||||
-e "s|path/to/PrivyDrop/frontend|$NGINX_FRONTEND_ROOT|g" \
|
|
||||||
-e "s/localhost:3001/localhost:$BACKEND_PORT/g" \
|
|
||||||
-e "s/TurnServerName/$TURN_REALM/g" \
|
|
||||||
"$NGINX_TEMPLATE" > "$TEMP_NGINX"
|
|
||||||
|
|
||||||
# Copy the configuration file to the target location
|
|
||||||
mkdir -p /etc/nginx/sites-enabled
|
|
||||||
cp "$TEMP_NGINX" /etc/nginx/sites-enabled/default
|
|
||||||
# cp "$TEMP_NGINX" default_temp
|
|
||||||
rm "$TEMP_NGINX"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Configure nginx.conf with variable substitution
|
|
||||||
configure_nginx_conf() {
|
|
||||||
echo "Configuring nginx.conf..."
|
|
||||||
|
|
||||||
NGINX_CONF_TEMPLATE="$SCRIPT_DIR/nginx.conf"
|
|
||||||
echo "reading $NGINX_CONF_TEMPLATE ..."
|
|
||||||
TEMP_NGINX_CONF=$(mktemp)
|
|
||||||
|
|
||||||
# Use sed to replace variables in nginx.conf
|
|
||||||
sed -e "s/TurnServerName/$TURN_REALM/g" \
|
|
||||||
"$NGINX_CONF_TEMPLATE" > "$TEMP_NGINX_CONF"
|
|
||||||
|
|
||||||
# Copy the configuration file to the target location
|
|
||||||
cp "$TEMP_NGINX_CONF" /etc/nginx/nginx.conf
|
|
||||||
rm "$TEMP_NGINX_CONF"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Execute configuration
|
|
||||||
configure_nginx
|
|
||||||
configure_nginx_conf
|
|
||||||
|
|
||||||
echo "Nginx configuration files generated successfully:"
|
|
||||||
echo " - /etc/nginx/sites-enabled/default (site configuration)"
|
|
||||||
echo " - /etc/nginx/nginx.conf (main configuration with TURN routing)"
|
|
||||||
echo "The script no longer restarts Nginx automatically."
|
|
||||||
echo ""
|
|
||||||
echo "NEXT STEP: Run Certbot to install the SSL certificate and automatically configure Nginx:"
|
|
||||||
echo "sudo certbot --nginx -d your_domain.com -d www.your_domain.com -d turn.your_domain.com"
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
server { # Redirect HTTP to HTTPS
|
|
||||||
listen 80;
|
|
||||||
server_name YourDomain www.YourDomain;
|
|
||||||
return 301 https://$server_name$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
|
|
||||||
# No longer listening on public 443/TCP, change to listening on internal port
|
|
||||||
listen 127.0.0.1:4443 ssl;
|
|
||||||
http2 on;
|
|
||||||
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
|
|
||||||
server_name YourDomain www.YourDomain;
|
|
||||||
|
|
||||||
# Redirect bare domain to www
|
|
||||||
if ($host = 'YourDomain') {
|
|
||||||
return 301 https://www.YourDomain$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
# SSL Configuration (using placeholder certs for Certbot)
|
|
||||||
# Certbot will find this block and replace these with the real certificates.
|
|
||||||
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
|
|
||||||
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
|
|
||||||
|
|
||||||
# SSL Optimization
|
|
||||||
ssl_session_timeout 1d;
|
|
||||||
ssl_session_cache shared:SSL:50m;
|
|
||||||
ssl_session_tickets off;
|
|
||||||
|
|
||||||
# Modern Configuration
|
|
||||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
|
||||||
ssl_prefer_server_ciphers off;
|
|
||||||
|
|
||||||
# HSTS (Enable with caution)
|
|
||||||
# add_header Strict-Transport-Security "max-age=63072000" always;
|
|
||||||
|
|
||||||
# Define the root path of the frontend build artifacts inside the container
|
|
||||||
# !!! Important: Please modify this path to the actual path of your frontend project build inside the Nginx container !!!
|
|
||||||
set $frontend_build_root path/to/PrivyDrop/frontend;
|
|
||||||
|
|
||||||
# 1. Prioritize handling of Next.js core static resources (_next/static)
|
|
||||||
location /_next/static/ {
|
|
||||||
alias $frontend_build_root/.next/static/;
|
|
||||||
expires 365d; # Long-term cache
|
|
||||||
access_log off; # Disable access log for this path
|
|
||||||
add_header Cache-Control "public"; # Explicitly inform the browser that it can be cached publicly
|
|
||||||
}
|
|
||||||
|
|
||||||
# WebSocket signaling server configuration
|
|
||||||
location /socket.io/ {
|
|
||||||
proxy_pass http://localhost:3001/socket.io/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# CORS Configuration
|
|
||||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
|
||||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
|
|
||||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
|
||||||
|
|
||||||
# WebSocket related optimizations
|
|
||||||
proxy_read_timeout 86400; # 24h
|
|
||||||
proxy_send_timeout 86400; # 24h
|
|
||||||
proxy_connect_timeout 7d;
|
|
||||||
proxy_buffering off;
|
|
||||||
}
|
|
||||||
# Backend API address -- forward
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://localhost:3001/api/; # Backend API address -- forward
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# Modify CORS configuration, only set one Origin
|
|
||||||
add_header Access-Control-Allow-Origin "https://www.privydrop.app" always;
|
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
|
||||||
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range" always;
|
|
||||||
add_header Access-Control-Allow-Credentials "true" always;
|
|
||||||
|
|
||||||
}
|
|
||||||
# Next.js Image Optimization Service (usually handled by the Next.js application)
|
|
||||||
location /_next/image {
|
|
||||||
proxy_pass http://localhost:3002; # Point to the Next.js application
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
}
|
|
||||||
# 2. Handle static files under the public directory and Next.js dynamic requests
|
|
||||||
# This location should be after specific proxies (like /api/, /socket.io/),
|
|
||||||
# but it can be before or after /_next/static/ because they match different paths.
|
|
||||||
# For clarity, we put it here.
|
|
||||||
location / {
|
|
||||||
# root points to the parent directory of the public directory, which is the root directory of the frontend build artifacts
|
|
||||||
root $frontend_build_root/public;
|
|
||||||
|
|
||||||
# Try to find files in order:
|
|
||||||
# 1. $uri: as a file in the public directory (e.g., /image.png -> $frontend_build_root/public/image.png)
|
|
||||||
# 2. @nextjs: If none of the above are found, pass the request to the Next.js application for processing
|
|
||||||
try_files $uri @nextjs_app;
|
|
||||||
}
|
|
||||||
# Named location, used to proxy requests to the Next.js application
|
|
||||||
location @nextjs_app {
|
|
||||||
proxy_pass http://localhost:3002; # Point to the Next.js application
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
server { # Add a server block for Certbot to install certificates for TURN server
|
|
||||||
listen 80;
|
|
||||||
server_name TurnServerName;
|
|
||||||
|
|
||||||
# Only process Let's Encrypt validation requests
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/html;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# --- Configuration ---
|
|
||||||
NGINX_CONF_FILE="/etc/nginx/sites-enabled/default"
|
|
||||||
|
|
||||||
# Define the new configuration block to be added
|
|
||||||
read -r -d '' NEW_BLOCK <<'EOF'
|
|
||||||
|
|
||||||
# Configuration for turn.privydrop.app - used only for Certbot renewal
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name turn.privydrop.app;
|
|
||||||
|
|
||||||
# Handle only Let's Encrypt ACME challenge requests
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Return 404 for all other requests
|
|
||||||
location / {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# --- Main function ---
|
|
||||||
main() {
|
|
||||||
echo "▶️ Starting Nginx configuration check..."
|
|
||||||
|
|
||||||
# Check for root privileges
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
|
||||||
echo "❌ Error: This script must be run as root"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if config file exists
|
|
||||||
if [ ! -f "$NGINX_CONF_FILE" ]; then
|
|
||||||
echo "❌ Error: Configuration file not found: $NGINX_CONF_FILE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create a temporary backup
|
|
||||||
TEMP_FILE=$(mktemp)
|
|
||||||
cp "$NGINX_CONF_FILE" "$TEMP_FILE"
|
|
||||||
echo "🔐 Backup created at: $TEMP_FILE"
|
|
||||||
|
|
||||||
# Use Python to count and optionally remove the last two server blocks
|
|
||||||
ACTION=$(python3 -c "
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Read the file
|
|
||||||
try:
|
|
||||||
with open('$NGINX_CONF_FILE', 'r') as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
except Exception as e:
|
|
||||||
print('ERROR: Unable to read config file')
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
# Find all server block start and end positions
|
|
||||||
server_blocks = []
|
|
||||||
i = 0
|
|
||||||
while i < len(lines):
|
|
||||||
if re.match(r'^\s*server\s*\{', lines[i]):
|
|
||||||
start = i
|
|
||||||
brace_count = 1
|
|
||||||
j = i + 1
|
|
||||||
while j < len(lines) and brace_count > 0:
|
|
||||||
brace_count += lines[j].count('{') - lines[j].count('}')
|
|
||||||
j += 1
|
|
||||||
server_blocks.append((start, j-1))
|
|
||||||
i = j
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
num_blocks = len(server_blocks)
|
|
||||||
print(f'🔍 Found {num_blocks} server blocks')
|
|
||||||
|
|
||||||
if num_blocks >= 4:
|
|
||||||
print('✅ Condition met (≥4 blocks), preparing to remove last two and add new config')
|
|
||||||
print('ACTION: MODIFY')
|
|
||||||
|
|
||||||
# Keep up to the third-to-last block end, or before last two if only 4
|
|
||||||
if num_blocks > 2:
|
|
||||||
keep_until = server_blocks[-3][1] + 1
|
|
||||||
else:
|
|
||||||
keep_until = server_blocks[-2][0]
|
|
||||||
result_lines = lines[:keep_until]
|
|
||||||
|
|
||||||
# Remove trailing empty lines
|
|
||||||
while result_lines and result_lines[-1].strip() == '':
|
|
||||||
result_lines.pop()
|
|
||||||
|
|
||||||
# Ensure ends with newline
|
|
||||||
if result_lines and not result_lines[-1].endswith('\n'):
|
|
||||||
result_lines[-1] += '\n'
|
|
||||||
|
|
||||||
# Write modified content back
|
|
||||||
with open('$NGINX_CONF_FILE', 'w') as f:
|
|
||||||
f.writelines(result_lines)
|
|
||||||
|
|
||||||
else:
|
|
||||||
print('ℹ️ Less than 4 server blocks found. No changes will be made.')
|
|
||||||
print('ACTION: SKIP')
|
|
||||||
")
|
|
||||||
|
|
||||||
# Extract action decision from Python script output
|
|
||||||
ACTION=$(echo "$ACTION" | grep '^ACTION:' | cut -d' ' -f2 | tr -d '\r')
|
|
||||||
|
|
||||||
# Show number of blocks
|
|
||||||
echo "$ACTION" | grep -o 'Found [0-9]* server blocks' | head -1
|
|
||||||
|
|
||||||
if [[ "$ACTION" == "SKIP" ]]; then
|
|
||||||
echo "⏭️ Skipping modification and new configuration addition."
|
|
||||||
rm "$TEMP_FILE"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Append the new configuration block
|
|
||||||
echo "✍️ Adding new configuration block for turn.privydrop.app..."
|
|
||||||
echo "$NEW_BLOCK" >> "$NGINX_CONF_FILE"
|
|
||||||
|
|
||||||
# Test the Nginx configuration
|
|
||||||
echo "🔍 Testing Nginx configuration..."
|
|
||||||
if nginx -t 2>/dev/null; then
|
|
||||||
echo "✅ Configuration test successful!"
|
|
||||||
echo "🚀 Apply changes with:"
|
|
||||||
echo " sudo systemctl reload nginx"
|
|
||||||
echo ""
|
|
||||||
rm "$TEMP_FILE"
|
|
||||||
else
|
|
||||||
echo "❌ Configuration test failed. Showing details:"
|
|
||||||
nginx -t
|
|
||||||
echo ""
|
|
||||||
echo "🔄 Restoring from backup..."
|
|
||||||
cp "$TEMP_FILE" "$NGINX_CONF_FILE"
|
|
||||||
echo "✅ Original configuration restored"
|
|
||||||
rm "$TEMP_FILE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Run main function with all arguments
|
|
||||||
main "$@"
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# The user that nginx runs as, needs file directory access permissions
|
|
||||||
user root;
|
|
||||||
# The number of worker processes, usually set to be equal to the number of CPUs
|
|
||||||
# worker_processes 1;
|
|
||||||
worker_processes auto;
|
|
||||||
pid /run/nginx.pid;
|
|
||||||
#include /etc/nginx/modules-enabled/*.conf;
|
|
||||||
|
|
||||||
events {
|
|
||||||
worker_connections 768;
|
|
||||||
# multi_accept on;
|
|
||||||
}
|
|
||||||
|
|
||||||
stream {
|
|
||||||
# Define backend services
|
|
||||||
upstream turns_backend {
|
|
||||||
# Coturn's TURNS service, listening on local port 5349
|
|
||||||
server 127.0.0.1:5349;
|
|
||||||
}
|
|
||||||
upstream website_backend {
|
|
||||||
# Your website is now listening on the internal HTTPS port
|
|
||||||
server 127.0.0.1:4443;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use SNI hostname to determine traffic destination
|
|
||||||
map $ssl_preread_server_name $backend {
|
|
||||||
TurnServerName turns_backend; # If accessing the turn subdomain, hand it over to Coturn
|
|
||||||
default website_backend; # All other domains are handed over to the website
|
|
||||||
}
|
|
||||||
|
|
||||||
# Listening for all TCP traffic on port 443
|
|
||||||
server {
|
|
||||||
listen 443;
|
|
||||||
listen [::]:443;
|
|
||||||
|
|
||||||
# Enable SSL pre-read feature to obtain SNI hostname
|
|
||||||
ssl_preread on;
|
|
||||||
|
|
||||||
# Proxy traffic to the corresponding backend based on map results
|
|
||||||
proxy_pass $backend;
|
|
||||||
proxy_timeout 1d; # Suggest setting a longer timeout for TURN
|
|
||||||
proxy_connect_timeout 5s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
|
|
||||||
##
|
|
||||||
# Basic Settings
|
|
||||||
##
|
|
||||||
|
|
||||||
sendfile on;
|
|
||||||
tcp_nopush on;
|
|
||||||
tcp_nodelay on;
|
|
||||||
keepalive_timeout 65;
|
|
||||||
types_hash_max_size 2048;
|
|
||||||
# server_tokens off;
|
|
||||||
|
|
||||||
# server_names_hash_bucket_size 64;
|
|
||||||
# server_name_in_redirect off;
|
|
||||||
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
default_type application/octet-stream;
|
|
||||||
|
|
||||||
##
|
|
||||||
# SSL Settings
|
|
||||||
##
|
|
||||||
|
|
||||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
|
|
||||||
ssl_prefer_server_ciphers on;
|
|
||||||
|
|
||||||
##
|
|
||||||
# Logging Settings
|
|
||||||
##
|
|
||||||
|
|
||||||
access_log /var/log/nginx/access.log;
|
|
||||||
error_log /var/log/nginx/error.log;
|
|
||||||
|
|
||||||
##
|
|
||||||
# Gzip Settings
|
|
||||||
##
|
|
||||||
|
|
||||||
gzip on;
|
|
||||||
|
|
||||||
# gzip_vary on;
|
|
||||||
# gzip_proxied any;
|
|
||||||
# gzip_comp_level 6;
|
|
||||||
# gzip_buffers 16 8k;
|
|
||||||
# gzip_http_version 1.1;
|
|
||||||
# gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
|
||||||
|
|
||||||
##
|
|
||||||
# Virtual Host Configs
|
|
||||||
##
|
|
||||||
|
|
||||||
include /etc/nginx/conf.d/*.conf;
|
|
||||||
include /etc/nginx/sites-enabled/*;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#mail {
|
|
||||||
# # See sample authentication script at:
|
|
||||||
# # http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript
|
|
||||||
#
|
|
||||||
# # auth_http localhost/auth.php;
|
|
||||||
# # pop3_capabilities "TOP" "USER";
|
|
||||||
# # imap_capabilities "IMAP4rev1" "UIDPLUS";
|
|
||||||
#
|
|
||||||
# server {
|
|
||||||
# listen localhost:110;
|
|
||||||
# protocol pop3;
|
|
||||||
# proxy on;
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
# server {
|
|
||||||
# listen localhost:143;
|
|
||||||
# protocol imap;
|
|
||||||
# proxy on;
|
|
||||||
# }
|
|
||||||
#}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/etc/init.d/nginx stop
|
|
||||||
rm /var/log/nginx/*
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Define required environment variables
|
|
||||||
declare -A required_vars=(
|
|
||||||
["TURN_EXTERNAL_IP"]="TURN server external IP address"
|
|
||||||
["TURN_REALM"]="TURN server realm"
|
|
||||||
["TURN_USERNAME"]="TURN server username"
|
|
||||||
["TURN_PASSWORD"]="TURN server password"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Additional required variables for production environment
|
|
||||||
production_vars=(
|
|
||||||
"TURN_CERT_PATH"
|
|
||||||
"TURN_KEY_PATH"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate environment variables
|
|
||||||
validate_env_vars() {
|
|
||||||
local missing_vars=()
|
|
||||||
local env_file=$1
|
|
||||||
|
|
||||||
echo "Verifying TURN server environment variable configuration..."
|
|
||||||
|
|
||||||
# Load environment variables
|
|
||||||
source "$env_file"
|
|
||||||
|
|
||||||
# Check basic required variables
|
|
||||||
for var in "${!required_vars[@]}"; do
|
|
||||||
if [ -z "${!var}" ]; then
|
|
||||||
missing_vars+=("$var (${required_vars[$var]})")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# If it is a production environment, check additional required variables
|
|
||||||
if [[ "$NODE_ENV" == "production" ]]; then
|
|
||||||
for var in "${production_vars[@]}"; do
|
|
||||||
if [ -z "${!var}" ]; then
|
|
||||||
missing_vars+=("$var (Required for production)")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If there are missing variables, display an error message and exit
|
|
||||||
if [ ${#missing_vars[@]} -ne 0 ]; then
|
|
||||||
echo "Error: The following required TURN server variables are not set:"
|
|
||||||
printf '%s\n' "${missing_vars[@]}" | sed 's/^/ - /'
|
|
||||||
echo "Please set these variables in $env_file and try again."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "TURN server environment variables verified successfully!"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check parameters
|
|
||||||
if [ -z "$1" ]; then
|
|
||||||
echo "Usage: $0 <env_file_path>"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
ENV_FILE=$1
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
# Check if the environment variable file exists
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
|
||||||
echo "Error: Environment file $ENV_FILE not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Validate environment variables
|
|
||||||
validate_env_vars "$ENV_FILE"
|
|
||||||
|
|
||||||
# Read environment variables
|
|
||||||
source "$ENV_FILE"
|
|
||||||
|
|
||||||
echo "Configuring TURN server..."
|
|
||||||
|
|
||||||
# Determine which configuration template to use
|
|
||||||
if [[ "$NODE_ENV" == "development" ]]; then
|
|
||||||
TEMPLATE_FILE="$SCRIPT_DIR/turnserver_development.conf"
|
|
||||||
else
|
|
||||||
TEMPLATE_FILE="$SCRIPT_DIR/turnserver_production.conf"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create a temporary configuration file
|
|
||||||
TEMP_CONF=$(mktemp)
|
|
||||||
|
|
||||||
# Read the template and replace variables
|
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
|
||||||
# Replace external-ip
|
|
||||||
if [[ $line =~ ^external-ip= ]]; then
|
|
||||||
echo "external-ip=$TURN_EXTERNAL_IP"
|
|
||||||
# Replace realm
|
|
||||||
elif [[ $line =~ ^realm= ]]; then
|
|
||||||
echo "realm=$TURN_REALM"
|
|
||||||
# Replace user credentials
|
|
||||||
elif [[ $line =~ ^user= ]]; then
|
|
||||||
echo "user=$TURN_USERNAME:$TURN_PASSWORD"
|
|
||||||
# Replace certificate path
|
|
||||||
elif [[ $line =~ ^cert= ]]; then
|
|
||||||
echo "cert=$TURN_CERT_PATH"
|
|
||||||
# Replace key path
|
|
||||||
elif [[ $line =~ ^pkey= ]]; then
|
|
||||||
echo "pkey=$TURN_KEY_PATH"
|
|
||||||
else
|
|
||||||
echo "$line"
|
|
||||||
fi
|
|
||||||
done < "$TEMPLATE_FILE" > "$TEMP_CONF"
|
|
||||||
|
|
||||||
# cp "$TEMP_CONF" turnserver.conf
|
|
||||||
# Use sudo to copy the configuration file to the target location
|
|
||||||
cp "$TEMP_CONF" /etc/turnserver.conf
|
|
||||||
|
|
||||||
# Delete temporary file
|
|
||||||
rm "$TEMP_CONF"
|
|
||||||
|
|
||||||
# Restart the TURN server
|
|
||||||
service coturn restart
|
|
||||||
|
|
||||||
echo "TURN server configuration has been updated and service restarted."
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# /etc/turnserver.conf
|
|
||||||
|
|
||||||
# Listen on all interfaces
|
|
||||||
listening-ip=0.0.0.0
|
|
||||||
|
|
||||||
# Use your server's public IP
|
|
||||||
external-ip=YourServerPublicIP
|
|
||||||
|
|
||||||
# TURN server port
|
|
||||||
listening-port=3478
|
|
||||||
# Enable TLS -- TURNS (encrypted TURN)
|
|
||||||
#tls-listening-port=5349
|
|
||||||
|
|
||||||
# Relay port range
|
|
||||||
min-port=49152
|
|
||||||
max-port=65535
|
|
||||||
|
|
||||||
# Long-term certificate mechanism
|
|
||||||
lt-cred-mech
|
|
||||||
|
|
||||||
# TURN server domain (if any) IP or YourTURNDomain
|
|
||||||
# realm=YourTURNDomain
|
|
||||||
realm=YourServerPublicIP
|
|
||||||
|
|
||||||
# TURN server certificate and key (for TLS) certificates are not required in the development environment
|
|
||||||
# cert=/etc/letsencrypt/live/turn.privydrop.app/fullchain.pem
|
|
||||||
# pkey=/etc/letsencrypt/live/turn.privydrop.app/privkey.pem
|
|
||||||
|
|
||||||
# Username and password (a more secure method should be used in a production environment)
|
|
||||||
user=UserName:PassWord
|
|
||||||
|
|
||||||
# Enable verbose logging
|
|
||||||
verbose
|
|
||||||
|
|
||||||
# Allow loopback addresses
|
|
||||||
# allow-loopback-peers
|
|
||||||
|
|
||||||
# Set maximum bandwidth (bytes/second)
|
|
||||||
# max-bandwidth=0
|
|
||||||
|
|
||||||
# Disable TLS
|
|
||||||
# no-tls
|
|
||||||
|
|
||||||
# Disable DTLS
|
|
||||||
# no-dtls
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# /etc/turnserver.conf
|
|
||||||
|
|
||||||
# Listen on all interfaces
|
|
||||||
listening-ip=0.0.0.0
|
|
||||||
|
|
||||||
# Use your server's public IP
|
|
||||||
external-ip=YourServerPublicIP
|
|
||||||
|
|
||||||
# TURN server port
|
|
||||||
listening-port=3478
|
|
||||||
# Enable TLS -- TURNS (encrypted TURN)
|
|
||||||
tls-listening-port=5349
|
|
||||||
|
|
||||||
# Relay port range
|
|
||||||
min-port=49152
|
|
||||||
max-port=65535
|
|
||||||
|
|
||||||
# Long-term certificate mechanism
|
|
||||||
lt-cred-mech
|
|
||||||
|
|
||||||
# TURN server domain (if any) IP or YourTURNDomain
|
|
||||||
# realm=YourServerPublicIP
|
|
||||||
realm=YourTURNDomain
|
|
||||||
|
|
||||||
# TURN server certificate and key (for TLS)
|
|
||||||
cert=path/to/your/certFile
|
|
||||||
pkey=path/to/your/privkeyFile
|
|
||||||
|
|
||||||
# Username and password (a more secure method should be used in a production environment)
|
|
||||||
user=UserName:PassWord
|
|
||||||
|
|
||||||
# Enable verbose logging
|
|
||||||
verbose
|
|
||||||
|
|
||||||
# Allow loopback addresses
|
|
||||||
# allow-loopback-peers
|
|
||||||
|
|
||||||
# Set maximum bandwidth (bytes/second)
|
|
||||||
# max-bandwidth=0
|
|
||||||
|
|
||||||
# Disable TLS
|
|
||||||
# no-tls
|
|
||||||
|
|
||||||
# Disable DTLS
|
|
||||||
# no-dtls
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
sudo apt install -y certbot python3-certbot-nginx ssl-cert
|
|
||||||
sudo apt-get install -y vim coturn
|
|
||||||
|
|
||||||
sudo apt-get install -y redis-server
|
|
||||||
|
|
||||||
sudo apt-get install -y curl lsb-release
|
|
||||||
|
|
||||||
sudo apt install -y ca-certificates gnupg && sudo mkdir -p /etc/apt/keyrings
|
|
||||||
|
|
||||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
|
||||||
|
|
||||||
export NODE_MAJOR=20
|
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
|
|
||||||
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt install -y nodejs
|
|
||||||
sudo npm install -g pnpm pm2
|
|
||||||
|
|
||||||
# Install Nginx from official repository
|
|
||||||
curl -fsSL https://nginx.org/keys/nginx_signing.key | sudo apt-key add -
|
|
||||||
echo "deb https://nginx.org/packages/ubuntu/ $(lsb_release -cs) nginx" | sudo tee /etc/apt/sources.list.d/nginx.list
|
|
||||||
sudo apt update && sudo apt install -y nginx
|
|
||||||
# Verify stream module
|
|
||||||
nginx -V 2>&1 | grep -o with-stream || echo "Stream module not available"
|
|
||||||
|
|
||||||
sudo apt-get clean autoclean
|
|
||||||
sudo apt-get autoremove --yes
|
|
||||||
sudo rm -rf /var/lib/{apt,cache,log}/ && sudo rm -rf /tmp/*
|
|
||||||
Generated
+2369
-2369
File diff suppressed because it is too large
Load Diff
@@ -47,16 +47,21 @@ const createRoomHandler: RequestHandler<{}, any, CreateRoomRequest> = async (
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const exists = await roomService.isRoomExist(roomId);
|
const exists = await roomService.isRoomExist(roomId);
|
||||||
const response = {
|
|
||||||
success: !exists,
|
|
||||||
message: exists ? "roomId is already exists" : "create room success",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!exists) {
|
// Idempotent behavior for long IDs (>= 8): allow reuse for reconnect scenarios.
|
||||||
await roomService.createRoom(roomId);
|
// Short IDs keep strict uniqueness (prevent accidental collisions).
|
||||||
|
if (exists) {
|
||||||
|
if (roomId.length >= 8) {
|
||||||
|
// Do NOT refresh TTL here; actual join will refresh on success.
|
||||||
|
res.json({ success: true, message: "room exists (rejoin allowed)" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: false, message: "roomId is already exists" });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(response);
|
await roomService.createRoom(roomId);
|
||||||
|
res.json({ success: true, message: "create room success" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error checking room:", error);
|
console.error("Error checking room:", error);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
|||||||
@@ -741,6 +741,13 @@ main() {
|
|||||||
|
|
||||||
# If full + nginx, automatically issue certs and enable 443
|
# If full + nginx, automatically issue certs and enable 443
|
||||||
provision_letsencrypt_cert || true
|
provision_letsencrypt_cert || true
|
||||||
|
if [[ "$DEPLOYMENT_MODE" == "full" && "$WITH_NGINX" == "true" ]]; then
|
||||||
|
log_info "Refreshing edge containers to apply generated HTTPS/SNI config..."
|
||||||
|
docker compose up -d --force-recreate nginx >/dev/null
|
||||||
|
if [[ "$WITH_TURN" == "true" ]]; then
|
||||||
|
docker compose up -d --force-recreate coturn >/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
# Ensure TURN is running (when requested with --with-turn)
|
# Ensure TURN is running (when requested with --with-turn)
|
||||||
ensure_turn_running || true
|
ensure_turn_running || true
|
||||||
|
|
||||||
|
|||||||
@@ -232,8 +232,21 @@ generate_env_file() {
|
|||||||
turn_enabled="true"
|
turn_enabled="true"
|
||||||
;;
|
;;
|
||||||
full)
|
full)
|
||||||
cors_origin="https://${DOMAIN_NAME:-$LOCAL_IP}"
|
if [[ -n "$DOMAIN_NAME" ]]; then
|
||||||
api_url="https://${DOMAIN_NAME:-$LOCAL_IP}"
|
if [[ "$DOMAIN_NAME" == www.* ]]; then
|
||||||
|
local bare_domain="${DOMAIN_NAME#www.}"
|
||||||
|
cors_origin="https://${DOMAIN_NAME},https://${bare_domain}"
|
||||||
|
else
|
||||||
|
cors_origin="https://${DOMAIN_NAME},https://www.${DOMAIN_NAME}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
cors_origin="https://${LOCAL_IP}"
|
||||||
|
fi
|
||||||
|
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||||
|
api_url=""
|
||||||
|
else
|
||||||
|
api_url="https://${DOMAIN_NAME:-$LOCAL_IP}"
|
||||||
|
fi
|
||||||
ssl_mode="letsencrypt"
|
ssl_mode="letsencrypt"
|
||||||
turn_enabled="true"
|
turn_enabled="true"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -76,4 +76,9 @@ graph TD
|
|||||||
|
|
||||||
- **Privacy First**: Core file data is never uploaded to the server. The server only acts as an "introducer" or "matchmaker."
|
- **Privacy First**: Core file data is never uploaded to the server. The server only acts as an "introducer" or "matchmaker."
|
||||||
- **Frontend-Backend Separation**: Responsibilities are clearly separated. The frontend handles all user interaction and the complex logic of WebRTC; the backend provides lightweight, efficient signaling and room management services.
|
- **Frontend-Backend Separation**: Responsibilities are clearly separated. The frontend handles all user interaction and the complex logic of WebRTC; the backend provides lightweight, efficient signaling and room management services.
|
||||||
- **Horizontal Scalability**: The backend is stateless (with state managed in Redis), which theoretically allows it to be scaled horizontally by adding more Node.js instances to handle a large volume of concurrent signaling requests.
|
- **Horizontal Scalability**: The backend is stateless (with state managed in Redis), which theoretically allows it to be scaled horizontally by adding more Node.js instances to handle a large volume of concurrent signaling requests.
|
||||||
|
|
||||||
|
## 4. Runtime Session Model (Frontend)
|
||||||
|
|
||||||
|
- **SPA In-App Navigation Persistence**: The frontend is an SPA (App Router). Within the same browser tab, in-app navigation does not tear down the singleton app state (Zustand) nor the WebRTC connection service (webrtcService). Ongoing transfers continue, and selected/received content remains available.
|
||||||
|
- **Boundary**: Page refresh, closing the tab, or opening in a new tab are not covered. If changing layout/SSR strategy, avoid cleaning the connection during layout unmount.
|
||||||
|
|||||||
@@ -77,3 +77,8 @@ graph TD
|
|||||||
- **隐私优先**: 核心文件数据永不上传到服务器。服务器只承担“介绍人”的角色。
|
- **隐私优先**: 核心文件数据永不上传到服务器。服务器只承担“介绍人”的角色。
|
||||||
- **前后端分离**: 前后端职责清晰。前端负责所有与用户交互和 WebRTC 的复杂逻辑;后端则提供轻量、高效的信令和房间管理服务。
|
- **前后端分离**: 前后端职责清晰。前端负责所有与用户交互和 WebRTC 的复杂逻辑;后端则提供轻量、高效的信令和房间管理服务。
|
||||||
- **水平扩展**: 后端是无状态的(状态存储在 Redis 中),理论上可以通过增加 Node.js 实例来水平扩展,以应对大量并发信令请求。
|
- **水平扩展**: 后端是无状态的(状态存储在 Redis 中),理论上可以通过增加 Node.js 实例来水平扩展,以应对大量并发信令请求。
|
||||||
|
|
||||||
|
## 四、运行时会话模型(前端)
|
||||||
|
|
||||||
|
- **SPA 站内导航保持**:前端为单页应用(App Router)。在同一标签页内进行站内跳转时,应用状态(Zustand 单例)与 WebRTC 连接服务(webrtcService 单例)不会被销毁,进行中的传输不中断,已选择/已接收内容保持。
|
||||||
|
- **边界说明**:页面刷新、关闭标签页或在新标签页打开页面,不属于保持范围;如调整布局/SSR 策略,需避免在布局卸载阶段清理连接。
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ backend/
|
|||||||
│ │ ├── room.ts
|
│ │ ├── room.ts
|
||||||
│ │ └── socket.ts
|
│ │ └── socket.ts
|
||||||
│ └── server.ts # Main application entry point: Express and Socket.IO setup
|
│ └── server.ts # Main application entry point: Express and Socket.IO setup
|
||||||
├── ecosystem.config.js # PM2 configuration file
|
|
||||||
├── package.json
|
├── package.json
|
||||||
└── tsconfig.json
|
└── tsconfig.json
|
||||||
```
|
```
|
||||||
@@ -132,4 +131,4 @@ Redis is a key component of the backend, used to store all temporary state. We c
|
|||||||
- **Purpose**: Tracks the number of visits from different sources (Referrers) on a daily basis.
|
- **Purpose**: Tracks the number of visits from different sources (Referrers) on a daily basis.
|
||||||
- **Fields**: The referrer's domain name (e.g., `google.com`, `github.com`).
|
- **Fields**: The referrer's domain name (e.g., `google.com`, `github.com`).
|
||||||
- **Value**: The cumulative visit count for the day.
|
- **Value**: The cumulative visit count for the day.
|
||||||
- **Logic**: The `HINCRBY` command is used to atomically increment the count for a specified source.
|
- **Logic**: The `HINCRBY` command is used to atomically increment the count for a specified source.
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ backend/
|
|||||||
│ │ ├── room.ts
|
│ │ ├── room.ts
|
||||||
│ │ └── socket.ts
|
│ │ └── socket.ts
|
||||||
│ └── server.ts # 主应用程序入口点: Express 和 Socket.IO 设置
|
│ └── server.ts # 主应用程序入口点: Express 和 Socket.IO 设置
|
||||||
├── ecosystem.config.js # PM2 配置文件
|
|
||||||
├── package.json
|
├── package.json
|
||||||
└── tsconfig.json
|
└── tsconfig.json
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
# PrivyDrop Deployment Guide (Bare-Metal)
|
|
||||||
|
|
||||||
> Audience & Scope: This guide targets developers/operators who prefer a non-container (bare-metal) setup.
|
|
||||||
>
|
|
||||||
> Recommended: Prefer the one-click Docker deployment for simplicity and robustness, including auto HTTPS and TURN. See [Docker Deployment Guide](./DEPLOYMENT_docker.md).
|
|
||||||
|
|
||||||
This guide provides comprehensive instructions for deploying the full-stack PrivyDrop application, including setting up Redis, a TURN server, the backend service, the frontend application, and configuring Nginx as a reverse proxy.
|
|
||||||
|
|
||||||
## 1. Introduction
|
|
||||||
|
|
||||||
This document will guide you through preparing your server environment, configuring dependencies, and deploying both the frontend and backend of PrivyDrop. Whether you are setting up a development/testing environment or a full production instance, this guide aims to cover all essential aspects.
|
|
||||||
|
|
||||||
## 2. Prerequisites
|
|
||||||
|
|
||||||
Before you begin, please ensure your server environment meets the following requirements:
|
|
||||||
|
|
||||||
- **Operating System:** A Linux distribution (e.g., Ubuntu 20.04 LTS or newer is recommended).
|
|
||||||
- **Node.js:** v18.x or higher.
|
|
||||||
- **npm/pnpm:** The package manager for Node.js.
|
|
||||||
- **Root or Sudo Privileges:** Required for installing packages and configuring services.
|
|
||||||
- **Domain Name:** Required for a production deployment.
|
|
||||||
- **Optional: Base Environment & Docker Image Reference:** If you are starting from a very clean system environment or wish to see the base dependencies for a Docker build, you can refer to the `backend/docker/Dockerfile` (for Docker image creation) and `backend/docker/env_install.log` (dependency installation log) files.
|
|
||||||
|
|
||||||
## 3. Environment Installation
|
|
||||||
|
|
||||||
**Important Note:** The `backend/docker/env_install.sh` script in the project root contains all necessary dependency installation commands, including Node.js, Redis, Coturn, Nginx, and more. You can run this script directly to install all dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Make the script executable
|
|
||||||
chmod +x backend/docker/env_install.sh
|
|
||||||
|
|
||||||
# Run the installation script
|
|
||||||
sudo bash backend/docker/env_install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This script will automatically install:
|
|
||||||
|
|
||||||
- **Node.js v20** - Runtime environment
|
|
||||||
- **Redis Server** - Used for room management and caching
|
|
||||||
- **Coturn** - TURN/STUN server (optional, for NAT traversal)
|
|
||||||
- **Nginx** - Web server and reverse proxy (from official repository)
|
|
||||||
- **PM2** - Node.js process manager
|
|
||||||
- **Certbot** - SSL certificate management
|
|
||||||
|
|
||||||
After installation, you can verify the services:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verify Node.js version
|
|
||||||
node -v
|
|
||||||
|
|
||||||
# Verify Redis status
|
|
||||||
sudo systemctl status redis-server
|
|
||||||
|
|
||||||
# Verify Nginx installation
|
|
||||||
nginx -V
|
|
||||||
|
|
||||||
# Verify Coturn installation
|
|
||||||
sudo systemctl status coturn
|
|
||||||
```
|
|
||||||
|
|
||||||
**Configuration Notes:**
|
|
||||||
|
|
||||||
- **Redis Configuration:** Default listening on `127.0.0.1:6379`, ensure your backend `.env` file includes correct `REDIS_HOST` and `REDIS_PORT`
|
|
||||||
- **TURN Service:** Optional configuration, PrivyDrop uses public STUN servers by default, only needed for extremely high NAT traversal requirements
|
|
||||||
- **Nginx:** Script installs official version and verifies stream module support
|
|
||||||
|
|
||||||
**TURN Server Firewall Configuration (if configuring TURN service):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable the Coturn service
|
|
||||||
sudo sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn
|
|
||||||
|
|
||||||
# Firewall Configuration: Open Turnserver default ports
|
|
||||||
sudo ufw allow Turnserver
|
|
||||||
sudo ufw reload
|
|
||||||
```
|
|
||||||
|
|
||||||
The ports seen via `sudo ufw app info Turnserver` are as follows:
|
|
||||||
|
|
||||||
- `3478,3479,5349,5350,49152:65535/tcp`
|
|
||||||
- `3478,3479,5349,5350,49152:65535/udp`
|
|
||||||
|
|
||||||
## 4. Application Deployment (Production)
|
|
||||||
|
|
||||||
This section describes how to deploy PrivyDrop in a production environment using Nginx and PM2.
|
|
||||||
|
|
||||||
### 4.1. Get the Code and Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/david-bai00/PrivyDrop.git
|
|
||||||
cd PrivyDrop
|
|
||||||
|
|
||||||
# Install backend dependencies
|
|
||||||
cd backend && npm install && cd ..
|
|
||||||
|
|
||||||
# Install frontend dependencies
|
|
||||||
cd frontend && pnpm install && cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2. Build the Application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && pnpm build && cd ..
|
|
||||||
cd backend && npm run build && cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
This will generate an optimized production build in the `frontend/.next` and `backend/dist` directories.
|
|
||||||
|
|
||||||
### 4.3. Configure Nginx as a Reverse Proxy
|
|
||||||
|
|
||||||
In production, Nginx will act as the entry point for all traffic, handling SSL termination and routing requests to the correct frontend or backend service.
|
|
||||||
|
|
||||||
1. **Prepare Production Environment Variables for Backend and Frontend**
|
|
||||||
Before deployment, ensure the production environment files for both backend and frontend are ready. You will need to copy them from the example files and modify them with your server's information.
|
|
||||||
|
|
||||||
- **Backend Configuration:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From the project root
|
|
||||||
cp backend/.env_production_example backend/.env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, edit `backend/.env.production`, configuring at least `CORS_ORIGIN` to your main domain (e.g., `https://privydrop.app`) and your `REDIS` details.
|
|
||||||
|
|
||||||
- **Frontend Configuration:**
|
|
||||||
```bash
|
|
||||||
# From the project root
|
|
||||||
cp frontend/.env_production_example frontend/.env.production
|
|
||||||
```
|
|
||||||
Then, edit `frontend/.env.production` to set `NEXT_PUBLIC_API_URL` to your backend service domain (e.g., `https://privydrop.app`).
|
|
||||||
|
|
||||||
2. **Firewall:**
|
|
||||||
Open 'Nginx Full' default ports and 443/udp:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ufw allow 'Nginx Full'
|
|
||||||
sudo ufw reload # or ufw enable
|
|
||||||
```
|
|
||||||
|
|
||||||
The ports seen via `sudo ufw app info 'Nginx Full'` are as follows:
|
|
||||||
80,443/tcp
|
|
||||||
|
|
||||||
3. **Generate Base Nginx Configuration:**
|
|
||||||
The `backend/docker/Nginx/` directory provides a configuration script and template. This template uses a temporary "placeholder" certificate to ensure the Nginx configuration is valid before obtaining a real certificate.
|
|
||||||
|
|
||||||
- Now, edit the `backend/.env.production` file and add the `NGINX_*` related variables. **Do not include SSL certificate paths yet**. Example:
|
|
||||||
```
|
|
||||||
NGINX_SERVER_NAME=privydrop.app # Your main domain
|
|
||||||
NGINX_FRONTEND_ROOT=/path/to/your/PrivyDrop/frontend # Path to the frontend project root
|
|
||||||
TURN_REALM=turn.privydrop.app # TURN server domain name (if configuring TURN service)
|
|
||||||
```
|
|
||||||
- Execute the script to generate the Nginx configuration file:
|
|
||||||
```bash
|
|
||||||
# This script uses variables from your .env file to generate the Nginx config
|
|
||||||
sudo bash backend/docker/Nginx/configure.sh backend/.env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4. Use Certbot to Install a Unified SSL Certificate
|
|
||||||
|
|
||||||
With the base Nginx configuration in place, we can now use Certbot to obtain and install a real SSL certificate. We will request a single, unified certificate for all our services (main domain, www, and TURN) and let Certbot automatically update your Nginx configuration.
|
|
||||||
|
|
||||||
1. **Install Certbot's Nginx Plugin:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install python3-certbot-nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Run Certbot to Request the Certificate:**
|
|
||||||
|
|
||||||
- This command automatically detects your Nginx configuration.
|
|
||||||
- The `-d` flag specifies all domains to be included in the certificate. Ensure your domains' DNS records correctly point to your server's IP.
|
|
||||||
- The `--deploy-hook` is a crucial parameter: it will automatically restart the Coturn service after a successful certificate renewal, applying the new certificate. This enables fully automated certificate maintenance.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Replace privydrop.app with your main domain
|
|
||||||
sudo certbot --nginx \
|
|
||||||
-d privydrop.app \
|
|
||||||
-d www.privydrop.app \
|
|
||||||
-d turn.privydrop.app \
|
|
||||||
--deploy-hook "sudo systemctl restart coturn"
|
|
||||||
```
|
|
||||||
|
|
||||||
Follow the on-screen prompts from Certbot (e.g., enter your email, agree to the ToS). Once complete, Certbot will automatically modify your Nginx configuration to enable HTTPS and reload the Nginx service.
|
|
||||||
|
|
||||||
Run the following command to check if the certificate path has been replaced:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo grep ssl_certificate /etc/nginx/sites-enabled/default
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see a path pointing to `/etc/letsencrypt/live/privydrop.app/`
|
|
||||||
|
|
||||||
3. **Remove the redundant configuration generated by Certbot:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo bash backend/docker/Nginx/del_redundant_cfg.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **start nginx:**
|
|
||||||
```bash
|
|
||||||
sudo systemctl start[reload] nginx
|
|
||||||
```
|
|
||||||
If you see an error "Address already in use" (check via `systemctl status nginx.service`), run `pkill nginx`.
|
|
||||||
|
|
||||||
### 4.5. Configure and Start the TURN Service (Production)
|
|
||||||
|
|
||||||
With the unified SSL certificate obtained, we can now complete the production configuration for the Coturn service.
|
|
||||||
|
|
||||||
1. **Configure Environment Variables**:
|
|
||||||
Open your `backend/.env.production` file and configure all `TURN_*` related variables.
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# .env.production
|
|
||||||
|
|
||||||
# ... other variables ...
|
|
||||||
|
|
||||||
# TURN/STUN Server (Coturn) Configuration
|
|
||||||
TURN_REALM=turn.privydrop.app # Your TURN domain
|
|
||||||
TURN_USERNAME=YourTurnUsername # Set a secure username
|
|
||||||
TURN_PASSWORD=YourTurnPassword # Set a strong password
|
|
||||||
|
|
||||||
# Critical: Point to the unified certificate generated by Certbot for your main domain
|
|
||||||
TURN_CERT_PATH=/etc/letsencrypt/live/privydrop.app/fullchain.pem
|
|
||||||
TURN_KEY_PATH=/etc/letsencrypt/live/privydrop.app/privkey.pem
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Verify SSL Certificate Permissions**:
|
|
||||||
The Coturn process typically runs as a low-privilege user (e.g., `turnserver` or `coturn`), while certificates generated by Certbot are owned by `root`. We need to adjust permissions to allow Coturn to read the certificate.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# (Optional) Find the user the coturn service runs as
|
|
||||||
# ps aux | grep turnserver
|
|
||||||
|
|
||||||
# Create a shared group and add the turnserver user to it
|
|
||||||
sudo groupadd -f ssl-cert
|
|
||||||
sudo usermod -a -G ssl-cert turnserver # Replace 'turnserver' if the user is different
|
|
||||||
|
|
||||||
# Change ownership and permissions of the certificate directories
|
|
||||||
sudo chown -R root:ssl-cert /etc/letsencrypt/
|
|
||||||
sudo chmod -R 750 /etc/letsencrypt/
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Generate Configuration File and Start the Service**:
|
|
||||||
Run the provided script, which will generate `/etc/turnserver.conf` from your `.env.production` file and restart Coturn.
|
|
||||||
```bash
|
|
||||||
# Located in the backend/ directory
|
|
||||||
# Use the path to your .env file
|
|
||||||
sudo bash ./docker/TURN/configure.sh backend/.env.production
|
|
||||||
```
|
|
||||||
4. **Check Service Status and Test Online**:
|
|
||||||
|
|
||||||
- Check the service status:
|
|
||||||
```bash
|
|
||||||
sudo systemctl status coturn
|
|
||||||
# Also, check the logs to ensure there are no permission errors
|
|
||||||
# sudo journalctl -u coturn -f
|
|
||||||
```
|
|
||||||
- **Online Test (Recommended)**:
|
|
||||||
Once the service is running, use an online tool like the [Metered TURN Server Tester](https://www.metered.ca/turn-server-testing) to verify that your TURNS service is working correctly:
|
|
||||||
|
|
||||||
- **TURNS URL**: `turn:turn.privydrop.app:3478` (replace with your domain)
|
|
||||||
- **Username**: `The username you set in your .env file`
|
|
||||||
- **Password**: `The password you set in your .env file`
|
|
||||||
|
|
||||||
If all checkpoints show a green "Success" or "Reachable", your TURN server is configured successfully.
|
|
||||||
|
|
||||||
### 4.6. Run the Application with PM2
|
|
||||||
|
|
||||||
PM2 is a powerful process manager for Node.js. We will use it to run both backend and frontend services.
|
|
||||||
|
|
||||||
1. **Start Services Using Unified Configuration:**
|
|
||||||
|
|
||||||
The project root directory provides a unified `ecosystem.config.js` configuration file that can start all services at once:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# If services were previously running, stop and delete them first
|
|
||||||
sudo pm2 stop all && sudo pm2 delete all
|
|
||||||
|
|
||||||
# Start all services using the unified configuration file
|
|
||||||
sudo pm2 start ecosystem.config.js
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Manage Applications:**
|
|
||||||
- View status: `pm2 list`
|
|
||||||
- View logs: `pm2 logs <app_name>` (e.g., `pm2 logs signaling-server` or `pm2 logs privydrop-frontend`)
|
|
||||||
- Set up startup script: `pm2 startup` followed by `pm2 save`
|
|
||||||
- Restart services: `pm2 restart all` or specific service `pm2 restart signaling-server`
|
|
||||||
- Stop services: `pm2 stop all` or specific service `pm2 stop privydrop-frontend`
|
|
||||||
|
|
||||||
## 5. Troubleshooting
|
|
||||||
|
|
||||||
- **Connection Issues:** Check firewall settings, Nginx proxy configurations, `CORS_ORIGIN` settings, and ensure all PM2 processes are running.
|
|
||||||
- **Nginx Errors:** Use `sudo nginx -t` to check syntax and review `/var/log/nginx/error.log`.
|
|
||||||
- **PM2 Issues:** Use `pm2 logs <app_name>` to view application logs.
|
|
||||||
- **Certificate Permissions (Production):** If Coturn or Nginx cannot read SSL certificates, carefully review the file permissions and user/group settings in `Section 4.5`.
|
|
||||||
|
|
||||||
## 6. Security & Maintenance
|
|
||||||
|
|
||||||
- **SSL Certificate Renewal:** When you successfully configure your certificate using `certbot --nginx` with the `--deploy-hook`, Certbot automatically creates a renewal task for both Nginx and Coturn. No manual intervention is required; the certificate will be renewed and applied automatically before it expires.
|
|
||||||
- **Firewall:** Maintain strict firewall rules, only allowing necessary ports.
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
# Privydrop 部署指南(裸机部署)
|
|
||||||
|
|
||||||
> 说明与定位:本指南面向具备 Linux 运维能力的开发者,介绍“裸机(非容器)”部署方式。
|
|
||||||
>
|
|
||||||
> 推荐方案:优先使用“一键 Docker 部署”,更简单、更稳健,支持自动签发/续期证书与 TURN。详见 [Docker 部署指南](./DEPLOYMENT_docker.zh-CN.md)。
|
|
||||||
|
|
||||||
本指南提供部署 Privydrop 全栈应用的全面说明,包括设置 Redis、TURN 服务器、后端服务、前端应用以及配置 Nginx 作为反向代理。
|
|
||||||
|
|
||||||
## 1. 引言
|
|
||||||
|
|
||||||
本文档将引导您完成准备服务器环境、配置依赖项和部署 Privydrop 的前后端。无论您是设置开发/测试环境还是完整的生产实例,本指南都旨在涵盖所有基本方面。
|
|
||||||
|
|
||||||
## 2. 先决条件
|
|
||||||
|
|
||||||
在开始之前,请确保您的服务器环境满足以下要求:
|
|
||||||
|
|
||||||
- **操作系统:** Linux 发行版(例如,推荐 Ubuntu 20.04 LTS 或更高版本)。
|
|
||||||
- **Node.js:** v18.x 或更高版本。
|
|
||||||
- **npm/pnpm:** Node.js 的包管理器。
|
|
||||||
- **Root 或 Sudo 权限:** 安装软件包和配置服务所需。
|
|
||||||
- **域名:** 生产环境部署需要一个域名。
|
|
||||||
- **可选:基础环境与 Docker 镜像参考:** 如果您需要从一个非常纯净的系统环境开始搭建,或者希望了解用于 Docker 构建的基础依赖,可以参考 `backend/docker/Dockerfile` 文件(用于 Docker 基础镜像构建)和 `backend/docker/env_install.log` 文件(依赖安装记录)。
|
|
||||||
|
|
||||||
## 3. 环境安装
|
|
||||||
|
|
||||||
**重要提示:** 项目根目录的 `backend/docker/env_install.sh` 脚本包含了所有必要的依赖安装命令,包括 Node.js、Redis、Coturn、Nginx 等。您可以直接运行此脚本来安装所有依赖:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 确保脚本有执行权限
|
|
||||||
chmod +x backend/docker/env_install.sh
|
|
||||||
|
|
||||||
# 运行安装脚本
|
|
||||||
sudo bash backend/docker/env_install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
该脚本将自动安装:
|
|
||||||
|
|
||||||
- **Node.js v20** - 运行环境
|
|
||||||
- **Redis Server** - 用于房间管理和缓存
|
|
||||||
- **Coturn** - TURN/STUN 服务器(可选,用于 NAT 穿透)
|
|
||||||
- **Nginx** - Web 服务器和反向代理(使用官方仓库)
|
|
||||||
- **PM2** - Node.js 进程管理器
|
|
||||||
- **Certbot** - SSL 证书管理
|
|
||||||
|
|
||||||
安装完成后,可以验证各服务状态:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 验证 Node.js 版本
|
|
||||||
node -v
|
|
||||||
|
|
||||||
# 验证 Redis 状态
|
|
||||||
sudo systemctl status redis-server
|
|
||||||
|
|
||||||
# 验证 Nginx 安装
|
|
||||||
nginx -V
|
|
||||||
|
|
||||||
# 验证 Coturn 安装
|
|
||||||
sudo systemctl status coturn
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意事项:**
|
|
||||||
|
|
||||||
- **Redis 配置:** 默认监听 `127.0.0.1:6379`,请确保后端 `.env` 文件中包含正确的 `REDIS_HOST` 和 `REDIS_PORT`
|
|
||||||
- **TURN 服务:** 为可选配置,Privydrop 默认使用公共 STUN 服务器,只有对 NAT 穿透有极高要求时才需要配置
|
|
||||||
- **Nginx:** 脚本安装官方版本并验证 stream 模块支持
|
|
||||||
|
|
||||||
**TURN 服务器防火墙配置(如果需要配置 TURN 服务):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启用 Coturn 服务
|
|
||||||
sudo sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn
|
|
||||||
|
|
||||||
# 防火墙配置:打开 Turnserver 默认端口
|
|
||||||
sudo ufw allow Turnserver
|
|
||||||
sudo ufw reload
|
|
||||||
```
|
|
||||||
|
|
||||||
通过 `sudo ufw app info Turnserver` 看到的端口如下:
|
|
||||||
|
|
||||||
- `3478,3479,5349,5350,49152:65535/tcp`
|
|
||||||
- `3478,3479,5349,5350,49152:65535/udp`
|
|
||||||
|
|
||||||
## 4. 应用部署 (生产环境)
|
|
||||||
|
|
||||||
本节介绍如何使用 Nginx 和 PM2 在生产环境部署 PrivyDrop。
|
|
||||||
|
|
||||||
### 4.1. 获取代码并安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/david-bai00/PrivyDrop.git
|
|
||||||
cd PrivyDrop
|
|
||||||
|
|
||||||
# 安装后端依赖
|
|
||||||
cd backend && npm install && cd ..
|
|
||||||
|
|
||||||
# 安装前端依赖
|
|
||||||
cd frontend && pnpm install && cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2. 构建应用
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && pnpm build && cd ..
|
|
||||||
cd backend && npm run build && cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
这将分别在 `frontend/.next` 和 `backend/dist` 目录生成优化后的生产版本。
|
|
||||||
|
|
||||||
### 4.3. 配置 Nginx 作为反向代理
|
|
||||||
|
|
||||||
在生产中,Nginx 将作为所有流量的入口,负责 SSL 终止,并将请求路由到正确的前端或后端服务。
|
|
||||||
|
|
||||||
1. **为后端和前端准备生产环境变量**
|
|
||||||
在部署之前,请确保后端和前端的生产环境变量文件已准备就绪。您需要从示例文件复制并根据您的服务器信息进行修改。
|
|
||||||
|
|
||||||
- **后端配置:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 位于项目根目录
|
|
||||||
cp backend/.env_production_example backend/.env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
然后编辑 `backend/.env.production`,至少配置 `CORS_ORIGIN` 为您的主域名 (例如 `https://privydrop.app`) 以及 `REDIS` 相关信息。
|
|
||||||
|
|
||||||
- **前端配置:**
|
|
||||||
```bash
|
|
||||||
# 位于项目根目录
|
|
||||||
cp frontend/.env_production_example frontend/.env.production
|
|
||||||
```
|
|
||||||
然后编辑 `frontend/.env.production`,配置 `NEXT_PUBLIC_API_URL` 为您的后端服务域名 (例如 `https://privydrop.app`)。
|
|
||||||
|
|
||||||
2. **防火墙:**
|
|
||||||
打开'Nginx Full'默认端口以及 443/udp
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ufw allow 'Nginx Full'
|
|
||||||
sudo ufw reload # 或 ufw enable
|
|
||||||
```
|
|
||||||
|
|
||||||
通过 sudo ufw app info 'Nginx Full'看到的端口如下:
|
|
||||||
80,443/tcp
|
|
||||||
|
|
||||||
3. **生成 Nginx 基础配置:**
|
|
||||||
后端项目 `backend/docker/Nginx/` 目录中提供了配置脚本和模板。此模板使用一个临时的"占位符"证书,以确保 Nginx 配置在申请真实证书前是有效的。
|
|
||||||
|
|
||||||
- 现在,编辑 `backend/.env.production` 文件,添加 `NGINX_*` 相关变量。**无需 SSL 证书路径**。示例为:
|
|
||||||
```
|
|
||||||
NGINX_SERVER_NAME=privydrop.app # 你的主域名
|
|
||||||
NGINX_FRONTEND_ROOT=/path/to/your/PrivyDrop/frontend # 前端项目根目录
|
|
||||||
TURN_REALM=turn.privydrop.app # TURN 服务器域名(如需配置 TURN 服务)
|
|
||||||
```
|
|
||||||
- 执行脚本生成 Nginx 配置文件:
|
|
||||||
```bash
|
|
||||||
# 此脚本会使用 .env 文件中的变量来生成 Nginx 配置文件
|
|
||||||
sudo bash backend/docker/Nginx/configure.sh backend/.env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4. 使用 Certbot 安装统一 SSL 证书
|
|
||||||
|
|
||||||
现在 Nginx 有了基础配置,我们可以使用 Certbot 来获取并安装真实的 SSL 证书。我们将为所有服务(主域名、www 和 TURN)申请一张统一的证书,并让 Certbot 自动更新 Nginx 配置。
|
|
||||||
|
|
||||||
1. **安装 Certbot 的 Nginx 插件:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install python3-certbot-nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **运行 Certbot 申请证书:**
|
|
||||||
|
|
||||||
- 此命令会自动检测您的 Nginx 配置并为其安装证书。
|
|
||||||
- `-d` 参数指定所有需要包含在此证书中的域名。请确保您的域名 DNS 已正确解析到服务器 IP。
|
|
||||||
- `--deploy-hook` 是一个关键参数:它会在证书成功续期后,自动重启 Coturn 服务,以加载新证书。这实现了完全自动化的证书维护。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 将 privydrop.app 替换为你的主域名
|
|
||||||
sudo certbot --nginx \
|
|
||||||
-d privydrop.app \
|
|
||||||
-d www.privydrop.app \
|
|
||||||
-d turn.privydrop.app \
|
|
||||||
--deploy-hook "sudo systemctl restart coturn"
|
|
||||||
```
|
|
||||||
|
|
||||||
按照 Certbot 的提示操作(例如输入邮箱、同意服务条款等)。
|
|
||||||
|
|
||||||
运行如下命令,查看证书路径是否已替换:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo grep ssl_certificate /etc/nginx/sites-enabled/default
|
|
||||||
```
|
|
||||||
|
|
||||||
应该能看到指向 `/etc/letsencrypt/live/privydrop.app/` 的路径
|
|
||||||
|
|
||||||
3. **删除由 Certbot 产生的多余配置:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo bash backend/docker/Nginx/del_redundant_cfg.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **启动 nginx 服务:**
|
|
||||||
```bash
|
|
||||||
sudo systemctl start[reload] nginx
|
|
||||||
```
|
|
||||||
如果报错显示 Address already in use(通过 systemctl status nginx.service 查看),则运行 pkill nginx。
|
|
||||||
|
|
||||||
### 4.5. 配置并启动 TURN 服务 (生产环境)
|
|
||||||
|
|
||||||
获取到统一的 SSL 证书后,我们现在来完成 Coturn 服务的生产环境配置。
|
|
||||||
|
|
||||||
1. **配置环境变量**:
|
|
||||||
打开后端的 `.env.production` 文件,配置所有 `TURN_*` 相关变量。
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# .env.production
|
|
||||||
|
|
||||||
# ... 其他变量 ...
|
|
||||||
|
|
||||||
# TURN/STUN Server (Coturn) Configuration
|
|
||||||
TURN_REALM=turn.privydrop.app # 你的 TURN 域名
|
|
||||||
TURN_USERNAME=YourTurnUsername # 设置一个安全的用户名
|
|
||||||
TURN_PASSWORD=YourTurnPassword # 设置一个强密码
|
|
||||||
|
|
||||||
# 关键:将证书路径指向由 Certbot 为主域名生成的统一证书
|
|
||||||
TURN_CERT_PATH=/etc/letsencrypt/live/privydrop.app/fullchain.pem
|
|
||||||
TURN_KEY_PATH=/etc/letsencrypt/live/privydrop.app/privkey.pem
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **验证 SSL 证书权限**:
|
|
||||||
Coturn 进程通常以一个低权限用户(如 `turnserver` 或 `coturn`)运行,而 Certbot 生成的证书文件默认属于 `root` 用户。因此,我们需要调整权限,确保 Coturn 有权限读取证书。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# (可选) 查找 coturn 服务的运行用户
|
|
||||||
# ps aux | grep turnserver
|
|
||||||
|
|
||||||
# 创建一个共享组,并将 turnserver 用户添加进去
|
|
||||||
sudo groupadd -f ssl-cert
|
|
||||||
sudo usermod -a -G ssl-cert turnserver # 如果运行用户不是 turnserver,请替换
|
|
||||||
|
|
||||||
# 更改证书目录的所有权和权限
|
|
||||||
sudo chown -R root:ssl-cert /etc/letsencrypt/
|
|
||||||
sudo chmod -R 750 /etc/letsencrypt/
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **生成配置文件并启动服务**:
|
|
||||||
运行项目提供的脚本,它会根据 `.env.production` 文件生成 `/etc/turnserver.conf` 并重启 Coturn。
|
|
||||||
```bash
|
|
||||||
# 使用你的 .env 文件路径
|
|
||||||
sudo bash backend/docker/TURN/configure.sh backend/.env.production
|
|
||||||
```
|
|
||||||
4. **检查服务状态与在线测试**:
|
|
||||||
|
|
||||||
- 检查服务状态:
|
|
||||||
```bash
|
|
||||||
sudo systemctl status coturn
|
|
||||||
# 同时检查日志确保没有权限错误
|
|
||||||
# sudo journalctl -u coturn -f
|
|
||||||
```
|
|
||||||
- **在线测试 (推荐)**:
|
|
||||||
服务启动后,使用在线工具,如 [Metered TURN Server Tester](https://www.metered.ca/turn-server-testing),验证 TURNS 服务是否正常工作:
|
|
||||||
|
|
||||||
- **TURNS URL**: `turn:turn.privydrop.app:3478` (将域名替换为你的)
|
|
||||||
- **Username**: `你在 .env 中设置的用户名`
|
|
||||||
- **Password**: `你在 .env 中设置的密码`
|
|
||||||
|
|
||||||
如果所有检查点都显示绿色 "Success" 或 "Reachable",则表示您的 TURN 服务器已成功配置。
|
|
||||||
|
|
||||||
### 4.6. 使用 PM2 运行应用
|
|
||||||
|
|
||||||
PM2 是一个强大的 Node.js 进程管理器,我们将用它来运行后端和前端服务。
|
|
||||||
|
|
||||||
1. **使用统一配置文件启动服务:**
|
|
||||||
|
|
||||||
项目根目录提供了一个统一的 `ecosystem.config.js` 配置文件,可以一次性启动所有服务:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 如果之前运行过服务,先停止并删除
|
|
||||||
sudo pm2 stop all && sudo pm2 delete all
|
|
||||||
|
|
||||||
# 使用统一配置文件启动所有服务
|
|
||||||
sudo pm2 start ecosystem.config.js
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **管理应用:**
|
|
||||||
- 查看状态: `pm2 list`
|
|
||||||
- 查看日志: `pm2 logs <app_name>` (例如:`pm2 logs signaling-server` 或 `pm2 logs privydrop-frontend`)
|
|
||||||
- 设置开机自启: `pm2 startup` 然后 `pm2 save`
|
|
||||||
- 重启服务: `pm2 restart all` 或指定服务 `pm2 restart signaling-server`
|
|
||||||
- 停止服务: `pm2 stop all` 或指定服务 `pm2 stop privydrop-frontend`
|
|
||||||
|
|
||||||
## 5. 故障排除
|
|
||||||
|
|
||||||
- **连接问题:** 检查防火墙、Nginx 代理设置、CORS_ORIGIN 配置,确保所有 PM2 进程都在运行。
|
|
||||||
- **Nginx 错误:** `sudo nginx -t` 检查语法,查看 `/var/log/nginx/error.log`。
|
|
||||||
- **PM2 问题:** `pm2 logs <app_name>` 查看应用日志。
|
|
||||||
- **证书权限 (生产环境):** 如果 Coturn 或 Nginx 无法读取 SSL 证书,请仔细检查 `第 4.5 节` 中的文件权限和用户/组设置。
|
|
||||||
|
|
||||||
## 6. 安全与维护
|
|
||||||
|
|
||||||
- **SSL 证书续订:** 当你使用 `certbot --nginx` 并配合 `--deploy-hook` 成功配置证书后,Certbot 会自动处理 Nginx 证书的续订和 Coturn 服务的重启。你无需手动干预或使用额外的脚本。
|
|
||||||
- **防火墙:** 保持防火墙规则严格,仅允许必要的端口。
|
|
||||||
+54
-22
@@ -14,8 +14,8 @@ bash ./deploy.sh --mode lan-http --with-turn
|
|||||||
# LAN HTTPS (self-signed; dev/managed env; explicitly enable 8443)
|
# LAN HTTPS (self-signed; dev/managed env; explicitly enable 8443)
|
||||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||||
|
|
||||||
# Public IP without domain (with TURN)
|
# Public IP without domain (with TURN; recommended with Nginx for same-origin)
|
||||||
bash ./deploy.sh --mode public --with-turn
|
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||||
|
|
||||||
# Public domain (HTTPS + Nginx + TURN + SNI 443, auto-issue/renew certs)
|
# Public domain (HTTPS + Nginx + TURN + SNI 443, auto-issue/renew certs)
|
||||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||||
@@ -60,6 +60,25 @@ Compared to traditional deployment methods, Docker deployment offers the followi
|
|||||||
- **Disk**: 5GB+ available space
|
- **Disk**: 5GB+ available space
|
||||||
- **Network**: 100Mbps+
|
- **Network**: 100Mbps+
|
||||||
|
|
||||||
|
### Low-Memory Server Notes (important for 1GB–2GB hosts)
|
||||||
|
|
||||||
|
- The frontend Docker build runs `next build`, which can be killed by the kernel on very small hosts.
|
||||||
|
- On fresh 1GB–2GB servers, add at least **1GB swap** before the first production build if memory pressure is high.
|
||||||
|
- Typical symptom: the frontend image build exits unexpectedly during `next build` or the host shows OOM messages in `dmesg`.
|
||||||
|
|
||||||
|
Recommended one-time swap setup on Ubuntu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo fallocate -l 1G /swapfile
|
||||||
|
sudo chmod 600 /swapfile
|
||||||
|
sudo mkswap /swapfile
|
||||||
|
sudo swapon /swapfile
|
||||||
|
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
|
||||||
|
free -h
|
||||||
|
```
|
||||||
|
|
||||||
|
After swap is enabled, rerun the deploy command.
|
||||||
|
|
||||||
### Software Dependencies
|
### Software Dependencies
|
||||||
|
|
||||||
- Docker 20.10+
|
- Docker 20.10+
|
||||||
@@ -80,8 +99,8 @@ cd PrivyDrop
|
|||||||
### 2. One-Click Deployment
|
### 2. One-Click Deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Basic deployment (recommended for beginners)
|
# Always pass an explicit deployment mode
|
||||||
bash ./deploy.sh
|
bash ./deploy.sh --mode lan-http
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it! 🎉
|
That's it! 🎉
|
||||||
@@ -128,10 +147,12 @@ bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn -
|
|||||||
|
|
||||||
**Features**:
|
**Features**:
|
||||||
|
|
||||||
- ✅ HTTPS secure access (Let’s Encrypt auto-issue/renew, zero downtime)
|
- ✅ HTTPS secure access (Let’s Encrypt auto-issue/renew)
|
||||||
- ✅ Nginx reverse proxy
|
- ✅ Nginx reverse proxy
|
||||||
- ✅ Built-in TURN server (default port range 49152-49252/udp)
|
- ✅ Built-in TURN server (default port range 49152-49252/udp)
|
||||||
- ✅ SNI 443 multiplexing (turn.<domain> → coturn:5349; others → web:8443)
|
- ✅ SNI 443 multiplexing (turn.<domain> → coturn:5349; others → web:8443)
|
||||||
|
- ✅ Same-origin frontend/API gateway by default when `--with-nginx` is enabled (`NEXT_PUBLIC_API_URL` is generated as an empty string, so the browser uses `/api` and `/socket.io/`)
|
||||||
|
- ✅ Production CORS generation covers the canonical domain and its `www` variant by default (for example `https://example.com,https://www.example.com`)
|
||||||
- ✅ Complete production setup
|
- ✅ Complete production setup
|
||||||
|
|
||||||
> Tip: The script no longer auto-detects the deployment mode; always pass `--mode lan-http|lan-tls|public|full`. If the detected LAN IP is not the one you expect, add `--local-ip 192.168.x.x` to override.
|
> Tip: The script no longer auto-detects the deployment mode; always pass `--mode lan-http|lan-tls|public|full`. If the detected LAN IP is not the one you expect, add `--local-ip 192.168.x.x` to override.
|
||||||
@@ -162,17 +183,14 @@ NO_PROXY=localhost,127.0.0.1,backend,frontend,redis,coturn
|
|||||||
### Common Flags
|
### Common Flags
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Enable only Nginx reverse proxy
|
# Always include an explicit --mode (examples)
|
||||||
bash ./deploy.sh --with-nginx
|
bash ./deploy.sh --mode lan-http --with-nginx
|
||||||
|
bash ./deploy.sh --mode lan-http --with-turn
|
||||||
# Enable TURN (recommended in public/full)
|
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||||
bash ./deploy.sh --with-turn
|
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --with-sni443 --le-email you@domain.com
|
||||||
|
|
||||||
# Explicitly enable SNI 443 (auto-enabled in full+domain; use --no-sni443 to disable)
|
|
||||||
bash ./deploy.sh --with-sni443
|
|
||||||
|
|
||||||
# Adjust TURN port range (default 49152-49252/udp)
|
# Adjust TURN port range (default 49152-49252/udp)
|
||||||
bash ./deploy.sh --mode full --with-turn --turn-port-range 55000-55100
|
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com --turn-port-range 55000-55100
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🌐 Access Methods
|
## 🌐 Access Methods
|
||||||
@@ -191,7 +209,7 @@ bash ./deploy.sh --mode full --with-turn --turn-port-range 55000-55100
|
|||||||
### HTTPS Access (lan-tls/full)
|
### HTTPS Access (lan-tls/full)
|
||||||
|
|
||||||
- lan-tls: with `--enable-web-https`, access via `https://localhost:8443` (certs in `docker/ssl/`). Import `docker/ssl/ca-cert.pem` into your browser or trust store on first use.
|
- lan-tls: with `--enable-web-https`, access via `https://localhost:8443` (certs in `docker/ssl/`). Import `docker/ssl/ca-cert.pem` into your browser or trust store on first use.
|
||||||
- full: after Let’s Encrypt issuance, access via `https://<your-domain>` (443). Certs auto-issue/renew; hot-reload is handled via deploy hook.
|
- full: after Let’s Encrypt issuance, access via `https://<your-domain>` (443). Certs auto-issue/renew; the deploy hook hot-reloads edge services on renewal, and the initial full-mode deploy also force-recreates `nginx` (and `coturn` when enabled) to guarantee the new HTTPS/SNI config is active.
|
||||||
|
|
||||||
## 🔍 Management Commands
|
## 🔍 Management Commands
|
||||||
|
|
||||||
@@ -325,8 +343,10 @@ sudo ufw status
|
|||||||
**Solution**:
|
**Solution**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Enable TURN server
|
# Enable TURN server (re-run with an explicit --mode)
|
||||||
bash ./deploy.sh --with-turn
|
bash ./deploy.sh --mode lan-http --with-turn
|
||||||
|
# or (public IP, recommended same-origin via Nginx)
|
||||||
|
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||||
|
|
||||||
# Check network connectivity
|
# Check network connectivity
|
||||||
curl -I http://localhost:3001/api/get_room
|
curl -I http://localhost:3001/api/get_room
|
||||||
@@ -366,7 +386,8 @@ docker system prune -f
|
|||||||
1. **Enable Nginx Caching**:
|
1. **Enable Nginx Caching**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash deploy.sh --with-nginx
|
# Example (public IP, same-origin via Nginx)
|
||||||
|
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Configure Resource Limits**:
|
2. **Configure Resource Limits**:
|
||||||
@@ -408,7 +429,7 @@ networks:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Auto-enabled (requires HTTPS)
|
# Auto-enabled (requires HTTPS)
|
||||||
bash deploy.sh --mode full --with-nginx
|
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔒 Security Configuration
|
## 🔒 Security Configuration
|
||||||
@@ -444,6 +465,7 @@ Usage (strongly recommended)
|
|||||||
3) CORS
|
3) CORS
|
||||||
- For convenience, common dev origins are allowed by default: `https://<LAN IP>:8443`, `https://localhost:8443`, `http://localhost`, `http://<LAN IP>`, `http://localhost:3002`, `http://<LAN IP>:3002`.
|
- For convenience, common dev origins are allowed by default: `https://<LAN IP>:8443`, `https://localhost:8443`, `http://localhost`, `http://<LAN IP>`, `http://localhost:3002`, `http://<LAN IP>:3002`.
|
||||||
- To minimize allowed origins, edit `CORS_ORIGIN` in `.env` and then `docker compose restart backend`.
|
- To minimize allowed origins, edit `CORS_ORIGIN` in `.env` and then `docker compose restart backend`.
|
||||||
|
- In production, `CORS_ORIGIN` is a comma-separated list consumed by both Express and Socket.IO. Example: `CORS_ORIGIN=https://example.com,https://www.example.com`.
|
||||||
|
|
||||||
4) Health checks
|
4) Health checks
|
||||||
- `curl -kfsS https://localhost:8443/api/health` → 200
|
- `curl -kfsS https://localhost:8443/api/health` → 200
|
||||||
@@ -456,6 +478,11 @@ Usage (strongly recommended)
|
|||||||
|
|
||||||
1) Point your domain A record to the server IP (optional: also `turn.<your-domain>` to the same IP)
|
1) Point your domain A record to the server IP (optional: also `turn.<your-domain>` to the same IP)
|
||||||
|
|
||||||
|
Recommended DNS / CDN layout:
|
||||||
|
- Web entry: choose one canonical hostname for `--domain` (for example `example.com`) and redirect alternate hostnames such as `www.example.com` at the CDN/DNS layer if desired.
|
||||||
|
- TURN entry: keep `turn.<your-domain>` as DNS-only when using Cloudflare so TURN traffic does not go through the HTTP proxy.
|
||||||
|
- Current certificate issuance covers `--domain` and `turn.<your-domain>` by default. If you need direct HTTPS on an additional hostname such as `www.<your-domain>`, add that certificate handling separately or make it redirect to the canonical host.
|
||||||
|
|
||||||
2) Run:
|
2) Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -471,7 +498,8 @@ Usage (strongly recommended)
|
|||||||
In full mode, certificates are auto-issued and auto-renewed:
|
In full mode, certificates are auto-issued and auto-renewed:
|
||||||
|
|
||||||
- Initial issuance: webroot (no downtime); system certs live under `/etc/letsencrypt/live/<domain>/`; copied to `docker/ssl/` and 443 is enabled.
|
- Initial issuance: webroot (no downtime); system certs live under `/etc/letsencrypt/live/<domain>/`; copied to `docker/ssl/` and 443 is enabled.
|
||||||
- Renewal: `certbot.timer` or `/etc/cron.d/certbot` runs daily; the deploy-hook copies new certs to `docker/ssl/` and hot-reloads Nginx/Coturn.
|
- Initial issuance is followed by `docker compose up -d --force-recreate nginx` (and `coturn` when enabled) so the freshly generated HTTPS/SNI config is guaranteed to be mounted and active. Expect a brief reconnect window during this first cutover.
|
||||||
|
- Renewal: `certbot.timer` or `/etc/cron.d/certbot` runs daily; the deploy-hook copies new certs to `docker/ssl/`, sends `HUP` to coturn when possible, and hot-reloads Nginx/Coturn (falling back to container restart if needed).
|
||||||
- Lineage suffixes (-0001/-0002) are handled automatically.
|
- Lineage suffixes (-0001/-0002) are handled automatically.
|
||||||
|
|
||||||
### Network Security
|
### Network Security
|
||||||
@@ -512,8 +540,12 @@ logs/
|
|||||||
# Pull latest code
|
# Pull latest code
|
||||||
git pull origin main
|
git pull origin main
|
||||||
|
|
||||||
# Redeploy
|
# Re-run the same deployment command you used initially (examples)
|
||||||
bash deploy.sh
|
bash ./deploy.sh --mode lan-http
|
||||||
|
# or (public IP)
|
||||||
|
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||||
|
# or (full domain)
|
||||||
|
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Backup
|
### Data Backup
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ bash ./deploy.sh --mode lan-http --with-turn
|
|||||||
# 内网 HTTPS(自签,开发/受管环境,需显式开启 8443)
|
# 内网 HTTPS(自签,开发/受管环境,需显式开启 8443)
|
||||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||||
|
|
||||||
# 公网IP(无域名),含 TURN
|
# 公网IP(无域名),含 TURN(推荐同源经 Nginx)
|
||||||
bash ./deploy.sh --mode public --with-turn
|
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||||
|
|
||||||
# 公网域名(HTTPS + Nginx + TURN + SNI 443 分流,自动申请/续期证书)
|
# 公网域名(HTTPS + Nginx + TURN + SNI 443 分流,自动申请/续期证书)
|
||||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||||
@@ -60,6 +60,25 @@ bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn -
|
|||||||
- **磁盘**: 5GB 及以上可用空间
|
- **磁盘**: 5GB 及以上可用空间
|
||||||
- **网络**: 100Mbps 及以上
|
- **网络**: 100Mbps 及以上
|
||||||
|
|
||||||
|
### 低内存服务器说明(1GB–2GB 主机建议必看)
|
||||||
|
|
||||||
|
- 前端镜像构建会执行 `next build`,在小内存服务器上可能被内核直接 OOM 杀掉。
|
||||||
|
- 对于全新 1GB–2GB 服务器,如果首轮生产构建时内存紧张,建议先加至少 **1GB swap**。
|
||||||
|
- 典型现象:前端镜像在 `next build` 阶段异常退出,或 `dmesg` 中出现 OOM / killed process 记录。
|
||||||
|
|
||||||
|
Ubuntu 推荐一次性 swap 配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo fallocate -l 1G /swapfile
|
||||||
|
sudo chmod 600 /swapfile
|
||||||
|
sudo mkswap /swapfile
|
||||||
|
sudo swapon /swapfile
|
||||||
|
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
|
||||||
|
free -h
|
||||||
|
```
|
||||||
|
|
||||||
|
启用 swap 后,再重新执行部署命令。
|
||||||
|
|
||||||
### 软件依赖
|
### 软件依赖
|
||||||
|
|
||||||
- Docker 20.10+
|
- Docker 20.10+
|
||||||
@@ -126,10 +145,12 @@ bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn -
|
|||||||
|
|
||||||
**特性**:
|
**特性**:
|
||||||
|
|
||||||
- ✅ HTTPS 安全访问(Let’s Encrypt 自动签发/续期,无停机)
|
- ✅ HTTPS 安全访问(Let’s Encrypt 自动签发/续期)
|
||||||
- ✅ Nginx 反向代理
|
- ✅ Nginx 反向代理
|
||||||
- ✅ 内置 TURN 服务器(默认端口段 49152-49252/udp,可覆盖)
|
- ✅ 内置 TURN 服务器(默认端口段 49152-49252/udp,可覆盖)
|
||||||
- ✅ SNI 443 分流(turn.<domain> → coturn:5349,其余 → web:8443)
|
- ✅ SNI 443 分流(turn.<domain> → coturn:5349,其余 → web:8443)
|
||||||
|
- ✅ 启用 `--with-nginx` 时默认前后端同源(`NEXT_PUBLIC_API_URL` 会生成为空字符串,浏览器统一走 `/api` 与 `/socket.io/`)
|
||||||
|
- ✅ 生产环境默认同时生成主域名与 `www` 变体的 CORS 来源(例如 `https://example.com,https://www.example.com`)
|
||||||
- ✅ 完整生产环境配置
|
- ✅ 完整生产环境配置
|
||||||
|
|
||||||
> 提示:脚本不再自动判断部署模式,请显式传递 `--mode lan-http|lan-tls|public|full`。若自动检测到的局域网 IP 与预期不符,可使用 `--local-ip 192.168.x.x` 进行覆盖。
|
> 提示:脚本不再自动判断部署模式,请显式传递 `--mode lan-http|lan-tls|public|full`。若自动检测到的局域网 IP 与预期不符,可使用 `--local-ip 192.168.x.x` 进行覆盖。
|
||||||
@@ -160,17 +181,14 @@ NO_PROXY=localhost,127.0.0.1,backend,frontend,redis,coturn
|
|||||||
### 常用开关
|
### 常用开关
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 仅启用 Nginx
|
# 部署命令请始终显式传递 --mode(示例)
|
||||||
bash ./deploy.sh --with-nginx
|
bash ./deploy.sh --mode lan-http --with-nginx
|
||||||
|
bash ./deploy.sh --mode lan-http --with-turn
|
||||||
# 启用 TURN(public/full 建议)
|
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||||
bash ./deploy.sh --with-turn
|
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --with-sni443 --le-email you@domain.com
|
||||||
|
|
||||||
# 显式启用 SNI 443(full+domain 默认开启,可用 --no-sni443 关闭)
|
|
||||||
bash ./deploy.sh --with-sni443
|
|
||||||
|
|
||||||
# 调整 TURN 端口段(默认 49152-49252/udp)
|
# 调整 TURN 端口段(默认 49152-49252/udp)
|
||||||
bash ./deploy.sh --mode full --with-turn --turn-port-range 55000-55100
|
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com --turn-port-range 55000-55100
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🌐 访问方式
|
## 🌐 访问方式
|
||||||
@@ -189,7 +207,7 @@ bash ./deploy.sh --mode full --with-turn --turn-port-range 55000-55100
|
|||||||
### HTTPS 访问(lan-tls/full)
|
### HTTPS 访问(lan-tls/full)
|
||||||
|
|
||||||
- lan-tls:开启 `--enable-web-https` 后通过 `https://localhost:8443` 访问(证书在 `docker/ssl/`)。首次访问需导入 `docker/ssl/ca-cert.pem` 到浏览器或系统信任。
|
- lan-tls:开启 `--enable-web-https` 后通过 `https://localhost:8443` 访问(证书在 `docker/ssl/`)。首次访问需导入 `docker/ssl/ca-cert.pem` 到浏览器或系统信任。
|
||||||
- full:签发 Let’s Encrypt 后通过 `https://<your-domain>` 访问(443)。
|
- full:签发 Let’s Encrypt 后通过 `https://<your-domain>` 访问(443)。续期阶段由 deploy-hook 热重载边缘服务;首次 full 部署还会强制重建 `nginx`(启用 TURN 时也会重建 `coturn`),确保新的 HTTPS/SNI 配置立即生效。
|
||||||
|
|
||||||
## 🔍 管理命令
|
## 🔍 管理命令
|
||||||
|
|
||||||
@@ -323,8 +341,10 @@ sudo ufw status
|
|||||||
**解决方案**:
|
**解决方案**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启用TURN服务器
|
# 启用 TURN(重新执行部署命令并显式指定 --mode)
|
||||||
bash ./deploy.sh --with-turn
|
bash ./deploy.sh --mode lan-http --with-turn
|
||||||
|
# 或(公网IP,推荐同源经 Nginx)
|
||||||
|
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||||
|
|
||||||
# 检查网络连接
|
# 检查网络连接
|
||||||
curl -I http://localhost:3001/api/get_room
|
curl -I http://localhost:3001/api/get_room
|
||||||
@@ -368,7 +388,8 @@ docker system prune -f
|
|||||||
1. **启用 Nginx 缓存**:
|
1. **启用 Nginx 缓存**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash ./deploy.sh --with-nginx
|
# 示例(公网IP,同源经 Nginx)
|
||||||
|
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **配置资源限制**:
|
2. **配置资源限制**:
|
||||||
@@ -410,7 +431,7 @@ networks:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 自动启用 (需要 HTTPS)
|
# 自动启用 (需要 HTTPS)
|
||||||
bash ./deploy.sh --mode full --with-nginx
|
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔒 HTTPS 与安全
|
## 🔒 HTTPS 与安全
|
||||||
@@ -446,6 +467,7 @@ bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
|||||||
3) 跨域(CORS)说明
|
3) 跨域(CORS)说明
|
||||||
- 为方便开发与调试,脚本已默认放开常见来源:`https://<局域网IP>:8443`、`https://localhost:8443`、`http://localhost`、`http://<局域网IP>`、`http://localhost:3002`、`http://<局域网IP>:3002`。
|
- 为方便开发与调试,脚本已默认放开常见来源:`https://<局域网IP>:8443`、`https://localhost:8443`、`http://localhost`、`http://<局域网IP>`、`http://localhost:3002`、`http://<局域网IP>:3002`。
|
||||||
- 若仍需最小化来源,请在 `.env` 中精准收敛 `CORS_ORIGIN`,并 `docker compose restart backend`。
|
- 若仍需最小化来源,请在 `.env` 中精准收敛 `CORS_ORIGIN`,并 `docker compose restart backend`。
|
||||||
|
- 生产环境里,`CORS_ORIGIN` 是供 Express 与 Socket.IO 共用的逗号分隔来源列表。例如:`CORS_ORIGIN=https://example.com,https://www.example.com`。
|
||||||
|
|
||||||
4) 健康检查
|
4) 健康检查
|
||||||
- `curl -kfsS https://localhost:8443/api/health` → 200
|
- `curl -kfsS https://localhost:8443/api/health` → 200
|
||||||
@@ -458,6 +480,11 @@ bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
|||||||
|
|
||||||
1) 将域名 A 记录解析至服务器 IP(可选:`turn.<your-domain>` 指向相同 IP)
|
1) 将域名 A 记录解析至服务器 IP(可选:`turn.<your-domain>` 指向相同 IP)
|
||||||
|
|
||||||
|
推荐的 DNS / CDN 布局:
|
||||||
|
- Web 入口:为 `--domain` 选择一个规范主机名(例如 `example.com`),其他主机名(例如 `www.example.com`)建议在 CDN / DNS 层做跳转到规范主机名。
|
||||||
|
- TURN 入口:如果使用 Cloudflare,建议将 `turn.<your-domain>` 设为 DNS only,避免 TURN 流量经过 HTTP 代理。
|
||||||
|
- 当前脚本默认申请 `--domain` 与 `turn.<your-domain>` 的证书;如果你还想直接提供 `https://www.<your-domain>`,请额外处理该证书,或让它跳转到规范主机名。
|
||||||
|
|
||||||
2) 运行:
|
2) 运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -473,7 +500,8 @@ bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
|||||||
full 模式自动申请并续期证书:
|
full 模式自动申请并续期证书:
|
||||||
|
|
||||||
- 首次签发:webroot 模式(无停机),系统证书在 `/etc/letsencrypt/live/<domain>/`,脚本复制到 `docker/ssl/` 并启用 443;
|
- 首次签发:webroot 模式(无停机),系统证书在 `/etc/letsencrypt/live/<domain>/`,脚本复制到 `docker/ssl/` 并启用 443;
|
||||||
- 续期:`certbot.timer` 或 `/etc/cron.d/certbot` 每日尝试 `certbot renew`;deploy-hook 自动复制新证书并热重载 Nginx/Coturn;
|
- 首次签发后,脚本还会执行 `docker compose up -d --force-recreate nginx`(启用 TURN 时也会重建 `coturn`),确保新生成的 HTTPS / SNI 配置已经挂载并生效;首次切换时可能会有短暂重连。
|
||||||
|
- 续期:`certbot.timer` 或 `/etc/cron.d/certbot` 每日尝试 `certbot renew`;deploy-hook 自动复制新证书,优先对 coturn 发送 `HUP`,并热重载 Nginx/Coturn(必要时回退到容器重启);
|
||||||
- 证书谱系(-0001/-0002)已自动适配,无需手动处理。
|
- 证书谱系(-0001/-0002)已自动适配,无需手动处理。
|
||||||
|
|
||||||
### 网络安全
|
### 网络安全
|
||||||
@@ -514,8 +542,12 @@ logs/
|
|||||||
# 拉取最新代码
|
# 拉取最新代码
|
||||||
git pull origin main
|
git pull origin main
|
||||||
|
|
||||||
# 重新部署
|
# 重新执行你最初的部署命令(示例)
|
||||||
bash ./deploy.sh
|
bash ./deploy.sh --mode lan-http
|
||||||
|
# 或(公网IP)
|
||||||
|
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||||
|
# 或(公网域名 full)
|
||||||
|
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||||
```
|
```
|
||||||
|
|
||||||
### 数据备份
|
### 数据备份
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ In a recent refactor, we established a design philosophy centered on "**Separati
|
|||||||
- **Framework**: Next.js 14 (App Router)
|
- **Framework**: Next.js 14 (App Router)
|
||||||
- **Language**: TypeScript
|
- **Language**: TypeScript
|
||||||
- **UI**: React 18, Tailwind CSS, shadcn/ui (based on Radix UI)
|
- **UI**: React 18, Tailwind CSS, shadcn/ui (based on Radix UI)
|
||||||
- **State Management**: Modular state management centered on custom React Hooks
|
- **State Management**: Zustand + custom React Hooks (modular business logic with shared app state)
|
||||||
- **WebRTC Signaling**: Socket.IO Client
|
- **WebRTC Signaling**: Socket.IO Client
|
||||||
- **Data Fetching**: React Server Components (RSC), Fetch API
|
- **Data Fetching**: React Server Components (RSC), Fetch API
|
||||||
- **Internationalization**: `next/server` middleware + dynamic JSON dictionaries
|
- **Internationalization**: `next/server` middleware + dynamic JSON dictionaries
|
||||||
@@ -118,11 +118,16 @@ This section details how the application's most critical P2P transfer feature is
|
|||||||
|
|
||||||
### 3.2 State Management Strategy
|
### 3.2 State Management Strategy
|
||||||
|
|
||||||
The project uses **custom React Hooks as the core for modular state management**. We deliberately avoided introducing global state management libraries (like Redux or Zustand) for the following reasons:
|
The project adopts a combined approach of **Zustand + custom Hooks**:
|
||||||
|
|
||||||
- **Reduce Complexity**: For the current scale of the application, a global state would introduce unnecessary complexity.
|
- **Zustand (shared app state)**: Manages cross-page/component application-level state such as room/connection states, send/receive progress, and UI states. Implementation lives in `frontend/stores/fileTransferStore.ts`, offering minimal boilerplate and strong type support.
|
||||||
- **Promote Cohesion**: Encapsulating related state and logic within the same Hook makes the code easier to understand and maintain.
|
- **Custom Hooks (cohesive business logic)**: Complex flows (WebRTC connection, room management, file transfer orchestration) remain encapsulated within Hooks, preserving cohesion, testability, and reusability.
|
||||||
- **Leverage React's Native Capabilities**: Passing state managed by Hooks through Context and Props is sufficient for all current needs.
|
|
||||||
|
Benefits:
|
||||||
|
|
||||||
|
- **Clear boundaries**: Observable/shared data goes to Zustand; highly cohesive/transient state stays in each module/Hook.
|
||||||
|
- **Less boilerplate & maintainable**: Zustand remains lightweight while Hooks keep composability and testability.
|
||||||
|
- **Aligned with reality**: Keeps documentation consistent with the actual implementation.
|
||||||
|
|
||||||
### 3.3 Internationalization (i18n)
|
### 3.3 Internationalization (i18n)
|
||||||
|
|
||||||
@@ -130,6 +135,14 @@ The project uses **custom React Hooks as the core for modular state management**
|
|||||||
- **Automatic Detection**: `middleware.ts` intercepts requests and automatically redirects to the appropriate language path based on the `Accept-Language` header or a cookie.
|
- **Automatic Detection**: `middleware.ts` intercepts requests and automatically redirects to the appropriate language path based on the `Accept-Language` header or a cookie.
|
||||||
- **Dynamic Loading**: The `getDictionary` function in `lib/dictionary.ts` asynchronously loads the corresponding `messages/*.json` file based on the `lang` parameter, enabling code splitting.
|
- **Dynamic Loading**: The `getDictionary` function in `lib/dictionary.ts` asynchronously loads the corresponding `messages/*.json` file based on the `lang` parameter, enabling code splitting.
|
||||||
|
|
||||||
|
### 3.4 State & Connection Lifecycle (In-App Navigation)
|
||||||
|
|
||||||
|
- **Singleton Store (Zustand)**: `frontend/stores/fileTransferStore.ts` is a module-level singleton, preserving in-memory state across routes (e.g., share content, files to send, received files/meta, progress states).
|
||||||
|
- **Singleton Connection Service (webrtcService)**: `frontend/lib/webrtcService.ts` holds `RTCPeerConnection`/`RTCDataChannel` and FileSender/FileReceiver as a singleton. App Router page switches do not tear it down.
|
||||||
|
- **Effect**: In the same browser tab, in-app navigation (App Router page switches) does not interrupt ongoing transfers, and selected/received content remains intact.
|
||||||
|
- **Boundary**: Page refresh/closing the tab or opening in a new tab is not covered; when changing layout hierarchy/SSR behavior, avoid cleaning the connection in layout unmount.
|
||||||
|
- **Note**: Do not call `webrtcService.leaveRoom()` or reset the global Store inside route-change side effects; only do so on explicit user actions.
|
||||||
|
|
||||||
## 4. Summary and Outlook
|
## 4. Summary and Outlook
|
||||||
|
|
||||||
The current frontend architecture successfully deconstructs a complex WebRTC application into a series of clean, maintainable modules through layered design and Hook-centric logic encapsulation. The boundaries between UI, business logic, and underlying libraries are clear, laying a solid foundation for future feature expansion and maintenance.
|
The current frontend architecture successfully deconstructs a complex WebRTC application into a series of clean, maintainable modules through layered design and Hook-centric logic encapsulation. The boundaries between UI, business logic, and underlying libraries are clear, laying a solid foundation for future feature expansion and maintenance.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Privydrop 是一个基于 WebRTC 的 P2P 文件/文本分享工具,旨在提
|
|||||||
- **框架**: Next.js 14 (App Router)
|
- **框架**: Next.js 14 (App Router)
|
||||||
- **语言**: TypeScript
|
- **语言**: TypeScript
|
||||||
- **UI**: React 18, Tailwind CSS, shadcn/ui (基于 Radix UI)
|
- **UI**: React 18, Tailwind CSS, shadcn/ui (基于 Radix UI)
|
||||||
- **状态管理**: 以自定义 React Hooks 为核心的模块化状态管理
|
- **状态管理**: Zustand + 自定义 React Hooks(模块化业务逻辑与全局共享状态结合)
|
||||||
- **WebRTC 信令**: Socket.IO Client
|
- **WebRTC 信令**: Socket.IO Client
|
||||||
- **数据获取**: React Server Components (RSC), Fetch API
|
- **数据获取**: React Server Components (RSC), Fetch API
|
||||||
- **国际化**: `next/server` 中间件 + 动态 JSON 字典
|
- **国际化**: `next/server` 中间件 + 动态 JSON 字典
|
||||||
@@ -118,11 +118,16 @@ graph TD
|
|||||||
|
|
||||||
### 3.2 状态管理策略
|
### 3.2 状态管理策略
|
||||||
|
|
||||||
项目**以自定义 React Hooks 为核心进行模块化状态管理**。我们刻意避免了引入全局状态管理库(如 Redux, Zustand),理由如下:
|
当前项目采用“Zustand + 自定义 Hooks”的组合策略:
|
||||||
|
|
||||||
- **降低复杂性**: 对于当前应用规模,全局状态会引入不必要的复杂性。
|
- **Zustand(全局共享状态)**: 用于管理跨页面/跨组件的应用级状态,例如房间与连接状态、发送/接收进度、UI 活动 Tab 等。实现位于 `frontend/stores/fileTransferStore.ts`,API 简洁、零样板、类型友好。
|
||||||
- **促进内聚**: 将相关联的状态和逻辑封装在同一个 Hook 内,使得代码更易于理解和维护。
|
- **自定义 Hooks(业务内聚)**: 复杂的业务流程(如 WebRTC 连接、房间管理、文件传输编排)仍以 Hooks 为边界进行封装,保持“逻辑内聚、可测试、可复用”。
|
||||||
- **利用 React 原生能力**: 通过 Context 和 Props 传递由 Hooks 管理的状态,足以满足当前所有需求。
|
|
||||||
|
这样做的收益:
|
||||||
|
|
||||||
|
- **边界清晰**: 全局可观察/可共享的数据进 Zustand,强业务内聚的瞬时/局部状态放在各自 Hook/模块内。
|
||||||
|
- **减样板与可维护**: Zustand 足够轻量,不引入冗长样板;同时保留 Hooks 的可组合性和可测试性。
|
||||||
|
- **更贴合现状**: 与代码实现保持一致,避免文档与实现脱节。
|
||||||
|
|
||||||
### 3.3 国际化 (i18n)
|
### 3.3 国际化 (i18n)
|
||||||
|
|
||||||
@@ -130,6 +135,14 @@ graph TD
|
|||||||
- **自动检测**: `middleware.ts` 拦截请求,根据 `Accept-Language` 头或 Cookie 自动重定向到合适的语言路径。
|
- **自动检测**: `middleware.ts` 拦截请求,根据 `Accept-Language` 头或 Cookie 自动重定向到合适的语言路径。
|
||||||
- **动态加载**: `lib/dictionary.ts` 中的 `getDictionary` 函数根据 `lang` 参数异步加载对应的 `messages/*.json` 文件,实现了代码分割。
|
- **动态加载**: `lib/dictionary.ts` 中的 `getDictionary` 函数根据 `lang` 参数异步加载对应的 `messages/*.json` 文件,实现了代码分割。
|
||||||
|
|
||||||
|
### 3.4 状态与连接生命周期(站内导航保持)
|
||||||
|
|
||||||
|
- **单例 Store(Zustand)**:`frontend/stores/fileTransferStore.ts` 为模块级单例,跨路由保持内存状态(如分享内容、待发送文件、已接收文件/元信息、进度等)。
|
||||||
|
- **单例连接服务(webrtcService)**:`frontend/lib/webrtcService.ts` 单例持有 `RTCPeerConnection`/`RTCDataChannel` 与 FileSender/FileReceiver。App Router 的页面切换不会销毁该实例。
|
||||||
|
- **效果**:在同一标签页内的站内跳转(App Router 页面切换),进行中的传输不会中断,已选择/已接收的内容保持不丢失。
|
||||||
|
- **边界**:刷新/关闭标签页或新开标签页不在此保证范围内;SSR/布局层级调整时需确保不在布局卸载处做连接清理。
|
||||||
|
- **注意**:不要在路由切换副作用中调用 `webrtcService.leaveRoom()` 或重置全局 Store;离开房间应仅在用户显式操作时触发。
|
||||||
|
|
||||||
## 四、 总结与展望
|
## 四、 总结与展望
|
||||||
|
|
||||||
当前的前端架构通过分层设计和以 Hooks 为中心的逻辑封装,成功地将一个复杂的 WebRTC 应用拆解为一系列清晰、可维护的模块。UI、业务逻辑和底层库之间的界限分明,为未来的功能扩展和维护奠定了坚实的基础。
|
当前的前端架构通过分层设计和以 Hooks 为中心的逻辑封装,成功地将一个复杂的 WebRTC 应用拆解为一系列清晰、可维护的模块。UI、业务逻辑和底层库之间的界限分明,为未来的功能扩展和维护奠定了坚实的基础。
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# PrivyDrop AI Playbook — Code Map
|
||||||
|
|
||||||
|
This map is designed for quick orientation. It lists directories and key entry files with short notes, and intentionally does not cover “frequently changed spots” or detailed impact analysis.
|
||||||
|
|
||||||
|
## Frontend (Next.js, TypeScript)
|
||||||
|
|
||||||
|
- `frontend/app/` — App Router routes and pages.
|
||||||
|
|
||||||
|
- `frontend/app/[lang]/page.tsx` — Home page entry; generates metadata and SEO structured data (JsonLd), with multilingual canonical links.
|
||||||
|
- `frontend/app/[lang]/*/page.tsx` — Static pages: features, about, faq, help, terms, privacy; each generates multilingual SEO metadata.
|
||||||
|
- `frontend/app/[lang]/blog/page.tsx` — Blog list page; renders multilingual post lists.
|
||||||
|
- `frontend/app/[lang]/blog/[slug]/page.tsx` — Blog post page; MDX rendering, TOC, breadcrumbs, and JSON-LD structured data.
|
||||||
|
- `frontend/app/[lang]/blog/tag/[tag]/page.tsx` — Blog tag page; lists posts by tag.
|
||||||
|
- `frontend/app/[lang]/layout.tsx` — Global layout & providers (ThemeProvider, Header/Footer); generates organization/site structured data.
|
||||||
|
- `frontend/app/[lang]/HomeClient.tsx` — Main client component composing the home layout (Hero, ClipboardApp, HowItWorks, video demo, system diagram, features, FAQ), with multi-platform video links (YouTube/Bilibili).
|
||||||
|
- `frontend/app/api/health/route.ts` — Basic health API.
|
||||||
|
- `frontend/app/api/health/detailed/route.ts` — Detailed health API.
|
||||||
|
- `frontend/app/sitemap.ts` — Sitemap generator; multilingual URLs and dynamic blog entries.
|
||||||
|
- `frontend/middleware.ts` — i18n and routing middleware.
|
||||||
|
- `frontend/app/config/environment.ts` — Runtime/env config (ICE, endpoints, etc.).
|
||||||
|
- `frontend/app/config/api.ts` — Backend API client wrapper.
|
||||||
|
|
||||||
|
- `frontend/components/` — UI layer, including the orchestrator and child components.
|
||||||
|
|
||||||
|
- `frontend/components/ClipboardApp.tsx` — Top-level UI orchestrator. Integrates 5 business hooks (useWebRTCConnection/useFileTransferHandler/useRoomManager/usePageSetup/useClipboardAppMessages), and handles global drag events plus the Send/Retrieve tabs.
|
||||||
|
- UX: when switching to Retrieve and all of the following hold—“not in a room, no roomId in URL, empty input, cached ID exists”—it auto-fills and joins (reads `frontend/lib/roomIdCache.ts`).
|
||||||
|
- Connection feedback: integrates `useConnectionFeedback` (`frontend/hooks/useConnectionFeedback.ts`) to map WebRTC states to UI messages (negotiating, 8s slow hint, disconnect/reconnect/restored hints when visible). Slow hints reuse `frontend/utils/useOneShotSlowHint.ts`.
|
||||||
|
|
||||||
|
- `frontend/hooks/` — Business logic hub (React Hooks).
|
||||||
|
- `useRoomManager.ts`
|
||||||
|
- Join flow: `join_inProgress` (immediate), `join_slow` (3s, reuses `useOneShotSlowHint`), `join_timeout` (15s); timers are cleared on both success and failure.
|
||||||
|
- Equivalent success signals: before `joinResponse`, receiving `ready/recipient-ready/offer` is treated as “joined”, and clears the 3s/15s timers.
|
||||||
|
- Others: room status copy, share-link generation, leave room, input validation (750ms debounce).
|
||||||
|
- `useConnectionFeedback.ts`
|
||||||
|
- State normalization: `new/connecting` → `negotiating`; `failed/closed` → `disconnected` (reuses `utils/rtcPhase.ts`).
|
||||||
|
- Negotiation slow hint: an 8s timer (`rtc_slow`), shown at most once per negotiation attempt; if it fires in background, it’s deferred and emitted once on foreground if still negotiating (reuses `useOneShotSlowHint`).
|
||||||
|
- One-shot hints: first `connected` (`rtc_connected`) is shown once; foreground reconnecting (`rtc_reconnecting`) and restored (`rtc_restored`) hints.
|
||||||
|
|
||||||
|
- i18n copy & types
|
||||||
|
- Copy: `frontend/constants/messages/*.{ts}` (zh/en/ja/es/de/fr/ko filled).
|
||||||
|
- Types: `frontend/types/messages.ts` (ClipboardApp includes `join_*` and `rtc_*` message keys).
|
||||||
|
- `frontend/components/ClipboardApp/SendTabPanel.tsx` — Send panel: rich-text editor, file upload, room ID generation (4-digit vs UUID), share-link generation.
|
||||||
|
- UX: clicking “Use cached ID” triggers join immediately on the sender side, saving one manual click.
|
||||||
|
- `frontend/components/ClipboardApp/RetrieveTabPanel.tsx` — Retrieve panel: room join, file receiving, directory selection (File System Access API), rich-text display.
|
||||||
|
- `frontend/components/ClipboardApp/FileListDisplay.tsx` — File list: file/folder grouping, progress tracking, browser-specific download strategies (Chrome auto download; others prompt manual save), download count stats.
|
||||||
|
- `frontend/components/ClipboardApp/FullScreenDropZone.tsx` — Full-screen drag overlay/feedback.
|
||||||
|
- `frontend/components/ClipboardApp/*` — Other subcomponents: FileUploadHandler, ShareCard (QR code sharing), TransferProgress, CachedIdActionButton, FileTransferButton.
|
||||||
|
- `frontend/components/Editor/` — Rich-text editor module: RichTextEditor, toolbar components (BasicFormatTools/FontTools/AlignmentTools/InsertTools), SelectMenu, types, and editor hooks.
|
||||||
|
- `frontend/components/blog/` — Blog components: TableOfContents (Chinese heading ID generation + scroll tracking), Mermaid rendering, MDXComponents, ArticleListItem, etc.
|
||||||
|
- `frontend/components/common/` — Shared components: clipboard_btn (clipboard read/write buttons), AutoPopupDialog, LazyLoadWrapper, YouTubePlayer.
|
||||||
|
- `frontend/components/web/` — Site components: Header (responsive nav + language), Footer (copyright + language links), FAQSection, HowItWorks, SystemDiagram, KeyFeatures, theme-provider.
|
||||||
|
- `frontend/components/web/ThemeToggle.tsx` — Theme toggle (single Light/Dark button), used in Header (desktop & mobile).
|
||||||
|
- `frontend/components/seo/JsonLd.tsx` — SEO structured data component for multiple JSON-LD types.
|
||||||
|
- `frontend/components/LanguageSwitcher.tsx` — Language switcher.
|
||||||
|
- `frontend/components/ui/*` — Base UI atoms (Radix UI + shadcn/ui): Button (variants), Accordion, Dialog, Card, Tooltip, Select, Input, Textarea, Checkbox, DropdownMenu, Toast system, AnimatedButton.
|
||||||
|
|
||||||
|
- `frontend/hooks/` — Business logic hub (React Hooks).
|
||||||
|
|
||||||
|
- `frontend/hooks/useWebRTCConnection.ts` — WebRTC lifecycle and orchestration APIs.
|
||||||
|
- `frontend/hooks/useRoomManager.ts` — Room create/join/validate and UI state; supports cached-ID reconnect (≥8 chars auto-sends initiator-online).
|
||||||
|
- `frontend/hooks/useFileTransferHandler.ts` — File/text payload orchestration and callbacks; uses getState() to avoid stale closures; supports JSZip folder downloads.
|
||||||
|
- `frontend/hooks/useClipboardActions.ts` — Clipboard actions/state; supports modern APIs and document.execCommand fallback; handles HTML/rich-text paste.
|
||||||
|
- `frontend/hooks/useClipboardAppMessages.ts` — App messaging (shareMessage/retrieveMessage) with a 4-second auto-dismiss mechanism.
|
||||||
|
- `frontend/hooks/useLocale.ts` — Language selection by parsing pathname.
|
||||||
|
- `frontend/hooks/usePageSetup.ts` — Page setup & SEO; auto-join from URL roomId; referrer tracking.
|
||||||
|
- `frontend/hooks/useRichTextToPlainText.ts` — Rich-text → plain-text helper; block-level line breaks and text-node wrapping.
|
||||||
|
|
||||||
|
- `frontend/lib/` — Core libraries and utilities.
|
||||||
|
|
||||||
|
- WebRTC base & roles
|
||||||
|
- `frontend/lib/webrtc_base.ts` — WebRTC base class: Socket.IO signaling, RTCPeerConnection management, ICE candidate queues, dual-disconnect reconnection, wake lock management, DataChannel send retries (5 attempts with increasing delays), graceful disconnect tracking (`gracefullyDisconnectedPeers` Set), and multi-format payload compatibility (ArrayBuffer/Blob/Uint8Array/TypedArray). joinRoom uses a 15s timeout and an “equivalent success signal” fallback: Initiator treats `ready/recipient-ready` as joined; Recipient treats `offer` as joined; once triggered it sets inRoom and clears listeners/timers to reduce false timeouts on weak networks.
|
||||||
|
- `frontend/lib/webrtc_Initiator.ts` — Initiator role: handles `ready`/`recipient-ready`, creates RTCPeerConnection and a proactive DataChannel, sends offers, handles answers, supports a 256KB buffer threshold.
|
||||||
|
- `frontend/lib/webrtc_Recipient.ts` — Recipient role: handles `offer`, creates RTCPeerConnection and a reactive DataChannel (`ondatachannel`), generates and sends answers, handles `initiator-online` reconnect signals and connection cleanup.
|
||||||
|
- `frontend/lib/webrtcService.ts` — WebRTC service singleton (persists across routes): manages sender/receiver instances, exposes a unified business API, handles connection-state changes, broadcasting, file requests, and disconnect cleanup.
|
||||||
|
- Sending (sender)
|
||||||
|
- `frontend/lib/fileSender.ts` — Backward-compatible sender wrapper; internally uses FileTransferOrchestrator.
|
||||||
|
- `frontend/lib/transfer/FileTransferOrchestrator.ts` — Sender main orchestrator; manages the file transfer lifecycle.
|
||||||
|
- `frontend/lib/transfer/StreamingFileReader.ts` — High-performance streaming reader using the 32MB batch + 64KB network chunk dual-layer buffer design.
|
||||||
|
- `frontend/lib/transfer/NetworkTransmitter.ts` — Network transmitter; uses native WebRTC backpressure control and supports embedded-metadata chunk packets.
|
||||||
|
- `frontend/lib/transfer/StateManager.ts` — State hub; tracks peer state, pending files, folder metadata.
|
||||||
|
- `frontend/lib/transfer/ProgressTracker.ts` — Progress tracker; computes file/folder progress and speed stats; triggers callbacks.
|
||||||
|
- `frontend/lib/transfer/MessageHandler.ts` — Message routing (fileRequest/fileReceiveComplete/folderReceiveComplete).
|
||||||
|
- `frontend/lib/transfer/TransferConfig.ts` — Transfer config: 4MB file read chunks, 32MB batches, 64KB network chunks.
|
||||||
|
- Receiving (receiver)
|
||||||
|
- `frontend/lib/fileReceiver.ts` — Backward-compatible receiver wrapper; internally uses FileReceiveOrchestrator.
|
||||||
|
- `frontend/lib/receive/FileReceiveOrchestrator.ts` — Receiver main orchestrator; manages reception lifecycle with resume support and streaming disk writes.
|
||||||
|
- `frontend/lib/receive/ReceptionStateManager.ts` — State hub; manages file metadata, active reception state, folder progress, and save-mode config.
|
||||||
|
- `frontend/lib/receive/ChunkProcessor.ts` — Chunk processor: payload conversion, embedded-metadata parsing, validation, and index mapping.
|
||||||
|
- `frontend/lib/receive/StreamingFileWriter.ts` — Streaming writer with SequencedDiskWriter for strict in-order disk writes; supports large streaming files.
|
||||||
|
- `frontend/lib/receive/FileAssembler.ts` — In-memory assembler for small files; reassembles, checks integrity, and creates a File object.
|
||||||
|
- `frontend/lib/receive/MessageProcessor.ts` — Message routing (fileMeta/stringMetadata/fileRequest/folderReceiveComplete).
|
||||||
|
- `frontend/lib/receive/ProgressReporter.ts` — Progress reporter: progress/speed stats and throttled callbacks.
|
||||||
|
- `frontend/lib/receive/ReceptionConfig.ts` — Reception config: 1GB “large file” threshold, 64KB chunks, buffer sizes, debug toggles.
|
||||||
|
- Tools & helpers
|
||||||
|
- `frontend/lib/fileReceiver.ts`, `frontend/lib/fileUtils.ts`, `frontend/lib/speedCalculator.ts`, `frontend/lib/utils.ts` — general utilities.
|
||||||
|
- `frontend/lib/roomIdCache.ts` — room ID cache management.
|
||||||
|
- `frontend/lib/wakeLockManager.tsx` — wake lock manager (mobile optimization).
|
||||||
|
- `frontend/lib/utils/ChunkRangeCalculator.ts` — chunk-range calculations.
|
||||||
|
- `frontend/lib/browserUtils.ts` — browser compatibility helpers.
|
||||||
|
- `frontend/lib/tracking.ts` — user behavior tracking.
|
||||||
|
- `frontend/lib/dictionary.ts`, `frontend/lib/mdx-config.ts`, `frontend/lib/blog.ts` — i18n/content/SEO helpers.
|
||||||
|
|
||||||
|
- `frontend/stores/` — Shared app state (Zustand).
|
||||||
|
|
||||||
|
- `frontend/stores/fileTransferStore.ts` — Single source of truth for transfer progress/state (Zustand singleton, persists across routes).
|
||||||
|
|
||||||
|
- `frontend/types/`, `frontend/constants/` — Types and constants.
|
||||||
|
|
||||||
|
- `frontend/types/global.d.ts` — Global types (lodash module, FileSystemDirectoryHandle).
|
||||||
|
- `frontend/types/messages.ts` — i18n message and UI content types (Meta, Text, Messages, etc.).
|
||||||
|
- `frontend/types/webrtc.ts` — WebRTC transfer protocol types (metadata, chunk shape, state machine interfaces).
|
||||||
|
- `frontend/constants/messages/` — i18n message files (7 languages: en, zh, de, es, fr, ja, ko).
|
||||||
|
- `frontend/constants/i18n-config.ts` — i18n config (default language, supported languages, display-name mapping).
|
||||||
|
|
||||||
|
- `frontend/content/` — Content.
|
||||||
|
|
||||||
|
- `frontend/content/blog/` — Blog posts (MDX, multilingual), including OSS release, WebRTC file transfer, resume, etc.
|
||||||
|
- `frontend/lib/blog.ts` — Blog utilities: multilingual post loading, frontmatter parsing, tag extraction, content validation.
|
||||||
|
|
||||||
|
- **Config & build**
|
||||||
|
- `frontend/package.json`, `frontend/tsconfig.json`, `frontend/tailwind.config.ts` — project configuration.
|
||||||
|
- `frontend/next.config.mjs`, `frontend/postcss.config.mjs`, `frontend/components.json` — Next.js and component config.
|
||||||
|
- `frontend/.eslintrc.json` — lint configuration.
|
||||||
|
- `frontend/Dockerfile`, `frontend/health-check.js` — Docker deploy and health checks.
|
||||||
|
|
||||||
|
## Backend (Express, Socket.IO, Redis)
|
||||||
|
|
||||||
|
- `backend/src/server.ts` — Server entry: Express + Socket.IO init and listen.
|
||||||
|
- `backend/src/config/env.ts`, `backend/src/config/server.ts` — Environment and server config.
|
||||||
|
- `backend/src/config/env.ts` — Env var validation (port, CORS, Redis), supports per-env `.env` loading.
|
||||||
|
- `backend/src/config/server.ts` — CORS config for dev/prod, supports multi-origin and LAN regex matching.
|
||||||
|
- `backend/src/routes/api.ts` — REST: room create/validate, tracking, debug logs.
|
||||||
|
- `backend/src/routes/health.ts` — Health checks.
|
||||||
|
- `backend/src/socket/handlers.ts` — Signaling events: `join`, `initiator-online`, `recipient-ready`, `offer`, `answer`, `ice-candidate`.
|
||||||
|
- `backend/src/services/redis.ts` — Redis client.
|
||||||
|
- `backend/src/services/room.ts` — Room/member storage and helpers.
|
||||||
|
- `backend/src/services/rateLimit.ts` — Redis Sorted Set IP rate limiter.
|
||||||
|
- `backend/src/types/room.ts`, `backend/src/types/socket.ts` — Types and interfaces.
|
||||||
|
|
||||||
|
- `backend/src/types/socket.ts` — Socket.IO types: JoinData, SignalingData (offer/answer/candidate), InitiatorData, RecipientData.
|
||||||
|
- `backend/src/types/room.ts` — Room types: RoomInfo (createdAt), ReferrerTrack, LogMessage.
|
||||||
|
|
||||||
|
- **Backend config & scripts**
|
||||||
|
- `backend/package.json`, `backend/tsconfig.json` — project configuration.
|
||||||
|
- `backend/Dockerfile`, `backend/.dockerignore` — Docker configuration.
|
||||||
|
- `backend/health-check.js` — health-check script.
|
||||||
|
- `backend/scripts/export-tracking-data.js` — data export script.
|
||||||
|
|
||||||
|
## Deployment & Ops
|
||||||
|
|
||||||
|
- **Root-level config**
|
||||||
|
|
||||||
|
- `deploy.sh` — Docker one-click entry (env checks, config generation, cert automation, start/clean).
|
||||||
|
- `docker-compose.yml` — Docker Compose services (frontend/backend/redis/nginx/turn).
|
||||||
|
- `.env` — Docker deployment env vars (generated/maintained by scripts).
|
||||||
|
|
||||||
|
- **Docker infrastructure**
|
||||||
|
|
||||||
|
- `docker/nginx/` — Nginx reverse proxy config.
|
||||||
|
- `docker/scripts/` — deployment scripts (env checks, config generation, deployment tests).
|
||||||
|
- `docker/ssl/` — SSL certificate directory.
|
||||||
|
- `docker/coturn/` — TURN server config.
|
||||||
|
- `docker/letsencrypt-www/` — Let’s Encrypt config.
|
||||||
|
|
||||||
|
- **Build & docs**
|
||||||
|
- `build/` — ignore this temporary directory.
|
||||||
|
- `test-health-apis.sh` — health API test script.
|
||||||
|
- `README.md`, `README.zh-CN.md`, `ROADMAP.md`, `ROADMAP.zh-CN.md` — project docs.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# PrivyDrop AI Playbook — 代码地图(中文)
|
||||||
|
|
||||||
|
本地图以“快速定位”为目标,仅给出目录与关键入口文件的简要说明,不包含“常改动点/影响范围”。
|
||||||
|
|
||||||
|
## 前端(Next.js,TypeScript)
|
||||||
|
|
||||||
|
- `frontend/app/` — App Router 路由与页面。
|
||||||
|
|
||||||
|
- `frontend/app/[lang]/page.tsx` — 主页入口,生成元数据和 SEO 结构化数据(JsonLd),支持多语言 canonical 链接。
|
||||||
|
- `frontend/app/[lang]/*/page.tsx` — 静态页面:features(功能特性)、about(关于)、faq(常见问题)、help(帮助)、terms(服务条款)、privacy(隐私政策),均包含多语言 SEO 元数据生成。
|
||||||
|
- `frontend/app/[lang]/blog/page.tsx` — 博客列表页,展示多语言文章列表。
|
||||||
|
- `frontend/app/[lang]/blog/[slug]/page.tsx` — 博客文章详情页,支持 MDX 渲染、目录生成、面包屑导航和 JSON-LD 结构化数据。
|
||||||
|
- `frontend/app/[lang]/blog/tag/[tag]/page.tsx` — 博客标签页,按标签分类展示文章。
|
||||||
|
- `frontend/app/[lang]/layout.tsx` — 全局布局与 Provider 注入,包含 ThemeProvider、Header/Footer,生成组织架构和网站结构化数据。
|
||||||
|
- `frontend/app/[lang]/HomeClient.tsx` — 主客户端组件,组织页面结构(Hero 区域、ClipboardApp、HowItWorks、视频演示、系统架构图、功能特性、FAQ),支持多平台视频链接(YouTube/B 站)。
|
||||||
|
- `frontend/app/api/health/route.ts` — 基础健康检查 API。
|
||||||
|
- `frontend/app/api/health/detailed/route.ts` — 详细健康检查 API。
|
||||||
|
- `frontend/app/sitemap.ts` — 站点地图生成,支持多语言 URL 和博客文章动态收录。
|
||||||
|
- `frontend/middleware.ts` — i18n 与路由中间件。
|
||||||
|
- `frontend/app/config/environment.ts` — 运行时/环境配置(ICE、端点等)。
|
||||||
|
- `frontend/app/config/api.ts` — 后端 API 交互封装。
|
||||||
|
|
||||||
|
- `frontend/components/` — UI,包括协调器与子组件。
|
||||||
|
|
||||||
|
- `frontend/components/ClipboardApp.tsx` — 顶层 UI 协调器,集成 5 个业务 hooks(useWebRTCConnection/useFileTransferHandler/useRoomManager/usePageSetup/useClipboardAppMessages),处理全局拖拽事件和双标签页(发送/接收)管理。
|
||||||
|
- 体验增强:切到接收端(retrieve)且满足“未在房间、URL 无 roomId、输入为空、存在缓存ID”时自动填充并加入房间(读取 `frontend/lib/roomIdCache.ts`)。
|
||||||
|
- 连接反馈:集成 `useConnectionFeedback`(`frontend/hooks/useConnectionFeedback.ts`),桥接 WebRTC 连接态到 UI 文案,含协商中提示、8s 慢连接提示、断开/重连/恢复提示(前台可见时提示)。慢提示统一复用 `frontend/utils/useOneShotSlowHint.ts`。
|
||||||
|
|
||||||
|
- `frontend/hooks/` — 业务中枢 Hooks。
|
||||||
|
- `useRoomManager.ts`
|
||||||
|
- 入房流程:`join_inProgress`(立即)、`join_slow`(3s,复用 `useOneShotSlowHint`)、`join_timeout`(15s);join 成功/失败均清理定时器。
|
||||||
|
- 等效成功信号:在 `joinResponse` 之前若收到 `ready/recipient-ready/offer`,提前判定入房成功并清理 3s/15s 定时器。
|
||||||
|
- 其他:房间状态文案、分享链接生成、离开房间、输入校验(750ms 防抖)。
|
||||||
|
- `useConnectionFeedback.ts`
|
||||||
|
- 状态归一化:`new/connecting`→`negotiating`;`failed/closed`→`disconnected`(复用 `utils/rtcPhase.ts`)。
|
||||||
|
- 协商慢提示:8s 定时器(`rtc_slow`),单次协商仅提示一次;若在后台到时则挂起,回到前台且仍协商时补发一次(复用 `useOneShotSlowHint`)。
|
||||||
|
- 一次性提示:首次 `connected`(`rtc_connected`)仅提示一次;断开前台重连(`rtc_reconnecting`)与恢复(`rtc_restored`)。
|
||||||
|
|
||||||
|
- i18n 文案与类型
|
||||||
|
- 文案定义:`frontend/constants/messages/*.{ts}`(已补齐 zh/en/ja/es/de/fr/ko)。
|
||||||
|
- 类型定义:`frontend/types/messages.ts`(`ClipboardApp` 下包含 `join_*` 与 `rtc_*` 文案键)。
|
||||||
|
- `frontend/components/ClipboardApp/SendTabPanel.tsx` — 发送面板,集成富文本编辑器、文件上传、房间 ID 生成(支持 4 位数字/UUID 两种模式)、分享链接生成。
|
||||||
|
- 体验增强:点击“使用缓存ID”将立即触发加入房间(sender 侧),减少一次手动点击。
|
||||||
|
- `frontend/components/ClipboardApp/RetrieveTabPanel.tsx` — 接收面板,处理房间加入、文件接收、目录选择(File System Access API)、富文本内容显示。
|
||||||
|
- `frontend/components/ClipboardApp/FileListDisplay.tsx` — 文件列表显示组件,支持文件/文件夹分组显示、进度跟踪、多浏览器下载策略(Chrome 自动下载/其他浏览器手动保存)、下载计数统计。
|
||||||
|
- `frontend/components/ClipboardApp/FullScreenDropZone.tsx` — 全屏拖拽提示组件,文件拖拽时的视觉反馈。
|
||||||
|
- `frontend/components/ClipboardApp/*` — 其他子组件:FileUploadHandler(文件上传处理)、ShareCard(二维码分享)、TransferProgress(进度条)、CachedIdActionButton(缓存 ID 操作)、FileTransferButton(文件传输按钮)。
|
||||||
|
- `frontend/components/Editor/` — 富文本编辑器模块,包含 RichTextEditor 主编辑器、工具栏组件(BasicFormatTools/FontTools/AlignmentTools/InsertTools)、SelectMenu 下拉选择、类型定义和编辑器 hooks。
|
||||||
|
- `frontend/components/blog/` — 博客相关组件,包含 TableOfContents(支持中文目录生成和滚动跟踪)、Mermaid 图表渲染、MDXComponents、ArticleListItem 文章列表。
|
||||||
|
- `frontend/components/common/` — 通用组件,包含 clipboard_btn(读写剪贴板按钮)、AutoPopupDialog(自动弹出对话框)、LazyLoadWrapper(懒加载包装器)、YouTubePlayer(YouTube 播放器)。
|
||||||
|
- `frontend/components/web/` — 网站页面组件,包含 Header(响应式导航和多语言支持)、Footer(版权和语言链接)、FAQSection(可配置 FAQ 展示)、HowItWorks(步骤说明和视频演示)、SystemDiagram(系统架构图)、KeyFeatures(功能特性展示)、theme-provider 主题提供者。
|
||||||
|
- `frontend/components/web/ThemeToggle.tsx` — 主题切换按钮(单按钮 Light/Dark 切换),集成于 Header(桌面与移动)。
|
||||||
|
- `frontend/components/seo/JsonLd.tsx` — SEO 结构化数据组件,支持多类型 JSON-LD 数据生成。
|
||||||
|
- `frontend/components/LanguageSwitcher.tsx` — 语言切换器。
|
||||||
|
- `frontend/components/ui/*` — 基础 UI 原子组件(基于 Radix UI 和 shadcn/ui),包含 Button(多变体按钮)、Accordion(手风琴)、Dialog(模态对话框)、Card(卡片)、Tooltip(工具提示)、Select、Input、Textarea、Checkbox、DropdownMenu、Toast 通知系统和 AnimatedButton 动画按钮。
|
||||||
|
|
||||||
|
- `frontend/hooks/` — 业务逻辑中枢(React Hooks)。
|
||||||
|
|
||||||
|
- `frontend/hooks/useWebRTCConnection.ts` — WebRTC 生命周期与编排 API。
|
||||||
|
- `frontend/hooks/useRoomManager.ts` — 房间创建/加入/校验与 UI 状态,支持缓存 ID 重连(≥8 字符自动发送 initiator-online)。
|
||||||
|
- `frontend/hooks/useFileTransferHandler.ts` — 文件/文本负载编排与回调,使用 getState() 修复闭包问题,支持 JSZip 文件夹下载。
|
||||||
|
- `frontend/hooks/useClipboardActions.ts` — 剪贴板操作与状态管理,支持现代 API 和 document.execCommand 降级,处理 HTML/富文本粘贴。
|
||||||
|
- `frontend/hooks/useClipboardAppMessages.ts` — 应用消息处理(shareMessage/retrieveMessage),4 秒自动消失机制。
|
||||||
|
- `frontend/hooks/useLocale.ts` — 国际化语言切换,基于 pathname 解析。
|
||||||
|
- `frontend/hooks/usePageSetup.ts` — 页面配置与 SEO 设置,处理 URL 参数 roomId 自动加入和引荐来源追踪。
|
||||||
|
- `frontend/hooks/useRichTextToPlainText.ts` — 富文本转纯文本工具,处理块级元素换行和文本节点包装。
|
||||||
|
|
||||||
|
- `frontend/lib/` — 核心库与工具。
|
||||||
|
|
||||||
|
- WebRTC 基础与角色
|
||||||
|
- `frontend/lib/webrtc_base.ts` — WebRTC 基础类,提供 Socket.IO 信令、RTCPeerConnection 管理、ICE 候选者队列、双重断开检测重连机制、唤醒锁管理、数据通道发送重试(5 次递增间隔)、优雅断开跟踪(gracefullyDisconnectedPeers Set)和多格式数据类型兼容性支持(ArrayBuffer/Blob/Uint8Array/TypedArray)。加入房间(joinRoom)采用 15 秒超时,并在 join 未返回时启用“等效成功信号”提前判定成功:Initiator 收到 `ready/recipient-ready`,Recipient 收到 `offer`;触发后立即设置 inRoom 并清理监听/定时器,降低弱网下误报。
|
||||||
|
- `frontend/lib/webrtc_Initiator.ts` — 发起方实现,处理`ready`/`recipient-ready`事件,创建 RTCPeerConnection 和主动式 DataChannel,发送 offer,处理 answer 响应,支持 256KB 缓冲阈值配置。
|
||||||
|
- `frontend/lib/webrtc_Recipient.ts` — 接收方实现,处理`offer`事件,创建 RTCPeerConnection 和响应式 DataChannel(ondatachannel),生成并发送 answer,处理`initiator-online`重连信号和现有连接清理。
|
||||||
|
- `frontend/lib/webrtcService.ts` — WebRTC 服务单例封装(跨路由常驻),管理 sender/receiver 实例,提供统一业务接口,处理连接状态变更、数据广播、文件请求和连接断开清理。
|
||||||
|
- 发送(sender)
|
||||||
|
- `frontend/lib/fileSender.ts` — 发送端向后兼容包装层,内部使用 FileTransferOrchestrator 提供统一服务。
|
||||||
|
- `frontend/lib/transfer/FileTransferOrchestrator.ts` — 发送端主编排器,集成所有组件管理文件传输生命周期。
|
||||||
|
- `frontend/lib/transfer/StreamingFileReader.ts` — 高性能流式文件读取器,采用 32MB 批次+64KB 网络块的双层缓冲架构。
|
||||||
|
- `frontend/lib/transfer/NetworkTransmitter.ts` — 网络传输器,使用 WebRTC 原生背压控制,支持嵌入元数据分片发送。
|
||||||
|
- `frontend/lib/transfer/StateManager.ts` — 状态管理中心,跟踪 peer 状态、待发送文件、文件夹元数据。
|
||||||
|
- `frontend/lib/transfer/ProgressTracker.ts` — 进度跟踪器,处理文件/文件夹进度计算、速度统计和回调触发。
|
||||||
|
- `frontend/lib/transfer/MessageHandler.ts` — 消息处理器,负责 WebRTC 消息路由(fileRequest/fileReceiveComplete/folderReceiveComplete)。
|
||||||
|
- `frontend/lib/transfer/TransferConfig.ts` — 传输配置管理,定义文件读取 4MB 分片、32MB 批次、64KB 网络发送块。
|
||||||
|
- 接收(receiver)
|
||||||
|
- `frontend/lib/fileReceiver.ts` — 接收端向后兼容包装层,内部使用 FileReceiveOrchestrator 提供统一服务。
|
||||||
|
- `frontend/lib/receive/FileReceiveOrchestrator.ts` — 接收端主编排器,集成所有组件管理文件接收生命周期,支持断点续传和磁盘流式写入。
|
||||||
|
- `frontend/lib/receive/ReceptionStateManager.ts` — 状态管理中心,管理文件元数据、活跃接收状态、文件夹进度、保存类型配置。
|
||||||
|
- `frontend/lib/receive/ChunkProcessor.ts` — 分片处理器,处理多种数据格式转换、嵌入元数据解析、分片验证和索引映射。
|
||||||
|
- `frontend/lib/receive/StreamingFileWriter.ts` — 流式文件写入器,包含 SequencedDiskWriter 严格顺序写入机制,支持大文件磁盘流式写入。
|
||||||
|
- `frontend/lib/receive/FileAssembler.ts` — 内存文件组装器,处理小块文件的内存重组、完整性校验和文件对象创建。
|
||||||
|
- `frontend/lib/receive/MessageProcessor.ts` — 消息处理器,负责 WebRTC 消息路由(fileMeta/stringMetadata/fileRequest/folderReceiveComplete)。
|
||||||
|
- `frontend/lib/receive/ProgressReporter.ts` — 进度报告器,处理文件/文件夹进度计算、速度统计和节流回调。
|
||||||
|
- `frontend/lib/receive/ReceptionConfig.ts` — 接收配置管理,定义大文件阈值 1GB、64KB 分片、缓冲区大小和调试开关。
|
||||||
|
- 工具与辅助
|
||||||
|
- `frontend/lib/fileReceiver.ts`、`frontend/lib/fileUtils.ts`、`frontend/lib/speedCalculator.ts`、`frontend/lib/utils.ts` — 基础工具。
|
||||||
|
- `frontend/lib/roomIdCache.ts` — 房间 ID 缓存管理。
|
||||||
|
- `frontend/lib/wakeLockManager.tsx` — 屏幕唤醒锁管理(移动端优化)。
|
||||||
|
- `frontend/lib/utils/ChunkRangeCalculator.ts` — 文件分片范围计算。
|
||||||
|
- `frontend/lib/browserUtils.ts` — 浏览器兼容性工具。
|
||||||
|
- `frontend/lib/tracking.ts` — 用户行为追踪。
|
||||||
|
- `frontend/lib/dictionary.ts`、`frontend/lib/mdx-config.ts`、`frontend/lib/blog.ts` — i18n/内容与 SEO 辅助。
|
||||||
|
|
||||||
|
- `frontend/stores/` — 共享应用状态(Zustand)。
|
||||||
|
|
||||||
|
- `frontend/stores/fileTransferStore.ts` — 传输进度/状态的唯一事实来源(Zustand 单例,跨路由保持)。
|
||||||
|
|
||||||
|
- `frontend/types/`、`frontend/constants/` — 类型定义与常量。
|
||||||
|
|
||||||
|
- `frontend/types/global.d.ts` — 全局类型定义(lodash 模块、FileSystemDirectoryHandle 接口)。
|
||||||
|
- `frontend/types/messages.ts` — 多语言消息与 UI 内容类型定义(Meta、Text、Messages 等国际化结构)。
|
||||||
|
- `frontend/types/webrtc.ts` — WebRTC 传输协议类型(文件元数据、分片结构、状态机接口)。
|
||||||
|
- `frontend/constants/messages/` — 多语言消息文件(7 种语言:en、zh、de、es、fr、ja、ko)。
|
||||||
|
- `frontend/constants/i18n-config.ts` — 国际化配置(默认语言、支持语言列表、显示名称映射)。
|
||||||
|
|
||||||
|
- `frontend/content/` — 内容资源。
|
||||||
|
|
||||||
|
- `frontend/content/blog/` — 博客文章(MDX 格式,多语言),包含开源发布、WebRTC 文件传输、断点续传等主题文章。
|
||||||
|
- `frontend/lib/blog.ts` — 博客工具函数,支持多语言文章读取、frontmatter 解析、标签提取和内容验证。
|
||||||
|
|
||||||
|
- **配置与构建**
|
||||||
|
- `frontend/package.json`、`frontend/tsconfig.json`、`frontend/tailwind.config.ts` — 项目配置。
|
||||||
|
- `frontend/next.config.mjs`、`frontend/postcss.config.mjs`、`frontend/components.json` — Next.js 与组件配置。
|
||||||
|
- `frontend/.eslintrc.json` — 代码检查配置。
|
||||||
|
- `frontend/Dockerfile`、`frontend/health-check.js` — Docker 部署与健康检查。
|
||||||
|
|
||||||
|
## 后端(Express,Socket.IO,Redis)
|
||||||
|
|
||||||
|
- `backend/src/server.ts` — 启动入口:Express + Socket.IO 初始化与监听。
|
||||||
|
- `backend/src/config/env.ts`、`backend/src/config/server.ts` — 环境与服务配置。
|
||||||
|
- `backend/src/config/env.ts` — 环境变量配置与验证,包含端口、CORS、Redis 连接设置,支持开发/生产环境自动加载对应.env 文件。
|
||||||
|
- `backend/src/config/server.ts` — CORS 配置,区分开发/生产环境,支持多域名配置和 LAN 地址正则匹配。
|
||||||
|
- `backend/src/routes/api.ts` — REST:房间创建/校验、追踪、调试日志。
|
||||||
|
- `backend/src/routes/health.ts` — 健康检查。
|
||||||
|
- `backend/src/socket/handlers.ts` — 信令事件:`join`、`initiator-online`、`recipient-ready`、`offer`、`answer`、`ice-candidate`。
|
||||||
|
- `backend/src/services/redis.ts` — Redis 客户端。
|
||||||
|
- `backend/src/services/room.ts` — 房间/成员存储与辅助。
|
||||||
|
- `backend/src/services/rateLimit.ts` — 基于 Redis 有序集的 IP 限流。
|
||||||
|
- `backend/src/types/room.ts`、`backend/src/types/socket.ts` — 类型定义与接口。
|
||||||
|
|
||||||
|
- `backend/src/types/socket.ts` — Socket.IO 相关类型,包含 JoinData 房间加入数据、SignalingData WebRTC 信令数据(offer/answer/candidate)、InitiatorData 发起方数据、RecipientData 接收方数据。
|
||||||
|
- `backend/src/types/room.ts` — 房间相关类型,包含 RoomInfo 房间信息(创建时间)、ReferrerTrack 来源追踪数据、LogMessage 日志消息结构。
|
||||||
|
|
||||||
|
- **后端配置与脚本**
|
||||||
|
- `backend/package.json`、`backend/tsconfig.json` — 项目配置。
|
||||||
|
- `backend/Dockerfile`、`backend/.dockerignore` — Docker 配置。
|
||||||
|
- `backend/health-check.js` — 健康检查脚本。
|
||||||
|
- `backend/scripts/export-tracking-data.js` — 数据导出脚本。
|
||||||
|
|
||||||
|
## 部署与运维
|
||||||
|
|
||||||
|
- **根目录配置**
|
||||||
|
|
||||||
|
- `deploy.sh` — Docker 一键部署入口(环境检测、配置生成、证书自动化、启动/清理)。
|
||||||
|
- `docker-compose.yml` — Docker Compose 编排(frontend/backend/redis/nginx/turn)。
|
||||||
|
- `.env` — Docker 部署环境变量(由脚本生成/维护)。
|
||||||
|
|
||||||
|
- **Docker 基础设施**
|
||||||
|
|
||||||
|
- `docker/nginx/` — Nginx 反向代理配置。
|
||||||
|
- `docker/scripts/` — 部署相关脚本(环境检测、配置生成、部署测试)。
|
||||||
|
- `docker/ssl/` — SSL 证书目录。
|
||||||
|
- `docker/coturn/` — TURN 服务器配置。
|
||||||
|
- `docker/letsencrypt-www/` — Let's Encrypt 配置。
|
||||||
|
|
||||||
|
- **构建与文档**
|
||||||
|
- `build/` — 请忽略这个临时目录。
|
||||||
|
- `test-health-apis.sh` — 健康 API 测试脚本。
|
||||||
|
- `README.md`、`README.zh-CN.md`、`ROADMAP.md`、`ROADMAP.zh-CN.md` — 项目文档。
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# PrivyDrop AI Playbook — Collaboration Rules
|
||||||
|
|
||||||
|
These rules govern how “human developers + AI assistants” collaborate to evolve the codebase efficiently and safely, without breaking the privacy stance or technical baseline. This document complements the index (`index.md`) and the flow map (`flows.md`): it constrains “how we work” without re-stating “what the system does”.
|
||||||
|
|
||||||
|
- Scope: the entire repository (code + docs)
|
||||||
|
- Audience: human developers, AI assistants, reviewers
|
||||||
|
- Change principles: best practices first, one goal per change, reversible, verifiable
|
||||||
|
|
||||||
|
## 1. Collaboration Principles
|
||||||
|
|
||||||
|
- Best practices first: prefer proven approaches consistent with the existing stack; avoid reinventing the wheel.
|
||||||
|
- One change, one purpose: each change should focus on one goal; avoid “while I’m here” fixes.
|
||||||
|
- Privacy stance: never introduce (or suggest) server-relayed file transfers; the backend is for signaling and room coordination only.
|
||||||
|
- Small steps: keep PRs small and easy to roll back; prefer the minimum viable change.
|
||||||
|
- Traceability: commit messages, PR descriptions, and code comments should be clear and reproducible.
|
||||||
|
|
||||||
|
## 2. Plan First (Hard Requirement)
|
||||||
|
|
||||||
|
Before implementing anything, you must propose a “change plan” and get approval. The plan must include: goals, scope + file list, approach, risks + mitigations, acceptance criteria, rollback, docs to update, and validation.
|
||||||
|
|
||||||
|
Recommended reading before implementation (and reference in the plan):
|
||||||
|
|
||||||
|
- `docs/ai-playbook/index.md`
|
||||||
|
- `docs/ai-playbook/code-map.md`
|
||||||
|
- `docs/ai-playbook/flows.md`
|
||||||
|
|
||||||
|
## 3. Language & Comments
|
||||||
|
|
||||||
|
- Communication: always use Simplified Chinese when communicating with the project owner/maintainers.
|
||||||
|
- Code comments, exported symbol naming, commit messages, and PR titles/descriptions must be in English.
|
||||||
|
- User/marketing docs may be bilingual; this collaboration guide is maintained in both languages.
|
||||||
|
- Use TSDoc/JSDoc (English) for exported functions, complex flows, and shared types to keep APIs readable.
|
||||||
|
|
||||||
|
## 4. Next.js (Frontend) Conventions
|
||||||
|
|
||||||
|
- App Router defaults to Server Components; use `"use client"` only when interaction truly requires it.
|
||||||
|
- Reuse existing UI (Tailwind + shadcn/ui + Radix); do not introduce new UI libraries without approval.
|
||||||
|
- i18n: all visible copy must go through dictionaries and the `frontend/app/[lang]` routes; do not hardcode strings in components.
|
||||||
|
- Naming & files:
|
||||||
|
- Components: PascalCase file names and exports (ExampleCard.tsx)
|
||||||
|
- Hooks: camelCase file names; exports start with use* (useSomething.ts)
|
||||||
|
- Centralize types/constants; avoid circular deps
|
||||||
|
- SEO: use Next Metadata and `frontend/components/seo/JsonLd.tsx`; pages must include canonical and multilingual links.
|
||||||
|
- Performance & a11y: dynamically import heavy components when needed; ensure basic aria/focus behavior.
|
||||||
|
|
||||||
|
## 5. TypeScript & Style
|
||||||
|
|
||||||
|
- Keep types strict; avoid any. Use unknown when needed and narrow explicitly; exported functions should have explicit return types.
|
||||||
|
- Follow existing ESLint/Prettier and path aliases (`@/...`); do not introduce new formatters.
|
||||||
|
- Keep functions small and clear; move complex logic into service/util layers. Components should consume state, not mutate global state.
|
||||||
|
- Avoid 1-letter variable names; avoid magic numbers—centralize them as constants.
|
||||||
|
|
||||||
|
## 6. WebRTC / Transfer Guardrails (Do Not Break)
|
||||||
|
|
||||||
|
- Keep established strategy: 32MB batches + 64KB network chunks; do not casually change DataChannel bufferedAmountLowThreshold or maxBuffer strategy.
|
||||||
|
- Resume, strict sequential disk writes, and multi-format compatibility are baseline capabilities—do not downgrade/remove them.
|
||||||
|
- Signaling and message names (offer/answer/ice-candidate, etc.) must stay compatible; any breaking change must follow the “must ask” process.
|
||||||
|
- Reconnect and queue handling (ICE candidate caching, backpressure, send retries) must remain consistent; changes require risk assessment and thorough validation.
|
||||||
|
- Never send file contents (in any form) to the backend or third-party services.
|
||||||
|
|
||||||
|
## 7. Backend Constraints (Signaling Service)
|
||||||
|
|
||||||
|
- Signaling + room management only. Never persist user file data; logs must not include sensitive content or raw payloads.
|
||||||
|
- Keep rate limiting and abuse protection; if extending APIs, ensure backward compatibility or provide a migration path.
|
||||||
|
|
||||||
|
## 8. Dependencies & Security
|
||||||
|
|
||||||
|
- New dependencies require justification in the plan: size (ESM/SSR compatibility), maintenance health, license, alternatives, security impact.
|
||||||
|
- Do not add telemetry/tracking; do not log sensitive data; follow least privilege.
|
||||||
|
- Inject config via env vars; never hardcode secrets or service endpoints in the repo.
|
||||||
|
|
||||||
|
## 9. Documentation Sync
|
||||||
|
|
||||||
|
- If code changes affect flows, interfaces, or key entry points, update in the same PR:
|
||||||
|
- `docs/ai-playbook/flows.zh-CN.md`
|
||||||
|
- `docs/ai-playbook/code-map.zh-CN.md`
|
||||||
|
- PRs must list “docs impacted” to avoid the playbook going stale; keep the index page lean and only update it when adding new links.
|
||||||
|
|
||||||
|
## 10. Validation & Regression
|
||||||
|
|
||||||
|
- Frontend: must build (`next build`); include manual verification for key paths (at least: create/join room, single/multi file, folder, large files, resume, cross-browser transfers, i18n routes + SEO metadata).
|
||||||
|
- Backend: Socket.IO core flow works.
|
||||||
|
- Regression checklist: reconnect flow, download counts + state cleanup, store single-source-of-truth constraint, browser compatibility (Chromium/Firefox).
|
||||||
|
|
||||||
|
## 11. Must Ask First (Approval Required)
|
||||||
|
|
||||||
|
- Breaking changes to protocols/message names/public APIs/storage formats.
|
||||||
|
- Architecture changes impacting privacy stance or crossing boundaries (any relay or persistence).
|
||||||
|
- New dependencies, new infrastructure, or large refactors.
|
||||||
|
- Changes to transfer guardrail parameters (chunking, backpressure, retries, etc.).
|
||||||
|
|
||||||
|
## 12. Common Pitfalls
|
||||||
|
|
||||||
|
- Mutating global state from inside components (breaks one-way dataflow).
|
||||||
|
- Changing code without updating docs, leaving the playbook stale.
|
||||||
|
- Using any to bypass type and boundary checks.
|
||||||
|
- Hardcoding UI copy in components instead of using dictionaries/i18n.
|
||||||
|
- Tweaking critical WebRTC parameters without validation, causing silent regressions.
|
||||||
|
|
||||||
|
## 13. Templates
|
||||||
|
|
||||||
|
Change Plan Template
|
||||||
|
|
||||||
|
```
|
||||||
|
Title: <concise title>
|
||||||
|
|
||||||
|
Goals
|
||||||
|
- <what you intend to achieve>
|
||||||
|
|
||||||
|
Scope / Files
|
||||||
|
- <files to change/add + why>
|
||||||
|
|
||||||
|
Approach
|
||||||
|
- <implementation approach and key design points>
|
||||||
|
|
||||||
|
Risks & Mitigations
|
||||||
|
- <major risk> -> <mitigation>
|
||||||
|
|
||||||
|
Acceptance Criteria
|
||||||
|
- <verifiable acceptance items>
|
||||||
|
|
||||||
|
Rollback
|
||||||
|
- <how to roll back quickly>
|
||||||
|
|
||||||
|
Docs to Update
|
||||||
|
- code-map.zh-CN.md / flows.zh-CN.md / README(.zh-CN).md / others?
|
||||||
|
|
||||||
|
Validation
|
||||||
|
- Build: next build / backend health
|
||||||
|
- Manual: <key scenarios>
|
||||||
|
```
|
||||||
|
|
||||||
|
PR Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Single-topic change only
|
||||||
|
- [ ] Code comments and commit messages are in English
|
||||||
|
- [ ] No unapproved dependencies/UI libraries added
|
||||||
|
- [ ] i18n and SEO follow conventions (if applicable)
|
||||||
|
- [ ] Transfer guardrails are unchanged (or approved + validated)
|
||||||
|
- [ ] flows / code-map docs are updated in sync
|
||||||
|
- [ ] Validation notes and regression checklist included
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. References & Quick Entry Points
|
||||||
|
|
||||||
|
- Index & context: `docs/ai-playbook/index.md`
|
||||||
|
- Code map: `docs/ai-playbook/code-map.md`
|
||||||
|
- Key flows: `docs/ai-playbook/flows.md`
|
||||||
|
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# PrivyDrop AI Playbook — 协作规则(中文)
|
||||||
|
|
||||||
|
本规则面向“人类开发者 + AI 助手”的协作,确保在保持隐私立场与技术基线的前提下,高效而可控地演进代码。与目录索引(index.zh-CN.md)和流程地图(flows.zh-CN.md)互补:它约束“如何做”,不复述“做什么”。
|
||||||
|
|
||||||
|
- 适用范围:本仓库全部代码与文档
|
||||||
|
- 读者对象:人类开发者、AI 助手、评审者
|
||||||
|
- 变更原则:最佳实践优先、一次只解决一类问题、可回滚、可验证
|
||||||
|
|
||||||
|
## 一、协作原则
|
||||||
|
|
||||||
|
- Best Practices 优先:选用经过验证且与现有栈一致的方案,避免“自造轮子”。
|
||||||
|
- 单一主题:每个变更聚焦一个目标,避免“顺手修复”无关问题。
|
||||||
|
- 隐私立场:严禁引入服务器中转文件数据的实现或建议;后端仅做信令与房间协调。
|
||||||
|
- 小步快跑:小 PR、易回滚,优先最小可行改动。
|
||||||
|
- 可追溯:提交信息、PR 描述、代码注释清晰、可复现。
|
||||||
|
|
||||||
|
## 二、计划先行(强约束)
|
||||||
|
|
||||||
|
任何实现前,必须先提交“变更计划”,经同意后再实施。计划应包含:目标、影响范围与文件列表、方案概述、风险与缓解、验收标准、回滚策略、需更新的文档、验证方式。
|
||||||
|
|
||||||
|
推荐模板见“模板”章节。实施前需阅读并引用:
|
||||||
|
|
||||||
|
- docs/ai-playbook/index.zh-CN.md
|
||||||
|
- docs/ai-playbook/code-map.zh-CN.md
|
||||||
|
- docs/ai-playbook/flows.zh-CN.md
|
||||||
|
|
||||||
|
## 三、语言与注释
|
||||||
|
|
||||||
|
- 沟通语言:与项目负责人沟通一律使用中文(简体)。
|
||||||
|
- 代码注释、导出符号命名、提交信息、PR 标题/描述一律英文。
|
||||||
|
- 用户/市场文档可中英双语;本协作规则为中文。
|
||||||
|
- 导出函数、复杂流程、公共类型使用 TSDoc/JSDoc(英文),保证 API 可读性。
|
||||||
|
|
||||||
|
## 四、Next.js(前端)约定
|
||||||
|
|
||||||
|
- App Router 默认 Server Components;仅在确需交互时使用 "use client"。
|
||||||
|
- 复用现有 UI(Tailwind + shadcn/ui + Radix);未经批准不引入新组件库。
|
||||||
|
- i18n:所有可见文案走字典与 `frontend/app/[lang]` 路由,不在组件内硬编码。
|
||||||
|
- 命名与文件:
|
||||||
|
- 组件:PascalCase 文件与导出(ExampleCard.tsx)
|
||||||
|
- Hooks:camelCase 文件,导出以 use\* 开头(useSomething.ts)
|
||||||
|
- 类型/常量集中维护,避免循环依赖
|
||||||
|
- SEO:使用 Next Metadata 与 `frontend/components/seo/JsonLd.tsx`;页面需补 canonical、多语言链接。
|
||||||
|
- 性能与可访问性:按需动态导入重组件;确保 aria/焦点管理基本可用。
|
||||||
|
|
||||||
|
## 五、TypeScript 与风格
|
||||||
|
|
||||||
|
- 类型严格,避免 any;必要时用 unknown 并显式收窄;导出函数显式返回类型。
|
||||||
|
- 遵循现有 ESLint/Prettier 与路径别名(`@/...`);不引入新格式化器。
|
||||||
|
- 函数小而清晰;复杂逻辑下沉到 service/util,组件只消费不变更状态。
|
||||||
|
- 不使用一字母变量名;避免魔法数,集中到常量。
|
||||||
|
|
||||||
|
## 六、WebRTC/传输“护栏”(不得突破)
|
||||||
|
|
||||||
|
- 保持既定策略:32MB 批次 + 64KB 网络块;DataChannel bufferedAmountLowThreshold 与 maxBuffer 策略不随意更改。
|
||||||
|
- 断点续传、严格顺序写入、多格式兼容是默认能力,禁止降级或移除。
|
||||||
|
- 信令与消息名(offer/answer/ice-candidate 等)保持兼容;如需破坏性变更,必须走“必须请示”流程。
|
||||||
|
- 重连与队列处理(ICE 候选缓存、背压、发送重试)策略保持一致,变更需风险评估与充分验证。
|
||||||
|
- 严禁将文件内容(任何形式)发往服务器或第三方服务。
|
||||||
|
|
||||||
|
## 七、后端约束(信令服务)
|
||||||
|
|
||||||
|
- 仅负责信令与房间管理;不落地用户文件数据;日志中不得包含敏感内容或原始 payload。
|
||||||
|
- 速率与滥用防护保留;如需扩展接口,必须保证向后兼容或提供迁移策略。
|
||||||
|
|
||||||
|
## 八、依赖与安全
|
||||||
|
|
||||||
|
- 新依赖需在计划中论证:体积(含 ESM/SSR 兼容性)、维护健康度、许可、替代方案、安全影响。
|
||||||
|
- 不引入遥测/埋点;不将敏感数据写入日志;最小权限原则。
|
||||||
|
- 配置通过环境变量注入;严禁在仓库中硬编码密钥或服务端点。
|
||||||
|
|
||||||
|
## 九、文档同步更新
|
||||||
|
|
||||||
|
- 代码改动若影响流程、接口或关键文件入口,须同步更新:
|
||||||
|
- docs/ai-playbook/flows.zh-CN.md
|
||||||
|
- docs/ai-playbook/code-map.zh-CN.md
|
||||||
|
- PR 必须列出“受影响文档”,避免 AI Playbook 过时;索引页(index.zh-CN.md)保持简洁,仅新增链接时更新。
|
||||||
|
|
||||||
|
## 十、验证与回归
|
||||||
|
|
||||||
|
- 前端:能构建通过(next build);关键路径手测说明(至少:创建/加入房间、单/多文件、文件夹、大文件、断点续传、双浏览器互传、i18n 路由与 SEO 元数据)。
|
||||||
|
- 后端:Socket.IO 基本流程可用。
|
||||||
|
- 回归清单:重连流程、下载计数与状态清理、Store 单一数据源约束、浏览器兼容(Chromium/Firefox)。
|
||||||
|
|
||||||
|
## 十一、必须请示(需先获批)
|
||||||
|
|
||||||
|
- 协议/消息名/公共 API/存储格式的破坏性变更。
|
||||||
|
- 影响隐私立场或跨边界的架构调整(如任何形式的中转或持久化)。
|
||||||
|
- 引入新依赖、新基础设施或大规模重构。
|
||||||
|
- 修改传输“护栏”参数(分片、背压、重试等)。
|
||||||
|
|
||||||
|
## 十二、常见误区
|
||||||
|
|
||||||
|
- 组件内直改全局状态(违背单向数据流)。
|
||||||
|
- 只改代码不更文档,导致 Playbook 过期。
|
||||||
|
- 使用 any 绕过类型与边界检查。
|
||||||
|
- 将 UI 文案硬编码在组件内,绕过字典/i18n。
|
||||||
|
- 擅自调整 WebRTC 关键参数,导致隐性性能回退或兼容性问题。
|
||||||
|
|
||||||
|
## 十三、模板
|
||||||
|
|
||||||
|
变更计划模板
|
||||||
|
|
||||||
|
```
|
||||||
|
Title: <简明标题>
|
||||||
|
|
||||||
|
Goals
|
||||||
|
- <预期达成的目标>
|
||||||
|
|
||||||
|
Scope / Files
|
||||||
|
- <将修改与新增的文件路径清单 + 原因>
|
||||||
|
|
||||||
|
Approach
|
||||||
|
- <实现思路与关键设计点>
|
||||||
|
|
||||||
|
Risks & Mitigations
|
||||||
|
- <主要风险> → <缓解策略>
|
||||||
|
|
||||||
|
Acceptance Criteria
|
||||||
|
- <可验证的验收项>
|
||||||
|
|
||||||
|
Rollback
|
||||||
|
- <如何快速回滚>
|
||||||
|
|
||||||
|
Docs to Update
|
||||||
|
- code-map.zh-CN.md / flows.zh-CN.md / README(.zh-CN).md / others?
|
||||||
|
|
||||||
|
Validation
|
||||||
|
- Build: next build / backend health
|
||||||
|
- Manual: <列出关键用例>
|
||||||
|
```
|
||||||
|
|
||||||
|
PR 校验清单
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] 仅包含单一主题改动
|
||||||
|
- [ ] 代码注释与提交信息为英文
|
||||||
|
- [ ] 未引入未批准的依赖/组件库
|
||||||
|
- [ ] i18n 与 SEO 按约定接入(如适用)
|
||||||
|
- [ ] 传输护栏未被破坏(或已获批且有验证)
|
||||||
|
- [ ] flows / code-map 文档已同步
|
||||||
|
- [ ] 附带验证说明与回归清单
|
||||||
|
```
|
||||||
|
|
||||||
|
## 十四、引用与快速入口
|
||||||
|
|
||||||
|
- 索引与上下文:docs/ai-playbook/index.zh-CN.md
|
||||||
|
- 代码地图:docs/ai-playbook/code-map.zh-CN.md
|
||||||
|
- 关键流程:docs/ai-playbook/flows.zh-CN.md
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# PrivyDrop AI Playbook — Flows (with Micro-Plan Template)
|
||||||
|
|
||||||
|
This page summarizes the core P2P transfer flows and signaling/reconnection sequences, plus practical debugging notes and a compact “micro-plan template”. Use it to align on phases, events, and entry points before making changes.
|
||||||
|
|
||||||
|
## Quick Navigation
|
||||||
|
|
||||||
|
- Fast path: this page contains 1–5 (key flows/messages/debug notes) and 11 (micro-plan template).
|
||||||
|
- Deep dives (split out from this page):
|
||||||
|
- Frontend component collaboration (was Section 6): [`docs/ai-playbook/flows/frontend.md`](./flows/frontend.md)
|
||||||
|
- Backpressure & chunking (was Section 7): [`docs/ai-playbook/flows/backpressure-chunking.md`](./flows/backpressure-chunking.md)
|
||||||
|
- Resume / partial transfer (was Section 9): [`docs/ai-playbook/flows/resume.md`](./flows/resume.md)
|
||||||
|
- Reconnect consistency (was Section 10): [`docs/ai-playbook/flows/reconnect-consistency.md`](./flows/reconnect-consistency.md)
|
||||||
|
|
||||||
|
## 1) File Transfer (Single File)
|
||||||
|
|
||||||
|
Sequence (via DataChannel, sender ↔ receiver):
|
||||||
|
|
||||||
|
1. Sender → `fileMetadata` (id, name, size, type, fullName, folderName).
|
||||||
|
2. Receiver → `fileRequest` (ack metadata; supports offset-based resume).
|
||||||
|
3. Sender → chunk stream (high-performance, dual-layer buffering):
|
||||||
|
- StreamingFileReader reads in 32MB batches and sends 64KB network chunks
|
||||||
|
- NetworkTransmitter uses native WebRTC backpressure (bufferedAmountLowThreshold)
|
||||||
|
- Each send embeds metadata (chunkIndex, totalChunks, fileOffset, fileId)
|
||||||
|
4. Receiver → integrity checks & assembly (strict sequential disk writes or in-memory assembly; supports resume).
|
||||||
|
5. Receiver → `fileReceiveComplete` (success receipt, includes receivedSize).
|
||||||
|
6. Sender → MessageHandler fires 100% progress callback and clears sending state.
|
||||||
|
|
||||||
|
Sender-side detailed flow:
|
||||||
|
|
||||||
|
1. FileTransferOrchestrator.sendFileMeta() → StateManager records folder/file sizes
|
||||||
|
2. Receive `fileRequest` → FileTransferOrchestrator.handleFileRequest()
|
||||||
|
3. Initialize StreamingFileReader (supports startOffset resume)
|
||||||
|
4. processSendQueue() loop:
|
||||||
|
- getNextNetworkChunk() returns the next 64KB chunk (efficient slicing within a batch)
|
||||||
|
- NetworkTransmitter.sendEmbeddedChunk() sends with backpressure control
|
||||||
|
- ProgressTracker.updateFileProgress() updates progress and speed
|
||||||
|
5. Wait for `fileReceiveComplete`, then clear isSending state
|
||||||
|
|
||||||
|
Entry points:
|
||||||
|
|
||||||
|
- Sender: `frontend/lib/fileSender.ts` (compat wrapper) → `frontend/lib/transfer/FileTransferOrchestrator.ts` (main orchestrator)
|
||||||
|
- Key components: StreamingFileReader (fast reads), NetworkTransmitter (backpressure sending), StateManager (state), ProgressTracker (progress)
|
||||||
|
|
||||||
|
Receiver-side detailed flow:
|
||||||
|
|
||||||
|
1. MessageProcessor.handleFileMetadata() → ReceptionStateManager stores file metadata
|
||||||
|
2. FileReceiveOrchestrator.requestFile() → check resume (getPartialFileSize)
|
||||||
|
3. Initialize reception: compute expected chunk count; choose storage mode (memory vs disk) based on size
|
||||||
|
4. Send `fileRequest` (with offset if needed) → wait for sender to start
|
||||||
|
5. handleBinaryChunkData() loop:
|
||||||
|
- ChunkProcessor.convertToArrayBuffer() handles multiple payload types (Blob/Uint8Array/ArrayBuffer)
|
||||||
|
- ChunkProcessor.parseEmbeddedChunkPacket() parses the “embedded metadata” packet format
|
||||||
|
- ChunkProcessor.validateChunk() validates fileId, chunkIndex, chunkSize
|
||||||
|
- Store chunks in an array (or write sequentially via SequencedDiskWriter)
|
||||||
|
- ProgressReporter.updateFileProgress() throttles progress updates (100ms)
|
||||||
|
6. Auto completion detection: checkAndAutoFinalize() validates completeness
|
||||||
|
7. Finalize based on storage mode:
|
||||||
|
- Large/disk: StreamingFileWriter.finalizeWrite()
|
||||||
|
- Small/memory: FileAssembler.assembleFileFromChunks()
|
||||||
|
8. Send `fileReceiveComplete` with receivedSize and receivedChunks
|
||||||
|
|
||||||
|
Entry points:
|
||||||
|
|
||||||
|
- Sender: `frontend/lib/fileSender.ts` (compat wrapper) → `frontend/lib/transfer/FileTransferOrchestrator.ts` (main orchestrator)
|
||||||
|
- Receiver: `frontend/lib/fileReceiver.ts` (compat wrapper) → `frontend/lib/receive/FileReceiveOrchestrator.ts` (main orchestrator)
|
||||||
|
- Key components: StreamingFileReader (fast reads), NetworkTransmitter (backpressure), ChunkProcessor (format handling), StreamingFileWriter (disk writes), FileAssembler (memory assembly)
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Sender**: dual-layer buffering (32MB batches + 64KB network chunks), native WebRTC backpressure, resume support
|
||||||
|
- **Receiver**: strict sequential disk writer (SequencedDiskWriter), multi-format conversion, smart storage selection (≥1GB auto disk mode)
|
||||||
|
- **Compatibility**: ChunkProcessor supports Blob/Uint8Array/ArrayBuffer to address Firefox quirks
|
||||||
|
- **Progress throttling**: ProgressReporter updates at different rates (file 100ms, folder 200ms) to avoid UI overload
|
||||||
|
- **Resume**: getPartialFileSize() checks local partial files; fileRequest.offset drives resume
|
||||||
|
- **Debug support**: ReceptionConfig provides verbose chunk/progress logs for investigation
|
||||||
|
|
||||||
|
## 2) File Transfer (Folder)
|
||||||
|
|
||||||
|
Sequence (run the single-file flow for each file):
|
||||||
|
|
||||||
|
1. Sender → send `fileMetadata` for all files in the folder.
|
||||||
|
2. Receiver → `folderRequest` (confirm starting the batch transfer).
|
||||||
|
3. For each file: run the single-file flow, but do not mark global 100% on individual file completion.
|
||||||
|
4. Receiver → after all files finish, send `folderReceiveComplete`.
|
||||||
|
5. Sender → mark folder-level progress as 100% (fire final callback).
|
||||||
|
|
||||||
|
## 3) Signaling & Reconnect (Socket.IO)
|
||||||
|
|
||||||
|
High-level sequence:
|
||||||
|
|
||||||
|
1. Client → REST: create or fetch a `roomId` (`backend/src/routes/api.ts`).
|
||||||
|
2. Client → Socket.IO: `join` the room (backend validates and binds socket ↔ room).
|
||||||
|
3. Online state & reconnection coordination within the room:
|
||||||
|
- Initiator → `initiator-online` (online/ready; tells the peer it can rebuild the connection).
|
||||||
|
- Recipient → `recipient-ready` (ready; initiator may start offer).
|
||||||
|
4. WebRTC negotiation relay:
|
||||||
|
- `offer` → backend → peer.
|
||||||
|
- `answer` → backend → peer.
|
||||||
|
- `ice-candidate` → backend → peer.
|
||||||
|
|
||||||
|
### Join Success Conditions & Timeout Strategy (Frontend Fault-Tolerance)
|
||||||
|
|
||||||
|
- Primary success condition: receive `joinResponse(success=true)` (backend completed validation and socket↔room binding).
|
||||||
|
- Equivalent “success signals” (fault-tolerance; any one means “we’re effectively in” and should clear listeners/timers):
|
||||||
|
- Initiator: receives `ready` or `recipient-ready`
|
||||||
|
- Recipient: receives `offer`
|
||||||
|
- Timeout: 15 seconds. This covers weak networks, mobile, and Socket.IO polling fallback where joinResponse can arrive late.
|
||||||
|
- Why it’s safe: `ready/recipient-ready` are room broadcast events; `offer` is the P2P handshake starting point. If you can receive these, you’re in the room and negotiation has begun—treat it as success to avoid false “Join room timeout” errors.
|
||||||
|
|
||||||
|
Reconnect mechanics (mobile network switching support):
|
||||||
|
|
||||||
|
- **Dual disconnect detection**: Socket.IO `disconnect` → set `isSocketDisconnected = true`; P2P disconnect → set `isPeerDisconnected = true` and call `cleanupExistingConnection()`
|
||||||
|
- **Reconnect trigger**: only when both socket and P2P are disconnected → `attemptReconnection()`, guarded by `reconnectionInProgress` to avoid concurrent reconnects
|
||||||
|
- **State restoration**: reconnect calls `joinRoom(roomId, isInitiator, sendInitiatorOnline)`; the initiator auto-sends `initiator-online` and the recipient replies `recipient-ready`
|
||||||
|
- **ICE candidate queue**: cache candidates in `iceCandidatesQueue` until ready, then flush; support re-queuing invalid candidates with connection-state validation
|
||||||
|
- **Wake lock**: request via WakeLockManager when connected; release on disconnect to stabilize mobile transfers
|
||||||
|
- **Graceful disconnect tracking**: `gracefullyDisconnectedPeers` tracks intentionally closed peers; send retries skip them
|
||||||
|
- **DataChannel send retries**: up to 5 attempts with backoff from 100ms to 1000ms; skip peers marked as gracefully disconnected
|
||||||
|
|
||||||
|
**Backend signaling & room management**:
|
||||||
|
|
||||||
|
Socket.IO event handling flow:
|
||||||
|
|
||||||
|
1. **join**: IP rate limit → validate room existence → bind socket-room → success response → broadcast `ready`
|
||||||
|
2. **Reconnect state sync**: initiator reconnect sends `initiator-online`; recipient replies `recipient-ready`
|
||||||
|
3. **Relay signaling**: offer/answer/ice-candidate are forwarded with `socket.to(peerId).emit()`, including a `from` field
|
||||||
|
4. **Disconnect cleanup**: broadcast `peer-disconnected` → unbind socket-room → delete empty rooms after 15 minutes
|
||||||
|
|
||||||
|
**Room management**:
|
||||||
|
|
||||||
|
- Redis structures:
|
||||||
|
- `room:<roomId>` (Hash): room creation time
|
||||||
|
- `room:<roomId>:sockets` (Set): sockets in the room
|
||||||
|
- `socket:<socketId>` (String): the roomId for a socket
|
||||||
|
- ID generation: prefer 4-digit numeric IDs; fall back to 4-character alphanumeric on collision
|
||||||
|
- Idempotency: long IDs (≥8 chars) can be reused across reconnects
|
||||||
|
- TTL: 24 hours, refreshed on activity
|
||||||
|
|
||||||
|
**Rate limiting**:
|
||||||
|
|
||||||
|
- Redis Sorted Set based IP rate limit
|
||||||
|
- Up to 2 requests per 5-second window
|
||||||
|
- Uses pipeline to keep operations atomic
|
||||||
|
|
||||||
|
Entry points:
|
||||||
|
|
||||||
|
- Frontend: `frontend/hooks/useWebRTCConnection.ts`, `frontend/lib/webrtc_base.ts`, `frontend/lib/webrtc_Initiator.ts`, `frontend/lib/webrtc_Recipient.ts`
|
||||||
|
- Backend: `backend/src/socket/handlers.ts` (all signaling events), `backend/src/services/room.ts`, `backend/src/routes/api.ts`
|
||||||
|
|
||||||
|
## 4) DataChannel Messages & Constraints (Overview)
|
||||||
|
|
||||||
|
- Messages (example names): `fileMetadata`, `fileRequest`, `chunk`, `fileReceiveComplete`, `folderRequest`, `folderReceiveComplete`, plus potential flow-control/keepalive messages.
|
||||||
|
- Core fields: file/folder id, indices/ranges, sizes, names, optional checksums.
|
||||||
|
- Key constraints:
|
||||||
|
- Chunk size: choose a safe range per browser/network; mind channel buffer thresholds.
|
||||||
|
- Backpressure: check `RTCDataChannel.bufferedAmount` and throttle as needed.
|
||||||
|
- Completion: mark 100% only after `fileReceiveComplete` / `folderReceiveComplete`.
|
||||||
|
- Resume: `fileRequest` can carry offset/range to support partial transfers.
|
||||||
|
|
||||||
|
## 5) Debugging Notes (Distilled from Experience)
|
||||||
|
|
||||||
|
- Download races / double counting: treat `frontend/stores/fileTransferStore.ts` as the single source of truth; provide cleanup APIs at the store level (e.g. `clearSendProgress`, `clearReceiveProgress`) rather than deleting objects locally in components.
|
||||||
|
- Recipient reconnect & room state: reset state correctly; UI must strictly derive from the store; leaving/rejoining should clear related state; follow `initiator-online`/`recipient-ready` ordering before starting an offer; verify room membership after reconnect.
|
||||||
|
- Reconnect with cached roomId: if `roomId` is cached, ensure renegotiation is triggered via online state sync (`initiator-online`/`recipient-ready`); backend must correctly clean and restore socket↔room mappings on disconnect/reconnect.
|
||||||
|
- Multiple transfers: don’t over-dedupe and mask real “second downloads”; rely on correct state cleanup.
|
||||||
|
- Dataflow principle: one-way dataflow (Store → Hooks → Components); hooks adapt, components consume but do not mutate shared state.
|
||||||
|
- **Practical debugging tactics**:
|
||||||
|
- Add structured logs for connection state changes and store updates; for timing/race issues, `setTimeout(..., 0)` can help reorder updates
|
||||||
|
- DataChannel send retries: `sendToPeer()` supports 5 retries with backoff from 100ms→1000ms; skip gracefully disconnected peers
|
||||||
|
- WebRTC payload compatibility: handle `ArrayBuffer`/`Blob`/`Uint8Array`/`TypedArray` to cover Firefox quirks
|
||||||
|
- Connection state monitoring: react to `connectionState` changes (connected/disconnected/failed/closed)
|
||||||
|
- Backpressure control: DataChannel uses `bufferedAmountLowThreshold = 256KB`; check `bufferedAmount` before sending
|
||||||
|
- Join false positives: if “Join room timeout” appears but you immediately see `offer/answer/connected` logs, it’s often a late joinResponse rather than a real failure; the 15s window plus “equivalent success signals” corrects it automatically
|
||||||
|
|
||||||
|
## 6) Frontend Component System & Core-Orchestrator Collaboration
|
||||||
|
|
||||||
|
This section is split into: [`docs/ai-playbook/flows/frontend.md`](./flows/frontend.md)
|
||||||
|
|
||||||
|
- Use it for: understanding boundaries and collaboration among UI components, hooks, and the store
|
||||||
|
- Includes: ClipboardApp orchestrator, hook layering, connection feedback state machine, dataflow patterns, etc.
|
||||||
|
|
||||||
|
## 7) Backpressure & Chunking Strategy (Deep Dive)
|
||||||
|
|
||||||
|
This section is split into: [`docs/ai-playbook/flows/backpressure-chunking.md`](./flows/backpressure-chunking.md)
|
||||||
|
|
||||||
|
- Use it for: verifying thresholds, chunk/batch strategy, embedded packet format, and performance tuning points
|
||||||
|
- Includes: sender dual-buffering, receiver storage strategy, debugging/monitoring tips, etc.
|
||||||
|
|
||||||
|
## 9) Resume / Partial Transfer (Deep Dive)
|
||||||
|
|
||||||
|
This section is split into: [`docs/ai-playbook/flows/resume.md`](./flows/resume.md)
|
||||||
|
|
||||||
|
- Use it for: verifying resume detection, offset negotiation, and chunk-range calculations
|
||||||
|
- Includes: ChunkRangeCalculator, sender/receiver resume flows, limitations, and debugging notes
|
||||||
|
|
||||||
|
## 10) Reconnect & State Consistency (Deep Dive)
|
||||||
|
|
||||||
|
This section is split into: [`docs/ai-playbook/flows/reconnect-consistency.md`](./flows/reconnect-consistency.md)
|
||||||
|
|
||||||
|
- Use it for: verifying dual disconnect rules, ICE candidate queues, send retries, and consistency safeguards
|
||||||
|
- Includes: reconnect triggers, retry strategy, mobile-specific additions, debugging notes
|
||||||
|
|
||||||
|
## 11) Micro-Plan Template (for Aligning on Small Changes)
|
||||||
|
|
||||||
|
Title: <short summary>
|
||||||
|
|
||||||
|
Background / Problem
|
||||||
|
|
||||||
|
- What user scenario or defect are we fixing?
|
||||||
|
|
||||||
|
Goals & Non-Goals
|
||||||
|
|
||||||
|
- What’s in scope, and what’s explicitly out of scope?
|
||||||
|
|
||||||
|
Impacted Files & Messages
|
||||||
|
|
||||||
|
- Code: list key files (e.g. `frontend/lib/webrtc_base.ts`, `backend/src/socket/handlers.ts`).
|
||||||
|
- Protocol: list DataChannel messages/fields to be changed.
|
||||||
|
|
||||||
|
State Machine / Flow Changes
|
||||||
|
|
||||||
|
- Add/remove/modify phases; include a short sequence diagram or steps.
|
||||||
|
|
||||||
|
Tests & Regression Checklist
|
||||||
|
|
||||||
|
- Unit/integration (if applicable), manual scenarios, performance/boundaries, reconnect cases.
|
||||||
|
|
||||||
|
Docs to Update
|
||||||
|
|
||||||
|
- `code-map.md` (if new entry points appear)
|
||||||
|
- `flows.md` (if flows/messages/constraints change)
|
||||||
|
- Other architecture or deployment docs (if involved)
|
||||||
|
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
# PrivyDrop AI Playbook — 流程(含微方案模板,中文)
|
||||||
|
|
||||||
|
本文汇总 P2P 传输与信令重连的关键流程与消息序列,并给出简明的调试要点与“微方案模板”。用于在改动前快速对齐阶段、事件与入口文件。
|
||||||
|
|
||||||
|
## 快速导航
|
||||||
|
|
||||||
|
- 速查:本页包含 1–5(关键流程/消息/调试要点)与 11(微方案模板)。
|
||||||
|
- 深度阅读(已从本页拆分):
|
||||||
|
- 前端组件协作(原第 6 节):[`docs/ai-playbook/flows/frontend.zh-CN.md`](./flows/frontend.zh-CN.md)
|
||||||
|
- 背压与分片(原第 7 节):[`docs/ai-playbook/flows/backpressure-chunking.zh-CN.md`](./flows/backpressure-chunking.zh-CN.md)
|
||||||
|
- 断点续传(原第 9 节):[`docs/ai-playbook/flows/resume.zh-CN.md`](./flows/resume.zh-CN.md)
|
||||||
|
- 重连一致性(原第 10 节):[`docs/ai-playbook/flows/reconnect-consistency.zh-CN.md`](./flows/reconnect-consistency.zh-CN.md)
|
||||||
|
|
||||||
|
## 1)文件传输(单文件)
|
||||||
|
|
||||||
|
序列(通过 DataChannel,发送端 ↔ 接收端):
|
||||||
|
|
||||||
|
1. 发送端 → `fileMetadata`(id、name、size、type、fullName、folderName)。
|
||||||
|
2. 接收端 → `fileRequest`(确认元信息;支持 offset 续传)。
|
||||||
|
3. 发送端 → 分片流(高性能双层缓冲架构):
|
||||||
|
- StreamingFileReader 使用 32MB 批次读取 + 64KB 网络块发送
|
||||||
|
- NetworkTransmitter 使用 WebRTC 原生背压控制(bufferedAmountLowThreshold)
|
||||||
|
- 发送时嵌入元数据(chunkIndex、totalChunks、fileOffset、fileId)
|
||||||
|
4. 接收端 → 完整性检查与组装(严格顺序写入或内存组装,支持断点续传)。
|
||||||
|
5. 接收端 → `fileReceiveComplete`(成功回执,包含 receivedSize)。
|
||||||
|
6. 发送端 → MessageHandler 触发 100% 进度回调,清理发送状态。
|
||||||
|
|
||||||
|
发送侧详细流程:
|
||||||
|
|
||||||
|
1. FileTransferOrchestrator.sendFileMeta() → StateManager 记录文件夹文件大小
|
||||||
|
2. 接收 fileRequest → FileTransferOrchestrator.handleFileRequest()
|
||||||
|
3. 初始化 StreamingFileReader(支持 startOffset 续传)
|
||||||
|
4. processSendQueue() 循环:
|
||||||
|
- getNextNetworkChunk() 获取 64KB 块(批次内高效切片)
|
||||||
|
- NetworkTransmitter.sendEmbeddedChunk() 背压控制发送
|
||||||
|
- ProgressTracker.updateFileProgress() 更新进度和速度
|
||||||
|
5. 等待 fileReceiveComplete 确认,清理 isSending 状态
|
||||||
|
|
||||||
|
入口:
|
||||||
|
|
||||||
|
- 发送侧:`frontend/lib/fileSender.ts`(兼容层)→ `frontend/lib/transfer/FileTransferOrchestrator.ts`(主编排器)
|
||||||
|
- 关键组件:StreamingFileReader(高性能读取)、NetworkTransmitter(背压发送)、StateManager(状态管理)、ProgressTracker(进度计算)
|
||||||
|
|
||||||
|
接收侧详细流程:
|
||||||
|
|
||||||
|
1. MessageProcessor.handleFileMetadata() → ReceptionStateManager 记录文件元数据
|
||||||
|
2. FileReceiveOrchestrator.requestFile() → 检查断点续传(getPartialFileSize)
|
||||||
|
3. 初始化接收:计算期望分片数,根据文件大小选择存储方式(内存 vs 磁盘)
|
||||||
|
4. 发送 fileRequest(带 offset 参数)→ 等待发送端开始传输
|
||||||
|
5. handleBinaryChunkData() 循环:
|
||||||
|
- ChunkProcessor.convertToArrayBuffer() 处理多种数据格式(Blob/Uint8Array/ArrayBuffer)
|
||||||
|
- ChunkProcessor.parseEmbeddedChunkPacket() 解析嵌入元数据包格式
|
||||||
|
- ChunkProcessor.validateChunk() 验证 fileId、chunkIndex、chunkSize
|
||||||
|
- 存储分片到 chunks 数组(或通过 SequencedDiskWriter 顺序写入磁盘)
|
||||||
|
- ProgressReporter.updateFileProgress() 节流更新进度(100ms 间隔)
|
||||||
|
6. 自动完成检测:checkAndAutoFinalize() 验证分片完整性
|
||||||
|
7. 根据存储方式选择最终化:
|
||||||
|
- 大文件/磁盘存储:StreamingFileWriter.finalizeWrite()
|
||||||
|
- 小文件/内存存储:FileAssembler.assembleFileFromChunks()
|
||||||
|
8. 发送 fileReceiveComplete 确认,包含 receivedSize 和 receivedChunks
|
||||||
|
|
||||||
|
入口:
|
||||||
|
|
||||||
|
- 发送侧:`frontend/lib/fileSender.ts`(兼容层)→ `frontend/lib/transfer/FileTransferOrchestrator.ts`(主编排器)
|
||||||
|
- 接收侧:`frontend/lib/fileReceiver.ts`(兼容层)→ `frontend/lib/receive/FileReceiveOrchestrator.ts`(主编排器)
|
||||||
|
- 关键组件:StreamingFileReader(高性能读取)、NetworkTransmitter(背压发送)、ChunkProcessor(格式处理)、StreamingFileWriter(磁盘写入)、FileAssembler(内存组装)
|
||||||
|
|
||||||
|
备注:
|
||||||
|
|
||||||
|
- **发送侧**:双层缓冲架构(32MB 批次+64KB 网络块),WebRTC 原生背压控制,支持断点续传
|
||||||
|
- **接收侧**:严格顺序写入机制(SequencedDiskWriter),支持多种数据格式转换,智能存储选择(≥1GB 文件自动磁盘存储)
|
||||||
|
- **兼容性处理**:ChunkProcessor 支持 Blob/Uint8Array/ArrayBuffer 多种格式,解决 Firefox 兼容性问题
|
||||||
|
- **进度节流**:ProgressReporter 使用不同频率更新(文件 100ms,文件夹 200ms),避免 UI 过载
|
||||||
|
- **断点续传**:通过 getPartialFileSize() 检查本地部分文件,fileRequest.offset 参数指定续传位置
|
||||||
|
- **调试支持**:ReceptionConfig 提供详细的分片日志和进度日志,便于问题排查
|
||||||
|
|
||||||
|
## 2)文件传输(文件夹)
|
||||||
|
|
||||||
|
序列(对文件逐个进行单文件流程):
|
||||||
|
|
||||||
|
1. 发送端 → 发送文件夹内全部文件的 `fileMetadata`。
|
||||||
|
2. 接收端 → `folderRequest`(确认开始批量传输)。
|
||||||
|
3. 每个文件:按“单文件流程”执行,但单个文件完成时不标记全局 100%。
|
||||||
|
4. 接收端 → 所有文件完成后发送 `folderReceiveComplete`。
|
||||||
|
5. 发送端 → 将文件夹整体进度标记为 100%(触发最终回调)。
|
||||||
|
|
||||||
|
## 3)信令与重连(Socket.IO)
|
||||||
|
|
||||||
|
高层序列:
|
||||||
|
|
||||||
|
1. 客户端 → REST:创建或获取 `roomId`(`backend/src/routes/api.ts`)。
|
||||||
|
2. 客户端 → Socket.IO:`join` 房间(后端校验并绑定 socket 到房间)。
|
||||||
|
3. 在房间内进行在线状态与重连协作:
|
||||||
|
- 发起方 → `initiator-online`(上线/就绪,通知对端可重建连接)。
|
||||||
|
- 接收方 → `recipient-ready`(表示就绪;发起方可发起 offer)。
|
||||||
|
4. WebRTC 协商转发:
|
||||||
|
- `offer` → 后端 → 转发给对端。
|
||||||
|
- `answer` → 后端 → 转发给对端。
|
||||||
|
- `ice-candidate` → 后端 → 转发给对端。
|
||||||
|
|
||||||
|
### Join 成功条件与超时策略(前端容错增强)
|
||||||
|
|
||||||
|
- 首选成功条件:收到 `joinResponse(success=true)`(后端完成房间校验与 socket↔room 绑定)。
|
||||||
|
- 等效成功信号(容错,任一满足即判定已入房并清理监听/定时器):
|
||||||
|
- 发起方(Initiator):收到 `ready` 或 `recipient-ready`;
|
||||||
|
- 接收方(Recipient):收到 `offer`。
|
||||||
|
- 超时时间:15 秒。用于兼容弱网、移动端与 Socket.IO 轮询降级造成的 joinResponse 迟到。
|
||||||
|
- 说明:`ready/recipient-ready` 为房间广播事件,`offer` 为点对点握手起点;能收到这些事件即表明“已在房间且握手已开始”,可视为等效成功,避免“Join room timeout”的误报。
|
||||||
|
|
||||||
|
重连机制细节(移动端网络切换支持):
|
||||||
|
|
||||||
|
- **双重断开检测**:Socket.IO 断开触发 `disconnect` 事件 → 标记 `isSocketDisconnected = true`;P2P 连接断开触发 `disconnected` 状态 → 标记 `isPeerDisconnected = true`,自动调用 `cleanupExistingConnection()` 清理资源
|
||||||
|
- **重连触发条件**:仅当 socket 和 P2P 都断开时才启动重连 → `attemptReconnection()`,防止重复重连;使用 `reconnectionInProgress` 标志防止并发重连
|
||||||
|
- **状态恢复机制**:重连时调用 `joinRoom(roomId, isInitiator, sendInitiatorOnline)` 恢复状态,发送方自动发送 `initiator-online` 信号,接收方响应 `recipient-ready`
|
||||||
|
- **ICE 候选者队列机制**:连接未就绪时缓存候选者到 `iceCandidatesQueue` Map,连接就绪后批量处理;支持候选者失效时的重新入队和连接状态验证
|
||||||
|
- **唤醒锁管理**:连接建立时通过 `WakeLockManager` 请求屏幕唤醒锁,连接断开时释放,优化移动端传输稳定性
|
||||||
|
- **优雅断开跟踪**:`gracefullyDisconnectedPeers` Set 跟踪正常断开的 peer,发送重试时跳过这些 peer,避免不必要的重试
|
||||||
|
- **数据通道发送重试**:5 次重试机制,间隔从 100ms 递增到 1000ms,支持 `gracefullyDisconnectedPeers` 检测跳过重试
|
||||||
|
|
||||||
|
**后端信令与房间管理**:
|
||||||
|
|
||||||
|
Socket.IO 事件处理流程:
|
||||||
|
|
||||||
|
1. **join 事件**:IP 限流检查 → 房间存在性验证 → socket-room 绑定 → 成功响应 → 广播 `ready` 通知新用户加入
|
||||||
|
2. **重连状态同步**:发送方重连时发送 `initiator-online` 信号,接收方响应 `recipient-ready` 确认就绪状态
|
||||||
|
3. **信令转发**:offer/answer/ice-candidate 直接通过 `socket.to(peerId).emit()` 转发给目标客户端,包含 from 字段标识发送者
|
||||||
|
4. **断开清理**:广播 `peer-disconnected` → 解绑 socket-room 关系 → 空房间 15 分钟后删除
|
||||||
|
|
||||||
|
**房间管理机制**:
|
||||||
|
|
||||||
|
- Redis 数据结构:
|
||||||
|
- `room:<roomId>` (Hash): 存储房间创建时间
|
||||||
|
- `room:<roomId>:sockets` (Set): 管理房间内 socket 连接
|
||||||
|
- `socket:<socketId>` (String): 存储 socket 对应的 roomId
|
||||||
|
- ID 生成策略:优先 4 位数字 ID,冲突时切换到 4 位字母数字 ID
|
||||||
|
- 幂等设计:长 ID(≥8 字符)支持重连时的房间复用
|
||||||
|
- TTL 管理:24 小时过期,活动时自动刷新
|
||||||
|
|
||||||
|
**限流保护**:
|
||||||
|
|
||||||
|
- 基于 Redis Sorted Set 实现 IP 限流
|
||||||
|
- 5 秒时间窗口最多允许 2 次请求
|
||||||
|
- 使用 pipeline 确保原子性操作
|
||||||
|
|
||||||
|
入口:
|
||||||
|
|
||||||
|
- 前端:`frontend/hooks/useWebRTCConnection.ts`、`frontend/lib/webrtc_base.ts`、`frontend/lib/webrtc_Initiator.ts`、`frontend/lib/webrtc_Recipient.ts`。
|
||||||
|
- 后端:`backend/src/socket/handlers.ts`(全部信令事件)、`backend/src/services/room.ts`、`backend/src/routes/api.ts`。
|
||||||
|
|
||||||
|
## 4)DataChannel 消息与约束(概览)
|
||||||
|
|
||||||
|
- 消息(示例命名):`fileMetadata`、`fileRequest`、`chunk`、`fileReceiveComplete`、`folderRequest`、`folderReceiveComplete`,以及可能的流控/保活。
|
||||||
|
- 核心字段:文件/文件夹 id、索引/范围、大小、名称、可选校验信息。
|
||||||
|
- 关键约束:
|
||||||
|
- 分片大小:按浏览器/网络选择安全范围;注意通道缓冲阈值。
|
||||||
|
- 背压:检查 `RTCDataChannel.bufferedAmount` 并按需节流。
|
||||||
|
- 完成:仅在收到 `fileReceiveComplete`/`folderReceiveComplete` 后标记 100%。
|
||||||
|
- 续传:`fileRequest` 可设计为带 offset/range 以支持续传。
|
||||||
|
|
||||||
|
## 5)调试要点(凝练自历史经验)
|
||||||
|
|
||||||
|
- 下载竞争/重复计数:以 `frontend/stores/fileTransferStore.ts` 为单一事实来源;在 Store 层提供清理 API(如 `clearSendProgress`、`clearReceiveProgress`),避免组件本地删除对象导致重复计数。
|
||||||
|
- 接收方重连与房间状态:正确的状态重置;UI 严格来源于 Store;离开/重进需清理相关状态;遵循 `initiator-online`/`recipient-ready` 的时序再发起 offer;重连后校验房间成员关系。
|
||||||
|
- 缓存 roomId 的重连:若存在缓存 `roomId`,确保依赖在线状态同步(`initiator-online`/`recipient-ready`)触发重新协商;后端需保证 socket↔room 映射在断开/重连路径上被正确清理与恢复。
|
||||||
|
- 多次传输计数:避免过度“去重”掩盖真实的二次下载;依赖正确的状态清理。
|
||||||
|
- 数据流原则:单向数据流(Store → Hooks → Components);Hooks 做适配,组件只消费不修改。
|
||||||
|
- **实用调试策略**:
|
||||||
|
- 为连接状态变化与 Store 更新添加结构化日志;遇到时序/竞态可用 `setTimeout(..., 0)` 调整更新顺序
|
||||||
|
- DataChannel 发送重试机制:`sendToPeer()` 支持 5 次重试,间隔 100ms→1000ms 递增;优雅断开的 peer 跳过重试
|
||||||
|
- WebRTC 数据类型兼容性:支持 `ArrayBuffer`/`Blob`/`Uint8Array`/`TypedArray` 多种格式,解决 Firefox 兼容性问题
|
||||||
|
- 连接状态监控:`connectionState` 变化时触发相应的处理逻辑(connected/disconnected/failed/closed)
|
||||||
|
- 背压控制:DataChannel 设置 `bufferedAmountLowThreshold = 256KB`,发送时检查 `bufferedAmount` 状态
|
||||||
|
- Join 误报识别:若出现“Join room timeout”但紧接着能看到 `offer/answer/connected` 等日志,通常是 joinResponse 迟到所致,并非真实失败;15 秒超时窗口与“等效成功信号”会自动纠偏。
|
||||||
|
|
||||||
|
## 6)前端组件系统与业务中枢协作流程
|
||||||
|
|
||||||
|
本节已拆分到:[`docs/ai-playbook/flows/frontend.zh-CN.md`](./flows/frontend.zh-CN.md)
|
||||||
|
|
||||||
|
```
|
||||||
|
App Router (page.tsx/layout.tsx)
|
||||||
|
↓
|
||||||
|
HomeClient (页面布局与SEO)
|
||||||
|
↓
|
||||||
|
ClipboardApp (顶层UI协调器)
|
||||||
|
↓
|
||||||
|
SendTabPanel/RetrieveTabPanel (功能面板)
|
||||||
|
↓
|
||||||
|
业务中枢 Hooks (状态管理与业务逻辑)
|
||||||
|
↓
|
||||||
|
Core Services (webrtcService) + Store (fileTransferStore)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ClipboardApp 顶层协调器模式
|
||||||
|
|
||||||
|
**核心职责**:
|
||||||
|
|
||||||
|
- 集成 5 个关键业务 hooks:useWebRTCConnection、useFileTransferHandler、useRoomManager、usePageSetup、useClipboardAppMessages
|
||||||
|
- 全局拖拽事件处理:dragenter/dragleave/dragover/drop,支持多文件和文件夹树遍历
|
||||||
|
- 双标签页状态管理:发送/接收面板切换,通过 activeTab 控制
|
||||||
|
- 统一消息系统:shareMessage/retrieveMessage 4 秒自动消失机制
|
||||||
|
|
||||||
|
### Hook 层级与职责分离
|
||||||
|
|
||||||
|
**useWebRTCConnection**(状态桥梁):
|
||||||
|
|
||||||
|
- 计算全局传输状态(isAnyFileTransferring)
|
||||||
|
- 暴露 webrtcService 方法(broadcastDataToAllPeers、requestFile、requestFolder)
|
||||||
|
- 提供连接重置方法(resetSenderConnection、resetReceiverConnection)
|
||||||
|
|
||||||
|
**useFileTransferHandler**(文件与内容管理):
|
||||||
|
|
||||||
|
- 文件操作:addFilesToSend(去重)、removeFileToSend
|
||||||
|
- 下载功能:handleDownloadFile(支持文件夹压缩下载)
|
||||||
|
- 关键修复:使用 `useFileTransferStore.getState()` 获取最新状态,避免闭包问题
|
||||||
|
- 重试机制:最大 3 次重试,50ms 间隔,详细错误日志
|
||||||
|
|
||||||
|
**useRoomManager**(房间生命周期管理):
|
||||||
|
|
||||||
|
- 房间操作:joinRoom(支持缓存 ID 重连)、processRoomIdInput(750ms 防抖)
|
||||||
|
- 离开保护:传输中确认提示(isAnyFileTransferring 检查)
|
||||||
|
- 状态文本:动态更新房间状态文本
|
||||||
|
- 链接生成:自动生成分享链接
|
||||||
|
|
||||||
|
**usePageSetup**(页面初始化):
|
||||||
|
|
||||||
|
- 国际化消息加载与错误处理
|
||||||
|
- URL 参数处理:roomId 自动提取并触发加入房间(200ms 延迟确保 DOM 就绪)
|
||||||
|
- 引荐来源追踪(trackReferrer)
|
||||||
|
|
||||||
|
**useClipboardAppMessages**(消息管理):
|
||||||
|
|
||||||
|
- 分离式消息状态:shareMessage(发送相关)和 retrieveMessage(接收相关)
|
||||||
|
- 统一消息显示接口:putMessageInMs(message, isShareEnd, displayTimeMs)
|
||||||
|
- 自动清理机制:4 秒后自动清空消息状态
|
||||||
|
|
||||||
|
### 面板组件特化设计
|
||||||
|
|
||||||
|
**SendTabPanel 发送面板**:
|
||||||
|
|
||||||
|
- 房间 ID 双模式生成:4 位数字(后端 API 生成)和 UUID(前端 crypto API)
|
||||||
|
- 富文本编辑器集成(动态导入,SSR 禁用)
|
||||||
|
- 文件上传处理和文件列表管理
|
||||||
|
- 分享链接生成与二维码显示
|
||||||
|
|
||||||
|
**RetrieveTabPanel 接收面板**:
|
||||||
|
|
||||||
|
- File System Access API 集成:目录选择和直接保存到磁盘
|
||||||
|
- 富文本内容渲染(dangerouslySetInnerHTML)
|
||||||
|
- 文件请求和下载状态管理
|
||||||
|
- 保存位置选择和大文件/文件夹提示
|
||||||
|
|
||||||
|
**FileListDisplay 文件列表**:
|
||||||
|
|
||||||
|
- 智能文件/文件夹分组和统计显示
|
||||||
|
- 多浏览器下载策略:Chrome 自动下载,其他浏览器手动保存提示
|
||||||
|
- 下载计数统计和传输进度跟踪
|
||||||
|
- 断点续传和存储方式显示(内存/磁盘)
|
||||||
|
|
||||||
|
### 关键用户体验优化
|
||||||
|
|
||||||
|
1. **下载状态闭包修复**:`useFileTransferHandler.ts:110` 使用 `useFileTransferStore.getState()` 获取最新状态
|
||||||
|
2. **房间 ID 输入防抖**:`useRoomManager.ts:247` 使用 lodash debounce 750ms 延迟验证
|
||||||
|
3. **传输中离开保护**:`useRoomManager.ts:164,218` 检查 `isAnyFileTransferring` 状态并显示确认对话框
|
||||||
|
4. **缓存 ID 重连**:`useRoomManager.ts:91` 检测长 ID(≥8 字符)自动发送 `initiator-online`
|
||||||
|
5. **文件夹压缩下载**:`useFileTransferHandler.ts:89` 使用 JSZip 动态创建 ZIP 文件
|
||||||
|
6. **全局拖拽优化**:ClipboardApp 使用 dragCounter 防止拖拽状态误判,支持 webkitGetAsEntry 文件树遍历
|
||||||
|
7. **剪贴板兼容性**:useClipboardActions 支持现代 navigator.clipboard API 和 document.execCommand 降级方案
|
||||||
|
8. **富文本安全处理**:useRichTextToPlainText 服务端渲染安全,客户端 DOM 转换处理块级元素
|
||||||
|
9. **站内导航不中断(同一标签页)**:依赖 `frontend/stores/fileTransferStore.ts`(Zustand 单例)与 `frontend/lib/webrtcService.ts`(服务单例)。App Router 页面切换不打断传输且保留已选择/已接收内容。注意不要在路由切换副作用中调用 `webrtcService.leaveRoom()` 或重置 Store;刷新/新标签不在保证范围内。
|
||||||
|
|
||||||
|
## 7)SSR 与 DOM 访问防护(必读)
|
||||||
|
|
||||||
|
为避免“服务端异常(Application error)”这类 SSR 侧报错,前端改动需遵循以下守卫清单:
|
||||||
|
|
||||||
|
- 仅在客户端生命周期中访问 DOM/Navigator
|
||||||
|
- 将 `document/window/navigator` 的访问放入 `useEffect`、事件回调或显式的客户端组件(文件顶部包含 `"use client";`)。
|
||||||
|
- 在回调内使用前加守卫:`typeof document !== 'undefined'`、`typeof window !== 'undefined'`、`typeof navigator !== 'undefined'`。
|
||||||
|
- 定时器与可见性判断
|
||||||
|
- 使用全局 `setTimeout`/`clearTimeout`,避免直接引用 `window.setTimeout`。
|
||||||
|
- 监听可见性:注册/移除 `visibilitychange` 事件前先判断 `typeof document !== 'undefined'`。
|
||||||
|
- 事件监听的清理
|
||||||
|
- 所有 `addEventListener` 都应在 `useEffect` 返回函数中对称 `removeEventListener`,并在服务端(无 `document`)时跳过注册。
|
||||||
|
- 单例与模块副作用(重要)
|
||||||
|
- 禁止在模块顶层初始化依赖浏览器环境的实例(如 Socket、WebRTC、WakeLock 等)。应在客户端首次需要时惰性创建(lazy-init)。
|
||||||
|
|
||||||
|
参考实现:
|
||||||
|
|
||||||
|
- `frontend/utils/useOneShotSlowHint.ts`:在 `useEffect` 中对 `document` 做判空;定时器使用全局 `setTimeout`。
|
||||||
|
- `frontend/hooks/useConnectionFeedback.ts`:读取 `document.visibilityState` 前判空;仅在客户端注册 `visibilitychange`。
|
||||||
|
- `frontend/hooks/usePageSetup.ts`、`frontend/lib/tracking.ts`:读取 `window.location` 前判空。
|
||||||
|
|
||||||
|
### UI 连接反馈状态机(弱网/VPN 提示)
|
||||||
|
|
||||||
|
- 入房阶段(join)
|
||||||
|
- 立即:`join_inProgress`(“正在加入房间…”)。
|
||||||
|
- 3s 未完成:`join_slow`(“连接较慢,建议检查网络/VPN…”)。
|
||||||
|
- 15s 超时:`join_timeout`(“加入超时…”)。
|
||||||
|
- 等效成功信号:在等待 `joinResponse` 期间,若收到 `ready/recipient-ready/offer`,视为提前入房成功并即时清理 3s/15s 定时器与提示,避免“成功后再出现慢/超时提示”。
|
||||||
|
- 协商阶段(WebRTC)
|
||||||
|
- 进入 `new/connecting`:归一为 “协商中” → `rtc_negotiating`。
|
||||||
|
- 8s 未连上:`rtc_slow`(“网络可能受限,尝试关闭 VPN 或稍后再试”)。仅在页面前台可见时触发;同一次协商尝试仅提示一次(发送端/接收端任一进入协商即启动计时,提示归属以最先进入协商的一侧为准)。
|
||||||
|
- 连接与重连
|
||||||
|
- 首次 `connected`:`rtc_connected`(仅一次)。
|
||||||
|
- 前台断开:`rtc_reconnecting` → 恢复后 `rtc_restored`。
|
||||||
|
- 后台断开不提示;回到前台若仍断开立即提示 `rtc_reconnecting`。
|
||||||
|
- 已断开期间若页面在后台,返回前台时若仍处于协商态且此前触发了慢协商计时,则会补发一次 `rtc_slow` 并标记本次协商已提示,以避免重复。
|
||||||
|
|
||||||
|
实现位置:
|
||||||
|
|
||||||
|
- `frontend/hooks/useRoomManager.ts`:入房阶段提示与定时器管理(3s 慢网、15s 超时),并在 join 成功/失败时清理定时器;支持“等效成功信号”提前判定成功(`ready/recipient-ready/offer`)。
|
||||||
|
- `frontend/hooks/useConnectionFeedback.ts`:桥接 WebRTC 连接态到 UI 提示。
|
||||||
|
- 状态归一化(mapPhase):`new/connecting`→`negotiating`;`failed/closed`→`disconnected`。
|
||||||
|
- 协商慢提示:8s 定时器、前后台可见性节制、单次协商尝试仅提示一次(含挂起 → 前台补发)。
|
||||||
|
- 一次性提示:首次 `connected` 只显示一次;断开 → 恢复显示 `rtc_restored`;仅前台显示 `rtc_reconnecting`。
|
||||||
|
- 复用:慢提示定时与前后台补发由 `frontend/utils/useOneShotSlowHint.ts` 统一实现;状态归一化由 `frontend/utils/rtcPhase.ts` 提供。
|
||||||
|
|
||||||
|
文案与 i18n:
|
||||||
|
|
||||||
|
- 文案键均位于 `frontend/constants/messages/*.{ts}`,类型定义见 `frontend/types/messages.ts`。
|
||||||
|
- 关键键:`join_inProgress`、`join_slow`、`join_timeout`、`rtc_negotiating`、`rtc_slow`、`rtc_connected`、`rtc_reconnecting`、`rtc_restored`(已在 en/ja/es/de/fr/ko 全部补齐)。
|
||||||
|
|
||||||
|
节流与展示:
|
||||||
|
|
||||||
|
- 所有提示默认 4–6 秒自动消失;通过 `useClipboardAppMessages.putMessageInMs(message, isShareEnd, ms)` 统一展示。
|
||||||
|
- 连接反馈提示在“状态迁移 + ever/wasDisc 标记 + 可见性判断”三重约束下触发,避免提示风暴。
|
||||||
|
|
||||||
|
10. **切到接收端自动加入(缓存 ID)**:当用户切换到接收端、未在房间、URL 无 `roomId`、输入框为空且本地存在缓存 ID 时,自动填充并直接调用加入房间以提升体验。入口:`frontend/components/ClipboardApp.tsx`(监听 `activeTab` 变化,读取 `frontend/lib/roomIdCache.ts`)。
|
||||||
|
11. **发送端“使用缓存 ID”即刻加入**:发送端在 `SendTabPanel` 点击“使用缓存 ID”后会立即调用加入房间(而非仅填充输入框)。入口:`frontend/components/ClipboardApp/CachedIdActionButton.tsx`(`onUseCached` 回调)+ `frontend/components/ClipboardApp/SendTabPanel.tsx`。
|
||||||
|
12. **深色主题切换**:提供单按钮 Light/Dark 切换,入口:`frontend/components/web/ThemeToggle.tsx`;集成位置:`frontend/components/web/Header.tsx`(桌面与移动);局部样式从硬编码颜色迁移为设计令牌(例如接收面板使用 `bg-card text-card-foreground`)。
|
||||||
|
|
||||||
|
### 前端组件架构特化
|
||||||
|
|
||||||
|
**富文本编辑器模块**:
|
||||||
|
|
||||||
|
- **RichTextEditor**:主编辑器组件,支持 contentEditable、图片粘贴、格式化工具、SSR 禁用
|
||||||
|
- **工具栏组件分离**:BasicFormatTools(粗体/斜体/下划线)、FontTools(字体/大小/颜色)、AlignmentTools(对齐)、InsertTools(链接/图片/代码块)
|
||||||
|
- **类型安全设计**:完整的 TypeScript 类型定义(FormatType、AlignmentType、FontStyleType、CustomClipboardEvent)
|
||||||
|
- **编辑器 Hooks**:useEditorCommands(命令执行)、useSelection(选择管理)、useStyleManagement(样式管理)
|
||||||
|
|
||||||
|
**网站页面组件设计**:
|
||||||
|
|
||||||
|
- **Header 响应式导航**:桌面端水平导航+移动端汉堡菜单,集成 GitHub 链接和语言切换器
|
||||||
|
- **Footer 国际化**:动态版权年份、多语言支持链接显示,使用 languageDisplayNames 配置
|
||||||
|
- **FAQSection 灵活配置**:支持工具页面/独立页面切换、标题级别控制、自动 FAQ 数组生成
|
||||||
|
- **内容展示组件**:HowItWorks(步骤动画+视频)、SystemDiagram(架构图)、KeyFeatures(图标+特性说明)
|
||||||
|
|
||||||
|
**UI 组件库架构**:
|
||||||
|
|
||||||
|
- **基于 Radix UI**:Button(CVA 多变体系统)、Accordion(手风琴)、Dialog(模态对话框)、Select、DropdownMenu
|
||||||
|
- **设计系统一致性**:统一的 cn 工具函数、主题色彩系统、动画过渡效果
|
||||||
|
- **组件组合模式**:DialogHeader/DialogFooter/DialogTitle/DialogDescription 组合设计
|
||||||
|
- **懒加载优化**:LazyLoadWrapper 使用 react-intersection-observer,支持 rootMargin 配置防止布局跳动
|
||||||
|
|
||||||
|
**通用组件工具化**:
|
||||||
|
|
||||||
|
- **clipboard_btn**:WriteClipboardButton/ReadClipboardButton 分离设计,集成 useClipboardActions hook,支持国际化消息
|
||||||
|
- **TableOfContents**:支持中文标题 ID 生成、滚动跟踪、层级缩进、IntersectionObserver 监听
|
||||||
|
- **JsonLd SEO**:多类型数据支持、suppressHydrationWarning、数组/单对象处理
|
||||||
|
- **AutoPopupDialog/YouTubePlayer**:业务场景封装,复用性设计
|
||||||
|
|
||||||
|
### 数据流模式
|
||||||
|
|
||||||
|
- **单向数据流**:Store → Hooks → Components
|
||||||
|
- **状态管理集中化**:所有状态通过 `useFileTransferStore` 统一管理
|
||||||
|
- **错误处理标准化**:统一的消息提示机制(putMessageInMs)
|
||||||
|
- **国际化集成**:useLocale + getDictionary 提供多语言支持
|
||||||
|
|
||||||
|
## 8)背压与分片策略深度分析
|
||||||
|
|
||||||
|
本节已拆分到:[`docs/ai-playbook/flows/backpressure-chunking.zh-CN.md`](./flows/backpressure-chunking.zh-CN.md)
|
||||||
|
|
||||||
|
- 适用:核对背压阈值、分片/批次策略、嵌入元数据包格式与性能调优点
|
||||||
|
- 包含:发送侧双层缓冲、接收侧存储策略、调试与监控建议等
|
||||||
|
|
||||||
|
## 9)断点续传深度分析
|
||||||
|
|
||||||
|
本节已拆分到:[`docs/ai-playbook/flows/resume.zh-CN.md`](./flows/resume.zh-CN.md)
|
||||||
|
|
||||||
|
- 适用:核对续传检测、offset 协商与分片范围计算的一致性
|
||||||
|
- 包含:ChunkRangeCalculator、接收侧/发送侧续传流程、限制与调试要点等
|
||||||
|
|
||||||
|
## 10)重连与状态一致性深度分析
|
||||||
|
|
||||||
|
本节已拆分到:[`docs/ai-playbook/flows/reconnect-consistency.zh-CN.md`](./flows/reconnect-consistency.zh-CN.md)
|
||||||
|
|
||||||
|
- 适用:核对 WebRTC/Socket 双重断开判定、ICE 候选者队列、发送重试与一致性保障
|
||||||
|
- 包含:重连触发条件、重试策略、移动端补充策略、调试要点等
|
||||||
|
|
||||||
|
## 11)微方案模板(用于小改动前的对齐)
|
||||||
|
|
||||||
|
标题:<简述>
|
||||||
|
|
||||||
|
背景/问题
|
||||||
|
|
||||||
|
- 要解决的用户场景或缺陷是什么?
|
||||||
|
|
||||||
|
目标与非目标
|
||||||
|
|
||||||
|
- 本次改动包含与不包含的范围?
|
||||||
|
|
||||||
|
影响文件与消息
|
||||||
|
|
||||||
|
- 代码:列出关键文件(如 `frontend/lib/webrtc_base.ts`、`backend/src/socket/handlers.ts`)。
|
||||||
|
- 协议:列出将修改的 DataChannel 消息/字段。
|
||||||
|
|
||||||
|
状态机/流程变化
|
||||||
|
|
||||||
|
- 增删改的阶段;给出简要时序或步骤。
|
||||||
|
|
||||||
|
测试与回归清单
|
||||||
|
|
||||||
|
- 单测/集成(如适用)、手测场景、性能/边界、重连。
|
||||||
|
|
||||||
|
需要更新的文档
|
||||||
|
|
||||||
|
- `code-map.md`(如出现新的入口)
|
||||||
|
- `flows.md`(流程/消息/约束变化)
|
||||||
|
- 相关架构或部署文档(如涉及)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# PrivyDrop AI Playbook — Backpressure & Chunking Strategy (Deep Dive)
|
||||||
|
|
||||||
|
← Back to flow index: [`docs/ai-playbook/flows.md`](../flows.md)
|
||||||
|
|
||||||
|
(This page is the English edition of content split out from `docs/ai-playbook/flows.zh-CN.md`, preserving the original section numbering and structure.)
|
||||||
|
|
||||||
|
## 7) Backpressure & Chunking Strategy (Deep Dive)
|
||||||
|
|
||||||
|
### Sender Dual-Layer Buffering Architecture
|
||||||
|
|
||||||
|
**Design rationale**:
|
||||||
|
|
||||||
|
- **File read layer**: 4MB chunks reduce FileReader calls; 8 chunks form a 32MB batch
|
||||||
|
- **Network layer**: 64KB pieces fit WebRTC DataChannel limits and avoid sendData failed errors
|
||||||
|
- **Performance**: efficient slicing inside each batch; a single FileReader.read() yields 512 network chunks
|
||||||
|
|
||||||
|
**Key parameters**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
TransferConfig.FILE_CONFIG = {
|
||||||
|
CHUNK_SIZE: 4194304, // 4MB - file read chunks
|
||||||
|
BATCH_SIZE: 8, // 8 chunks = 32MB batch
|
||||||
|
NETWORK_CHUNK_SIZE: 65536, // 64KB - safe WebRTC send size
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backpressure control**:
|
||||||
|
|
||||||
|
- **DataChannel thresholds**: `bufferedAmountLowThreshold = 256KB` (Initiator) and `512KB` (NetworkTransmitter)
|
||||||
|
- **Max buffer**: `maxBuffer = 1MB`; wait until pressure releases when exceeded
|
||||||
|
- **Async waiting**: listens to `bufferedamountlow`, with a timeout safeguard (10 seconds)
|
||||||
|
|
||||||
|
**Embedded metadata packet format**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[4-byte length][JSON metadata][payload bytes]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Each network chunk includes: chunkIndex, totalChunks, fileOffset, fileId, isLastChunk
|
||||||
|
- Receiver can parse each packet independently without relying on extra shared state
|
||||||
|
|
||||||
|
### Receiver Smart Storage Strategy
|
||||||
|
|
||||||
|
**Storage decision logic**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ReceptionConfig.shouldSaveToDisk(fileSize, hasSaveDirectory);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **In-memory**: file < 1GB and no save directory chosen
|
||||||
|
- **Disk**: file ≥ 1GB or the user selected a save directory
|
||||||
|
- **Buffer cap**: up to 100 chunks buffered (≈ 6.4MB)
|
||||||
|
|
||||||
|
**Chunk validation**:
|
||||||
|
|
||||||
|
- **Format compatibility**: ArrayBuffer/Blob/Uint8Array/TypedArray supported
|
||||||
|
- **Integrity checks**: validate fileId, chunkIndex, chunkSize consistency
|
||||||
|
- **Firefox quirks**: Blob size checks and conversion error handling
|
||||||
|
|
||||||
|
**Strict sequential disk writes**:
|
||||||
|
|
||||||
|
- **SequencedDiskWriter**: guarantees in-order writes; enables streaming for large files
|
||||||
|
- **Resume**: `getPartialFileSize()` checks existing partial files
|
||||||
|
- **Auto completion**: `checkAndAutoFinalize()` verifies completeness
|
||||||
|
|
||||||
|
### Performance Tuning Details
|
||||||
|
|
||||||
|
**Sender-side optimizations**:
|
||||||
|
|
||||||
|
- **Batch reads**: 32MB batches reduce I/O operations and improve large file read throughput
|
||||||
|
- **Network fit**: 64KB balances transfer efficiency with cross-browser compatibility
|
||||||
|
- **Backpressure response**: leverages native WebRTC backpressure to prevent drops
|
||||||
|
|
||||||
|
**Receiver-side optimizations**:
|
||||||
|
|
||||||
|
- **Unified format conversion**: ChunkProcessor handles multiple payload formats in one place
|
||||||
|
- **Progress throttling**: 100ms for files, 200ms for folders to avoid UI overload
|
||||||
|
- **Memory management**: small files assemble in memory; large files stream to disk
|
||||||
|
|
||||||
|
**Error handling**:
|
||||||
|
|
||||||
|
- **Send retries**: NetworkTransmitter returns boolean for upper-layer retry logic
|
||||||
|
- **Conversion tolerance**: when Blob conversion fails, return null instead of aborting the transfer
|
||||||
|
- **Timeout safeguards**: 30s completion timeout; 5s graceful close timeout
|
||||||
|
|
||||||
|
### Debugging & Monitoring
|
||||||
|
|
||||||
|
**Dev logs**:
|
||||||
|
|
||||||
|
- **Chunk tracking**: log details every 100 chunks and for the last chunk
|
||||||
|
- **Backpressure monitoring**: buffer size changes and wait-time stats
|
||||||
|
- **Performance metrics**: transfer speed, batch processing time, conversion cost
|
||||||
|
|
||||||
|
**Production optimizations**:
|
||||||
|
|
||||||
|
- **Conditional logging**: `ENABLE_CHUNK_LOGGING` and `ENABLE_PROGRESS_LOGGING`
|
||||||
|
- **Error reporting**: critical errors are sent to the backend via `postLogToBackend`
|
||||||
|
- **Performance sampling**: use `performance.now()` for precise timings
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# PrivyDrop AI Playbook — 背压与分片策略深度分析(中文)
|
||||||
|
|
||||||
|
← 返回流程入口:[`docs/ai-playbook/flows.zh-CN.md`](../flows.zh-CN.md)
|
||||||
|
|
||||||
|
(本页从 `docs/ai-playbook/flows.zh-CN.md` 拆分,保留原章节编号与内容。)
|
||||||
|
|
||||||
|
## 7)背压与分片策略深度分析
|
||||||
|
|
||||||
|
### 发送侧双层缓冲架构
|
||||||
|
|
||||||
|
**设计原理**:
|
||||||
|
|
||||||
|
- **文件读取层**:4MB 分片减少 FileReader 调用,8 个分片组成 32MB 批次
|
||||||
|
- **网络传输层**:64KB 小块适配 WebRTC DataChannel 限制,避免 sendData failed 错误
|
||||||
|
- **性能优化**:批次内高效切片,一次 FileReader.read()产生 512 个网络块
|
||||||
|
|
||||||
|
**配置参数**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
TransferConfig.FILE_CONFIG = {
|
||||||
|
CHUNK_SIZE: 4194304, // 4MB - 文件读取分片
|
||||||
|
BATCH_SIZE: 8, // 8个分片 = 32MB批次
|
||||||
|
NETWORK_CHUNK_SIZE: 65536, // 64KB - WebRTC安全发送大小
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**背压控制机制**:
|
||||||
|
|
||||||
|
- **DataChannel 阈值**:`bufferedAmountLowThreshold = 256KB`(Initiator)和`512KB`(NetworkTransmitter)
|
||||||
|
- **最大缓冲限制**:`maxBuffer = 1MB`,超过时等待背压释放
|
||||||
|
- **异步等待策略**:监听`bufferedamountlow`事件,支持超时机制(10 秒)
|
||||||
|
|
||||||
|
**嵌入元数据包格式**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[4字节长度][JSON元数据][实际数据块]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 每个网络块都包含:chunkIndex、totalChunks、fileOffset、fileId、isLastChunk
|
||||||
|
- 接收端可独立解析,无需依赖额外状态
|
||||||
|
|
||||||
|
### 接收侧智能存储策略
|
||||||
|
|
||||||
|
**存储选择逻辑**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ReceptionConfig.shouldSaveToDisk(fileSize, hasSaveDirectory);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **内存存储**:文件 < 1GB 且未指定保存目录
|
||||||
|
- **磁盘存储**:文件 ≥ 1GB 或用户选择了保存目录
|
||||||
|
- **缓冲管理**:最多缓存 100 个分片(约 6.4MB)
|
||||||
|
|
||||||
|
**分片验证机制**:
|
||||||
|
|
||||||
|
- **格式兼容**:支持 ArrayBuffer/Blob/Uint8Array/TypedArray 多种格式
|
||||||
|
- **完整性检查**:验证 fileId、chunkIndex、chunkSize 一致性
|
||||||
|
- **Firefox 兼容**:Blob size 检测和转换错误处理
|
||||||
|
|
||||||
|
**严格顺序写入**:
|
||||||
|
|
||||||
|
- **SequencedDiskWriter**:确保分片按序写入磁盘,支持大文件流式处理
|
||||||
|
- **断点续传**:通过`getPartialFileSize()`检查本地部分文件
|
||||||
|
- **自动完成检测**:`checkAndAutoFinalize()`验证分片完整性
|
||||||
|
|
||||||
|
### 性能优化细节
|
||||||
|
|
||||||
|
**发送侧优化**:
|
||||||
|
|
||||||
|
- **批量读取**:32MB 批次减少 I/O 操作,提升大文件读取性能
|
||||||
|
- **网络适配**:64KB 块平衡传输效率与浏览器兼容性
|
||||||
|
- **背压响应**:利用 WebRTC 原生背压控制,避免数据丢失
|
||||||
|
|
||||||
|
**接收侧优化**:
|
||||||
|
|
||||||
|
- **格式转换**:ChunkProcessor 统一处理多种数据格式
|
||||||
|
- **进度节流**:文件 100ms、文件夹 200ms 间隔更新,避免 UI 过载
|
||||||
|
- **内存管理**:小文件内存组装,大文件直接写入磁盘
|
||||||
|
|
||||||
|
**错误处理**:
|
||||||
|
|
||||||
|
- **发送重试**:NetworkTransmitter 返回 boolean 状态,支持上层重试逻辑
|
||||||
|
- **转换容错**:Blob conversion failed 时返回 null,不中断整体传输
|
||||||
|
- **超时保护**:文件完成 30 秒超时,优雅关闭 5 秒超时
|
||||||
|
|
||||||
|
### 调试与监控
|
||||||
|
|
||||||
|
**开发环境日志**:
|
||||||
|
|
||||||
|
- **分片跟踪**:每 100 个分片或最后分片记录详细信息
|
||||||
|
- **背压监控**:缓冲区大小变化和等待时间统计
|
||||||
|
- **性能指标**:传输速度、批次处理时间、格式转换耗时
|
||||||
|
|
||||||
|
**生产环境优化**:
|
||||||
|
|
||||||
|
- **条件日志**:`ENABLE_CHUNK_LOGGING`和`ENABLE_PROGRESS_LOGGING`开关
|
||||||
|
- **错误上报**:关键错误通过`postLogToBackend`发送到后端
|
||||||
|
- **性能采样**:通过`performance.now()`精确测量耗时
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# PrivyDrop AI Playbook — Frontend Component System & Core-Orchestrator Collaboration
|
||||||
|
|
||||||
|
← Back to flow index: [`docs/ai-playbook/flows.md`](../flows.md)
|
||||||
|
|
||||||
|
(This page is the English edition of content split out from `docs/ai-playbook/flows.zh-CN.md`, preserving the original section numbering and structure.)
|
||||||
|
|
||||||
|
## 6) Frontend Component System & Core-Orchestrator Collaboration
|
||||||
|
|
||||||
|
### Component Architecture Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
App Router (page.tsx/layout.tsx)
|
||||||
|
↓
|
||||||
|
HomeClient (layout & SEO)
|
||||||
|
↓
|
||||||
|
ClipboardApp (top-level UI orchestrator)
|
||||||
|
↓
|
||||||
|
SendTabPanel/RetrieveTabPanel (feature panels)
|
||||||
|
↓
|
||||||
|
Business Hooks (state + orchestration)
|
||||||
|
↓
|
||||||
|
Core Services (webrtcService) + Store (fileTransferStore)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ClipboardApp Orchestrator Pattern
|
||||||
|
|
||||||
|
**Core responsibilities**:
|
||||||
|
|
||||||
|
- Integrates 5 key business hooks: useWebRTCConnection, useFileTransferHandler, useRoomManager, usePageSetup, useClipboardAppMessages
|
||||||
|
- Handles global drag events: dragenter/dragleave/dragover/drop, supports multi-file and folder-tree traversal
|
||||||
|
- Manages the Send/Retrieve tabs via activeTab
|
||||||
|
- Unified messaging: shareMessage/retrieveMessage auto-dismiss after 4 seconds
|
||||||
|
|
||||||
|
### Hook Layering & Separation of Concerns
|
||||||
|
|
||||||
|
**useWebRTCConnection** (state bridge):
|
||||||
|
|
||||||
|
- Computes global transfer state (isAnyFileTransferring)
|
||||||
|
- Exposes webrtcService methods (broadcastDataToAllPeers, requestFile, requestFolder)
|
||||||
|
- Provides reset methods (resetSenderConnection, resetReceiverConnection)
|
||||||
|
|
||||||
|
**useFileTransferHandler** (files and content):
|
||||||
|
|
||||||
|
- File ops: addFilesToSend (dedupe), removeFileToSend
|
||||||
|
- Downloads: handleDownloadFile (supports folder ZIP downloads)
|
||||||
|
- Key fix: uses `useFileTransferStore.getState()` to read the latest state and avoid stale closures
|
||||||
|
- Retry: up to 3 retries with 50ms interval and detailed error logs
|
||||||
|
|
||||||
|
**useRoomManager** (room lifecycle):
|
||||||
|
|
||||||
|
- Room ops: joinRoom (supports cached-ID reconnect), processRoomIdInput (750ms debounce)
|
||||||
|
- Leave protection: confirmation prompt while transferring (checks isAnyFileTransferring)
|
||||||
|
- Status text: dynamic room status copy
|
||||||
|
- Link generation: auto-generates share links
|
||||||
|
|
||||||
|
**usePageSetup** (page initialization):
|
||||||
|
|
||||||
|
- i18n dictionary loading and error handling
|
||||||
|
- URL param handling: extracts roomId and auto-joins (200ms delay to ensure DOM readiness)
|
||||||
|
- Referrer tracking (trackReferrer)
|
||||||
|
|
||||||
|
**useClipboardAppMessages** (messages):
|
||||||
|
|
||||||
|
- Split message states: shareMessage (send side) and retrieveMessage (receive side)
|
||||||
|
- Unified API: putMessageInMs(message, isShareEnd, displayTimeMs)
|
||||||
|
- Auto cleanup: clears message state after 4 seconds
|
||||||
|
|
||||||
|
### Panel-Specific Design
|
||||||
|
|
||||||
|
**SendTabPanel**:
|
||||||
|
|
||||||
|
- Dual-mode room ID generation: 4-digit numbers (via backend API) and UUID (via Web Crypto)
|
||||||
|
- Rich-text editor integration (dynamic import, SSR disabled)
|
||||||
|
- File upload handling + file list management
|
||||||
|
- Share link + QR code
|
||||||
|
|
||||||
|
**RetrieveTabPanel**:
|
||||||
|
|
||||||
|
- File System Access API integration: directory selection and direct disk saves
|
||||||
|
- Rich-text rendering (dangerouslySetInnerHTML)
|
||||||
|
- File requests + download state management
|
||||||
|
- Save-location selection and large file/folder hints
|
||||||
|
|
||||||
|
**FileListDisplay**:
|
||||||
|
|
||||||
|
- Smart grouping and stats for files/folders
|
||||||
|
- Cross-browser download strategy: Chrome auto-download; other browsers show manual save guidance
|
||||||
|
- Download count stats + transfer progress tracking
|
||||||
|
- Resume state and storage mode display (memory/disk)
|
||||||
|
|
||||||
|
### Key UX Improvements
|
||||||
|
|
||||||
|
1. **Stale-closure fix for download state**: `useFileTransferHandler.ts:110` uses `useFileTransferStore.getState()`
|
||||||
|
2. **Debounced roomId validation**: `useRoomManager.ts:247` uses lodash debounce (750ms)
|
||||||
|
3. **Leaving while transferring**: `useRoomManager.ts:164,218` checks `isAnyFileTransferring` and shows a confirmation dialog
|
||||||
|
4. **Cached-ID reconnect**: `useRoomManager.ts:91` detects long IDs (≥8 chars) and auto-sends `initiator-online`
|
||||||
|
5. **Folder ZIP downloads**: `useFileTransferHandler.ts:89` builds ZIPs on the fly with JSZip
|
||||||
|
6. **Global drag-and-drop robustness**: ClipboardApp uses dragCounter to avoid mis-detecting drag state; supports webkitGetAsEntry folder traversal
|
||||||
|
7. **Clipboard compatibility**: useClipboardActions supports modern navigator.clipboard APIs with document.execCommand fallback
|
||||||
|
8. **Rich-text safety**: useRichTextToPlainText is safe on server render; client-side DOM conversion handles block elements
|
||||||
|
9. **In-app navigation without breaking transfers (same tab)**: relies on `frontend/stores/fileTransferStore.ts` (Zustand singleton) and `frontend/lib/webrtcService.ts` (service singleton). App Router navigation keeps transfers and selected/received content intact. Avoid calling `webrtcService.leaveRoom()` or resetting the store in route-change side effects. Refresh/new tab is not covered.
|
||||||
|
|
||||||
|
### UI Connection Feedback State Machine (Weak Network / VPN Hints)
|
||||||
|
|
||||||
|
- Join phase
|
||||||
|
- Immediate: `join_inProgress` (“Joining the room…”).
|
||||||
|
- Not finished after 3s: `join_slow` (“Connection seems slow—check your network/VPN…”).
|
||||||
|
- Timeout after 15s: `join_timeout` (“Join timed out…”).
|
||||||
|
- Equivalent success signal: while waiting for `joinResponse`, if `ready/recipient-ready/offer` arrives, treat it as “joined” and immediately clear the 3s/15s timers and hints to avoid “slow/timeout hints after success”.
|
||||||
|
- Negotiation phase (WebRTC)
|
||||||
|
- Enter `new/connecting`: normalize to “negotiating” → `rtc_negotiating`.
|
||||||
|
- Not connected after 8s: `rtc_slow` (“Your network may be restricted—try turning off VPN or try again later”). Only fires when the page is visible. Only once per negotiation attempt (timer starts when either side enters negotiating; ownership goes to the side that entered negotiating first).
|
||||||
|
- Connection & reconnection
|
||||||
|
- First `connected`: `rtc_connected` (one-time).
|
||||||
|
- Foreground disconnect: `rtc_reconnecting` → upon recovery `rtc_restored`.
|
||||||
|
- Background disconnect does not notify; when returning to foreground, if still disconnected, notify `rtc_reconnecting` immediately.
|
||||||
|
- If the page is backgrounded during a disconnect, when returning to foreground, if still negotiating and the slow timer had fired, emit `rtc_slow` once and mark it as already shown to avoid repeats.
|
||||||
|
|
||||||
|
Implementation locations:
|
||||||
|
|
||||||
|
- `frontend/hooks/useRoomManager.ts`: join-phase hints and timers (3s slow, 15s timeout), cleared on join success/failure; supports early “equivalent success signals” (`ready/recipient-ready/offer`).
|
||||||
|
- `frontend/hooks/useConnectionFeedback.ts`: maps WebRTC connection states to UI hints.
|
||||||
|
- Phase normalization (mapPhase): `new/connecting` → `negotiating`; `failed/closed` → `disconnected`.
|
||||||
|
- Negotiation slow hint: 8s timer, foreground/background throttling, only once per attempt (including deferred background → emitted on foreground).
|
||||||
|
- One-shot hints: first `connected` only once; disconnected → restored shows `rtc_restored`; `rtc_reconnecting` only in foreground.
|
||||||
|
- Shared helpers: timer + visibility control via `frontend/utils/useOneShotSlowHint.ts`; phase normalization via `frontend/utils/rtcPhase.ts`.
|
||||||
|
|
||||||
|
Copy & i18n:
|
||||||
|
|
||||||
|
- Message keys live in `frontend/constants/messages/*.{ts}`; types in `frontend/types/messages.ts`.
|
||||||
|
- Key messages: `join_inProgress`, `join_slow`, `join_timeout`, `rtc_negotiating`, `rtc_slow`, `rtc_connected`, `rtc_reconnecting`, `rtc_restored` (filled across en/ja/es/de/fr/ko).
|
||||||
|
|
||||||
|
Throttling & display:
|
||||||
|
|
||||||
|
- All hints auto-dismiss after ~4–6 seconds; use `useClipboardAppMessages.putMessageInMs(message, isShareEnd, ms)` as the unified display channel.
|
||||||
|
- Connection feedback fires under three constraints: state transition + ever/wasDisc markers + visibility checks, preventing “hint storms”.
|
||||||
|
|
||||||
|
10. **Auto-join on switching to Retrieve (cached ID)**: when switching to Retrieve, not in a room, no `roomId` in URL, empty input, and a cached ID exists locally, auto-fill and call joinRoom. Entry: `frontend/components/ClipboardApp.tsx` (watches activeTab, reads `frontend/lib/roomIdCache.ts`).
|
||||||
|
11. **Sender “Use cached ID” joins immediately**: clicking “Use cached ID” in SendTabPanel triggers joining right away (not just filling the input). Entry: `frontend/components/ClipboardApp/CachedIdActionButton.tsx` (`onUseCached`) + `frontend/components/ClipboardApp/SendTabPanel.tsx`.
|
||||||
|
12. **Dark theme toggle**: single-button Light/Dark toggle in `frontend/components/web/ThemeToggle.tsx`, integrated into `frontend/components/web/Header.tsx` (desktop & mobile). Some local styles are migrated from hardcoded colors to tokens (e.g. retrieve panel uses `bg-card text-card-foreground`).
|
||||||
|
|
||||||
|
### Frontend Architecture Specializations
|
||||||
|
|
||||||
|
**Rich-text editor module**:
|
||||||
|
|
||||||
|
- **RichTextEditor**: main editor component; contentEditable, image paste, formatting tools; SSR disabled
|
||||||
|
- **Toolbar separation**: BasicFormatTools (bold/italic/underline), FontTools (font/size/color), AlignmentTools, InsertTools (link/image/code block)
|
||||||
|
- **Type-safe design**: complete TypeScript types (FormatType, AlignmentType, FontStyleType, CustomClipboardEvent)
|
||||||
|
- **Editor hooks**: useEditorCommands (commands), useSelection (selection), useStyleManagement (style)
|
||||||
|
|
||||||
|
**Website page components**:
|
||||||
|
|
||||||
|
- **Header responsive nav**: desktop horizontal nav + mobile hamburger; integrates GitHub link and language switcher
|
||||||
|
- **Footer i18n**: dynamic copyright year and multilingual support links via languageDisplayNames
|
||||||
|
- **FAQSection**: configurable “tool page vs standalone page”, heading level control, and automatic FAQ array generation
|
||||||
|
- **Content components**: HowItWorks (animated steps + video), SystemDiagram, KeyFeatures
|
||||||
|
|
||||||
|
**UI component library architecture**:
|
||||||
|
|
||||||
|
- **Built on Radix UI**: Button (CVA variants), Accordion, Dialog, Select, DropdownMenu
|
||||||
|
- **Design-system consistency**: shared cn utility, theme token system, animation transitions
|
||||||
|
- **Composable patterns**: DialogHeader/DialogFooter/DialogTitle/DialogDescription
|
||||||
|
- **Lazy-load optimizations**: LazyLoadWrapper uses react-intersection-observer with rootMargin tuning to reduce layout shifts
|
||||||
|
|
||||||
|
**Shared components as utilities**:
|
||||||
|
|
||||||
|
- **clipboard_btn**: WriteClipboardButton/ReadClipboardButton split; integrates useClipboardActions; supports i18n messages
|
||||||
|
- **TableOfContents**: Chinese heading ID generation, scroll tracking, indentation, IntersectionObserver
|
||||||
|
- **JsonLd SEO**: multi-type support, suppressHydrationWarning, array vs single object handling
|
||||||
|
- **AutoPopupDialog/YouTubePlayer**: scenario-driven wrappers designed for reuse
|
||||||
|
|
||||||
|
### Dataflow Pattern
|
||||||
|
|
||||||
|
- **One-way dataflow**: Store → Hooks → Components
|
||||||
|
- **Centralized state**: all state is owned by `useFileTransferStore`
|
||||||
|
- **Standardized error handling**: unified message channel (putMessageInMs)
|
||||||
|
- **i18n integration**: useLocale + getDictionary provide multilingual content
|
||||||
|
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# PrivyDrop AI Playbook — 前端组件系统与业务中枢协作流程(中文)
|
||||||
|
|
||||||
|
← 返回流程入口:[`docs/ai-playbook/flows.zh-CN.md`](../flows.zh-CN.md)
|
||||||
|
|
||||||
|
(本页从 `docs/ai-playbook/flows.zh-CN.md` 拆分,保留原章节编号与内容。)
|
||||||
|
|
||||||
|
## 6)前端组件系统与业务中枢协作流程
|
||||||
|
|
||||||
|
### 组件架构层级
|
||||||
|
|
||||||
|
```
|
||||||
|
App Router (page.tsx/layout.tsx)
|
||||||
|
↓
|
||||||
|
HomeClient (页面布局与SEO)
|
||||||
|
↓
|
||||||
|
ClipboardApp (顶层UI协调器)
|
||||||
|
↓
|
||||||
|
SendTabPanel/RetrieveTabPanel (功能面板)
|
||||||
|
↓
|
||||||
|
业务中枢 Hooks (状态管理与业务逻辑)
|
||||||
|
↓
|
||||||
|
Core Services (webrtcService) + Store (fileTransferStore)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ClipboardApp 顶层协调器模式
|
||||||
|
|
||||||
|
**核心职责**:
|
||||||
|
|
||||||
|
- 集成 5 个关键业务 hooks:useWebRTCConnection、useFileTransferHandler、useRoomManager、usePageSetup、useClipboardAppMessages
|
||||||
|
- 全局拖拽事件处理:dragenter/dragleave/dragover/drop,支持多文件和文件夹树遍历
|
||||||
|
- 双标签页状态管理:发送/接收面板切换,通过 activeTab 控制
|
||||||
|
- 统一消息系统:shareMessage/retrieveMessage 4 秒自动消失机制
|
||||||
|
|
||||||
|
### Hook 层级与职责分离
|
||||||
|
|
||||||
|
**useWebRTCConnection**(状态桥梁):
|
||||||
|
|
||||||
|
- 计算全局传输状态(isAnyFileTransferring)
|
||||||
|
- 暴露 webrtcService 方法(broadcastDataToAllPeers、requestFile、requestFolder)
|
||||||
|
- 提供连接重置方法(resetSenderConnection、resetReceiverConnection)
|
||||||
|
|
||||||
|
**useFileTransferHandler**(文件与内容管理):
|
||||||
|
|
||||||
|
- 文件操作:addFilesToSend(去重)、removeFileToSend
|
||||||
|
- 下载功能:handleDownloadFile(支持文件夹压缩下载)
|
||||||
|
- 关键修复:使用 `useFileTransferStore.getState()` 获取最新状态,避免闭包问题
|
||||||
|
- 重试机制:最大 3 次重试,50ms 间隔,详细错误日志
|
||||||
|
|
||||||
|
**useRoomManager**(房间生命周期管理):
|
||||||
|
|
||||||
|
- 房间操作:joinRoom(支持缓存 ID 重连)、processRoomIdInput(750ms 防抖)
|
||||||
|
- 离开保护:传输中确认提示(isAnyFileTransferring 检查)
|
||||||
|
- 状态文本:动态更新房间状态文本
|
||||||
|
- 链接生成:自动生成分享链接
|
||||||
|
|
||||||
|
**usePageSetup**(页面初始化):
|
||||||
|
|
||||||
|
- 国际化消息加载与错误处理
|
||||||
|
- URL 参数处理:roomId 自动提取并触发加入房间(200ms 延迟确保 DOM 就绪)
|
||||||
|
- 引荐来源追踪(trackReferrer)
|
||||||
|
|
||||||
|
**useClipboardAppMessages**(消息管理):
|
||||||
|
|
||||||
|
- 分离式消息状态:shareMessage(发送相关)和 retrieveMessage(接收相关)
|
||||||
|
- 统一消息显示接口:putMessageInMs(message, isShareEnd, displayTimeMs)
|
||||||
|
- 自动清理机制:4 秒后自动清空消息状态
|
||||||
|
|
||||||
|
### 面板组件特化设计
|
||||||
|
|
||||||
|
**SendTabPanel 发送面板**:
|
||||||
|
|
||||||
|
- 房间 ID 双模式生成:4 位数字(后端 API 生成)和 UUID(前端 crypto API)
|
||||||
|
- 富文本编辑器集成(动态导入,SSR 禁用)
|
||||||
|
- 文件上传处理和文件列表管理
|
||||||
|
- 分享链接生成与二维码显示
|
||||||
|
|
||||||
|
**RetrieveTabPanel 接收面板**:
|
||||||
|
|
||||||
|
- File System Access API 集成:目录选择和直接保存到磁盘
|
||||||
|
- 富文本内容渲染(dangerouslySetInnerHTML)
|
||||||
|
- 文件请求和下载状态管理
|
||||||
|
- 保存位置选择和大文件/文件夹提示
|
||||||
|
|
||||||
|
**FileListDisplay 文件列表**:
|
||||||
|
|
||||||
|
- 智能文件/文件夹分组和统计显示
|
||||||
|
- 多浏览器下载策略:Chrome 自动下载,其他浏览器手动保存提示
|
||||||
|
- 下载计数统计和传输进度跟踪
|
||||||
|
- 断点续传和存储方式显示(内存/磁盘)
|
||||||
|
|
||||||
|
### 关键用户体验优化
|
||||||
|
|
||||||
|
1. **下载状态闭包修复**:`useFileTransferHandler.ts:110` 使用 `useFileTransferStore.getState()` 获取最新状态
|
||||||
|
2. **房间 ID 输入防抖**:`useRoomManager.ts:247` 使用 lodash debounce 750ms 延迟验证
|
||||||
|
3. **传输中离开保护**:`useRoomManager.ts:164,218` 检查 `isAnyFileTransferring` 状态并显示确认对话框
|
||||||
|
4. **缓存 ID 重连**:`useRoomManager.ts:91` 检测长 ID(≥8 字符)自动发送 `initiator-online`
|
||||||
|
5. **文件夹压缩下载**:`useFileTransferHandler.ts:89` 使用 JSZip 动态创建 ZIP 文件
|
||||||
|
6. **全局拖拽优化**:ClipboardApp 使用 dragCounter 防止拖拽状态误判,支持 webkitGetAsEntry 文件树遍历
|
||||||
|
7. **剪贴板兼容性**:useClipboardActions 支持现代 navigator.clipboard API 和 document.execCommand 降级方案
|
||||||
|
8. **富文本安全处理**:useRichTextToPlainText 服务端渲染安全,客户端 DOM 转换处理块级元素
|
||||||
|
9. **站内导航不中断(同一标签页)**:依赖 `frontend/stores/fileTransferStore.ts`(Zustand 单例)与 `frontend/lib/webrtcService.ts`(服务单例)。App Router 页面切换不打断传输且保留已选择/已接收内容。注意不要在路由切换副作用中调用 `webrtcService.leaveRoom()` 或重置 Store;刷新/新标签不在保证范围内。
|
||||||
|
|
||||||
|
### UI 连接反馈状态机(弱网/VPN 提示)
|
||||||
|
|
||||||
|
- 入房阶段(join)
|
||||||
|
- 立即:`join_inProgress`(“正在加入房间…”)。
|
||||||
|
- 3s 未完成:`join_slow`(“连接较慢,建议检查网络/VPN…”)。
|
||||||
|
- 15s 超时:`join_timeout`(“加入超时…”)。
|
||||||
|
- 等效成功信号:在等待 `joinResponse` 期间,若收到 `ready/recipient-ready/offer`,视为提前入房成功并即时清理 3s/15s 定时器与提示,避免“成功后再出现慢/超时提示”。
|
||||||
|
- 协商阶段(WebRTC)
|
||||||
|
- 进入 `new/connecting`:归一为 “协商中” → `rtc_negotiating`。
|
||||||
|
- 8s 未连上:`rtc_slow`(“网络可能受限,尝试关闭 VPN 或稍后再试”)。仅在页面前台可见时触发;同一次协商尝试仅提示一次(发送端/接收端任一进入协商即启动计时,提示归属以最先进入协商的一侧为准)。
|
||||||
|
- 连接与重连
|
||||||
|
- 首次 `connected`:`rtc_connected`(仅一次)。
|
||||||
|
- 前台断开:`rtc_reconnecting` → 恢复后 `rtc_restored`。
|
||||||
|
- 后台断开不提示;回到前台若仍断开立即提示 `rtc_reconnecting`。
|
||||||
|
- 已断开期间若页面在后台,返回前台时若仍处于协商态且此前触发了慢协商计时,则会补发一次 `rtc_slow` 并标记本次协商已提示,以避免重复。
|
||||||
|
|
||||||
|
实现位置:
|
||||||
|
- `frontend/hooks/useRoomManager.ts`:入房阶段提示与定时器管理(3s 慢网、15s 超时),并在 join 成功/失败时清理定时器;支持“等效成功信号”提前判定成功(`ready/recipient-ready/offer`)。
|
||||||
|
- `frontend/hooks/useConnectionFeedback.ts`:桥接 WebRTC 连接态到 UI 提示。
|
||||||
|
- 状态归一化(mapPhase):`new/connecting`→`negotiating`;`failed/closed`→`disconnected`。
|
||||||
|
- 协商慢提示:8s 定时器、前后台可见性节制、单次协商尝试仅提示一次(含挂起→前台补发)。
|
||||||
|
- 一次性提示:首次 `connected` 只显示一次;断开→恢复显示 `rtc_restored`;仅前台显示 `rtc_reconnecting`。
|
||||||
|
- 复用:慢提示定时与前后台补发由 `frontend/utils/useOneShotSlowHint.ts` 统一实现;状态归一化由 `frontend/utils/rtcPhase.ts` 提供。
|
||||||
|
|
||||||
|
文案与 i18n:
|
||||||
|
- 文案键均位于 `frontend/constants/messages/*.{ts}`,类型定义见 `frontend/types/messages.ts`。
|
||||||
|
- 关键键:`join_inProgress`、`join_slow`、`join_timeout`、`rtc_negotiating`、`rtc_slow`、`rtc_connected`、`rtc_reconnecting`、`rtc_restored`(已在 en/ja/es/de/fr/ko 全部补齐)。
|
||||||
|
|
||||||
|
节流与展示:
|
||||||
|
- 所有提示默认 4–6 秒自动消失;通过 `useClipboardAppMessages.putMessageInMs(message, isShareEnd, ms)` 统一展示。
|
||||||
|
- 连接反馈提示在“状态迁移 + ever/wasDisc 标记 + 可见性判断”三重约束下触发,避免提示风暴。
|
||||||
|
10. **切到接收端自动加入(缓存ID)**:当用户切换到接收端、未在房间、URL 无 `roomId`、输入框为空且本地存在缓存 ID 时,自动填充并直接调用加入房间以提升体验。入口:`frontend/components/ClipboardApp.tsx`(监听 `activeTab` 变化,读取 `frontend/lib/roomIdCache.ts`)。
|
||||||
|
11. **发送端“使用缓存ID”即刻加入**:发送端在 `SendTabPanel` 点击“使用缓存ID”后会立即调用加入房间(而非仅填充输入框)。入口:`frontend/components/ClipboardApp/CachedIdActionButton.tsx`(`onUseCached` 回调)+ `frontend/components/ClipboardApp/SendTabPanel.tsx`。
|
||||||
|
12. **深色主题切换**:提供单按钮 Light/Dark 切换,入口:`frontend/components/web/ThemeToggle.tsx`;集成位置:`frontend/components/web/Header.tsx`(桌面与移动);局部样式从硬编码颜色迁移为设计令牌(例如接收面板使用 `bg-card text-card-foreground`)。
|
||||||
|
|
||||||
|
### 前端组件架构特化
|
||||||
|
|
||||||
|
**富文本编辑器模块**:
|
||||||
|
|
||||||
|
- **RichTextEditor**:主编辑器组件,支持 contentEditable、图片粘贴、格式化工具、SSR 禁用
|
||||||
|
- **工具栏组件分离**:BasicFormatTools(粗体/斜体/下划线)、FontTools(字体/大小/颜色)、AlignmentTools(对齐)、InsertTools(链接/图片/代码块)
|
||||||
|
- **类型安全设计**:完整的 TypeScript 类型定义(FormatType、AlignmentType、FontStyleType、CustomClipboardEvent)
|
||||||
|
- **编辑器 Hooks**:useEditorCommands(命令执行)、useSelection(选择管理)、useStyleManagement(样式管理)
|
||||||
|
|
||||||
|
**网站页面组件设计**:
|
||||||
|
|
||||||
|
- **Header 响应式导航**:桌面端水平导航+移动端汉堡菜单,集成 GitHub 链接和语言切换器
|
||||||
|
- **Footer 国际化**:动态版权年份、多语言支持链接显示,使用 languageDisplayNames 配置
|
||||||
|
- **FAQSection 灵活配置**:支持工具页面/独立页面切换、标题级别控制、自动 FAQ 数组生成
|
||||||
|
- **内容展示组件**:HowItWorks(步骤动画+视频)、SystemDiagram(架构图)、KeyFeatures(图标+特性说明)
|
||||||
|
|
||||||
|
**UI 组件库架构**:
|
||||||
|
|
||||||
|
- **基于 Radix UI**:Button(CVA 多变体系统)、Accordion(手风琴)、Dialog(模态对话框)、Select、DropdownMenu
|
||||||
|
- **设计系统一致性**:统一的 cn 工具函数、主题色彩系统、动画过渡效果
|
||||||
|
- **组件组合模式**:DialogHeader/DialogFooter/DialogTitle/DialogDescription 组合设计
|
||||||
|
- **懒加载优化**:LazyLoadWrapper 使用 react-intersection-observer,支持 rootMargin 配置防止布局跳动
|
||||||
|
|
||||||
|
**通用组件工具化**:
|
||||||
|
|
||||||
|
- **clipboard_btn**:WriteClipboardButton/ReadClipboardButton 分离设计,集成 useClipboardActions hook,支持国际化消息
|
||||||
|
- **TableOfContents**:支持中文标题 ID 生成、滚动跟踪、层级缩进、IntersectionObserver 监听
|
||||||
|
- **JsonLd SEO**:多类型数据支持、suppressHydrationWarning、数组/单对象处理
|
||||||
|
- **AutoPopupDialog/YouTubePlayer**:业务场景封装,复用性设计
|
||||||
|
|
||||||
|
### 数据流模式
|
||||||
|
|
||||||
|
- **单向数据流**:Store → Hooks → Components
|
||||||
|
- **状态管理集中化**:所有状态通过 `useFileTransferStore` 统一管理
|
||||||
|
- **错误处理标准化**:统一的消息提示机制(putMessageInMs)
|
||||||
|
- **国际化集成**:useLocale + getDictionary 提供多语言支持
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# PrivyDrop AI Playbook — Reconnect & State Consistency (Deep Dive)
|
||||||
|
|
||||||
|
← Back to flow index: [`docs/ai-playbook/flows.md`](../flows.md)
|
||||||
|
|
||||||
|
(This page is the English edition of content split out from `docs/ai-playbook/flows.zh-CN.md`, preserving the original section numbering and structure.)
|
||||||
|
|
||||||
|
## 10) Reconnect & State Consistency (Deep Dive)
|
||||||
|
|
||||||
|
### WebRTC Base-Layer Reconnect Mechanics
|
||||||
|
|
||||||
|
**Dual disconnect detection**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// webrtc_base.ts
|
||||||
|
private isSocketDisconnected = false; // Socket.IO connection state
|
||||||
|
private isPeerDisconnected = false; // P2P connection state
|
||||||
|
private gracefullyDisconnectedPeers = new Set(); // peers closed gracefully
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reconnect trigger**: only start reconnection when both Socket.IO and P2P are disconnected:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Avoid duplicate reconnects: socket disconnect != P2P disconnect
|
||||||
|
if (
|
||||||
|
this.isSocketDisconnected &&
|
||||||
|
this.isPeerDisconnected &&
|
||||||
|
!this.reconnectionInProgress
|
||||||
|
) {
|
||||||
|
this.attemptReconnection();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ICE Candidate Queue Management
|
||||||
|
|
||||||
|
**Candidate caching strategy**:
|
||||||
|
|
||||||
|
- **Before ready**: cache candidates in the `iceCandidatesQueue` Map, grouped by peerId
|
||||||
|
- **After ready**: flush cached candidates and add them to RTCPeerConnection in order
|
||||||
|
- **Invalid handling**: re-queue invalid candidates and retry after validating connection state
|
||||||
|
|
||||||
|
**Implementation detail**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private iceCandidatesQueue = new Map<string, RTCIceCandidate[]>();
|
||||||
|
// Cache candidates until the connection is ready
|
||||||
|
if (dataChannel?.readyState !== 'open') {
|
||||||
|
this.queueIceCandidate(candidate, peerId);
|
||||||
|
} else {
|
||||||
|
this.addIceCandidate(candidate, peerId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataChannel Send-Retry Mechanism
|
||||||
|
|
||||||
|
**5-attempt retry policy**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async sendToPeer(data: string | ArrayBuffer, peerId: string): Promise<boolean> {
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||||
|
try {
|
||||||
|
dataChannel.send(data);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.gracefullyDisconnectedPeers.has(peerId)) {
|
||||||
|
return false; // skip peers that were closed gracefully
|
||||||
|
}
|
||||||
|
if (attempt === 5) throw error;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, attempt * 100)); // 100ms→1000ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backoff**: 100ms → 200ms → 300ms → 400ms → 500ms, up to 5 attempts
|
||||||
|
|
||||||
|
### Room-Layer Reconnect Support
|
||||||
|
|
||||||
|
**Idempotency**:
|
||||||
|
|
||||||
|
- **Long IDs**: roomId length ≥ 8 supports room reuse across reconnects
|
||||||
|
- **Short IDs**: 4-digit numeric IDs must be re-generated after disconnect to avoid collisions
|
||||||
|
|
||||||
|
**Cached-ID reconnect optimization**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useRoomManager.ts
|
||||||
|
if (roomId.length >= 8) {
|
||||||
|
// long IDs auto-send initiator-online
|
||||||
|
this.sendInitiatorOnline();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State sync sequence**:
|
||||||
|
|
||||||
|
1. **Initiator reconnects**: sends `initiator-online` to signal readiness
|
||||||
|
2. **Recipient replies**: `recipient-ready` confirms readiness
|
||||||
|
3. **WebRTC negotiation**: re-run offer/answer/ICE exchange
|
||||||
|
4. **Transfer continues**: resume file transfer on the new DataChannel
|
||||||
|
|
||||||
|
### State Consistency Safeguards
|
||||||
|
|
||||||
|
**Store as the single source of truth**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// fileTransferStore.ts
|
||||||
|
export const useFileTransferStore = create<TransferState>((set, get) => ({
|
||||||
|
sendProgress: new Map(),
|
||||||
|
receiveProgress: new Map(),
|
||||||
|
// cleanup APIs to avoid double counting
|
||||||
|
clearSendProgress: (fileId: string) =>
|
||||||
|
set((state) => {
|
||||||
|
const newProgress = new Map(state.sendProgress);
|
||||||
|
newProgress.delete(fileId);
|
||||||
|
return { sendProgress: newProgress };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Connection state machine**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
||||||
|
|
||||||
|
// react to state transitions
|
||||||
|
connectionStateChangeHandler(status: ConnectionStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case 'connected':
|
||||||
|
this.gracefullyDisconnectedPeers.clear(peerId);
|
||||||
|
this.resetReconnectionState();
|
||||||
|
break;
|
||||||
|
case 'disconnected':
|
||||||
|
case 'failed':
|
||||||
|
this.cleanupExistingConnection(peerId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile Optimizations
|
||||||
|
|
||||||
|
**Wake lock management**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WakeLockManager
|
||||||
|
async requestWakeLock(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.wakeLock = await navigator.wakeLock.request('screen');
|
||||||
|
this.wakeLock.addEventListener('release', () => {
|
||||||
|
this.wakeLock = null;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Wake lock request failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adapting to network changes**:
|
||||||
|
|
||||||
|
- **Detection**: listen to `connectionstatechange` to infer network quality changes
|
||||||
|
- **Auto-reconnect**: `connectionState: 'disconnected' | 'failed' | 'closed'` all route into the same reconnect path (attemptReconnection)
|
||||||
|
- **Restore state**: after reconnect, restore room status and transfer progress
|
||||||
|
|
||||||
|
**Mobile background/foreground addendum**:
|
||||||
|
|
||||||
|
- **Auto re-join on socket reconnect**: on `socket.on('connect')`, if a `roomId` exists and (`lastJoinedSocketId !== socket.id` or `!isInRoom`), force `joinRoom(roomId, isInitiator, isInitiator)`. The initiator auto-broadcasts `initiator-online`; the recipient replies `recipient-ready`.
|
||||||
|
- **Identity tracking**: after a successful `joinRoom`, record `lastJoinedSocketId = socket.id` to detect “socketId changed after background resume”.
|
||||||
|
- **Lowered threshold**: `attemptReconnection` can start as long as `roomId` exists and any of the following hold: P2P disconnected / socket disconnected / socketId changed. It no longer requires “socket and P2P disconnected at the same time”.
|
||||||
|
|
||||||
|
### Reconnect Debugging Notes
|
||||||
|
|
||||||
|
**Key log points**:
|
||||||
|
|
||||||
|
- **Dual disconnect detection**: record timestamps for Socket.IO vs P2P disconnects
|
||||||
|
- **Candidate queue**: count cached ICE candidates and flush durations
|
||||||
|
- **Send retries**: record retry attempts, delays, and the final result
|
||||||
|
- **State restoration**: trace `initiator-online` → `recipient-ready` ordering
|
||||||
|
|
||||||
|
**Common diagnostics**:
|
||||||
|
|
||||||
|
- **Duplicate reconnects**: check `reconnectionInProgress` and the `gracefullyDisconnectedPeers` set
|
||||||
|
- **Invalid candidates**: validate `iceConnectionState` and `iceGatheringState`
|
||||||
|
- **State divergence**: confirm store progress cleanup and connection-state synchronization
|
||||||
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# PrivyDrop AI Playbook — 重连与状态一致性深度分析(中文)
|
||||||
|
|
||||||
|
← 返回流程入口:[`docs/ai-playbook/flows.zh-CN.md`](../flows.zh-CN.md)
|
||||||
|
|
||||||
|
(本页从 `docs/ai-playbook/flows.zh-CN.md` 拆分,保留原章节编号与内容。)
|
||||||
|
|
||||||
|
## 10)重连与状态一致性深度分析
|
||||||
|
|
||||||
|
### WebRTC 基础层重连机制
|
||||||
|
|
||||||
|
**双重断开检测架构**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// webrtc_base.ts
|
||||||
|
private isSocketDisconnected = false; // Socket.IO 连接状态
|
||||||
|
private isPeerDisconnected = false; // P2P 连接状态
|
||||||
|
private gracefullyDisconnectedPeers = new Set(); // 优雅断开的 peer 列表
|
||||||
|
```
|
||||||
|
|
||||||
|
**重连触发条件**:仅当 Socket.IO 和 P2P 连接都断开时才启动重连:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 避免重复重连:socket 断开 ≠ P2P 断开
|
||||||
|
if (
|
||||||
|
this.isSocketDisconnected &&
|
||||||
|
this.isPeerDisconnected &&
|
||||||
|
!this.reconnectionInProgress
|
||||||
|
) {
|
||||||
|
this.attemptReconnection();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ICE 候选者队列管理
|
||||||
|
|
||||||
|
**候选者缓存策略**:
|
||||||
|
|
||||||
|
- **连接未就绪时**:候选者缓存到 `iceCandidatesQueue` Map,按 peerId 分组
|
||||||
|
- **连接就绪后**:批量处理缓存的候选者,按序添加到 RTCPeerConnection
|
||||||
|
- **失效处理**:候选者失效时重新入队,验证连接状态后重试
|
||||||
|
|
||||||
|
**实现细节**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private iceCandidatesQueue = new Map<string, RTCIceCandidate[]>();
|
||||||
|
// 缓存候选项直到连接就绪
|
||||||
|
if (dataChannel?.readyState !== 'open') {
|
||||||
|
this.queueIceCandidate(candidate, peerId);
|
||||||
|
} else {
|
||||||
|
this.addIceCandidate(candidate, peerId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据通道发送重试机制
|
||||||
|
|
||||||
|
**5 次重试策略**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async sendToPeer(data: string | ArrayBuffer, peerId: string): Promise<boolean> {
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||||
|
try {
|
||||||
|
dataChannel.send(data);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.gracefullyDisconnectedPeers.has(peerId)) {
|
||||||
|
return false; // 跳过已优雅断开的 peer
|
||||||
|
}
|
||||||
|
if (attempt === 5) throw error;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, attempt * 100)); // 100ms→1000ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**重试间隔递增**:100ms → 200ms → 300ms → 400ms → 500ms,最大 5 次尝试
|
||||||
|
|
||||||
|
### 房间管理层的重连支持
|
||||||
|
|
||||||
|
**幂等性设计**:
|
||||||
|
|
||||||
|
- **长 ID 重连**:≥8 字符的 roomId 支持断线重连时复用房间
|
||||||
|
- **短 ID 限制**:4 位数字 ID 断线后需重新生成房间,避免冲突
|
||||||
|
|
||||||
|
**缓存 ID 重连优化**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useRoomManager.ts
|
||||||
|
if (roomId.length >= 8) {
|
||||||
|
// 长ID自动发送 initiator-online 信号
|
||||||
|
this.sendInitiatorOnline();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**状态同步序列**:
|
||||||
|
|
||||||
|
1. **发送方重连**:`initiator-online` 信号通知接收方准备重建连接
|
||||||
|
2. **接收方响应**:`recipient-ready` 确认就绪状态
|
||||||
|
3. **WebRTC 协商**:重新开始 offer/answer/ICE 候选者交换
|
||||||
|
4. **传输恢复**:在新的 DataChannel 上恢复文件传输
|
||||||
|
|
||||||
|
### 状态一致性保证机制
|
||||||
|
|
||||||
|
**Store 层单一事实来源**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// fileTransferStore.ts
|
||||||
|
export const useFileTransferStore = create<TransferState>((set, get) => ({
|
||||||
|
sendProgress: new Map(),
|
||||||
|
receiveProgress: new Map(),
|
||||||
|
// 提供清理 API 避免重复计数
|
||||||
|
clearSendProgress: (fileId: string) =>
|
||||||
|
set((state) => {
|
||||||
|
const newProgress = new Map(state.sendProgress);
|
||||||
|
newProgress.delete(fileId);
|
||||||
|
return { sendProgress: newProgress };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
**连接状态机**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
||||||
|
|
||||||
|
// 状态变更时触发相应处理
|
||||||
|
connectionStateChangeHandler(status: ConnectionStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case 'connected':
|
||||||
|
this.gracefullyDisconnectedPeers.clear(peerId);
|
||||||
|
this.resetReconnectionState();
|
||||||
|
break;
|
||||||
|
case 'disconnected':
|
||||||
|
case 'failed':
|
||||||
|
this.cleanupExistingConnection(peerId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 移动端优化策略
|
||||||
|
|
||||||
|
**唤醒锁管理**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WakeLockManager
|
||||||
|
async requestWakeLock(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.wakeLock = await navigator.wakeLock.request('screen');
|
||||||
|
this.wakeLock.addEventListener('release', () => {
|
||||||
|
this.wakeLock = null;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Wake lock request failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**网络切换适应**:
|
||||||
|
|
||||||
|
- **连接检测**:监听 `connectionstatechange` 事件检测网络质量变化
|
||||||
|
- **自动重连**:`connectionState: 'disconnected' | 'failed' | 'closed'` 时均触发重连流程(统一走 attemptReconnection)
|
||||||
|
- **状态恢复**:重连成功后恢复房间状态和传输进度
|
||||||
|
|
||||||
|
**移动端后台/前台切换补充策略**:
|
||||||
|
|
||||||
|
- **socket 连接恢复自动入房**:`socket.on('connect')` 时,若已持有 `roomId` 且(`lastJoinedSocketId !== socket.id` 或 `!isInRoom`),则强制重新 `joinRoom(roomId, isInitiator, isInitiator)`;发送端会自动广播 `initiator-online`,接收端回复 `recipient-ready`。
|
||||||
|
- **身份追踪**:成功 `joinRoom` 后记录 `lastJoinedSocketId = socket.id`,用以检测“后台恢复时 socketId 更换”的情形。
|
||||||
|
- **门槛放宽**:`attemptReconnection` 只要满足“`roomId` 存在,且满足任一:P2P 断开 / socket 断开 / socketId 改变”,即可发起重连;不再强依赖“socket 与 P2P 同时断开”。
|
||||||
|
|
||||||
|
### 重连调试要点
|
||||||
|
|
||||||
|
**关键日志点**:
|
||||||
|
|
||||||
|
- **双重断开检测**:记录 Socket.IO 和 P2P 断开的具体时间戳
|
||||||
|
- **候选者队列**:统计缓存的 ICE 候选者数量和处理时间
|
||||||
|
- **发送重试**:记录重试次数、间隔和最终结果
|
||||||
|
- **状态恢复**:追踪 `initiator-online` → `recipient-ready` 的时序
|
||||||
|
|
||||||
|
**常见问题诊断**:
|
||||||
|
|
||||||
|
- **重复重连**:检查 `reconnectionInProgress` 标志和 `gracefullyDisconnectedPeers` 集合
|
||||||
|
- **候选者失效**:验证 `iceConnectionState` 和 `iceGatheringState` 状态
|
||||||
|
- **状态不一致**:确认 Store 层的进度清理和连接状态同步
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# PrivyDrop AI Playbook — Resume / Partial Transfer (Deep Dive)
|
||||||
|
|
||||||
|
← Back to flow index: [`docs/ai-playbook/flows.md`](../flows.md)
|
||||||
|
|
||||||
|
(This page is the English edition of content split out from `docs/ai-playbook/flows.zh-CN.md`, preserving the original section numbering and structure.)
|
||||||
|
|
||||||
|
## 9) Resume / Partial Transfer (Deep Dive)
|
||||||
|
|
||||||
|
### Core Resume Mechanism
|
||||||
|
|
||||||
|
**Resume detection & state restoration**:
|
||||||
|
|
||||||
|
- **Sender init**: `StreamingFileReader constructor(file, startOffset)` supports starting from any offset
|
||||||
|
- **Receiver detection**: `StreamingFileWriter.getPartialFileSize()` checks partial files via the File System Access API
|
||||||
|
- **State sync**: the fileRequest message includes an offset parameter to tell the sender where to continue
|
||||||
|
|
||||||
|
**Chunk index calculation**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// unified chunk calculation logic
|
||||||
|
const startChunk = Math.floor(startOffset / chunkSize);
|
||||||
|
const expectedChunks = Math.ceil((fileSize - startOffset) / chunkSize);
|
||||||
|
```
|
||||||
|
|
||||||
|
### ChunkRangeCalculator (Single Source of Truth)
|
||||||
|
|
||||||
|
**Purpose**: ensure sender and receiver use the exact same chunk-range math
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
getChunkRange(fileSize, startOffset, chunkSize) {
|
||||||
|
const startChunk = Math.floor(startOffset / chunkSize);
|
||||||
|
const endChunk = Math.floor((fileSize - 1) / chunkSize);
|
||||||
|
return { startChunk, endChunk, totalChunks: endChunk - startChunk + 1 };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key methods**:
|
||||||
|
|
||||||
|
- `getRelativeChunkIndex()`: convert absolute index to relative index for receiver-side array mapping
|
||||||
|
- `isChunkIndexValid()`: validate that a chunk index is within the expected range
|
||||||
|
- `calculateExpectedChunks()`: compute expected chunk count, aligned with ReceptionConfig
|
||||||
|
|
||||||
|
### Receiver-Side Resume Flow
|
||||||
|
|
||||||
|
**Partial-file detection**:
|
||||||
|
|
||||||
|
1. **Prepare directories**: `createFolderStructure()` ensures the target directory exists
|
||||||
|
2. **Lookup file**: `getFileHandle(fileName, {create: false})` checks if a file already exists
|
||||||
|
3. **Get size**: `file.getFile()` returns the current size as the resume starting point
|
||||||
|
|
||||||
|
**Resume decision logic**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// FileReceiveOrchestrator.ts
|
||||||
|
const offset = await this.streamingFileWriter.getPartialFileSize(
|
||||||
|
fileInfo.name,
|
||||||
|
fileInfo.fullName
|
||||||
|
);
|
||||||
|
if (offset === fileInfo.size) {
|
||||||
|
// file is already complete; skip transfer
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (offset > 0) {
|
||||||
|
// partial file found; resume
|
||||||
|
// send fileRequest with offset
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sender-Side Resume Response
|
||||||
|
|
||||||
|
**Preparation**:
|
||||||
|
|
||||||
|
- **Reset reader**: `StreamingFileReader.reset(startOffset)` starts reading from the new offset
|
||||||
|
- **Batch alignment**: `currentBatchStartOffset` and `totalFileOffset` are updated in sync
|
||||||
|
- **Chunk indices**: `startChunkIndex` records the transfer start point for boundary checks
|
||||||
|
|
||||||
|
**Resume log**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const chunkRange = ChunkRangeCalculator.getChunkRange(
|
||||||
|
fileSize,
|
||||||
|
startOffset,
|
||||||
|
chunkSize
|
||||||
|
);
|
||||||
|
postLogToBackend(
|
||||||
|
`[SEND-SUMMARY] File: ${file.name}, offset: ${startOffset}, startChunk: ${chunkRange.startChunk}, endChunk: ${chunkRange.endChunk}`
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits & Limitations
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
|
||||||
|
- **Saves bandwidth**: avoids re-sending already received bytes
|
||||||
|
- **Faster recovery**: large transfers can resume quickly after interruption
|
||||||
|
- **Better UX**: transient network issues don’t reset progress to zero
|
||||||
|
|
||||||
|
**Limitations / caveats**:
|
||||||
|
|
||||||
|
- **File consistency**: assumes file content hasn’t changed; consider validating size/mtime before resuming
|
||||||
|
- **Save location requirement**: supported when the user chose a save directory via the File System Access API
|
||||||
|
- **Browser support**: File System Access API is mainly Chrome/Edge; other browsers fall back to in-memory storage
|
||||||
|
|
||||||
|
**Debug support**:
|
||||||
|
|
||||||
|
- **Verbose logs**: record resume offset, chunk range, and expected transfer volume in dev
|
||||||
|
- **Error handling**: if file access fails, fall back to a full transfer from the start
|
||||||
|
- **State tracking**: the store records resume state and actual received size
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# PrivyDrop AI Playbook — 断点续传深度分析(中文)
|
||||||
|
|
||||||
|
← 返回流程入口:[`docs/ai-playbook/flows.zh-CN.md`](../flows.zh-CN.md)
|
||||||
|
|
||||||
|
(本页从 `docs/ai-playbook/flows.zh-CN.md` 拆分,保留原章节编号与内容。)
|
||||||
|
|
||||||
|
## 9)断点续传深度分析
|
||||||
|
|
||||||
|
### 断点续传核心机制
|
||||||
|
|
||||||
|
**续传检测与状态恢复**:
|
||||||
|
|
||||||
|
- **发送侧初始化**:`StreamingFileReader constructor(file, startOffset)` 支持从任意偏移量开始
|
||||||
|
- **接收侧检测**:`StreamingFileWriter.getPartialFileSize()` 通过 File System Access API 检查部分文件
|
||||||
|
- **状态同步**:fileRequest 消息包含 offset 参数,通知发送方从指定位置继续传输
|
||||||
|
|
||||||
|
**分片索引计算**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 统一的分片计算逻辑
|
||||||
|
const startChunk = Math.floor(startOffset / chunkSize);
|
||||||
|
const expectedChunks = Math.ceil((fileSize - startOffset) / chunkSize);
|
||||||
|
```
|
||||||
|
|
||||||
|
### ChunkRangeCalculator 统一计算器
|
||||||
|
|
||||||
|
**设计目的**:确保发送端和接收端使用完全相同的分片计算逻辑
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
getChunkRange(fileSize, startOffset, chunkSize) {
|
||||||
|
const startChunk = Math.floor(startOffset / chunkSize);
|
||||||
|
const endChunk = Math.floor((fileSize - 1) / chunkSize);
|
||||||
|
return { startChunk, endChunk, totalChunks: endChunk - startChunk + 1 };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键方法**:
|
||||||
|
|
||||||
|
- `getRelativeChunkIndex()`:绝对索引转相对索引,用于接收端数组映射
|
||||||
|
- `isChunkIndexValid()`:验证分片索引是否在预期范围内
|
||||||
|
- `calculateExpectedChunks()`:计算预期分片数量,与 ReceptionConfig 保持一致
|
||||||
|
|
||||||
|
### 接收侧续传流程
|
||||||
|
|
||||||
|
**部分文件检测**:
|
||||||
|
|
||||||
|
1. **目录准备**:`createFolderStructure()` 确保目标目录存在
|
||||||
|
2. **文件查询**:通过 `getFileHandle(fileName, {create: false})` 检查文件是否存在
|
||||||
|
3. **大小获取**:`file.getFile()` 获取当前文件大小作为续传起点
|
||||||
|
|
||||||
|
**续传决策逻辑**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// FileReceiveOrchestrator.ts
|
||||||
|
const offset = await this.streamingFileWriter.getPartialFileSize(
|
||||||
|
fileInfo.name,
|
||||||
|
fileInfo.fullName
|
||||||
|
);
|
||||||
|
if (offset === fileInfo.size) {
|
||||||
|
// 文件已完整,跳过传输
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (offset > 0) {
|
||||||
|
// 发现部分文件,准备续传
|
||||||
|
// 发送包含 offset 的 fileRequest
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发送侧续传响应
|
||||||
|
|
||||||
|
**续传准备**:
|
||||||
|
|
||||||
|
- **重置读取器**:`StreamingFileReader.reset(startOffset)` 从新的偏移量开始
|
||||||
|
- **批次调整**:`currentBatchStartOffset` 和 `totalFileOffset` 同步更新
|
||||||
|
- **分片索引**:`startChunkIndex` 记录传输起始点,用于边界检测
|
||||||
|
|
||||||
|
**续传日志**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const chunkRange = ChunkRangeCalculator.getChunkRange(
|
||||||
|
fileSize,
|
||||||
|
startOffset,
|
||||||
|
chunkSize
|
||||||
|
);
|
||||||
|
postLogToBackend(
|
||||||
|
`[SEND-SUMMARY] File: ${file.name}, offset: ${startOffset}, startChunk: ${chunkRange.startChunk}, endChunk: ${chunkRange.endChunk}`
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 续传的优势与限制
|
||||||
|
|
||||||
|
**优势**:
|
||||||
|
|
||||||
|
- **带宽节省**:避免重新传输已接收的数据
|
||||||
|
- **时间效率**:大文件传输中断后可快速恢复
|
||||||
|
- **用户体验**:网络波动不会导致传输进度完全丢失
|
||||||
|
|
||||||
|
**限制与注意点**:
|
||||||
|
|
||||||
|
- **文件一致性**:依赖文件内容未发生变化,续传前应验证文件大小/修改时间
|
||||||
|
- **存储位置**:仅在使用 File System Access API 选择保存目录时支持
|
||||||
|
- **浏览器兼容**:File System Access API 主要支持 Chrome/Edge,其他浏览器降级为内存存储
|
||||||
|
|
||||||
|
**调试支持**:
|
||||||
|
|
||||||
|
- **详细日志**:开发环境下记录续传起点、分片范围、预期传输量
|
||||||
|
- **错误处理**:文件访问失败时回退到从头开始传输
|
||||||
|
- **状态跟踪**:Store 层记录续传状态和实际接收大小
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# PrivyDrop AI Playbook — Context & Index
|
||||||
|
|
||||||
|
This playbook is a high signal-to-noise entry point for AI and developers, helping you jump to the right place in the codebase fast. It contains project context and an index of links, not step-by-step task guides.
|
||||||
|
|
||||||
|
## Project Snapshot
|
||||||
|
|
||||||
|
- Product: WebRTC-based P2P file/text sharing. Data transfers directly between browsers via RTCDataChannel with end-to-end encryption.
|
||||||
|
- Frontend: Next.js 14 (App Router), React 18, TypeScript, Tailwind, shadcn/ui.
|
||||||
|
- Backend: Node.js, Express, Socket.IO, Redis; optional STUN/TURN for NAT traversal.
|
||||||
|
- Privacy stance: The server must never relay file data; the backend is for signaling and room coordination only.
|
||||||
|
|
||||||
|
## Document Index
|
||||||
|
|
||||||
|
- README
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
- AI Playbook
|
||||||
|
|
||||||
|
- Code map: `docs/ai-playbook/code-map.md`
|
||||||
|
- Flows (includes micro-plan template): `docs/ai-playbook/flows.md`
|
||||||
|
- Flows (deep dives, split out): `docs/ai-playbook/flows/frontend.md`, `docs/ai-playbook/flows/backpressure-chunking.md`, `docs/ai-playbook/flows/resume.md`, `docs/ai-playbook/flows/reconnect-consistency.md`
|
||||||
|
- Collaboration rules: `docs/ai-playbook/collab-rules.md`
|
||||||
|
|
||||||
|
- System & Architecture
|
||||||
|
|
||||||
|
- System architecture: `docs/ARCHITECTURE.md` / `docs/ARCHITECTURE.zh-CN.md`
|
||||||
|
- Frontend architecture: `docs/FRONTEND_ARCHITECTURE.md` / `docs/FRONTEND_ARCHITECTURE.zh-CN.md`
|
||||||
|
- Backend architecture: `docs/BACKEND_ARCHITECTURE.md` / `docs/BACKEND_ARCHITECTURE.zh-CN.md`
|
||||||
|
|
||||||
|
- Deployment
|
||||||
|
- Docker deployment: `docs/DEPLOYMENT_docker.md` / `docs/DEPLOYMENT_docker.zh-CN.md`
|
||||||
|
|
||||||
|
## Key Modules at a Glance
|
||||||
|
|
||||||
|
- Frontend core
|
||||||
|
- Hooks: `frontend/hooks/useWebRTCConnection.ts` (connection orchestration), `useRoomManager.ts` (room lifecycle), `useFileTransferHandler.ts` (payload orchestration).
|
||||||
|
- WebRTC base: `frontend/lib/webrtc_base.ts` (Socket.IO signaling, RTCPeerConnection, data channel).
|
||||||
|
- Roles: `frontend/lib/webrtc_Initiator.ts`, `frontend/lib/webrtc_Recipient.ts` (initiator/recipient behavior).
|
||||||
|
- Sending: `frontend/lib/transfer/*`, `frontend/lib/fileSender.ts` (metadata, chunking, progress).
|
||||||
|
- Receiving: `frontend/lib/receive/*`, `frontend/lib/fileReceiver.ts` (assembly, validation, persistence).
|
||||||
|
- Store: `frontend/stores/fileTransferStore.ts` (single source of truth for progress/state).
|
||||||
|
- Backend core
|
||||||
|
- Socket.IO: `backend/src/socket/handlers.ts` (join, initiator-online, recipient-ready, offer/answer/ice-candidate).
|
||||||
|
- Services: `backend/src/services/{room,redis,rateLimit}.ts`.
|
||||||
|
- REST: `backend/src/routes/api.ts` (rooms, tracking, debug logs).
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
- Keep it lean and factual; avoid duplicating system-level docs.
|
||||||
|
- This playbook exists to support collaboration and quick orientation.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# PrivyDrop AI Playbook — 上下文与索引(中文)
|
||||||
|
|
||||||
|
本手册为 AI 与开发者提供一个高信噪比的入口,帮助快速定位到正确的代码位置。仅包含项目上下文与链接索引,不提供步骤化的任务指南。
|
||||||
|
|
||||||
|
## 项目快照
|
||||||
|
|
||||||
|
- 产品:基于 WebRTC 的 P2P 文件/文本分享,浏览器之间通过 RTCDataChannel 直接传输,端到端加密。
|
||||||
|
- 前端:Next.js 14(App Router)、React 18、TypeScript、Tailwind、shadcn/ui。
|
||||||
|
- 后端:Node.js、Express、Socket.IO、Redis;可选 STUN/TURN 做 NAT 穿透。
|
||||||
|
- 隐私立场:服务器不承载文件数据中转;后端仅负责信令与房间协调。
|
||||||
|
|
||||||
|
## 文档索引
|
||||||
|
|
||||||
|
- README
|
||||||
|
|
||||||
|
- `README.zh-CN.md`
|
||||||
|
|
||||||
|
- AI Playbook
|
||||||
|
|
||||||
|
- 代码地图:`docs/ai-playbook/code-map.zh-CN.md`
|
||||||
|
- 流程(含微方案模板):`docs/ai-playbook/flows.zh-CN.md`
|
||||||
|
- 流程(深度阅读拆分):`docs/ai-playbook/flows/frontend.zh-CN.md`、`docs/ai-playbook/flows/backpressure-chunking.zh-CN.md`、`docs/ai-playbook/flows/resume.zh-CN.md`、`docs/ai-playbook/flows/reconnect-consistency.zh-CN.md`
|
||||||
|
- 协作规则:`docs/ai-playbook/collab-rules.zh-CN.md`
|
||||||
|
|
||||||
|
- 系统与架构
|
||||||
|
|
||||||
|
- 系统架构:`docs/ARCHITECTURE.md` / `docs/ARCHITECTURE.zh-CN.md`
|
||||||
|
- 前端架构:`docs/FRONTEND_ARCHITECTURE.md` / `docs/FRONTEND_ARCHITECTURE.zh-CN.md`
|
||||||
|
- 后端架构:`docs/BACKEND_ARCHITECTURE.md` / `docs/BACKEND_ARCHITECTURE.zh-CN.md`
|
||||||
|
|
||||||
|
- 部署
|
||||||
|
- Docker 部署:`docs/DEPLOYMENT_docker.md` / `docs/DEPLOYMENT_docker.zh-CN.md`
|
||||||
|
|
||||||
|
## 关键模块速览
|
||||||
|
|
||||||
|
- 前端核心
|
||||||
|
- Hooks:`frontend/hooks/useWebRTCConnection.ts`(连接编排)、`useRoomManager.ts`(房间生命周期)、`useFileTransferHandler.ts`(负载编排)。
|
||||||
|
- WebRTC 基础:`frontend/lib/webrtc_base.ts`(Socket.IO 信令、RTCPeerConnection、数据通道)。
|
||||||
|
- 角色:`frontend/lib/webrtc_Initiator.ts`、`frontend/lib/webrtc_Recipient.ts`(发起/接收角色行为)。
|
||||||
|
- 发送:`frontend/lib/transfer/*`、`frontend/lib/fileSender.ts`(元数据、分片、进度)。
|
||||||
|
- 接收:`frontend/lib/receive/*`、`frontend/lib/fileReceiver.ts`(组装、校验、持久化)。
|
||||||
|
- Store:`frontend/stores/fileTransferStore.ts`(进度/状态的单一事实来源)。
|
||||||
|
- 后端核心
|
||||||
|
- Socket.IO:`backend/src/socket/handlers.ts`(join、initiator-online、recipient-ready、offer/answer/ice-candidate)。
|
||||||
|
- Services:`backend/src/services/{room,redis,rateLimit}.ts`。
|
||||||
|
- REST:`backend/src/routes/api.ts`(房间、追踪、调试日志)。
|
||||||
|
|
||||||
|
## 维护
|
||||||
|
|
||||||
|
- 保持精简与事实,避免与系统级文档重复。
|
||||||
|
- 本文用于团队协作与快速理解。
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
name: "signaling-server",
|
|
||||||
cwd: "./backend",
|
|
||||||
script: "./dist/server.js",
|
|
||||||
watch: false,
|
|
||||||
env: {
|
|
||||||
NODE_ENV: "production",
|
|
||||||
BACKEND_PORT: 3001,
|
|
||||||
},
|
|
||||||
log_date_format: "YYYY-MM-DD HH:mm:ss",
|
|
||||||
error_file: "/var/log/signaling-server-error.log",
|
|
||||||
out_file: "/var/log/signaling-server-out.log",
|
|
||||||
max_memory_restart: "500M",
|
|
||||||
instances: 1,
|
|
||||||
exec_mode: "fork",
|
|
||||||
group: "ssl-cert",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "privydrop-frontend",
|
|
||||||
cwd: "./frontend",
|
|
||||||
script: "npm",
|
|
||||||
args: "run start",
|
|
||||||
watch: false,
|
|
||||||
env: {
|
|
||||||
NODE_ENV: "production"
|
|
||||||
},
|
|
||||||
log_date_format: "YYYY-MM-DD HH:mm:ss",
|
|
||||||
error_file: "/var/log/privydrop-frontend-error.log",
|
|
||||||
out_file: "/var/log/privydrop-frontend-out.log",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -2,4 +2,6 @@ NEXT_PUBLIC_API_URL=https://www.privydrop.app
|
|||||||
|
|
||||||
NEXT_PUBLIC_TURN_HOST=turn.privydrop.app
|
NEXT_PUBLIC_TURN_HOST=turn.privydrop.app
|
||||||
NEXT_PUBLIC_TURN_USERNAME=[Username]
|
NEXT_PUBLIC_TURN_USERNAME=[Username]
|
||||||
NEXT_PUBLIC_TURN_PASSWORD=[Password]
|
NEXT_PUBLIC_TURN_PASSWORD=[Password]
|
||||||
|
|
||||||
|
NEXT_IMAGE_UNOPTIMIZED=true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node-linker=hoisted
|
||||||
|
public-hoist-pattern[]=@next/env
|
||||||
|
public-hoist-pattern[]=styled-jsx
|
||||||
|
public-hoist-pattern[]=@swc/helpers
|
||||||
|
|
||||||
+1
-1
@@ -41,7 +41,7 @@ Before you start, please ensure you have **installed and started the backend ser
|
|||||||
|
|
||||||
- To understand the complete project architecture and how components collaborate, please see the [**Overall Project Architecture**](../docs/ARCHITECTURE.md).
|
- To understand the complete project architecture and how components collaborate, please see the [**Overall Project Architecture**](../docs/ARCHITECTURE.md).
|
||||||
- To dive deep into the frontend's code structure, Hooks design, and state management, please read the [**Frontend Architecture Deep Dive**](../docs/FRONTEND_ARCHITECTURE.md).
|
- To dive deep into the frontend's code structure, Hooks design, and state management, please read the [**Frontend Architecture Deep Dive**](../docs/FRONTEND_ARCHITECTURE.md).
|
||||||
- For instructions on deploying in a production environment, please refer to the [**Deployment Guide**](../docs/DEPLOYMENT.md).
|
- For production deployment, see the [**Docker Deployment Guide**](../docs/DEPLOYMENT_docker.md).
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
- 要了解完整的项目架构和组件协作方式,请参阅 [**项目整体架构**](../docs/ARCHITECTURE.zh-CN.md)。
|
- 要了解完整的项目架构和组件协作方式,请参阅 [**项目整体架构**](../docs/ARCHITECTURE.zh-CN.md)。
|
||||||
- 要深入理解前端的代码结构、Hooks 设计和状态管理,请阅读 [**前端架构详解**](../docs/FRONTEND_ARCHITECTURE.zh-CN.md)。
|
- 要深入理解前端的代码结构、Hooks 设计和状态管理,请阅读 [**前端架构详解**](../docs/FRONTEND_ARCHITECTURE.zh-CN.md)。
|
||||||
- 有关生产环境的部署方法,请参考 [**部署指南**](../docs/DEPLOYMENT.zh-CN.md)。
|
- 有关生产环境部署,请参考 [**Docker 部署指南**](../docs/DEPLOYMENT_docker.zh-CN.md)。
|
||||||
|
|
||||||
## 🤝 参与贡献
|
## 🤝 参与贡献
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import ClipboardApp from "@/components/ClipboardApp";
|
import ClipboardApp from "@/components/ClipboardApp";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import SystemDiagram from "@/components/web/SystemDiagram";
|
import SystemDiagram from "@/components/web/SystemDiagram";
|
||||||
import FAQSection from "@/components/web/FAQSection";
|
import FAQSection from "@/components/web/FAQSection";
|
||||||
import HowItWorks from "@/components/web/HowItWorks";
|
import HowItWorks from "@/components/web/HowItWorks";
|
||||||
import YouTubePlayer from "@/components/common/YouTubePlayer";
|
import YouTubePlayer from "@/components/common/YouTubePlayer";
|
||||||
import KeyFeatures from "@/components/web/KeyFeatures";
|
import KeyFeatures from "@/components/web/KeyFeatures";
|
||||||
import type { Messages } from "@/types/messages";
|
|
||||||
import LazyLoadWrapper from "@/components/common/LazyLoadWrapper";
|
import LazyLoadWrapper from "@/components/common/LazyLoadWrapper";
|
||||||
|
|
||||||
interface PageContentProps {
|
export default function HomeClient() {
|
||||||
messages: Messages;
|
const t = useTranslations("text.home");
|
||||||
lang: string;
|
const lang = useLocale();
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomeClient({ messages, lang }: PageContentProps) {
|
|
||||||
const youtube_videoId = lang === "zh" ? "I0RLCpcbUXs" : "ypt-po_R2Ds";
|
const youtube_videoId = lang === "zh" ? "I0RLCpcbUXs" : "ypt-po_R2Ds";
|
||||||
const bilibili_videoId = lang === "zh" ? "BV1knrjYZEfn" : "BV1yErjYFEV7";
|
const bilibili_videoId = lang === "zh" ? "BV1knrjYZEfn" : "BV1yErjYFEV7";
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<h1 className="text-4xl font-bold mb-2 text-center">
|
<h1 className="text-4xl font-bold mb-2 text-center">{t("hero.title")}</h1>
|
||||||
{messages.text.home.h1}
|
<p className="text-xl mb-4 text-center">{t("hero.subtitle")}</p>
|
||||||
</h1>
|
|
||||||
<p className="text-xl mb-4 text-center">{messages.text.home.h1P}</p>
|
|
||||||
{/* App Section */}
|
{/* App Section */}
|
||||||
<section
|
<section
|
||||||
id="clipboard-app"
|
id="clipboard-app"
|
||||||
@@ -33,33 +28,37 @@ export default function HomeClient({ messages, lang }: PageContentProps) {
|
|||||||
<div className="w-full max-w-none">
|
<div className="w-full max-w-none">
|
||||||
{/* sr-only--screen-only: visually hidden */}
|
{/* sr-only--screen-only: visually hidden */}
|
||||||
<h2 className={cn("sr-only", "text-3xl font-bold mb-8 text-center")}>
|
<h2 className={cn("sr-only", "text-3xl font-bold mb-8 text-center")}>
|
||||||
{messages.text.home.h2_screenOnly}
|
{t("hero.screenOnlyTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<ClipboardApp />
|
<ClipboardApp />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{/* How It Works Section */}
|
||||||
|
<section aria-label="How It Works">
|
||||||
|
<LazyLoadWrapper>
|
||||||
|
<HowItWorks />
|
||||||
|
</LazyLoadWrapper>
|
||||||
|
</section>
|
||||||
{/* Demo Video Section */}
|
{/* Demo Video Section */}
|
||||||
<section className="mb-12" aria-label="Product Demo">
|
<section className="mb-12" aria-label="Product Demo">
|
||||||
<LazyLoadWrapper>
|
<LazyLoadWrapper>
|
||||||
<h2 className="text-3xl font-bold mb-6 text-center">
|
<h2 className="text-3xl font-bold mb-6 text-center">
|
||||||
{messages.text.home.h2_demo}
|
{t("demo.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-center mb-6 text-gray-600">
|
<p className="text-center mb-6 text-muted-foreground">
|
||||||
{messages.text.home.h2P_demo}
|
{t("demo.description")}
|
||||||
</p>
|
</p>
|
||||||
<YouTubePlayer videoId={youtube_videoId} />
|
<YouTubePlayer videoId={youtube_videoId} />
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
<p className="mb-3 text-gray-700">
|
<p className="mb-3 text-foreground">{t("demo.watchTip")}</p>
|
||||||
{messages.text.home.watch_tips}
|
|
||||||
</p>
|
|
||||||
<a
|
<a
|
||||||
className="flex justify-center gap-4 text-blue-500 hover:underline transition-colors"
|
className="flex justify-center gap-4 text-blue-500 hover:underline transition-colors"
|
||||||
href={`https://www.youtube.com/watch?v=${youtube_videoId}`}
|
href={`https://www.youtube.com/watch?v=${youtube_videoId}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
{messages.text.home.youtube_tips}
|
{t("demo.youtube")}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
className="flex justify-center gap-4 text-blue-500 hover:underline transition-colors"
|
className="flex justify-center gap-4 text-blue-500 hover:underline transition-colors"
|
||||||
@@ -67,41 +66,27 @@ export default function HomeClient({ messages, lang }: PageContentProps) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
{messages.text.home.bilibili_tips}
|
{t("demo.bilibili")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</LazyLoadWrapper>
|
</LazyLoadWrapper>
|
||||||
</section>
|
</section>
|
||||||
{/* How It Works Section */}
|
|
||||||
<section aria-label="How It Works">
|
|
||||||
<LazyLoadWrapper>
|
|
||||||
<HowItWorks messages={messages} />
|
|
||||||
</LazyLoadWrapper>
|
|
||||||
</section>
|
|
||||||
{/* System Architecture Section */}
|
{/* System Architecture Section */}
|
||||||
<section aria-label="System Architecture">
|
<section aria-label="System Architecture">
|
||||||
<LazyLoadWrapper>
|
<LazyLoadWrapper>
|
||||||
<SystemDiagram messages={messages} />
|
<SystemDiagram />
|
||||||
</LazyLoadWrapper>
|
</LazyLoadWrapper>
|
||||||
</section>
|
</section>
|
||||||
{/* Key Features */}
|
{/* Key Features */}
|
||||||
<section aria-label="Key Features">
|
<section aria-label="Key Features">
|
||||||
<LazyLoadWrapper>
|
<LazyLoadWrapper>
|
||||||
<KeyFeatures
|
<KeyFeatures isInToolPage titleClassName="text-2xl md:text-3xl" />
|
||||||
messages={messages}
|
|
||||||
isInToolPage
|
|
||||||
titleClassName="text-2xl md:text-3xl"
|
|
||||||
/>
|
|
||||||
</LazyLoadWrapper>
|
</LazyLoadWrapper>
|
||||||
</section>
|
</section>
|
||||||
{/* FAQ Section */}
|
{/* FAQ Section */}
|
||||||
<section aria-label="Frequently Asked Questions">
|
<section aria-label="Frequently Asked Questions">
|
||||||
<LazyLoadWrapper>
|
<LazyLoadWrapper>
|
||||||
<FAQSection
|
<FAQSection isInToolPage titleClassName="text-2xl md:text-3xl" />
|
||||||
messages={messages}
|
|
||||||
isInToolPage
|
|
||||||
titleClassName="text-2xl md:text-3xl"
|
|
||||||
/>
|
|
||||||
</LazyLoadWrapper>
|
</LazyLoadWrapper>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,38 +1,39 @@
|
|||||||
import type { Messages } from "@/types/messages";
|
"use client";
|
||||||
|
|
||||||
interface AboutContentProps {
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
messages: Messages;
|
|
||||||
lang: string;
|
export default function AboutContent() {
|
||||||
}
|
const aboutT = useTranslations("text.about");
|
||||||
|
const privacyT = useTranslations("text.privacy");
|
||||||
|
const termsT = useTranslations("text.terms");
|
||||||
|
const helpT = useTranslations("text.help");
|
||||||
|
const lang = useLocale();
|
||||||
|
|
||||||
export default function AboutContent({ messages, lang }: AboutContentProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<h1 className="text-3xl font-bold text-center mb-6">
|
<h1 className="text-3xl font-bold text-center mb-6">{aboutT("h1")}</h1>
|
||||||
{messages.text.about.h1}
|
<p className="text-lg mb-4">{aboutT("paragraphs.0")}</p>
|
||||||
</h1>
|
<p className="text-lg mb-4">{aboutT("paragraphs.1")}</p>
|
||||||
<p className="text-lg mb-4">{messages.text.about.P1}</p>
|
<p className="text-lg mb-4">{aboutT("paragraphs.2")}</p>
|
||||||
<p className="text-lg mb-4">{messages.text.about.P2}</p>
|
<p className="text-lg mb-4">{aboutT("paragraphs.3")}</p>
|
||||||
<p className="text-lg mb-4">{messages.text.about.P3}</p>
|
<p className="text-lg mb-4">{aboutT("paragraphs.4")}</p>
|
||||||
<p className="text-lg mb-4">{messages.text.about.P4}</p>
|
|
||||||
<p className="text-lg mb-4">{messages.text.about.P5}</p>
|
|
||||||
<ul className="list-disc pl-6">
|
<ul className="list-disc pl-6">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={`/${lang}/privacy`}
|
href={`/${lang}/privacy`}
|
||||||
className="text-blue-500 hover:underline"
|
className="text-blue-500 hover:underline"
|
||||||
>
|
>
|
||||||
{messages.text.privacy.PrivacyPolicy_dis}
|
{privacyT("policyLabel")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={`/${lang}/terms`} className="text-blue-500 hover:underline">
|
<a href={`/${lang}/terms`} className="text-blue-500 hover:underline">
|
||||||
{messages.text.terms.TermsOfUse_dis}
|
{termsT("useLabel")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={`/${lang}/help`} className="text-blue-500 hover:underline">
|
<a href={`/${lang}/help`} className="text-blue-500 hover:underline">
|
||||||
{messages.text.help.Help_dis}
|
{helpT("label")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import { getDictionary } from "@/lib/dictionary";
|
|
||||||
import AboutContent from "./AboutContent";
|
import AboutContent from "./AboutContent";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { supportedLocales } from "@/constants/i18n-config";
|
import { getMessages } from "next-intl/server";
|
||||||
|
import { supportedLocales, type Locale } from "@/constants/i18n-config";
|
||||||
|
import type { Messages } from "@/types/messages";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { lang: string };
|
params: { lang: string };
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const messages = await getDictionary(params.lang);
|
const lang = params.lang as Locale;
|
||||||
|
const messages = (await getMessages({ locale: lang })) as Messages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: messages.meta.about.title,
|
title: messages.meta.about.title,
|
||||||
description: messages.meta.about.description,
|
description: messages.meta.about.description,
|
||||||
metadataBase: new URL("https://www.privydrop.app"),
|
metadataBase: new URL("https://www.privydrop.app"),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${params.lang}/about`,
|
canonical: `/${lang}/about`,
|
||||||
languages: Object.fromEntries(
|
languages: Object.fromEntries(
|
||||||
supportedLocales.map((lang) => [lang, `/${lang}/about`])
|
supportedLocales.map((lang) => [lang, `/${lang}/about`])
|
||||||
),
|
),
|
||||||
@@ -23,20 +25,14 @@ export async function generateMetadata({
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: messages.meta.about.title,
|
title: messages.meta.about.title,
|
||||||
description: messages.meta.about.description,
|
description: messages.meta.about.description,
|
||||||
url: `https://www.privydrop.app/${params.lang}/about`,
|
url: `https://www.privydrop.app/${lang}/about`,
|
||||||
siteName: "PrivyDrop",
|
siteName: "PrivyDrop",
|
||||||
locale: params.lang,
|
locale: lang,
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function About({
|
export default function About() {
|
||||||
params: { lang },
|
return <AboutContent />;
|
||||||
}: {
|
|
||||||
params: { lang: string };
|
|
||||||
}) {
|
|
||||||
const messages = await getDictionary(lang);
|
|
||||||
|
|
||||||
return <AboutContent messages={messages} lang={lang} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,49 @@
|
|||||||
// app/[lang]/blog/[slug]/metadata.ts
|
// app/[lang]/blog/[slug]/metadata.ts
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
import { getMessages } from "next-intl/server";
|
||||||
import { getPostBySlug } from "@/lib/blog";
|
import { getPostBySlug } from "@/lib/blog";
|
||||||
import { generateMetadata as generateBlogMetadata } from "../metadata";
|
import { generateMetadata as generateBlogMetadata } from "../metadata";
|
||||||
|
import { supportedLocales } from "@/constants/i18n-config";
|
||||||
|
import type { Messages } from "@/types/messages";
|
||||||
|
import type { Locale } from "@/constants/i18n-config";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { slug: string; lang: string };
|
params: { slug: string; lang: string };
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const post = await getPostBySlug(params.slug, params.lang);
|
const lang = params.lang as Locale;
|
||||||
|
const post = await getPostBySlug(params.slug, lang);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
//blog not found
|
//blog not found
|
||||||
// Call the generateMetadata function of the blog homepage and pass in the parameters
|
// Call the generateMetadata function of the blog homepage and pass in the parameters
|
||||||
return generateBlogMetadata({ params: { lang: params.lang } });
|
return generateBlogMetadata({ params: { lang } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messages = (await getMessages({ locale: lang })) as Messages;
|
||||||
|
const blogWord = messages.text.navigation.blog;
|
||||||
|
const blogCap = blogWord.charAt(0).toUpperCase() + blogWord.slice(1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${post.frontmatter.title} | PrivyDrop Blog`,
|
title: `${post.frontmatter.title} | PrivyDrop ${blogCap}`,
|
||||||
description: post.frontmatter.description,
|
description: post.frontmatter.description,
|
||||||
keywords: `${post.frontmatter.tags.join(
|
keywords: `${post.frontmatter.tags.join(
|
||||||
", "
|
", "
|
||||||
)}, secure file sharing, p2p transfer, privacy`,
|
)}, secure file sharing, p2p transfer, privacy`,
|
||||||
metadataBase: new URL("https://www.privydrop.app"),
|
metadataBase: new URL("https://www.privydrop.app"),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${params.lang}/blog/${params.slug}`,
|
canonical: `/${lang}/blog/${params.slug}`,
|
||||||
languages: {
|
languages: Object.fromEntries(
|
||||||
en: `/en/blog/${params.slug}`,
|
supportedLocales.map((l) => [l, `/${l}/blog/${params.slug}`])
|
||||||
zh: `/zh/blog/${params.slug}`,
|
),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: post.frontmatter.title,
|
title: post.frontmatter.title,
|
||||||
description: post.frontmatter.description,
|
description: post.frontmatter.description,
|
||||||
url: `https://www.privydrop.app/${params.lang}/blog/${params.slug}`,
|
url: `https://www.privydrop.app/${lang}/blog/${params.slug}`,
|
||||||
siteName: "PrivyDrop",
|
siteName: "PrivyDrop",
|
||||||
locale: params.lang,
|
locale: lang,
|
||||||
type: "article",
|
type: "article",
|
||||||
publishedTime: post.frontmatter.date,
|
publishedTime: post.frontmatter.date,
|
||||||
modifiedTime: post.frontmatter.date,
|
modifiedTime: post.frontmatter.date,
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
//Article detail page
|
//Article detail page
|
||||||
import { MDXRemote } from "next-mdx-remote/rsc";
|
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||||
|
import { getMessages } from "next-intl/server";
|
||||||
import { getPostBySlug } from "@/lib/blog";
|
import { getPostBySlug } from "@/lib/blog";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { mdxOptions } from "@/lib/mdx-config";
|
import { mdxOptions } from "@/lib/mdx-config";
|
||||||
import { mdxComponents } from "@/components/blog/MDXComponents";
|
import { mdxComponents } from "@/components/blog/MDXComponents";
|
||||||
import { TableOfContents } from "@/components/blog/TableOfContents";
|
import { TableOfContents } from "@/components/blog/TableOfContents";
|
||||||
import { generateMetadata } from "./metadata";
|
import { generateMetadata } from "./metadata";
|
||||||
|
import JsonLd from "@/components/seo/JsonLd";
|
||||||
|
import {
|
||||||
|
absoluteUrl,
|
||||||
|
buildBlogPostingJsonLd,
|
||||||
|
buildBreadcrumbJsonLd,
|
||||||
|
getSiteUrl,
|
||||||
|
} from "@/lib/seo/jsonld";
|
||||||
|
import type { Messages } from "@/types/messages";
|
||||||
|
import type { Locale } from "@/constants/i18n-config";
|
||||||
|
|
||||||
export { generateMetadata };
|
export { generateMetadata };
|
||||||
|
|
||||||
@@ -14,25 +24,50 @@ export default async function BlogPost({
|
|||||||
}: {
|
}: {
|
||||||
params: { slug: string; lang: string };
|
params: { slug: string; lang: string };
|
||||||
}) {
|
}) {
|
||||||
const post = await getPostBySlug(params.slug, params.lang);
|
const locale = params.lang as Locale;
|
||||||
|
const post = await getPostBySlug(params.slug, locale);
|
||||||
|
const messages = (await getMessages({ locale })) as Messages;
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return <div>Post not found</div>;
|
return <div>{messages.text.blog.postNotFound}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const siteUrl = getSiteUrl();
|
||||||
|
const postUrl = `${siteUrl}/${params.lang}/blog/${params.slug}`;
|
||||||
|
const imageUrl = absoluteUrl(post.frontmatter.cover, siteUrl);
|
||||||
|
const postLd = buildBlogPostingJsonLd({
|
||||||
|
siteUrl,
|
||||||
|
url: postUrl,
|
||||||
|
title: post.frontmatter.title,
|
||||||
|
description: post.frontmatter.description,
|
||||||
|
datePublished: post.frontmatter.date,
|
||||||
|
dateModified: post.frontmatter.date,
|
||||||
|
authorName: post.frontmatter.author,
|
||||||
|
imageUrl,
|
||||||
|
inLanguage: locale,
|
||||||
|
});
|
||||||
|
const breadcrumbsLd = buildBreadcrumbJsonLd({
|
||||||
|
items: [
|
||||||
|
{ name: messages.text.navigation.home, item: `${siteUrl}/${locale}` },
|
||||||
|
{ name: messages.text.navigation.blog, item: `${siteUrl}/${locale}/blog` },
|
||||||
|
{ name: post.frontmatter.title, item: postUrl },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
<JsonLd id="post-ld" data={[postLd, breadcrumbsLd]} />
|
||||||
{/* Use md: prefix to handle flex layout for medium screens and above */}
|
{/* Use md: prefix to handle flex layout for medium screens and above */}
|
||||||
<div className="block md:flex md:gap-8">
|
<div className="block md:flex md:gap-8">
|
||||||
{/* Article content area */}
|
{/* Article content area */}
|
||||||
<article className="w-full md:flex-1 max-w-4xl">
|
<article className="w-full md:flex-1 max-w-4xl">
|
||||||
<header className="mb-8">
|
<header className="mb-8">
|
||||||
<h1 className="text-3xl sm:text-4xl font-bold mb-4 text-gray-900">
|
<h1 className="text-3xl sm:text-4xl font-bold mb-4 text-foreground">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-wrap items-center text-gray-600 gap-2 sm:gap-4">
|
<div className="flex flex-wrap items-center text-muted-foreground gap-2 sm:gap-4">
|
||||||
<time className="text-sm">
|
<time className="text-sm">
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString("en-US", {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -40,7 +75,7 @@ export default async function BlogPost({
|
|||||||
</time>
|
</time>
|
||||||
<span className="hidden sm:inline">·</span>
|
<span className="hidden sm:inline">·</span>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
by <span className="font-bold">{post.frontmatter.author}</span>
|
{messages.text.blog.by} <span className="font-bold">{post.frontmatter.author}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -51,7 +86,7 @@ export default async function BlogPost({
|
|||||||
components={{
|
components={{
|
||||||
...mdxComponents,
|
...mdxComponents,
|
||||||
wrapper: ({ children }) => (
|
wrapper: ({ children }) => (
|
||||||
<div className="space-y-4 text-gray-700 overflow-x-auto">
|
<div className="space-y-4 text-foreground overflow-x-auto">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -60,7 +95,7 @@ export default async function BlogPost({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<TableOfContents content={post.content} />
|
<TableOfContents content={post.content} title={messages.text.blog.tocTitle} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +1,34 @@
|
|||||||
import { supportedLocales } from "@/constants/i18n-config";
|
import { supportedLocales } from "@/constants/i18n-config";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
import { getMessages } from "next-intl/server";
|
||||||
|
import type { Messages } from "@/types/messages";
|
||||||
|
import type { Locale } from "@/constants/i18n-config";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { lang: string };
|
params: { lang: string };
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
|
const lang = params.lang as Locale;
|
||||||
|
const messages = (await getMessages({ locale: lang })) as Messages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: "PrivyDrop Blog - Private P2P File Sharing & Collaboration",
|
title: messages.meta.blog.title,
|
||||||
description:
|
description: messages.meta.blog.description,
|
||||||
"Discover secure file sharing tips, privacy-focused collaboration strategies, and how to leverage P2P technology for safer data transfer. Learn about WebRTC, end-to-end encryption, and team collaboration.",
|
keywords: messages.meta.blog.keywords,
|
||||||
keywords:
|
|
||||||
"secure file sharing, p2p file transfer, private collaboration, webrtc, end-to-end encryption, team collaboration, privacy tools",
|
|
||||||
metadataBase: new URL("https://www.privydrop.app"),
|
metadataBase: new URL("https://www.privydrop.app"),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${params.lang}/blog`,
|
canonical: `/${lang}/blog`,
|
||||||
languages: {
|
languages: Object.fromEntries(
|
||||||
en: "/en/blog",
|
supportedLocales.map((l) => [l, `/${l}/blog`])
|
||||||
zh: "/zh/blog",
|
),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "PrivyDrop Blog - Private P2P File Sharing & Collaboration",
|
title: messages.meta.blog.title,
|
||||||
description:
|
description: messages.meta.blog.description,
|
||||||
"Explore secure file sharing, private collaboration tools, and data privacy best practices. Join our community of privacy-conscious professionals.",
|
url: `https://www.privydrop.app/${lang}/blog`,
|
||||||
url: `https://www.privydrop.app/${params.lang}/blog`,
|
|
||||||
siteName: "PrivyDrop",
|
siteName: "PrivyDrop",
|
||||||
locale: params.lang,
|
locale: lang,
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { getAllPosts } from "@/lib/blog";
|
import { getAllPosts } from "@/lib/blog";
|
||||||
import { ArticleListItem } from "@/components/blog/ArticleListItem";
|
import { ArticleListItem } from "@/components/blog/ArticleListItem";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { getMessages } from "next-intl/server";
|
||||||
import { slugifyTag } from "@/utils/tagUtils";
|
import { slugifyTag } from "@/utils/tagUtils";
|
||||||
import { generateMetadata } from "./metadata";
|
import { generateMetadata } from "./metadata";
|
||||||
|
import type { Messages } from "@/types/messages";
|
||||||
|
import type { Locale } from "@/constants/i18n-config";
|
||||||
|
|
||||||
export { generateMetadata };
|
export { generateMetadata };
|
||||||
|
|
||||||
@@ -11,7 +14,9 @@ export default async function BlogPage({
|
|||||||
}: {
|
}: {
|
||||||
params: { lang: string };
|
params: { lang: string };
|
||||||
}) {
|
}) {
|
||||||
const posts = await getAllPosts(lang);
|
const locale = lang as Locale;
|
||||||
|
const posts = await getAllPosts(locale);
|
||||||
|
const messages = (await getMessages({ locale })) as Messages;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
@@ -19,14 +24,14 @@ export default async function BlogPage({
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="lg:col-span-8">
|
<main className="lg:col-span-8">
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h1 className="text-4xl font-bold mb-4">Blog</h1>
|
<h1 className="text-4xl font-bold mb-4">{messages.text.blog.listTitle}</h1>
|
||||||
<p className="text-gray-600 text-lg">Latest articles and updates</p>
|
<p className="text-muted-foreground text-lg">{messages.text.blog.listSubtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Articles List */}
|
{/* Articles List */}
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<ArticleListItem key={post.slug} post={post} lang={lang} />
|
<ArticleListItem key={post.slug} post={post} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -35,14 +40,14 @@ export default async function BlogPage({
|
|||||||
<aside className="lg:col-span-4">
|
<aside className="lg:col-span-4">
|
||||||
<div className="sticky top-8">
|
<div className="sticky top-8">
|
||||||
{/* Recent Posts */}
|
{/* Recent Posts */}
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8 mb-8">
|
<div className="bg-card rounded-xl shadow-lg p-8 mb-8">
|
||||||
<h2 className="text-xl font-bold mb-6">Recent Posts</h2>
|
<h2 className="text-xl font-bold mb-6">{messages.text.blog.recentPosts}</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{posts.slice(0, 5).map((post) => (
|
{posts.slice(0, 5).map((post) => (
|
||||||
<Link
|
<Link
|
||||||
key={post.slug}
|
key={post.slug}
|
||||||
href={`/${lang}/blog/${post.slug}`}
|
href={`/${lang}/blog/${post.slug}`}
|
||||||
className="block hover:text-blue-600 text-base font-medium"
|
className="block hover:text-primary text-base font-medium"
|
||||||
>
|
>
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -50,8 +55,8 @@ export default async function BlogPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* tags */}
|
{/* tags */}
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
<div className="bg-card rounded-xl shadow-lg p-8">
|
||||||
<h2 className="text-xl font-bold mb-6">Tags</h2>
|
<h2 className="text-xl font-bold mb-6">{messages.text.blog.tags}</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Get all tags and deduplicate */}
|
{/* Get all tags and deduplicate */}
|
||||||
{Array.from(
|
{Array.from(
|
||||||
@@ -60,10 +65,10 @@ export default async function BlogPage({
|
|||||||
<Link
|
<Link
|
||||||
key={tag}
|
key={tag}
|
||||||
href={`/${lang}/blog/tag/${slugifyTag(tag)}`} // Jump to the tag filtering page
|
href={`/${lang}/blog/tag/${slugifyTag(tag)}`} // Jump to the tag filtering page
|
||||||
className="flex items-center justify-between hover:text-blue-600"
|
className="flex items-center justify-between hover:text-primary"
|
||||||
>
|
>
|
||||||
<span className="text-gray-700 font-medium">{tag}</span>
|
<span className="text-foreground font-medium">{tag}</span>
|
||||||
<span className="bg-gray-100 px-3 py-1 rounded-full text-sm text-gray-600">
|
<span className="bg-muted px-3 py-1 rounded-full text-sm text-muted-foreground">
|
||||||
{
|
{
|
||||||
posts.filter((p) => p.frontmatter.tags.includes(tag))
|
posts.filter((p) => p.frontmatter.tags.includes(tag))
|
||||||
.length
|
.length
|
||||||
|
|||||||
@@ -1,36 +1,38 @@
|
|||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
import { getMessages } from "next-intl/server";
|
||||||
import { getPostsByTag } from "@/lib/blog";
|
import { getPostsByTag } from "@/lib/blog";
|
||||||
import { ArticleListItem } from "@/components/blog/ArticleListItem";
|
import { ArticleListItem } from "@/components/blog/ArticleListItem";
|
||||||
import { supportedLocales } from "@/constants/i18n-config";
|
import { supportedLocales, type Locale } from "@/constants/i18n-config";
|
||||||
import { unslugifyTag } from "@/utils/tagUtils";
|
import { unslugifyTag } from "@/utils/tagUtils";
|
||||||
|
import type { Messages } from "@/types/messages";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params: { tag, lang },
|
params: { tag, lang },
|
||||||
}: {
|
}: {
|
||||||
params: { tag: string; lang: string };
|
params: { tag: string; lang: string };
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
|
const locale = lang as Locale;
|
||||||
const decodedTag = unslugifyTag(tag);
|
const decodedTag = unslugifyTag(tag);
|
||||||
|
const messages = (await getMessages({ locale })) as Messages;
|
||||||
|
|
||||||
|
// Note: metadata text kept concise and localized
|
||||||
return {
|
return {
|
||||||
title: `${decodedTag} - PrivyDrop Blog Articles`,
|
title: `${messages.text.blog.tagTitlePrefix}: ${decodedTag} - PrivyDrop`,
|
||||||
description: `Explore articles about ${decodedTag} - Learn about secure file sharing, private collaboration, and data privacy solutions related to ${decodedTag}`,
|
description: messages.text.blog.tagSubtitleTemplate.replace("{tag}", decodedTag),
|
||||||
keywords: `${decodedTag}, secure file sharing, p2p file transfer, privacy, collaboration, webrtc`,
|
keywords: `${decodedTag}, blog, privydrop`,
|
||||||
metadataBase: new URL("https://www.privydrop.app"),
|
metadataBase: new URL("https://www.privydrop.app"),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${lang}/blog/tag/${encodeURIComponent(tag)}`,
|
canonical: `/${locale}/blog/tag/${encodeURIComponent(tag)}`,
|
||||||
languages: {
|
languages: Object.fromEntries(
|
||||||
en: `/en/blog/tag/${encodeURIComponent(tag)}`,
|
supportedLocales.map((l) => [l, `/${l}/blog/tag/${encodeURIComponent(tag)}`])
|
||||||
zh: `/zh/blog/tag/${encodeURIComponent(tag)}`,
|
),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${decodedTag} - PrivyDrop Blog Articles`,
|
title: `${decodedTag} - PrivyDrop`,
|
||||||
description: `Discover articles about ${decodedTag} - Expert insights on secure file sharing and private collaboration solutions`,
|
description: `Articles tagged: ${decodedTag}`,
|
||||||
url: `https://www.privydrop.app/${lang}/blog/tag/${encodeURIComponent(
|
url: `https://www.privydrop.app/${locale}/blog/tag/${encodeURIComponent(tag)}`,
|
||||||
tag
|
|
||||||
)}`,
|
|
||||||
siteName: "PrivyDrop",
|
siteName: "PrivyDrop",
|
||||||
locale: lang,
|
locale,
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -40,8 +42,10 @@ export default async function TagPage({
|
|||||||
}: {
|
}: {
|
||||||
params: { tag: string; lang: string };
|
params: { tag: string; lang: string };
|
||||||
}) {
|
}) {
|
||||||
|
const locale = lang as Locale;
|
||||||
const decodedTag = unslugifyTag(tag);
|
const decodedTag = unslugifyTag(tag);
|
||||||
const posts = await getPostsByTag(decodedTag, lang);
|
const posts = await getPostsByTag(decodedTag, locale);
|
||||||
|
const messages = (await getMessages({ locale })) as Messages;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
@@ -49,22 +53,22 @@ export default async function TagPage({
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="lg:col-span-8">
|
<main className="lg:col-span-8">
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h1 className="text-4xl font-bold mb-4">Tag: {decodedTag}</h1>
|
<h1 className="text-4xl font-bold mb-4">{messages.text.blog.tagTitlePrefix}: {decodedTag}</h1>
|
||||||
<p className="text-gray-600 text-lg">
|
<p className="text-muted-foreground text-lg">
|
||||||
Articles tagged with {decodedTag}
|
{messages.text.blog.tagSubtitleTemplate.replace("{tag}", decodedTag)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Articles List */}
|
{/* Articles List */}
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{posts.length > 0 ? (
|
{posts.length > 0 ? (
|
||||||
posts.map((post) => (
|
posts.map((post) => (
|
||||||
<ArticleListItem key={post.slug} post={post} lang={lang} />
|
<ArticleListItem key={post.slug} post={post} />
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p>No articles found for this decodedTag.</p>
|
<p>{messages.text.blog.tagEmpty}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import FAQSection from "@/components/web/FAQSection";
|
import FAQSection from "@/components/web/FAQSection";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getDictionary } from "@/lib/dictionary";
|
import { getMessages } from "next-intl/server";
|
||||||
import { supportedLocales } from "@/constants/i18n-config";
|
import type { Messages } from "@/types/messages";
|
||||||
|
import { supportedLocales, type Locale } from "@/constants/i18n-config";
|
||||||
|
import JsonLd from "@/components/seo/JsonLd";
|
||||||
|
import { buildFaqJsonLd } from "@/lib/seo/jsonld";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { lang: string };
|
params: { lang: string };
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const messages = await getDictionary(params.lang);
|
const lang = params.lang as Locale;
|
||||||
|
const messages = (await getMessages({ locale: lang })) as Messages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: messages.meta.faq.title,
|
title: messages.meta.faq.title,
|
||||||
@@ -24,9 +28,9 @@ export async function generateMetadata({
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: messages.meta.faq.title,
|
title: messages.meta.faq.title,
|
||||||
description: messages.meta.faq.description,
|
description: messages.meta.faq.description,
|
||||||
url: `https://www.privydrop.app/${params.lang}/faq`,
|
url: `https://www.privydrop.app/${lang}/faq`,
|
||||||
siteName: "PrivyDrop",
|
siteName: "PrivyDrop",
|
||||||
locale: params.lang,
|
locale: lang,
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -37,6 +41,17 @@ export default async function FAQ({
|
|||||||
}: {
|
}: {
|
||||||
params: { lang: string };
|
params: { lang: string };
|
||||||
}) {
|
}) {
|
||||||
const messages = await getDictionary(lang);
|
const locale = lang as Locale;
|
||||||
return <FAQSection messages={messages} />;
|
const messages = (await getMessages({ locale })) as Messages;
|
||||||
|
const faqItems = (messages.text.faq.items ?? []) as { question: string; answer: string }[];
|
||||||
|
const faqs = faqItems.filter((item) => item.question && item.answer);
|
||||||
|
|
||||||
|
const faqLd = buildFaqJsonLd({ inLanguage: locale, faqs });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<JsonLd id="faq-ld" data={faqLd} />
|
||||||
|
<FAQSection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import KeyFeatures from "@/components/web/KeyFeatures";
|
import KeyFeatures from "@/components/web/KeyFeatures";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getDictionary } from "@/lib/dictionary";
|
import { getMessages } from "next-intl/server";
|
||||||
import { supportedLocales } from "@/constants/i18n-config";
|
import { supportedLocales, type Locale } from "@/constants/i18n-config";
|
||||||
|
import type { Messages } from "@/types/messages";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { lang: string };
|
params: { lang: string };
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const messages = await getDictionary(params.lang);
|
const lang = params.lang as Locale;
|
||||||
|
const messages = (await getMessages({ locale: lang })) as Messages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: messages.meta.features.title,
|
title: messages.meta.features.title,
|
||||||
@@ -24,19 +26,14 @@ export async function generateMetadata({
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: messages.meta.features.title,
|
title: messages.meta.features.title,
|
||||||
description: messages.meta.features.description,
|
description: messages.meta.features.description,
|
||||||
url: `https://www.privydrop.app/${params.lang}/features`,
|
url: `https://www.privydrop.app/${lang}/features`,
|
||||||
siteName: "PrivyDrop",
|
siteName: "PrivyDrop",
|
||||||
locale: params.lang,
|
locale: lang,
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Features({
|
export default function Features() {
|
||||||
params: { lang },
|
return <KeyFeatures />;
|
||||||
}: {
|
}
|
||||||
params: { lang: string };
|
|
||||||
}) {
|
|
||||||
const messages = await getDictionary(lang);
|
|
||||||
return <KeyFeatures messages={messages} />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -69,15 +69,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Custom prose styles */
|
/* Custom prose styles */
|
||||||
|
|
||||||
.prose {
|
.prose {
|
||||||
@apply text-gray-600;
|
@apply text-muted-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose h1,
|
.prose h1,
|
||||||
.prose h2,
|
.prose h2,
|
||||||
.prose h3,
|
.prose h3,
|
||||||
.prose h4 {
|
.prose h4 {
|
||||||
@apply text-gray-900 font-bold mt-8 mb-4;
|
@apply text-foreground font-bold mt-8 mb-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose h1 {
|
.prose h1 {
|
||||||
@@ -106,31 +107,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose code {
|
.prose code {
|
||||||
@apply text-sm bg-gray-50 rounded px-1.5 py-0.5 text-gray-800 border border-gray-200;
|
@apply text-sm bg-muted rounded px-1.5 py-0.5 text-foreground border border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose pre {
|
.prose pre {
|
||||||
@apply my-6 p-4 bg-gray-50 rounded-lg overflow-x-auto border border-gray-200;
|
@apply my-6 p-4 bg-muted rounded-lg overflow-x-auto border border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose pre code {
|
.prose pre code {
|
||||||
@apply bg-transparent text-gray-800 p-0 border-0;
|
@apply bg-transparent text-foreground p-0 border-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose blockquote {
|
.prose blockquote {
|
||||||
@apply border-l-4 border-blue-500 pl-4 my-6 italic text-gray-600;
|
@apply border-l-4 border-primary pl-4 my-6 italic text-muted-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose table {
|
.prose table {
|
||||||
@apply min-w-full divide-y divide-gray-200 my-6;
|
@apply min-w-full divide-y divide-border my-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose th {
|
.prose th {
|
||||||
@apply px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
|
@apply px-6 py-3 bg-muted text-left text-xs font-medium text-muted-foreground uppercase tracking-wider;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose td {
|
.prose td {
|
||||||
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-500;
|
@apply px-6 py-4 whitespace-nowrap text-sm text-muted-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose img {
|
.prose img {
|
||||||
@@ -138,7 +139,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose figure figcaption {
|
.prose figure figcaption {
|
||||||
@apply text-center text-sm text-gray-600 mt-2 italic;
|
@apply text-center text-sm text-muted-foreground mt-2 italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide GitHub ribbon on small screens */
|
/* Hide GitHub ribbon on small screens */
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
import type { Messages } from "@/types/messages";
|
"use client";
|
||||||
|
|
||||||
interface HelpContentProps {
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
messages: Messages;
|
|
||||||
lang: string;
|
export default function HelpContent() {
|
||||||
}
|
const helpT = useTranslations("text.help");
|
||||||
|
const aboutT = useTranslations("text.about");
|
||||||
|
const termsT = useTranslations("text.terms");
|
||||||
|
const privacyT = useTranslations("text.privacy");
|
||||||
|
const lang = useLocale();
|
||||||
|
|
||||||
export default function HelpContent({ messages, lang }: HelpContentProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-12">
|
<div className="container mx-auto py-12">
|
||||||
<h1 className="text-4xl font-bold mb-6">{messages.text.help.h1}</h1>
|
<h1 className="text-4xl font-bold mb-6">{helpT("h1")}</h1>
|
||||||
<p className="text-lg mb-4">{messages.text.help.h1_P}</p>
|
<p className="text-lg mb-4">{helpT("h1Paragraph")}</p>
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.help.h2_1}</h2>
|
<h2 className="text-2xl font-bold mb-4">{helpT("sections.contactUs")}</h2>
|
||||||
<p className="text-lg mb-4">
|
<p className="text-lg mb-4">
|
||||||
{messages.text.help.h2_1_P1}{" "}
|
{helpT("sections.contactUsParagraph1")}{" "}
|
||||||
<a
|
<a
|
||||||
href="mailto:david.vision66@gmail.com"
|
href="mailto:david.vision66@gmail.com"
|
||||||
className="text-blue-500 hover:underline"
|
className="text-blue-500 hover:underline"
|
||||||
>
|
>
|
||||||
david.vision66@gmail.com
|
david.vision66@gmail.com
|
||||||
</a>
|
</a>
|
||||||
{messages.text.help.h2_1_P2}
|
{helpT("sections.contactUsParagraph2")}
|
||||||
</p>
|
</p>
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.help.h2_2}</h2>
|
<h2 className="text-2xl font-bold mb-4">{helpT("sections.socialMedia")}</h2>
|
||||||
<p className="text-lg mb-4">{messages.text.help.h2_2_P}</p>
|
<p className="text-lg mb-4">{helpT("sections.socialMediaParagraph")}</p>
|
||||||
<ul className="list-disc pl-6">
|
<ul className="list-disc pl-6">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
@@ -36,17 +39,17 @@ export default function HelpContent({ messages, lang }: HelpContentProps) {
|
|||||||
<li><a href="https://www.linkedin.com/company/PrivyDrop" className="text-blue-500 hover:underline">LinkedIn</a></li> */}
|
<li><a href="https://www.linkedin.com/company/PrivyDrop" className="text-blue-500 hover:underline">LinkedIn</a></li> */}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.help.h2_3}</h2>
|
<h2 className="text-2xl font-bold mb-4">{helpT("sections.additionalResources")}</h2>
|
||||||
<p className="text-lg mb-4">{messages.text.help.h2_3_P}</p>
|
<p className="text-lg mb-4">{helpT("sections.additionalResourcesParagraph")}</p>
|
||||||
<ul className="list-disc pl-6">
|
<ul className="list-disc pl-6">
|
||||||
<li>
|
<li>
|
||||||
<a href={`/${lang}/about`} className="text-blue-500 hover:underline">
|
<a href={`/${lang}/about`} className="text-blue-500 hover:underline">
|
||||||
{messages.text.about.h1}
|
{aboutT("h1")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={`/${lang}/terms`} className="text-blue-500 hover:underline">
|
<a href={`/${lang}/terms`} className="text-blue-500 hover:underline">
|
||||||
{messages.text.terms.TermsOfUse_dis}
|
{termsT("useLabel")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -54,7 +57,7 @@ export default function HelpContent({ messages, lang }: HelpContentProps) {
|
|||||||
href={`/${lang}/privacy`}
|
href={`/${lang}/privacy`}
|
||||||
className="text-blue-500 hover:underline"
|
className="text-blue-500 hover:underline"
|
||||||
>
|
>
|
||||||
{messages.text.privacy.PrivacyPolicy_dis}
|
{privacyT("policyLabel")}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { getDictionary } from "@/lib/dictionary";
|
|
||||||
import HelpContent from "./HelpContent";
|
import HelpContent from "./HelpContent";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { supportedLocales } from "@/constants/i18n-config";
|
import { getMessages } from "next-intl/server";
|
||||||
|
import { supportedLocales, type Locale } from "@/constants/i18n-config";
|
||||||
|
import type { Messages } from "@/types/messages";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { lang: string };
|
params: { lang: string };
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const messages = await getDictionary(params.lang);
|
const lang = params.lang as Locale;
|
||||||
|
const messages = (await getMessages({ locale: lang })) as Messages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: messages.meta.help.title,
|
title: messages.meta.help.title,
|
||||||
@@ -23,18 +25,13 @@ export async function generateMetadata({
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: messages.meta.help.title,
|
title: messages.meta.help.title,
|
||||||
description: messages.meta.help.description,
|
description: messages.meta.help.description,
|
||||||
url: `https://www.privydrop.app/${params.lang}/help`,
|
url: `https://www.privydrop.app/${lang}/help`,
|
||||||
siteName: "PrivyDrop",
|
siteName: "PrivyDrop",
|
||||||
locale: params.lang,
|
locale: lang,
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export default async function Help({
|
export default function Help() {
|
||||||
params: { lang },
|
return <HelpContent />;
|
||||||
}: {
|
|
||||||
params: { lang: string };
|
|
||||||
}) {
|
|
||||||
const messages = await getDictionary(lang);
|
|
||||||
return <HelpContent messages={messages} lang={lang} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { hasLocale, NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
import Header from "@/components/web/Header";
|
import Header from "@/components/web/Header";
|
||||||
import Footer from "@/components/web/Footer";
|
import Footer from "@/components/web/Footer";
|
||||||
import { ThemeProvider } from "@/components/web/theme-provider";
|
import { ThemeProvider } from "@/components/web/theme-provider";
|
||||||
import { getDictionary } from "@/lib/dictionary";
|
import { routing } from "@/i18n/routing";
|
||||||
|
import JsonLd from "@/components/seo/JsonLd";
|
||||||
|
import {
|
||||||
|
absoluteUrl,
|
||||||
|
buildOrganizationJsonLd,
|
||||||
|
buildWebSiteJsonLd,
|
||||||
|
getSiteUrl,
|
||||||
|
} from "@/lib/seo/jsonld";
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -11,12 +21,33 @@ export default async function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: { lang: string };
|
params: { lang: string };
|
||||||
}>) {
|
}>) {
|
||||||
const messages = await getDictionary(lang);
|
if (!hasLocale(routing.locales, lang)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
setRequestLocale(lang);
|
||||||
|
const messages = await getMessages();
|
||||||
|
const siteUrl = getSiteUrl();
|
||||||
|
const logoUrl = absoluteUrl("/logo.png", siteUrl);
|
||||||
|
const orgJson = buildOrganizationJsonLd({
|
||||||
|
siteUrl,
|
||||||
|
logoUrl,
|
||||||
|
sameAs: [
|
||||||
|
"https://github.com/david-bai00/PrivyDrop",
|
||||||
|
"https://x.com/David_vision66",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const websiteJson = buildWebSiteJsonLd({
|
||||||
|
siteUrl,
|
||||||
|
name: "PrivyDrop",
|
||||||
|
inLanguage: lang,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={lang} className="h-full" suppressHydrationWarning>
|
<html lang={lang} className="h-full" suppressHydrationWarning>
|
||||||
<head />
|
<head />
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
|
<JsonLd id="global-ld" data={[orgJson, websiteJson]} />
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
@@ -24,9 +55,11 @@ export default async function RootLayout({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
storageKey="theme-preference"
|
storageKey="theme-preference"
|
||||||
>
|
>
|
||||||
<Header messages={messages} lang={lang} />
|
<NextIntlClientProvider locale={lang} messages={messages}>
|
||||||
<div className="flex-1">{children}</div>
|
<Header />
|
||||||
<Footer messages={messages} lang={lang} />
|
<div className="flex-1">{children}</div>
|
||||||
|
<Footer />
|
||||||
|
</NextIntlClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import HomeClient from "./HomeClient";
|
import HomeClient from "./HomeClient";
|
||||||
import { getDictionary } from "@/lib/dictionary";
|
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { supportedLocales } from "@/constants/i18n-config";
|
import { getMessages } from "next-intl/server";
|
||||||
|
import { supportedLocales, type Locale } from "@/constants/i18n-config";
|
||||||
|
import type { Messages } from "@/types/messages";
|
||||||
|
import JsonLd from "@/components/seo/JsonLd";
|
||||||
|
import { buildWebAppJsonLd, getSiteUrl, absoluteUrl } from "@/lib/seo/jsonld";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { lang: string };
|
params: { lang: string };
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const messages = await getDictionary(params.lang);
|
const lang = params.lang as Locale;
|
||||||
|
const messages = (await getMessages({ locale: lang })) as Messages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: messages.meta.home.title,
|
title: messages.meta.home.title,
|
||||||
@@ -16,7 +20,7 @@ export async function generateMetadata({
|
|||||||
keywords: messages.meta.home.keywords,
|
keywords: messages.meta.home.keywords,
|
||||||
metadataBase: new URL("https://www.privydrop.app"),
|
metadataBase: new URL("https://www.privydrop.app"),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${params.lang}`,
|
canonical: `/${lang}`,
|
||||||
languages: Object.fromEntries(
|
languages: Object.fromEntries(
|
||||||
supportedLocales.map((lang) => [lang, `/${lang}`])
|
supportedLocales.map((lang) => [lang, `/${lang}`])
|
||||||
),
|
),
|
||||||
@@ -25,9 +29,9 @@ export async function generateMetadata({
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: messages.meta.home.title,
|
title: messages.meta.home.title,
|
||||||
description: messages.meta.home.description,
|
description: messages.meta.home.description,
|
||||||
url: `https://www.privydrop.app/${params.lang}`,
|
url: `https://www.privydrop.app/${lang}`,
|
||||||
siteName: "PrivyDrop",
|
siteName: "PrivyDrop",
|
||||||
locale: params.lang,
|
locale: lang,
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -38,7 +42,29 @@ export default async function Home({
|
|||||||
}: {
|
}: {
|
||||||
params: { lang: string };
|
params: { lang: string };
|
||||||
}) {
|
}) {
|
||||||
const messages = await getDictionary(lang);
|
const locale = lang as Locale;
|
||||||
|
const messages = (await getMessages({ locale })) as Messages;
|
||||||
|
const siteUrl = getSiteUrl();
|
||||||
|
const webAppLd = buildWebAppJsonLd({
|
||||||
|
siteUrl,
|
||||||
|
path: `/${lang}`,
|
||||||
|
name: "PrivyDrop",
|
||||||
|
alternateName: [
|
||||||
|
"PrivyDrop",
|
||||||
|
"PrivyDrop APP",
|
||||||
|
"Open-source web-based AirDrop alternative",
|
||||||
|
],
|
||||||
|
description: messages.meta.home.description,
|
||||||
|
inLanguage: locale,
|
||||||
|
imageUrl: absoluteUrl("/logo.png", siteUrl),
|
||||||
|
applicationCategory: "UtilityApplication",
|
||||||
|
operatingSystem: "Web Browser",
|
||||||
|
});
|
||||||
|
|
||||||
return <HomeClient messages={messages} lang={lang} />;
|
return (
|
||||||
|
<>
|
||||||
|
<JsonLd id="home-ld" data={webAppLd} />
|
||||||
|
<HomeClient />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
import type { Messages } from "@/types/messages";
|
"use client";
|
||||||
|
|
||||||
interface PageContentProps {
|
import { useTranslations } from "next-intl";
|
||||||
messages: Messages;
|
|
||||||
}
|
export default function PrivacyContent() {
|
||||||
|
const t = useTranslations("text.privacy");
|
||||||
|
|
||||||
export default function PrivacyContent({ messages }: PageContentProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<h1 className="text-3xl font-bold text-center mb-6">
|
<h1 className="text-3xl font-bold text-center mb-6">{t("h1")}</h1>
|
||||||
{messages.text.privacy.h1}
|
<p className="text-lg mb-4">{t("h1Paragraph")}</p>
|
||||||
</h1>
|
<h2 className="text-2xl font-bold mb-4">{t("sections.informationCollection")}</h2>
|
||||||
<p className="text-lg mb-4">{messages.text.privacy.h1_P}</p>
|
<p className="text-lg mb-4">{t("sections.informationCollectionParagraph")}</p>
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.privacy.h2_1}</h2>
|
<h2 className="text-2xl font-bold mb-4">{t("sections.dataStorage")}</h2>
|
||||||
<p className="text-lg mb-4">{messages.text.privacy.h2_1_P}</p>
|
<p className="text-lg mb-4">{t("sections.dataStorageParagraph")}</p>
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.privacy.h2_2}</h2>
|
<h2 className="text-2xl font-bold mb-4">{t("sections.thirdPartyServices")}</h2>
|
||||||
<p className="text-lg mb-4">{messages.text.privacy.h2_2_P}</p>
|
<p className="text-lg mb-4">{t("sections.thirdPartyServicesParagraph")}</p>
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.privacy.h2_3}</h2>
|
<h2 className="text-2xl font-bold mb-4">{t("sections.amendments")}</h2>
|
||||||
<p className="text-lg mb-4">{messages.text.privacy.h2_3_P}</p>
|
<p className="text-lg mb-4">{t("sections.amendmentsParagraph")}</p>
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.privacy.h2_4}</h2>
|
<h2 className="text-2xl font-bold mb-4">{t("sections.contactUs")}</h2>
|
||||||
<p className="text-lg mb-4">{messages.text.privacy.h2_4_P}</p>
|
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.privacy.h2_5}</h2>
|
|
||||||
<p className="text-lg mb-4">
|
<p className="text-lg mb-4">
|
||||||
{messages.text.privacy.h2_5_P}{" "}
|
{t("sections.contactUsParagraph")}{" "}
|
||||||
<a
|
<a
|
||||||
href="mailto:david.vision66@gmail.com"
|
href="mailto:david.vision66@gmail.com"
|
||||||
className="text-blue-500 hover:underline"
|
className="text-blue-500 hover:underline"
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getDictionary } from "@/lib/dictionary";
|
import { getMessages } from "next-intl/server";
|
||||||
import PrivacyContent from "./PrivacyContent";
|
import PrivacyContent from "./PrivacyContent";
|
||||||
import { supportedLocales } from "@/constants/i18n-config";
|
import { supportedLocales, type Locale } from "@/constants/i18n-config";
|
||||||
|
import type { Messages } from "@/types/messages";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { lang: string };
|
params: { lang: string };
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const messages = await getDictionary(params.lang);
|
const lang = params.lang as Locale;
|
||||||
|
const messages = (await getMessages({ locale: lang })) as Messages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: messages.meta.privacy.title,
|
title: messages.meta.privacy.title,
|
||||||
@@ -23,18 +25,13 @@ export async function generateMetadata({
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: messages.meta.privacy.title,
|
title: messages.meta.privacy.title,
|
||||||
description: messages.meta.privacy.description,
|
description: messages.meta.privacy.description,
|
||||||
url: `https://www.privydrop.app/${params.lang}/privacy`,
|
url: `https://www.privydrop.app/${lang}/privacy`,
|
||||||
siteName: "PrivyDrop",
|
siteName: "PrivyDrop",
|
||||||
locale: params.lang,
|
locale: lang,
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export default async function Privacy({
|
export default function Privacy() {
|
||||||
params: { lang },
|
return <PrivacyContent />;
|
||||||
}: {
|
|
||||||
params: { lang: string };
|
|
||||||
}) {
|
|
||||||
const messages = await getDictionary(lang);
|
|
||||||
return <PrivacyContent messages={messages} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
import type { Messages } from "@/types/messages";
|
"use client";
|
||||||
|
|
||||||
interface PageContentProps {
|
import { useTranslations } from "next-intl";
|
||||||
messages: Messages;
|
|
||||||
}
|
export default function TermsContent() {
|
||||||
|
const t = useTranslations("text.terms");
|
||||||
|
|
||||||
export default function TermsContent({ messages }: PageContentProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<h1 className="text-3xl font-bold text-center mb-6">
|
<h1 className="text-3xl font-bold text-center mb-6">{t("h1")}</h1>
|
||||||
{messages.text.terms.h1}
|
<p className="text-lg mb-4">{t("h1Paragraph")}</p>
|
||||||
</h1>
|
<h2 className="text-2xl font-bold mb-4">{t("sections.useOfService")}</h2>
|
||||||
<p className="text-lg mb-4">{messages.text.terms.h1_P}</p>
|
<p className="text-lg mb-4">{t("sections.useOfServiceParagraph")}</p>
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.terms.h2_1}</h2>
|
<h2 className="text-2xl font-bold mb-4">{t("sections.dataPrivacy")}</h2>
|
||||||
<p className="text-lg mb-4">{messages.text.terms.h2_1_P}</p>
|
<p className="text-lg mb-4">{t("sections.dataPrivacyParagraph")}</p>
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.terms.h2_2}</h2>
|
<h2 className="text-2xl font-bold mb-4">{t("sections.acceptableUse")}</h2>
|
||||||
<p className="text-lg mb-4">{messages.text.terms.h2_2_P}</p>
|
<p className="text-lg mb-4">{t("sections.acceptableUseParagraph")}</p>
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.terms.h2_3}</h2>
|
<h2 className="text-2xl font-bold mb-4">{t("sections.liability")}</h2>
|
||||||
<p className="text-lg mb-4">{messages.text.terms.h2_3_P}</p>
|
<p className="text-lg mb-4">{t("sections.liabilityParagraph")}</p>
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.terms.h2_4}</h2>
|
<h2 className="text-2xl font-bold mb-4">{t("sections.changes")}</h2>
|
||||||
<p className="text-lg mb-4">{messages.text.terms.h2_4_P}</p>
|
<p className="text-lg mb-4">{t("sections.changesParagraph")}</p>
|
||||||
<h2 className="text-2xl font-bold mb-4">{messages.text.terms.h2_5}</h2>
|
|
||||||
<p className="text-lg mb-4">{messages.text.terms.h2_5_P}</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { getDictionary } from "@/lib/dictionary";
|
|
||||||
import TermsContent from "./TermsContent";
|
import TermsContent from "./TermsContent";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { supportedLocales } from "@/constants/i18n-config";
|
import { getMessages } from "next-intl/server";
|
||||||
|
import { supportedLocales, type Locale } from "@/constants/i18n-config";
|
||||||
|
import type { Messages } from "@/types/messages";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: { lang: string };
|
params: { lang: string };
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const messages = await getDictionary(params.lang);
|
const lang = params.lang as Locale;
|
||||||
|
const messages = (await getMessages({ locale: lang })) as Messages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: messages.meta.terms.title,
|
title: messages.meta.terms.title,
|
||||||
@@ -23,18 +25,13 @@ export async function generateMetadata({
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: messages.meta.terms.title,
|
title: messages.meta.terms.title,
|
||||||
description: messages.meta.terms.description,
|
description: messages.meta.terms.description,
|
||||||
url: `https://www.privydrop.app/${params.lang}/terms`,
|
url: `https://www.privydrop.app/${lang}/terms`,
|
||||||
siteName: "PrivyDrop",
|
siteName: "PrivyDrop",
|
||||||
locale: params.lang,
|
locale: lang,
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export default async function TermsOfUse({
|
export default function TermsOfUse() {
|
||||||
params: { lang },
|
return <TermsContent />;
|
||||||
}: {
|
|
||||||
params: { lang: string };
|
|
||||||
}) {
|
|
||||||
const messages = await getDictionary(lang);
|
|
||||||
return <TermsContent messages={messages} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-15
@@ -1,6 +1,7 @@
|
|||||||
import { MetadataRoute } from "next";
|
import { MetadataRoute } from "next";
|
||||||
import { supportedLocales } from "@/constants/i18n-config";
|
import { supportedLocales } from "@/constants/i18n-config";
|
||||||
import { getAllPosts } from "@/lib/blog";
|
import { getAllPosts } from "@/lib/blog";
|
||||||
|
import { slugifyTag } from "@/utils/tagUtils";
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const baseUrl = "https://www.privydrop.app";
|
const baseUrl = "https://www.privydrop.app";
|
||||||
@@ -26,23 +27,33 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
priority: 1,
|
priority: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add language specific URLs
|
// Add language specific URLs, blog posts and tag pages
|
||||||
languages.forEach((lang) => {
|
|
||||||
routes.forEach((route) => {
|
|
||||||
urls.push({
|
|
||||||
url: `${baseUrl}/${lang}${route}`,
|
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "weekly",
|
|
||||||
priority: route === "" ? 1.0 : 0.8,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add blog posts for each language
|
|
||||||
for (const lang of languages) {
|
for (const lang of languages) {
|
||||||
try {
|
try {
|
||||||
const posts = await getAllPosts(lang);
|
const posts = await getAllPosts(lang);
|
||||||
|
|
||||||
|
// compute latest blog post date for this language
|
||||||
|
const latestDate = posts.length
|
||||||
|
? new Date(
|
||||||
|
Math.max(
|
||||||
|
...posts.map((p) => new Date(p.frontmatter.date).getTime())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: new Date();
|
||||||
|
|
||||||
|
// Add static routes per language (optimize blog list lastModified)
|
||||||
|
routes.forEach((route) => {
|
||||||
|
const isRoot = route === "";
|
||||||
|
const isBlogList = route === "/blog";
|
||||||
|
urls.push({
|
||||||
|
url: `${baseUrl}/${lang}${route}`,
|
||||||
|
lastModified: isBlogList ? latestDate : new Date(),
|
||||||
|
changeFrequency: isRoot ? "weekly" : isBlogList ? "weekly" : "weekly",
|
||||||
|
priority: isRoot ? 1.0 : 0.8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add blog posts for this language
|
||||||
posts.forEach((post) => {
|
posts.forEach((post) => {
|
||||||
urls.push({
|
urls.push({
|
||||||
url: `${baseUrl}/${lang}/blog/${post.slug}`,
|
url: `${baseUrl}/${lang}/blog/${post.slug}`,
|
||||||
@@ -51,8 +62,38 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add tag pages for this language
|
||||||
|
const uniqueTags = Array.from(
|
||||||
|
new Set(posts.flatMap((p) => p.frontmatter.tags))
|
||||||
|
);
|
||||||
|
uniqueTags.forEach((tag) => {
|
||||||
|
const tagSlug = slugifyTag(tag);
|
||||||
|
const tagLatestDate = posts
|
||||||
|
.filter((p) => p.frontmatter.tags.includes(tag))
|
||||||
|
.map((p) => new Date(p.frontmatter.date).getTime());
|
||||||
|
const lastModified =
|
||||||
|
tagLatestDate.length > 0
|
||||||
|
? new Date(Math.max(...tagLatestDate))
|
||||||
|
: latestDate;
|
||||||
|
urls.push({
|
||||||
|
url: `${baseUrl}/${lang}/blog/tag/${tagSlug}`,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.6,
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to load blog posts for language ${lang}:`, error);
|
console.warn(`Failed to load blog data for language ${lang}:`, error);
|
||||||
|
// Fallback: keep at least the static routes
|
||||||
|
routes.forEach((route) => {
|
||||||
|
urls.push({
|
||||||
|
url: `${baseUrl}/${lang}${route}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: route === "" ? 1.0 : 0.8,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useRef, useCallback, useEffect, useMemo } from "react";
|
import React, { useRef, useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { useMessages, useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import useRichTextToPlainText from "../hooks/useRichTextToPlainText";
|
import useRichTextToPlainText from "../hooks/useRichTextToPlainText";
|
||||||
import QRCodeComponent from "./ClipboardApp/ShareCard";
|
import QRCodeComponent from "./ClipboardApp/ShareCard";
|
||||||
@@ -14,21 +15,30 @@ import { RetrieveTabPanel } from "./ClipboardApp/RetrieveTabPanel";
|
|||||||
import FullScreenDropZone from "./ClipboardApp/FullScreenDropZone";
|
import FullScreenDropZone from "./ClipboardApp/FullScreenDropZone";
|
||||||
import { traverseFileTree } from "@/lib/fileUtils";
|
import { traverseFileTree } from "@/lib/fileUtils";
|
||||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||||
|
import { getCachedId } from "@/lib/roomIdCache";
|
||||||
|
import { useConnectionFeedback } from "@/hooks/useConnectionFeedback";
|
||||||
|
import type { Messages } from "@/types/messages";
|
||||||
|
|
||||||
const ClipboardApp = () => {
|
const ClipboardApp = () => {
|
||||||
|
const messages = useMessages() as Messages;
|
||||||
|
const tTabs = useTranslations("text.clipboard.tabs");
|
||||||
|
const tTitles = useTranslations("text.clipboard.titles");
|
||||||
const { shareMessage, retrieveMessage, putMessageInMs } =
|
const { shareMessage, retrieveMessage, putMessageInMs } =
|
||||||
useClipboardAppMessages();
|
useClipboardAppMessages();
|
||||||
|
const roomText = messages.text.clipboard;
|
||||||
|
const fileTransferText = messages.text.clipboard.messages;
|
||||||
|
const connectionText = messages.text.clipboard.rtc;
|
||||||
|
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
const retrieveJoinRoomBtnRef = useRef<HTMLButtonElement>(null);
|
const retrieveJoinRoomBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const { messages, isLoadingMessages } = usePageSetup({
|
usePageSetup({
|
||||||
setRetrieveRoomId: useFileTransferStore.getState().setRetrieveRoomIdInput,
|
setRetrieveRoomId: useFileTransferStore.getState().setRetrieveRoomIdInput,
|
||||||
setActiveTab: useFileTransferStore.getState().setActiveTab,
|
setActiveTab: useFileTransferStore.getState().setActiveTab,
|
||||||
retrieveJoinRoomBtnRef,
|
retrieveJoinRoomBtnRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 从 store 中获取状态
|
// Get state from store
|
||||||
const {
|
const {
|
||||||
activeTab,
|
activeTab,
|
||||||
isDragging,
|
isDragging,
|
||||||
@@ -37,6 +47,9 @@ const ClipboardApp = () => {
|
|||||||
setIsDragging,
|
setIsDragging,
|
||||||
setRetrieveRoomIdInput,
|
setRetrieveRoomIdInput,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
|
// for auto-join on receiver side
|
||||||
|
isReceiverInRoom,
|
||||||
|
retrieveRoomIdInput,
|
||||||
} = useFileTransferStore();
|
} = useFileTransferStore();
|
||||||
|
|
||||||
const richTextToPlainText = useRichTextToPlainText();
|
const richTextToPlainText = useRichTextToPlainText();
|
||||||
@@ -47,7 +60,7 @@ const ClipboardApp = () => {
|
|||||||
addFilesToSend,
|
addFilesToSend,
|
||||||
removeFileToSend,
|
removeFileToSend,
|
||||||
handleDownloadFile,
|
handleDownloadFile,
|
||||||
} = useFileTransferHandler({ messages, putMessageInMs });
|
} = useFileTransferHandler({ text: fileTransferText, putMessageInMs });
|
||||||
|
|
||||||
// Simplified WebRTC connection initialization
|
// Simplified WebRTC connection initialization
|
||||||
const {
|
const {
|
||||||
@@ -56,7 +69,6 @@ const ClipboardApp = () => {
|
|||||||
setReceiverDirectoryHandle,
|
setReceiverDirectoryHandle,
|
||||||
getReceiverSaveType,
|
getReceiverSaveType,
|
||||||
} = useWebRTCConnection({
|
} = useWebRTCConnection({
|
||||||
messages,
|
|
||||||
putMessageInMs,
|
putMessageInMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +80,28 @@ const ClipboardApp = () => {
|
|||||||
handleLeaveReceiverRoom,
|
handleLeaveReceiverRoom,
|
||||||
handleLeaveSenderRoom,
|
handleLeaveSenderRoom,
|
||||||
} = useRoomManager({
|
} = useRoomManager({
|
||||||
messages,
|
text: {
|
||||||
|
join: roomText.join,
|
||||||
|
messages: {
|
||||||
|
waiting: roomText.messages.waiting,
|
||||||
|
confirmLeave: roomText.messages.confirmLeave,
|
||||||
|
leaveSuccess: roomText.messages.leaveSuccess,
|
||||||
|
fetchRoomError: roomText.messages.fetchRoomError,
|
||||||
|
generateShareLinkError: roomText.messages.generateShareLinkError,
|
||||||
|
leaveRoomError: roomText.messages.leaveRoomError,
|
||||||
|
validateRoomError: roomText.messages.validateRoomError,
|
||||||
|
resetSenderStateError: roomText.messages.resetSenderStateError,
|
||||||
|
},
|
||||||
|
roomCheck: roomText.roomCheck,
|
||||||
|
status: {
|
||||||
|
roomEmpty: roomText.status.roomEmpty,
|
||||||
|
receiverCanAccept: roomText.status.receiverCanAccept,
|
||||||
|
onlyOne: roomText.status.onlyOne,
|
||||||
|
peopleCount: roomText.status.peopleCount,
|
||||||
|
connected: roomText.status.connected,
|
||||||
|
leftRoom: roomText.status.leftRoom,
|
||||||
|
},
|
||||||
|
},
|
||||||
putMessageInMs,
|
putMessageInMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,20 +177,38 @@ const ClipboardApp = () => {
|
|||||||
};
|
};
|
||||||
}, [activeTab, handleFileDrop, setIsDragging]);
|
}, [activeTab, handleFileDrop, setIsDragging]);
|
||||||
|
|
||||||
if (isLoadingMessages || !messages) {
|
// Auto-join on switching to receiver tab when cached ID exists
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="container mx-auto px-4 py-8 w-full md:max-w-4xl">
|
if (activeTab !== "retrieve") return;
|
||||||
<div className="min-h-[1000px] w-full bg-gray-200/50 dark:bg-gray-800/50 rounded-lg animate-pulse">
|
if (isReceiverInRoom) return;
|
||||||
{" "}
|
|
||||||
Loading Editor...{" "}
|
// Do not auto-join if URL already specifies a roomId (URL 优先)
|
||||||
</div>
|
const params = new URLSearchParams(window.location.search);
|
||||||
</div>
|
if (params.get("roomId")) return;
|
||||||
);
|
|
||||||
}
|
// Do not override user's existing input
|
||||||
|
if ((retrieveRoomIdInput || "").trim().length > 0) return;
|
||||||
|
|
||||||
|
const cached = getCachedId();
|
||||||
|
if (!cached || cached.trim().length === 0) return;
|
||||||
|
|
||||||
|
// Fill input then join directly to improve UX
|
||||||
|
setRetrieveRoomIdInput(cached);
|
||||||
|
joinRoom(false, cached);
|
||||||
|
}, [
|
||||||
|
activeTab,
|
||||||
|
isReceiverInRoom,
|
||||||
|
retrieveRoomIdInput,
|
||||||
|
setRetrieveRoomIdInput,
|
||||||
|
joinRoom,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Connection feedback observer (Hook)
|
||||||
|
useConnectionFeedback({ text: connectionText, putMessageInMs });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full mx-auto px-1 sm:px-1 py-3 sm:py-8 md:max-w-4xl md:container">
|
<div className="w-full mx-auto px-1 sm:px-1 py-3 sm:py-8 md:max-w-4xl md:container">
|
||||||
<FullScreenDropZone isDragging={isDragging} messages={messages} />
|
<FullScreenDropZone isDragging={isDragging} />
|
||||||
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-4">
|
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-4">
|
||||||
<Button
|
<Button
|
||||||
variant={activeTab === "send" ? "default" : "outline"}
|
variant={activeTab === "send" ? "default" : "outline"}
|
||||||
@@ -167,7 +218,7 @@ const ClipboardApp = () => {
|
|||||||
id="send-tab"
|
id="send-tab"
|
||||||
aria-selected={activeTab === "send"}
|
aria-selected={activeTab === "send"}
|
||||||
>
|
>
|
||||||
{messages.text.ClipboardApp.html.senderTab}
|
{tTabs("send")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={activeTab === "retrieve" ? "default" : "outline"}
|
variant={activeTab === "retrieve" ? "default" : "outline"}
|
||||||
@@ -177,21 +228,20 @@ const ClipboardApp = () => {
|
|||||||
id="retrieve-tab"
|
id="retrieve-tab"
|
||||||
aria-selected={activeTab === "retrieve"}
|
aria-selected={activeTab === "retrieve"}
|
||||||
>
|
>
|
||||||
{messages.text.ClipboardApp.html.retrieveTab}
|
{tTabs("retrieve")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Card className="border-4 sm:border-8 shadow-md">
|
<Card className="border-4 sm:border-8 shadow-md">
|
||||||
<CardHeader className="px-3 sm:px-6 py-3 sm:py-6">
|
<CardHeader className="px-3 sm:px-6 py-3 sm:py-6">
|
||||||
<CardTitle className="text-lg sm:text-xl">
|
<CardTitle className="text-lg sm:text-xl">
|
||||||
{activeTab === "send"
|
{activeTab === "send"
|
||||||
? messages.text.ClipboardApp.html.shareTitle_dis
|
? tTitles("share")
|
||||||
: messages.text.ClipboardApp.html.retrieveTitle_dis}
|
: tTitles("retrieve")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-3 sm:px-6">
|
<CardContent className="px-3 sm:px-6">
|
||||||
{activeTab === "send" ? (
|
{activeTab === "send" ? (
|
||||||
<SendTabPanel
|
<SendTabPanel
|
||||||
messages={messages}
|
|
||||||
updateShareContent={updateShareContent}
|
updateShareContent={updateShareContent}
|
||||||
addFilesToSend={addFilesToSend}
|
addFilesToSend={addFilesToSend}
|
||||||
removeFileToSend={removeFileToSend}
|
removeFileToSend={removeFileToSend}
|
||||||
@@ -202,10 +252,10 @@ const ClipboardApp = () => {
|
|||||||
shareMessage={shareMessage}
|
shareMessage={shareMessage}
|
||||||
currentValidatedShareRoomId={shareRoomId}
|
currentValidatedShareRoomId={shareRoomId}
|
||||||
handleLeaveSenderRoom={handleLeaveSenderRoom}
|
handleLeaveSenderRoom={handleLeaveSenderRoom}
|
||||||
|
putMessageInMs={putMessageInMs}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RetrieveTabPanel
|
<RetrieveTabPanel
|
||||||
messages={messages}
|
|
||||||
putMessageInMs={putMessageInMs}
|
putMessageInMs={putMessageInMs}
|
||||||
setRetrieveRoomIdInput={setRetrieveRoomIdInput}
|
setRetrieveRoomIdInput={setRetrieveRoomIdInput}
|
||||||
joinRoom={joinRoom}
|
joinRoom={joinRoom}
|
||||||
@@ -222,11 +272,11 @@ const ClipboardApp = () => {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{activeTab === "send" && shareLink && messages && (
|
{activeTab === "send" && shareLink && (
|
||||||
<Card className="border-2 sm:border-4 shadow-md mt-2 sm:mt-4">
|
<Card className="border-2 sm:border-4 shadow-md mt-2 sm:mt-4">
|
||||||
<CardHeader className="pb-3 sm:pb-6">
|
<CardHeader className="pb-3 sm:pb-6">
|
||||||
<CardTitle className="text-base sm:text-lg">
|
<CardTitle className="text-base sm:text-lg">
|
||||||
{messages.text.ClipboardApp.html.RetrieveMethodTitle}
|
{tTitles("retrieveMethod")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 px-3 sm:px-6">
|
<CardContent className="pt-0 px-3 sm:px-6">
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Tooltip from "@/components/Tooltip";
|
||||||
|
import { getCachedId, setCachedId } from "@/lib/roomIdCache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CachedIdActionButton
|
||||||
|
*
|
||||||
|
* A reusable action button that unifies the "Use cached ID" and "Save ID" behaviors
|
||||||
|
* across sender and receiver panels.
|
||||||
|
*
|
||||||
|
* UX
|
||||||
|
* - If a cached Room ID exists:
|
||||||
|
* - Single click (no second click within dblClickWindowMs, default 400ms):
|
||||||
|
* writes the cached ID into the target input without availability checks
|
||||||
|
* (matching the current Random ID UX).
|
||||||
|
* - Double click (two clicks within dblClickWindowMs): switches to a temporary
|
||||||
|
* "Save ID" mode for saveModeDurationMs (default 3000ms) without filling.
|
||||||
|
* - If no cached Room ID exists: the button shows "Save ID" by default; when the
|
||||||
|
* current input length >= 8, clicking saves it to localStorage and reports success
|
||||||
|
* via putMessageInMs, then the button returns to "Use cached ID".
|
||||||
|
* - In "Save ID" mode: clicking saves the current input (>= 8) and exits the mode;
|
||||||
|
* if the user does nothing, the mode auto-exits after saveModeDurationMs.
|
||||||
|
*
|
||||||
|
* Props
|
||||||
|
* - messages: i18n dictionary used for labels/tooltips.
|
||||||
|
* - getInputValue / setInputValue: provide read/write access to the room ID input.
|
||||||
|
* - putMessageInMs: message dispatcher; isShareEnd tells which side (sender/receiver)
|
||||||
|
* should display the toast.
|
||||||
|
* - Optional styling/timing overrides: className, variant, size, dblClickWindowMs,
|
||||||
|
* saveModeDurationMs — with sensible defaults for drop‑in usage.
|
||||||
|
*
|
||||||
|
* Implementation
|
||||||
|
* - Local state tracks if a cached ID exists and whether we are in temporary
|
||||||
|
* "save override" mode.
|
||||||
|
* - Single/double click detection uses a short timer + click counter refs;
|
||||||
|
* timers are cleaned up on unmount to avoid leaks.
|
||||||
|
* - localStorage reads/writes are abstracted via getCachedId/setCachedId.
|
||||||
|
* - No network calls, and no availability checks during "Use cached ID" to keep
|
||||||
|
* the interaction snappy and consistent with Random ID behavior.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
getInputValue: () => string;
|
||||||
|
setInputValue: (val: string) => void;
|
||||||
|
putMessageInMs: (
|
||||||
|
message: string,
|
||||||
|
isShareEnd?: boolean,
|
||||||
|
displayTimeMs?: number
|
||||||
|
) => void;
|
||||||
|
isShareEnd: boolean; // true for sender, false for receiver
|
||||||
|
className?: string;
|
||||||
|
variant?:
|
||||||
|
| "default"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "secondary"
|
||||||
|
| "ghost"
|
||||||
|
| "link";
|
||||||
|
size?: "default" | "sm" | "lg" | "icon";
|
||||||
|
dblClickWindowMs?: number; // default 400ms
|
||||||
|
saveModeDurationMs?: number; // default 3000ms
|
||||||
|
// Optional: called after a cached ID is applied (single-click "Use cached ID")
|
||||||
|
onUseCached?: (cachedId: string) => void;
|
||||||
|
// Optional: external disabled flag
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CachedIdActionButton({
|
||||||
|
getInputValue,
|
||||||
|
setInputValue,
|
||||||
|
putMessageInMs,
|
||||||
|
isShareEnd,
|
||||||
|
className = "w-full sm:w-auto px-4",
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
dblClickWindowMs = 400,
|
||||||
|
saveModeDurationMs = 3000,
|
||||||
|
onUseCached,
|
||||||
|
disabled = false,
|
||||||
|
}: Props) {
|
||||||
|
const tCachedId = useTranslations("text.clipboard.cachedId");
|
||||||
|
const [hasCachedId, setHasCachedId] = useState<boolean>(false);
|
||||||
|
const [showSaveOverride, setShowSaveOverride] = useState<boolean>(false);
|
||||||
|
const clickCountRef = useRef(0);
|
||||||
|
const singleTimerRef = useRef<number | null>(null);
|
||||||
|
const saveTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasCachedId(!!getCachedId());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (singleTimerRef.current) {
|
||||||
|
clearTimeout(singleTimerRef.current);
|
||||||
|
singleTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isSaveMode = showSaveOverride || !hasCachedId;
|
||||||
|
const inputVal = getInputValue() || "";
|
||||||
|
const isSaveEnabled = inputVal.trim().length >= 8;
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (isSaveMode) {
|
||||||
|
const trimmed = (getInputValue() || "").trim();
|
||||||
|
if (trimmed.length >= 8) {
|
||||||
|
setCachedId(trimmed);
|
||||||
|
setHasCachedId(true);
|
||||||
|
setShowSaveOverride(false);
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
putMessageInMs(tCachedId("saveSuccess"), isShareEnd);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cached with single/double click detection
|
||||||
|
clickCountRef.current += 1;
|
||||||
|
if (clickCountRef.current === 1) {
|
||||||
|
// Single click timer
|
||||||
|
singleTimerRef.current = window.setTimeout(() => {
|
||||||
|
if (clickCountRef.current === 1) {
|
||||||
|
const cached = getCachedId();
|
||||||
|
if (cached) {
|
||||||
|
setInputValue(cached);
|
||||||
|
// Notify caller after applying cached value
|
||||||
|
onUseCached?.(cached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clickCountRef.current = 0;
|
||||||
|
if (singleTimerRef.current) {
|
||||||
|
clearTimeout(singleTimerRef.current);
|
||||||
|
singleTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, dblClickWindowMs);
|
||||||
|
} else if (clickCountRef.current === 2) {
|
||||||
|
// Double click => switch to save mode
|
||||||
|
if (singleTimerRef.current) {
|
||||||
|
clearTimeout(singleTimerRef.current);
|
||||||
|
singleTimerRef.current = null;
|
||||||
|
}
|
||||||
|
clickCountRef.current = 0;
|
||||||
|
setShowSaveOverride(true);
|
||||||
|
saveTimerRef.current = window.setTimeout(() => {
|
||||||
|
setShowSaveOverride(false);
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, saveModeDurationMs);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isSaveMode,
|
||||||
|
getInputValue,
|
||||||
|
setInputValue,
|
||||||
|
putMessageInMs,
|
||||||
|
tCachedId,
|
||||||
|
isShareEnd,
|
||||||
|
dblClickWindowMs,
|
||||||
|
saveModeDurationMs,
|
||||||
|
onUseCached,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
isSaveMode
|
||||||
|
? tCachedId("saveTip")
|
||||||
|
: tCachedId("useTip")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="inline-block">
|
||||||
|
<Button
|
||||||
|
className={className}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={
|
||||||
|
disabled || (isSaveMode ? !isSaveEnabled : !hasCachedId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSaveMode
|
||||||
|
? tCachedId("save")
|
||||||
|
: tCachedId("use")}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Download, Trash2 } from "lucide-react";
|
import { Download, Trash2 } from "lucide-react";
|
||||||
import { Tooltip } from "@/components/Tooltip";
|
import { Tooltip } from "@/components/Tooltip";
|
||||||
@@ -7,9 +8,6 @@ import { formatFileSize, generateFileId } from "@/lib/fileUtils";
|
|||||||
import { AutoPopupDialog } from "@/components/common/AutoPopupDialog";
|
import { AutoPopupDialog } from "@/components/common/AutoPopupDialog";
|
||||||
import { FileMeta, CustomFile, Progress } from "@/types/webrtc";
|
import { FileMeta, CustomFile, Progress } from "@/types/webrtc";
|
||||||
import FileTransferButton from "./FileTransferButton";
|
import FileTransferButton from "./FileTransferButton";
|
||||||
import { getDictionary } from "@/lib/dictionary";
|
|
||||||
import { useLocale } from "@/hooks/useLocale";
|
|
||||||
import type { Messages } from "@/types/messages";
|
|
||||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||||
import { supportsAutoDownload } from "@/lib/browserUtils";
|
import { supportsAutoDownload } from "@/lib/browserUtils";
|
||||||
import { postLogToBackend } from "@/app/config/api";
|
import { postLogToBackend } from "@/app/config/api";
|
||||||
@@ -68,8 +66,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
|||||||
saveType,
|
saveType,
|
||||||
largeFileThreshold = 500 * 1024 * 1024, // 500MB default
|
largeFileThreshold = 500 * 1024 * 1024, // 500MB default
|
||||||
}) => {
|
}) => {
|
||||||
const locale = useLocale();
|
const t = useTranslations("text.fileList");
|
||||||
const [messages, setMessages] = useState<Messages | null>(null);
|
|
||||||
|
|
||||||
// Get the cleaning method of the store
|
// Get the cleaning method of the store
|
||||||
const { clearSendProgress, clearReceiveProgress } = useFileTransferStore();
|
const { clearSendProgress, clearReceiveProgress } = useFileTransferStore();
|
||||||
@@ -111,12 +108,6 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getDictionary(locale)
|
|
||||||
.then((dict) => setMessages(dict))
|
|
||||||
.catch((error) => console.error("Failed to load messages:", error));
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Separate single files and folders
|
// Separate single files and folders
|
||||||
const tempSingleFiles: FileMeta[] = [];
|
const tempSingleFiles: FileMeta[] = [];
|
||||||
@@ -234,6 +225,9 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
|||||||
fileProgresses,
|
fileProgresses,
|
||||||
showFinished,
|
showFinished,
|
||||||
activeTransfers,
|
activeTransfers,
|
||||||
|
mode,
|
||||||
|
clearSendProgress,
|
||||||
|
clearReceiveProgress,
|
||||||
folders,
|
folders,
|
||||||
singleFiles,
|
singleFiles,
|
||||||
]);
|
]);
|
||||||
@@ -295,7 +289,15 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
|||||||
// Update the last status
|
// Update the last status
|
||||||
prevShowFinishedRef.current[item.fileId] = currentShowFinished;
|
prevShowFinishedRef.current[item.fileId] = currentShowFinished;
|
||||||
});
|
});
|
||||||
}, [showFinished, singleFiles, folders, saveType, onDownload]);
|
}, [
|
||||||
|
showFinished,
|
||||||
|
singleFiles,
|
||||||
|
folders,
|
||||||
|
saveType,
|
||||||
|
onDownload,
|
||||||
|
activeTransfers,
|
||||||
|
fileProgresses,
|
||||||
|
]);
|
||||||
|
|
||||||
//Actions corresponding to each file - progress, download, delete
|
//Actions corresponding to each file - progress, download, delete
|
||||||
const renderItemActions = (item: FileMeta) => {
|
const renderItemActions = (item: FileMeta) => {
|
||||||
@@ -309,25 +311,20 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
|||||||
// Get download count
|
// Get download count
|
||||||
const downloadCount = downloadCounts[item.fileId] || 0;
|
const downloadCount = downloadCounts[item.fileId] || 0;
|
||||||
|
|
||||||
if (messages === null) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 min-w-0 flex-shrink-0">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 min-w-0 flex-shrink-0">
|
||||||
{progress && progress.progress < 1 ? ( //Show progress or completed
|
{progress && progress.progress < 1 ? ( //Show progress or completed
|
||||||
<div className="w-full sm:w-auto">
|
<div className="w-full sm:w-auto">
|
||||||
<TransferProgress
|
<TransferProgress
|
||||||
message={
|
message={
|
||||||
mode === "sender"
|
mode === "sender" ? t("sending") : t("receiving")
|
||||||
? messages.text.FileListDisplay.sending_dis
|
|
||||||
: messages.text.FileListDisplay.receiving_dis
|
|
||||||
}
|
}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : showCompletion ? (
|
) : showCompletion ? (
|
||||||
<span className="text-sm text-green-500 whitespace-nowrap">
|
<span className="text-sm text-green-500 whitespace-nowrap">
|
||||||
{messages.text.FileListDisplay.finish_dis}
|
{t("finished")}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -351,28 +348,26 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
|||||||
{/* display download Num*/}
|
{/* display download Num*/}
|
||||||
{mode === "sender" && (
|
{mode === "sender" && (
|
||||||
<span className="text-xs sm:text-sm whitespace-nowrap">
|
<span className="text-xs sm:text-sm whitespace-nowrap">
|
||||||
{messages.text.FileListDisplay.downloadNum_dis}: {downloadCount}
|
{t("downloadCount")}: {downloadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{mode === "sender" && onDelete && (
|
{mode === "sender" && onDelete && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onDelete(item);
|
onDelete(item);
|
||||||
}}
|
}}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={
|
disabled={
|
||||||
progress
|
progress
|
||||||
? progress?.progress > 0 && progress.progress < 1
|
? progress?.progress > 0 && progress.progress < 1
|
||||||
: false
|
: false
|
||||||
}
|
}
|
||||||
className="text-xs sm:text-sm px-2 sm:px-3"
|
className="text-xs sm:text-sm px-2 sm:px-3"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 sm:mr-2" />
|
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 sm:mr-2" />
|
||||||
<span className="hidden sm:inline">
|
<span className="hidden sm:inline">{t("delete")}</span>
|
||||||
{messages.text.FileListDisplay.delete_dis}
|
</Button>
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -384,7 +379,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
|||||||
const formatSize = formatFileSize(item.size);
|
const formatSize = formatFileSize(item.size);
|
||||||
const tooltipContent = isFolder
|
const tooltipContent = isFolder
|
||||||
? `${formatFolderTips(
|
? `${formatFolderTips(
|
||||||
messages!.text.FileListDisplay.folder_tips_template,
|
t("folderSummary"),
|
||||||
item.name,
|
item.name,
|
||||||
item.fileCount || 0,
|
item.fileCount || 0,
|
||||||
formatSize
|
formatSize
|
||||||
@@ -394,7 +389,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.name}
|
key={item.name}
|
||||||
className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2 p-2 sm:p-3 border border-gray-100 rounded-lg"
|
className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2 p-2 sm:p-3 border border-border rounded-lg"
|
||||||
>
|
>
|
||||||
<Tooltip content={tooltipContent}>
|
<Tooltip content={tooltipContent}>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -404,10 +399,10 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
|||||||
? `${item.name.slice(0, filenameDisplayLen - 3)}...`
|
? `${item.name.slice(0, filenameDisplayLen - 3)}...`
|
||||||
: item.name}
|
: item.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs sm:text-sm text-gray-500">
|
<span className="text-xs sm:text-sm text-muted-foreground">
|
||||||
{isFolder
|
{isFolder
|
||||||
? `${formatFolderDis(
|
? `${formatFolderDis(
|
||||||
messages!.text.FileListDisplay.folder_dis_template,
|
t("folderInline"),
|
||||||
item.fileCount || 0,
|
item.fileCount || 0,
|
||||||
formatSize
|
formatSize
|
||||||
)}`
|
)}`
|
||||||
@@ -421,9 +416,6 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
if (messages === null) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(singleFiles.length > 0 || folders.length > 0) && (
|
{(singleFiles.length > 0 || folders.length > 0) && (
|
||||||
@@ -433,17 +425,13 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
|||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<AutoPopupDialog
|
<AutoPopupDialog
|
||||||
storageKey="Choose-location-popup-shown"
|
storageKey="Choose-location-popup-shown"
|
||||||
title={messages.text.FileListDisplay.PopupDialog_title}
|
title={t("saveDialog.title")}
|
||||||
description={
|
description={t("saveDialog.description")}
|
||||||
messages.text.FileListDisplay.PopupDialog_description
|
|
||||||
}
|
|
||||||
condition={() => needPickLocation}
|
condition={() => needPickLocation}
|
||||||
/>
|
/>
|
||||||
{/* Regular reminder to select the save directory */}
|
{/* Regular reminder to select the save directory */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<p className="text-red-500 mb-2">
|
<p className="text-red-500 mb-2">{t("saveDialog.tip")}</p>
|
||||||
{messages.text.FileListDisplay.chooseSavePath_tips}
|
|
||||||
</p>
|
|
||||||
{onLocationPick && (
|
{onLocationPick && (
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -454,7 +442,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="mr-2 text-red-500"
|
className="mr-2 text-red-500"
|
||||||
>
|
>
|
||||||
{messages.text.FileListDisplay.chooseSavePath_dis}
|
{t("saveDialog.button")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -7,9 +8,6 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { getDictionary } from "@/lib/dictionary";
|
|
||||||
import { useLocale } from "@/hooks/useLocale";
|
|
||||||
import type { Messages } from "@/types/messages";
|
|
||||||
|
|
||||||
interface FileTransferButtonProps {
|
interface FileTransferButtonProps {
|
||||||
onRequest: () => void;
|
onRequest: () => void;
|
||||||
@@ -28,30 +26,20 @@ const FileTransferButton = ({
|
|||||||
isSavedToDisk,
|
isSavedToDisk,
|
||||||
isPendingSave = false,
|
isPendingSave = false,
|
||||||
}: FileTransferButtonProps) => {
|
}: FileTransferButtonProps) => {
|
||||||
const locale = useLocale();
|
const t = useTranslations("text.fileTransfer");
|
||||||
const [messages, setMessages] = useState<Messages | null>(null);
|
|
||||||
// Button status judgment - 待保存状态时按钮应该可点击
|
// Button status judgment - 待保存状态时按钮应该可点击
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
isCurrentFileTransferring ||
|
isCurrentFileTransferring ||
|
||||||
isSavedToDisk ||
|
isSavedToDisk ||
|
||||||
(isOtherFileTransferring && !isPendingSave);
|
(isOtherFileTransferring && !isPendingSave);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getDictionary(locale)
|
|
||||||
.then((dict) => setMessages(dict))
|
|
||||||
.catch((error) => console.error("Failed to load messages:", error));
|
|
||||||
}, [locale]);
|
|
||||||
// Display different tooltips based on status
|
// Display different tooltips based on status
|
||||||
const getTooltipContent = () => {
|
const getTooltipContent = () => {
|
||||||
if (isSavedToDisk)
|
if (isSavedToDisk) return t("savedToDisk");
|
||||||
return messages!.text.FileTransferButton.SavedToDisk_tips;
|
if (isCurrentFileTransferring) return t("currentTransferring");
|
||||||
if (isCurrentFileTransferring)
|
if (isPendingSave) return t("pendingSave");
|
||||||
return messages!.text.FileTransferButton.CurrentFileTransferring_tips;
|
if (isOtherFileTransferring) return t("otherTransferring");
|
||||||
if (isPendingSave)
|
return t("download");
|
||||||
return messages!.text.FileTransferButton.PendingSave_tips;
|
|
||||||
if (isOtherFileTransferring)
|
|
||||||
return messages!.text.FileTransferButton.OtherFileTransferring_tips;
|
|
||||||
return messages!.text.FileTransferButton.download_tips;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set different button styles and class names based on status
|
// Set different button styles and class names based on status
|
||||||
@@ -59,7 +47,7 @@ const FileTransferButton = ({
|
|||||||
if (isSavedToDisk) {
|
if (isSavedToDisk) {
|
||||||
return {
|
return {
|
||||||
variant: "ghost" as const,
|
variant: "ghost" as const,
|
||||||
className: "mr-2 text-gray-500",
|
className: "mr-2 text-muted-foreground",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isCurrentFileTransferring) {
|
if (isCurrentFileTransferring) {
|
||||||
@@ -70,27 +58,23 @@ const FileTransferButton = ({
|
|||||||
}
|
}
|
||||||
if (isPendingSave) {
|
if (isPendingSave) {
|
||||||
return {
|
return {
|
||||||
variant: "default" as const, // 使用更明显的样式
|
variant: "default" as const,
|
||||||
className: "mr-2 bg-green-600 hover:bg-green-700 text-white",
|
className: "mr-2",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isOtherFileTransferring) {
|
if (isOtherFileTransferring) {
|
||||||
return {
|
return {
|
||||||
variant: "outline" as const,
|
variant: "outline" as const,
|
||||||
className:
|
className: "mr-2 cursor-not-allowed bg-muted text-muted-foreground",
|
||||||
"mr-2 cursor-not-allowed bg-gray-100 border-gray-300 text-gray-500",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
variant: "outline" as const,
|
variant: "outline" as const,
|
||||||
className: "mr-2 hover:bg-blue-50",
|
className: "mr-2 hover:bg-accent",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttonStyles = getButtonStyles();
|
const buttonStyles = getButtonStyles();
|
||||||
if (messages === null) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={100}>
|
<TooltipProvider delayDuration={100}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -109,19 +93,16 @@ const FileTransferButton = ({
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{isSavedToDisk
|
{isSavedToDisk
|
||||||
? messages.text.FileTransferButton.Saved_dis
|
? t("saved")
|
||||||
: isPendingSave
|
: isPendingSave
|
||||||
? messages.text.FileTransferButton.Save_dis
|
? t("pendingSave")
|
||||||
: isOtherFileTransferring
|
: isOtherFileTransferring
|
||||||
? messages.text.FileTransferButton.Waiting_dis
|
? t("waiting")
|
||||||
: messages.text.FileTransferButton.Download_dis}
|
: t("download")}
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent
|
<TooltipContent side="top" className="px-3 py-2 rounded-md text-sm">
|
||||||
side="top"
|
|
||||||
className="bg-gray-800 text-white px-3 py-2 rounded-md text-sm"
|
|
||||||
>
|
|
||||||
{getTooltipContent()}
|
{getTooltipContent()}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
import { FileMeta, CustomFile } from "@/types/webrtc";
|
import { FileMeta, CustomFile } from "@/types/webrtc";
|
||||||
@@ -23,11 +24,6 @@ declare module "@/components/ui/input" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import { getDictionary } from "@/lib/dictionary";
|
|
||||||
import { useLocale } from "@/hooks/useLocale";
|
|
||||||
import type { Messages } from "@/types/messages";
|
|
||||||
import { en } from "@/constants/messages/en"; // Import English dictionary as default
|
|
||||||
|
|
||||||
function formatFileChosen(
|
function formatFileChosen(
|
||||||
template: string,
|
template: string,
|
||||||
fileNum: number,
|
fileNum: number,
|
||||||
@@ -45,28 +41,17 @@ interface FileUploadHandlerProps {
|
|||||||
const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
||||||
onFilePicked,
|
onFilePicked,
|
||||||
}) => {
|
}) => {
|
||||||
const locale = useLocale();
|
const t = useTranslations("text.fileUpload");
|
||||||
const [messages, setMessages] = useState<Messages>(en); // Use English dictionary as initial value
|
|
||||||
|
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
// File selector -- message prompt
|
// File selector -- message prompt
|
||||||
const [fileText, setFileText] = useState<string>(
|
const [fileText, setFileText] = useState<string>(t("noFileChosen"));
|
||||||
en.text.fileUploadHandler.NoFileChosen_tips
|
|
||||||
);
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (locale !== "en") {
|
setFileText(t("noFileChosen"));
|
||||||
// Only load other language packs if not English
|
}, [t]);
|
||||||
getDictionary(locale)
|
|
||||||
.then((dict) => {
|
|
||||||
setMessages(dict);
|
|
||||||
setFileText(dict.text.fileUploadHandler.NoFileChosen_tips);
|
|
||||||
})
|
|
||||||
.catch((error) => console.error("Failed to load messages:", error));
|
|
||||||
}
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
const handleFileChange = useCallback(
|
||||||
(newFiles: CustomFile[]) => {
|
(newFiles: CustomFile[]) => {
|
||||||
@@ -77,16 +62,13 @@ const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
|||||||
const folderNum = newFiles.filter((file) => file.folderName).length;
|
const folderNum = newFiles.filter((file) => file.folderName).length;
|
||||||
|
|
||||||
const choose_dis = formatFileChosen(
|
const choose_dis = formatFileChosen(
|
||||||
messages!.text.fileUploadHandler.fileChosen_tips_template,
|
t("fileChosen"),
|
||||||
fileNum,
|
fileNum,
|
||||||
folderNum
|
folderNum
|
||||||
);
|
);
|
||||||
|
|
||||||
setFileText(choose_dis);
|
setFileText(choose_dis);
|
||||||
setTimeout(
|
setTimeout(() => setFileText(t("noFileChosen")), 2000);
|
||||||
() => setFileText(messages!.text.fileUploadHandler.NoFileChosen_tips),
|
|
||||||
2000
|
|
||||||
);
|
|
||||||
// Reset the file input
|
// Reset the file input
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = "";
|
||||||
@@ -95,7 +77,7 @@ const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
|||||||
folderInputRef.current.value = "";
|
folderInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[messages, onFilePicked]
|
[t, onFilePicked]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Click to upload file processing
|
// Click to upload file processing
|
||||||
@@ -154,20 +136,17 @@ const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
|||||||
const handleSelectFolder = () => {
|
const handleSelectFolder = () => {
|
||||||
folderInputRef.current?.click();
|
folderInputRef.current?.click();
|
||||||
};
|
};
|
||||||
if (messages === null) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer"
|
className="border-2 border-dashed border-border rounded-lg p-6 text-center cursor-pointer"
|
||||||
onClick={handleZoneClick}
|
onClick={handleZoneClick}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
{messages.text.fileUploadHandler.chooseFileTips}
|
{t("chooseTip")}
|
||||||
</p>
|
</p>
|
||||||
<Upload className="h-12 w-12 mx-auto mb-4 text-blue-500" />
|
<Upload className="h-12 w-12 mx-auto mb-4 text-primary" />
|
||||||
<p className="text-sm text-gray-600">{fileText}</p>
|
<p className="text-sm text-muted-foreground">{fileText}</p>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
id="file-upload"
|
id="file-upload"
|
||||||
@@ -193,24 +172,24 @@ const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
|||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl font-semibold">
|
<DialogTitle className="text-xl font-semibold">
|
||||||
{messages.text.fileUploadHandler.chosenDiagTitle}
|
{t("dialog.title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="mt-2 text-muted-foreground">
|
<DialogDescription className="mt-2 text-muted-foreground">
|
||||||
{messages.text.fileUploadHandler.chosenDiagDescription}
|
{t("dialog.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-center gap-4 mt-6">
|
<div className="flex justify-center gap-4 mt-6">
|
||||||
<button
|
<button
|
||||||
onClick={handleSelectFile}
|
onClick={handleSelectFile}
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
className="px-4 py-2 rounded transition-colors bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{messages.text.fileUploadHandler.SelectFile_dis}
|
{t("dialog.selectFile")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSelectFolder}
|
onClick={handleSelectFolder}
|
||||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
className="px-4 py-2 rounded transition-colors bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||||
>
|
>
|
||||||
{messages.text.fileUploadHandler.SelectFolder_dis}
|
{t("dialog.selectFolder")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
import type { Messages } from "@/types/messages";
|
|
||||||
|
|
||||||
interface FullScreenDropZoneProps {
|
interface FullScreenDropZoneProps {
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
messages: Messages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FullScreenDropZone: React.FC<FullScreenDropZoneProps> = ({
|
const FullScreenDropZone: React.FC<FullScreenDropZoneProps> = ({ isDragging }) => {
|
||||||
isDragging,
|
const t = useTranslations("text.fileUpload");
|
||||||
messages,
|
|
||||||
}) => {
|
|
||||||
if (!isDragging) return null;
|
if (!isDragging) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black bg-opacity-70 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black bg-opacity-70 backdrop-blur-sm">
|
||||||
<Upload className="h-24 w-24 text-white animate-bounce" />
|
<Upload className="h-24 w-24 text-white animate-bounce" />
|
||||||
<p className="mt-6 text-2xl font-bold text-white">
|
<p className="mt-6 text-2xl font-bold text-white">{t("dragTip")}</p>
|
||||||
{messages.text.fileUploadHandler.dragTips}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import React, { useCallback } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ReadClipboardButton,
|
ReadClipboardButton,
|
||||||
WriteClipboardButton,
|
WriteClipboardButton,
|
||||||
} from "@/components/common/clipboard_btn";
|
} from "@/components/common/clipboard_btn";
|
||||||
|
import CachedIdActionButton from "@/components/ClipboardApp/CachedIdActionButton";
|
||||||
import FileListDisplay from "@/components/ClipboardApp/FileListDisplay";
|
import FileListDisplay from "@/components/ClipboardApp/FileListDisplay";
|
||||||
import type { Messages } from "@/types/messages";
|
|
||||||
import type { FileMeta } from "@/types/webrtc";
|
import type { FileMeta } from "@/types/webrtc";
|
||||||
|
|
||||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||||
|
|
||||||
interface RetrieveTabPanelProps {
|
interface RetrieveTabPanelProps {
|
||||||
messages: Messages;
|
|
||||||
putMessageInMs: (
|
putMessageInMs: (
|
||||||
message: string,
|
message: string,
|
||||||
isShareEnd?: boolean,
|
isShareEnd?: boolean,
|
||||||
@@ -35,7 +35,6 @@ interface RetrieveTabPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RetrieveTabPanel({
|
export function RetrieveTabPanel({
|
||||||
messages,
|
|
||||||
putMessageInMs,
|
putMessageInMs,
|
||||||
setRetrieveRoomIdInput,
|
setRetrieveRoomIdInput,
|
||||||
joinRoom,
|
joinRoom,
|
||||||
@@ -49,6 +48,12 @@ export function RetrieveTabPanel({
|
|||||||
retrieveMessage,
|
retrieveMessage,
|
||||||
handleLeaveRoom,
|
handleLeaveRoom,
|
||||||
}: RetrieveTabPanelProps) {
|
}: RetrieveTabPanelProps) {
|
||||||
|
const tActions = useTranslations("text.clipboard.actions");
|
||||||
|
const tPlaceholders = useTranslations("text.clipboard.placeholders");
|
||||||
|
const tStatus = useTranslations("text.clipboard.status");
|
||||||
|
const tSaveLocation = useTranslations("text.clipboard.saveLocation");
|
||||||
|
const tCommon = useTranslations("text.common");
|
||||||
|
const t = useTranslations("text.clipboard");
|
||||||
// Get the status from the store
|
// Get the status from the store
|
||||||
const {
|
const {
|
||||||
retrieveRoomStatusText,
|
retrieveRoomStatusText,
|
||||||
@@ -61,25 +66,24 @@ export function RetrieveTabPanel({
|
|||||||
} = useFileTransferStore();
|
} = useFileTransferStore();
|
||||||
|
|
||||||
const onLocationPick = useCallback(async (): Promise<boolean> => {
|
const onLocationPick = useCallback(async (): Promise<boolean> => {
|
||||||
if (!messages) return false; // Should not happen if panel is rendered
|
|
||||||
if (!window.showDirectoryPicker) {
|
if (!window.showDirectoryPicker) {
|
||||||
putMessageInMs(messages.text.ClipboardApp.pickSaveUnsupported, false);
|
putMessageInMs(tSaveLocation("unsupported"), false);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!window.confirm(messages.text.ClipboardApp.pickSaveMsg)) return false;
|
if (!window.confirm(tSaveLocation("pickMsg"))) return false;
|
||||||
try {
|
try {
|
||||||
const directoryHandle = await window.showDirectoryPicker();
|
const directoryHandle = await window.showDirectoryPicker();
|
||||||
await setReceiverDirectoryHandle(directoryHandle);
|
await setReceiverDirectoryHandle(directoryHandle);
|
||||||
putMessageInMs(messages.text.ClipboardApp.pickSaveSuccess, false);
|
putMessageInMs(tSaveLocation("success"), false);
|
||||||
return true;
|
return true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name !== "AbortError") {
|
if (err.name !== "AbortError") {
|
||||||
console.error("Failed to set up folder receive:", err);
|
console.error("Failed to set up folder receive:", err);
|
||||||
putMessageInMs(messages.text.ClipboardApp.pickSaveError, false);
|
putMessageInMs(tSaveLocation("error"), false);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [messages, putMessageInMs, setReceiverDirectoryHandle]);
|
}, [tSaveLocation, putMessageInMs, setReceiverDirectoryHandle]);
|
||||||
|
|
||||||
const handleFileRequestFromPanel = useCallback(
|
const handleFileRequestFromPanel = useCallback(
|
||||||
(meta: FileMeta) => {
|
(meta: FileMeta) => {
|
||||||
@@ -97,27 +101,32 @@ export function RetrieveTabPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="retrieve-panel" role="tabpanel" aria-labelledby="retrieve-tab">
|
<div id="retrieve-panel" role="tabpanel" aria-labelledby="retrieve-tab">
|
||||||
<div className="mb-3 text-sm text-gray-600">
|
<div className="mb-3 text-sm text-muted-foreground">
|
||||||
{retrieveRoomStatusText ||
|
{retrieveRoomStatusText ||
|
||||||
(isReceiverInRoom
|
(isReceiverInRoom
|
||||||
? messages.text.ClipboardApp.roomStatus.connected_dis
|
? tStatus("connected")
|
||||||
: messages.text.ClipboardApp.roomStatus.receiverEmptyMsg)}
|
: tStatus("receiverCanAccept"))}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 mb-4">
|
<div className="space-y-3 mb-4">
|
||||||
{/* Room ID input section */}
|
{/* Room ID input section */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<ReadClipboardButton
|
<ReadClipboardButton
|
||||||
title={messages.text.ClipboardApp.html.readClipboard_dis}
|
title={tActions("readClipboard")}
|
||||||
onRead={setRetrieveRoomIdInput}
|
onRead={setRetrieveRoomIdInput}
|
||||||
/>
|
/>
|
||||||
|
{/* Save/Use Cached ID Button placed after Paste button */}
|
||||||
|
<CachedIdActionButton
|
||||||
|
getInputValue={() => retrieveRoomIdInput}
|
||||||
|
setInputValue={setRetrieveRoomIdInput}
|
||||||
|
putMessageInMs={putMessageInMs}
|
||||||
|
isShareEnd={false}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
aria-label="Retrieve Room ID"
|
aria-label="Retrieve Room ID"
|
||||||
value={retrieveRoomIdInput}
|
value={retrieveRoomIdInput}
|
||||||
onChange={(e) => setRetrieveRoomIdInput(e.target.value)}
|
onChange={(e) => setRetrieveRoomIdInput(e.target.value)}
|
||||||
placeholder={
|
placeholder={tPlaceholders("roomId")}
|
||||||
messages.text.ClipboardApp.html.retrieveRoomId_placeholder
|
|
||||||
}
|
|
||||||
className="flex-1 min-w-0"
|
className="flex-1 min-w-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +140,7 @@ export function RetrieveTabPanel({
|
|||||||
ref={retrieveJoinRoomBtnRef}
|
ref={retrieveJoinRoomBtnRef}
|
||||||
disabled={isReceiverInRoom || !retrieveRoomIdInput.trim()}
|
disabled={isReceiverInRoom || !retrieveRoomIdInput.trim()}
|
||||||
>
|
>
|
||||||
{messages.text.ClipboardApp.html.joinRoom_dis}
|
{tCommon("buttons.joinRoom")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={isAnyFileTransferring ? "destructive" : "outline"}
|
variant={isAnyFileTransferring ? "destructive" : "outline"}
|
||||||
@@ -140,19 +149,19 @@ export function RetrieveTabPanel({
|
|||||||
className="w-full sm:w-auto px-4 order-2"
|
className="w-full sm:w-auto px-4 order-2"
|
||||||
>
|
>
|
||||||
{isAnyFileTransferring
|
{isAnyFileTransferring
|
||||||
? messages.text.ClipboardApp.roomStatus.leaveRoomBtn + " ⚠️"
|
? tCommon("buttons.leaveRoom") + " ⚠️"
|
||||||
: messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
: tCommon("buttons.leaveRoom")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{retrievedContent && (
|
{retrievedContent && (
|
||||||
<div className="my-3 p-3 border rounded-md">
|
<div className="my-3 p-3 border rounded-md">
|
||||||
<div className="bg-white p-3 rounded border border-gray-200 text-sm leading-relaxed">
|
<div className="bg-card text-card-foreground p-3 rounded border text-sm leading-relaxed">
|
||||||
<div dangerouslySetInnerHTML={{ __html: retrievedContent }} />
|
<div dangerouslySetInnerHTML={{ __html: retrievedContent }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<WriteClipboardButton
|
<WriteClipboardButton
|
||||||
title={messages.text.ClipboardApp.html.Copy_dis}
|
title={tCommon("buttons.copy")}
|
||||||
textToCopy={richTextToPlainText(retrievedContent)}
|
textToCopy={richTextToPlainText(retrievedContent)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +178,7 @@ export function RetrieveTabPanel({
|
|||||||
saveType={getReceiverSaveType()}
|
saveType={getReceiverSaveType()}
|
||||||
/>
|
/>
|
||||||
{retrieveMessage && (
|
{retrieveMessage && (
|
||||||
<p className="mt-3 text-sm text-blue-600">{retrieveMessage}</p>
|
<p className="mt-3 text-sm text-primary">{retrieveMessage}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import CachedIdActionButton from "@/components/ClipboardApp/CachedIdActionButton";
|
||||||
import {
|
import {
|
||||||
ReadClipboardButton,
|
ReadClipboardButton,
|
||||||
WriteClipboardButton,
|
WriteClipboardButton,
|
||||||
@@ -9,7 +11,6 @@ import {
|
|||||||
import { FileUploadHandler } from "@/components/ClipboardApp/FileUploadHandler";
|
import { FileUploadHandler } from "@/components/ClipboardApp/FileUploadHandler";
|
||||||
import FileListDisplay from "@/components/ClipboardApp/FileListDisplay";
|
import FileListDisplay from "@/components/ClipboardApp/FileListDisplay";
|
||||||
import AnimatedButton from "@/components/ui/AnimatedButton";
|
import AnimatedButton from "@/components/ui/AnimatedButton";
|
||||||
import type { Messages } from "@/types/messages";
|
|
||||||
import type { CustomFile, FileMeta } from "@/types/webrtc";
|
import type { CustomFile, FileMeta } from "@/types/webrtc";
|
||||||
|
|
||||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||||
@@ -20,7 +21,7 @@ const RichTextEditor = dynamic(
|
|||||||
{
|
{
|
||||||
ssr: false, // This component is client-side only
|
ssr: false, // This component is client-side only
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="p-4 border rounded-lg min-h-[200px] md:min-h-[400px] bg-gray-50 flex items-center justify-center">
|
<div className="p-4 border rounded-lg min-h-[200px] md:min-h-[400px] bg-muted flex items-center justify-center">
|
||||||
Loading Editor...
|
Loading Editor...
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -28,7 +29,6 @@ const RichTextEditor = dynamic(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface SendTabPanelProps {
|
interface SendTabPanelProps {
|
||||||
messages: Messages;
|
|
||||||
updateShareContent: (content: string) => void;
|
updateShareContent: (content: string) => void;
|
||||||
addFilesToSend: (files: CustomFile[]) => void;
|
addFilesToSend: (files: CustomFile[]) => void;
|
||||||
removeFileToSend: (meta: FileMeta) => void;
|
removeFileToSend: (meta: FileMeta) => void;
|
||||||
@@ -39,10 +39,14 @@ interface SendTabPanelProps {
|
|||||||
shareMessage: string;
|
shareMessage: string;
|
||||||
currentValidatedShareRoomId: string;
|
currentValidatedShareRoomId: string;
|
||||||
handleLeaveSenderRoom: () => void; // New prop for leaving room
|
handleLeaveSenderRoom: () => void; // New prop for leaving room
|
||||||
|
putMessageInMs: (
|
||||||
|
message: string,
|
||||||
|
isShareEnd?: boolean,
|
||||||
|
displayTimeMs?: number
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SendTabPanel({
|
export function SendTabPanel({
|
||||||
messages,
|
|
||||||
updateShareContent,
|
updateShareContent,
|
||||||
addFilesToSend,
|
addFilesToSend,
|
||||||
removeFileToSend,
|
removeFileToSend,
|
||||||
@@ -53,7 +57,14 @@ export function SendTabPanel({
|
|||||||
shareMessage,
|
shareMessage,
|
||||||
currentValidatedShareRoomId,
|
currentValidatedShareRoomId,
|
||||||
handleLeaveSenderRoom,
|
handleLeaveSenderRoom,
|
||||||
|
putMessageInMs,
|
||||||
}: SendTabPanelProps) {
|
}: SendTabPanelProps) {
|
||||||
|
const tActions = useTranslations("text.clipboard.actions");
|
||||||
|
const tPlaceholders = useTranslations("text.clipboard.placeholders");
|
||||||
|
const tGenerateId = useTranslations("text.clipboard.generateId");
|
||||||
|
const tTitles = useTranslations("text.clipboard.titles");
|
||||||
|
const tStatus = useTranslations("text.clipboard.status");
|
||||||
|
const tCommon = useTranslations("text.common");
|
||||||
// Get the status from the store
|
// Get the status from the store
|
||||||
const {
|
const {
|
||||||
shareContent,
|
shareContent,
|
||||||
@@ -129,20 +140,20 @@ export function SendTabPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="send-panel" role="tabpanel" aria-labelledby="send-tab">
|
<div id="send-panel" role="tabpanel" aria-labelledby="send-tab">
|
||||||
<div className="mb-3 text-sm text-gray-600">
|
<div className="mb-3 text-sm text-muted-foreground">
|
||||||
{shareRoomStatusText ||
|
{shareRoomStatusText ||
|
||||||
(isSenderInRoom
|
(isSenderInRoom
|
||||||
? messages.text.ClipboardApp.roomStatus.onlyOneMsg
|
? tStatus("onlyOne")
|
||||||
: messages.text.ClipboardApp.roomStatus.senderEmptyMsg)}
|
: tStatus("roomEmpty"))}
|
||||||
</div>
|
</div>
|
||||||
<RichTextEditor value={shareContent} onChange={updateShareContent} />
|
<RichTextEditor value={shareContent} onChange={updateShareContent} />
|
||||||
<div className="flex flex-col sm:flex-row gap-2 my-3">
|
<div className="flex flex-col sm:flex-row gap-2 my-3">
|
||||||
<ReadClipboardButton
|
<ReadClipboardButton
|
||||||
title={messages.text.ClipboardApp.html.Paste_dis}
|
title={tCommon("buttons.paste")}
|
||||||
onRead={updateShareContent}
|
onRead={updateShareContent}
|
||||||
/>
|
/>
|
||||||
<WriteClipboardButton
|
<WriteClipboardButton
|
||||||
title={messages.text.ClipboardApp.html.Copy_dis}
|
title={tCommon("buttons.copy")}
|
||||||
textToCopy={richTextToPlainText(shareContent)}
|
textToCopy={richTextToPlainText(shareContent)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,8 +170,8 @@ export function SendTabPanel({
|
|||||||
<div className="space-y-3 mb-4">
|
<div className="space-y-3 mb-4">
|
||||||
{/* Room ID input section */}
|
{/* Room ID input section */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-muted-foreground">
|
||||||
{messages.text.ClipboardApp.html.inputRoomId_tips}
|
{tTitles("share")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -169,9 +180,7 @@ export function SendTabPanel({
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
className="flex-1 min-w-0"
|
className="flex-1 min-w-0"
|
||||||
placeholder={
|
placeholder={tPlaceholders("roomId")}
|
||||||
messages.text.ClipboardApp.html.retrieveRoomId_placeholder
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -180,15 +189,27 @@ export function SendTabPanel({
|
|||||||
disabled={isSenderInRoom}
|
disabled={isSenderInRoom}
|
||||||
>
|
>
|
||||||
{isSimpleIdMode
|
{isSimpleIdMode
|
||||||
? messages.text.ClipboardApp.html.generateRandomId_tips
|
? tGenerateId("random")
|
||||||
: messages.text.ClipboardApp.html.generateSimpleId_tips}
|
: tGenerateId("simple")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Save/Use Cached ID Button in between */}
|
||||||
|
<CachedIdActionButton
|
||||||
|
getInputValue={() => inputFieldValue}
|
||||||
|
setInputValue={setInputFieldValue}
|
||||||
|
putMessageInMs={putMessageInMs}
|
||||||
|
isShareEnd={true}
|
||||||
|
disabled={isSenderInRoom}
|
||||||
|
onUseCached={(id) => {
|
||||||
|
// Immediately join as sender after applying cached ID
|
||||||
|
joinRoom(true, id.trim());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="w-full sm:w-auto px-4"
|
className="w-full sm:w-auto px-4"
|
||||||
onClick={() => joinRoom(true, inputFieldValue.trim())}
|
onClick={() => joinRoom(true, inputFieldValue.trim())}
|
||||||
disabled={isSenderInRoom || !inputFieldValue.trim()}
|
disabled={isSenderInRoom || !inputFieldValue.trim()}
|
||||||
>
|
>
|
||||||
{messages.text.ClipboardApp.html.joinRoom_dis}
|
{tCommon("buttons.joinRoom")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,9 +219,7 @@ export function SendTabPanel({
|
|||||||
<AnimatedButton
|
<AnimatedButton
|
||||||
className="flex-1 order-1"
|
className="flex-1 order-1"
|
||||||
onClick={generateShareLinkAndBroadcast}
|
onClick={generateShareLinkAndBroadcast}
|
||||||
loadingText={
|
loadingText={tActions("syncLoading")}
|
||||||
messages.text.ClipboardApp.html.SyncSending_loadingText
|
|
||||||
}
|
|
||||||
disabled={
|
disabled={
|
||||||
!isSenderInRoom ||
|
!isSenderInRoom ||
|
||||||
(sendFiles.length === 0 && shareContent.trim() === "") ||
|
(sendFiles.length === 0 && shareContent.trim() === "") ||
|
||||||
@@ -208,7 +227,7 @@ export function SendTabPanel({
|
|||||||
isAnyFileTransferring
|
isAnyFileTransferring
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{messages.text.ClipboardApp.html.SyncSending_dis}
|
{tActions("sync")}
|
||||||
</AnimatedButton>
|
</AnimatedButton>
|
||||||
<Button
|
<Button
|
||||||
variant={isAnyFileTransferring ? "destructive" : "outline"}
|
variant={isAnyFileTransferring ? "destructive" : "outline"}
|
||||||
@@ -217,13 +236,13 @@ export function SendTabPanel({
|
|||||||
className="w-full sm:w-auto px-4 order-2"
|
className="w-full sm:w-auto px-4 order-2"
|
||||||
>
|
>
|
||||||
{isAnyFileTransferring
|
{isAnyFileTransferring
|
||||||
? messages.text.ClipboardApp.roomStatus.leaveRoomBtn + " ⚠️"
|
? tCommon("buttons.leaveRoom") + " ⚠️"
|
||||||
: messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
: tCommon("buttons.leaveRoom")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{shareMessage && (
|
{shareMessage && (
|
||||||
<p className="mt-3 text-sm text-blue-600">{shareMessage}</p>
|
<p className="mt-3 text-sm text-primary">{shareMessage}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import React, { useRef, useState, useEffect } from "react";
|
import React, { useRef, useState, useEffect } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Copy, Download, Check } from "lucide-react";
|
import { Copy, Download, Check } from "lucide-react";
|
||||||
import { WriteClipboardButton } from "../common/clipboard_btn";
|
import { WriteClipboardButton } from "../common/clipboard_btn";
|
||||||
|
|
||||||
import { getDictionary } from "@/lib/dictionary";
|
|
||||||
import { useLocale } from "@/hooks/useLocale";
|
|
||||||
import type { Messages } from "@/types/messages";
|
|
||||||
interface ShareCardProps {
|
interface ShareCardProps {
|
||||||
RoomID: string;
|
RoomID: string;
|
||||||
shareLink: string;
|
shareLink: string;
|
||||||
@@ -22,10 +19,23 @@ const QRCodeSVG = dynamic(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
|
const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
|
||||||
const locale = useLocale();
|
const t = useTranslations("text.retrieveMethod");
|
||||||
const [messages, setMessages] = useState<Messages | null>(null);
|
|
||||||
const qrRef = useRef<HTMLDivElement>(null);
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||||
|
const [isRoomIdCopied, setIsRoomIdCopied] = useState<boolean>(false);
|
||||||
|
const [isUrlCopied, setIsUrlCopied] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const copyRoomId = async () => {
|
||||||
|
await navigator.clipboard.writeText(RoomID);
|
||||||
|
setIsRoomIdCopied(true);
|
||||||
|
setTimeout(() => setIsRoomIdCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
await navigator.clipboard.writeText(shareLink);
|
||||||
|
setIsUrlCopied(true);
|
||||||
|
setTimeout(() => setIsUrlCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
const copyToClipboard = async () => {
|
const copyToClipboard = async () => {
|
||||||
if (!qrRef.current) return;
|
if (!qrRef.current) return;
|
||||||
@@ -86,12 +96,6 @@ const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
|
|||||||
downloadQRCode(); // Fallback to download on any error
|
downloadQRCode(); // Fallback to download on any error
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
|
||||||
getDictionary(locale)
|
|
||||||
.then((dict) => setMessages(dict))
|
|
||||||
.catch((error) => console.error("Failed to load messages:", error));
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
const downloadQRCode = () => {
|
const downloadQRCode = () => {
|
||||||
if (!qrRef.current) return;
|
if (!qrRef.current) return;
|
||||||
|
|
||||||
@@ -116,105 +120,91 @@ const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
|
|||||||
};
|
};
|
||||||
img.src = "data:image/svg+xml;base64," + btoa(svgData);
|
img.src = "data:image/svg+xml;base64," + btoa(svgData);
|
||||||
};
|
};
|
||||||
if (messages === null) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-blue-50 p-2 sm:p-4 rounded-lg border border-blue-200">
|
<div className="bg-primary/10 p-2 sm:p-4 rounded-lg border border-primary/20">
|
||||||
<p className="text-blue-700 mb-3 sm:mb-4 text-sm sm:text-base">
|
<p className="text-primary mb-3 sm:mb-4 text-sm sm:text-base">
|
||||||
{messages.text.RetrieveMethod.P}
|
{t("intro")}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
{/* Mobile-first responsive layout */}
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<span className="text-sm font-medium">{t("roomIdTip")}</span>
|
||||||
{/* RoomID section */}
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<div className="bg-white p-2 sm:p-3 rounded-lg border border-blue-100">
|
<Input value={RoomID} readOnly className="font-mono text-sm" />
|
||||||
<div className="space-y-2">
|
<Button
|
||||||
<p className="text-sm font-medium text-gray-700">
|
variant="outline"
|
||||||
{messages.text.RetrieveMethod.RoomId_tips}
|
size="sm"
|
||||||
</p>
|
onClick={copyRoomId}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
title={t("copyRoomId")}
|
||||||
<code className="flex-1 bg-gray-100 px-2 py-1 rounded text-sm font-mono break-all">
|
>
|
||||||
{RoomID}
|
{isRoomIdCopied ? (
|
||||||
</code>
|
<Check className="h-4 w-4" />
|
||||||
<WriteClipboardButton
|
) : (
|
||||||
title={messages.text.RetrieveMethod.copyRoomId_tips}
|
<Copy className="h-4 w-4" />
|
||||||
textToCopy={RoomID}
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{t("urlTip")}</span>
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<Input value={shareLink} readOnly className="font-mono text-sm" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={copyUrl}
|
||||||
|
title={t("copyUrl")}
|
||||||
|
>
|
||||||
|
{isUrlCopied ? (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-center text-muted-foreground pt-2">
|
||||||
|
{t("scanQr")}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="inline-block border-2 p-2 sm:p-4 bg-muted rounded-lg">
|
||||||
|
<div ref={qrRef}>
|
||||||
|
<QRCodeSVG
|
||||||
|
value={shareLink}
|
||||||
|
size={120}
|
||||||
|
className="sm:w-32 sm:h-32"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* URL section */}
|
<div className="flex justify-center gap-2 mt-4">
|
||||||
<div className="bg-white p-2 sm:p-3 rounded-lg border border-blue-100">
|
<Button
|
||||||
<div className="space-y-2">
|
variant="outline"
|
||||||
<p className="text-sm font-medium text-gray-700">
|
size="sm"
|
||||||
{messages.text.RetrieveMethod.url_tips}
|
onClick={copyToClipboard}
|
||||||
</p>
|
className="flex items-center gap-2"
|
||||||
<div className="bg-gray-100 px-2 py-2 rounded text-xs sm:text-sm break-all font-mono">
|
>
|
||||||
{shareLink}
|
{isCopied ? (
|
||||||
</div>
|
<>
|
||||||
<div className="flex justify-start">
|
<Check className="h-4 w-4" />
|
||||||
<WriteClipboardButton
|
{t("copied")}
|
||||||
title={messages.text.RetrieveMethod.copyUrl_tips}
|
</>
|
||||||
textToCopy={shareLink}
|
) : (
|
||||||
/>
|
<>
|
||||||
</div>
|
<Copy className="h-4 w-4" />
|
||||||
</div>
|
{t("copyQr")}
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
{/* QR Code section */}
|
</Button>
|
||||||
<div className="bg-white p-2 sm:p-3 rounded-lg border border-blue-100">
|
<Button
|
||||||
<div className="space-y-3">
|
variant="outline"
|
||||||
<p className="text-sm font-medium text-gray-700">
|
size="sm"
|
||||||
{messages.text.RetrieveMethod.scanQR_tips}
|
onClick={downloadQRCode}
|
||||||
</p>
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
{/* QR Code display area - moved up for better mobile UX */}
|
<Download className="h-4 w-4" />
|
||||||
<div className="flex justify-center">
|
{t("downloadQr")}
|
||||||
<div className="inline-block border-2 p-2 sm:p-4 bg-gray-50 rounded-lg">
|
</Button>
|
||||||
<div ref={qrRef}>
|
|
||||||
<QRCodeSVG
|
|
||||||
value={shareLink}
|
|
||||||
size={120}
|
|
||||||
className="sm:w-32 sm:h-32"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* QR Code action buttons */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={copyToClipboard}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<>
|
|
||||||
<Check className="w-4 h-4 mr-2" />
|
|
||||||
{messages.text.RetrieveMethod.Copied_dis}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
|
||||||
{messages.text.RetrieveMethod.Copy_QR_dis}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={downloadQRCode}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
{messages.text.RetrieveMethod.download_QR_dis}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,21 +9,21 @@ export function AlignmentTools({ alignText }: AlignmentToolsProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<button
|
<button
|
||||||
className="p-1.5 hover:bg-gray-200 rounded"
|
className="p-1.5 hover:bg-accent rounded"
|
||||||
onClick={() => alignText("left")}
|
onClick={() => alignText("left")}
|
||||||
title="Align left"
|
title="Align left"
|
||||||
>
|
>
|
||||||
<AlignLeft className="w-3.5 h-3.5" />
|
<AlignLeft className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="p-1.5 hover:bg-gray-200 rounded"
|
className="p-1.5 hover:bg-accent rounded"
|
||||||
onClick={() => alignText("center")}
|
onClick={() => alignText("center")}
|
||||||
title="Align center"
|
title="Align center"
|
||||||
>
|
>
|
||||||
<AlignCenter className="w-3.5 h-3.5" />
|
<AlignCenter className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="p-1.5 hover:bg-gray-200 rounded"
|
className="p-1.5 hover:bg-accent rounded"
|
||||||
onClick={() => alignText("right")}
|
onClick={() => alignText("right")}
|
||||||
title="Align right"
|
title="Align right"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function BasicFormatTools({
|
|||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<button
|
<button
|
||||||
className={`p-1.5 rounded ${
|
className={`p-1.5 rounded ${
|
||||||
isStyleActive("bold") ? "bg-gray-200" : "hover:bg-gray-200"
|
isStyleActive("bold") ? "bg-accent" : "hover:bg-accent"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => formatText("bold")}
|
onClick={() => formatText("bold")}
|
||||||
title="Bold"
|
title="Bold"
|
||||||
@@ -23,7 +23,7 @@ export function BasicFormatTools({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`p-1.5 rounded ${
|
className={`p-1.5 rounded ${
|
||||||
isStyleActive("italic") ? "bg-gray-200" : "hover:bg-gray-200"
|
isStyleActive("italic") ? "bg-accent" : "hover:bg-accent"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => formatText("italic")}
|
onClick={() => formatText("italic")}
|
||||||
title="Italic"
|
title="Italic"
|
||||||
@@ -32,7 +32,7 @@ export function BasicFormatTools({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`p-1.5 rounded ${
|
className={`p-1.5 rounded ${
|
||||||
isStyleActive("underline") ? "bg-gray-200" : "hover:bg-gray-200"
|
isStyleActive("underline") ? "bg-accent" : "hover:bg-accent"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => formatText("underline")}
|
onClick={() => formatText("underline")}
|
||||||
title="Underline"
|
title="Underline"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Link2, Image, Code } from "lucide-react";
|
import { Link2, Image as ImageIcon, Code } from "lucide-react";
|
||||||
|
|
||||||
interface InsertToolsProps {
|
interface InsertToolsProps {
|
||||||
insertLink: () => void;
|
insertLink: () => void;
|
||||||
@@ -14,21 +14,21 @@ export function InsertTools({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<button
|
<button
|
||||||
className="p-1.5 hover:bg-gray-200 rounded"
|
className="p-1.5 hover:bg-accent rounded"
|
||||||
onClick={insertLink}
|
onClick={insertLink}
|
||||||
title="Insert url"
|
title="Insert url"
|
||||||
>
|
>
|
||||||
<Link2 className="w-3.5 h-3.5" />
|
<Link2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="p-1.5 hover:bg-gray-200 rounded"
|
className="p-1.5 hover:bg-accent rounded"
|
||||||
onClick={insertImage}
|
onClick={insertImage}
|
||||||
title="Upload image"
|
title="Upload image"
|
||||||
>
|
>
|
||||||
<Image className="w-3.5 h-3.5" />
|
<ImageIcon className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="p-1.5 hover:bg-gray-200 rounded"
|
className="p-1.5 hover:bg-accent rounded"
|
||||||
onClick={insertCodeBlock}
|
onClick={insertCodeBlock}
|
||||||
title="Insert code"
|
title="Insert code"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ const RichTextEditor: React.FC<EditorProps> = ({ onChange, value = "" }) => {
|
|||||||
<div className="w-full space-x-2 mb-4">
|
<div className="w-full space-x-2 mb-4">
|
||||||
<div className="border rounded-lg shadow-sm overflow-hidden">
|
<div className="border rounded-lg shadow-sm overflow-hidden">
|
||||||
{/* Toolbar - Add light gray background and bottom border */}
|
{/* Toolbar - Add light gray background and bottom border */}
|
||||||
<div className="flex flex-wrap gap-1 p-2 bg-gray-50 border-b">
|
<div className="flex flex-wrap gap-1 p-2 bg-muted border-b border-border">
|
||||||
{/* Basic format tool group */}
|
{/* Basic format tool group */}
|
||||||
<BasicFormatTools
|
<BasicFormatTools
|
||||||
isStyleActive={isStyleActive}
|
isStyleActive={isStyleActive}
|
||||||
@@ -158,10 +158,10 @@ const RichTextEditor: React.FC<EditorProps> = ({ onChange, value = "" }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor area - Add pure white background and inner shadow effect */}
|
{/* Editor area - use theme tokens for background */}
|
||||||
<div
|
<div
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
className="p-4 min-h-[200px] md:min-h-[400px] focus:outline-none bg-white shadow-inner"
|
className="p-4 min-h-[200px] md:min-h-[400px] focus:outline-none bg-card shadow-inner"
|
||||||
contentEditable
|
contentEditable
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onInput={handleChange}
|
onInput={handleChange}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const SelectMenu: React.FC<SelectMenuProps> = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<div className="relative inline-block">
|
<div className="relative inline-block">
|
||||||
<select
|
<select
|
||||||
className={`appearance-none bg-transparent border rounded p-1.5 pr-6 hover:bg-gray-200 focus:outline-none ${className}`}
|
className={`appearance-none bg-transparent border border-border rounded p-1.5 pr-6 hover:bg-accent focus:outline-none ${className}`}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">{placeholder}</option>
|
<option value="">{placeholder}</option>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const useEditorCommands = (
|
|||||||
// Update HTML
|
// Update HTML
|
||||||
handleChange();
|
handleChange();
|
||||||
},
|
},
|
||||||
[findStyleParent, getSelection, removeStyle]
|
[editorRef, findStyleParent, getSelection, handleChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Align text
|
// Align text
|
||||||
@@ -129,7 +129,7 @@ export const useEditorCommands = (
|
|||||||
// Update HTML
|
// Update HTML
|
||||||
handleChange();
|
handleChange();
|
||||||
},
|
},
|
||||||
[getSelection]
|
[editorRef, getSelection, handleChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set font style
|
// Set font style
|
||||||
@@ -218,7 +218,7 @@ export const useEditorCommands = (
|
|||||||
|
|
||||||
handleChange();
|
handleChange();
|
||||||
},
|
},
|
||||||
[getSelection, findStyleParent, cleanupSpan]
|
[cleanupSpan, findStyleParent, getSelection, handleChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Insert link
|
// Insert link
|
||||||
@@ -258,7 +258,7 @@ export const useEditorCommands = (
|
|||||||
handleChange();
|
handleChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [getSelection]);
|
}, [getSelection, handleChange]);
|
||||||
|
|
||||||
// Insert image
|
// Insert image
|
||||||
const insertImage = useCallback(() => {
|
const insertImage = useCallback(() => {
|
||||||
@@ -290,7 +290,7 @@ export const useEditorCommands = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
}, [getSelection]);
|
}, [getSelection, handleChange]);
|
||||||
|
|
||||||
// Insert code block
|
// Insert code block
|
||||||
const insertCodeBlock = useCallback(() => {
|
const insertCodeBlock = useCallback(() => {
|
||||||
@@ -318,7 +318,7 @@ export const useEditorCommands = (
|
|||||||
range.deleteContents();
|
range.deleteContents();
|
||||||
range.insertNode(pre);
|
range.insertNode(pre);
|
||||||
handleChange();
|
handleChange();
|
||||||
}, [getSelection]);
|
}, [getSelection, handleChange]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
formatText,
|
formatText,
|
||||||
|
|||||||
@@ -7,17 +7,18 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { useLocale } from "next-intl";
|
||||||
|
import { usePathname, useRouter } from "@/i18n/navigation";
|
||||||
import { i18n, Locale, languageDisplayNames } from "@/constants/i18n-config";
|
import { i18n, Locale, languageDisplayNames } from "@/constants/i18n-config";
|
||||||
|
|
||||||
const LanguageSwitcher = () => {
|
const LanguageSwitcher = () => {
|
||||||
|
const locale = useLocale();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const switchLanguage = (locale: Locale) => {
|
const switchLanguage = (nextLocale: Locale) => {
|
||||||
const segments = pathname.split("/");
|
if (nextLocale === locale) return;
|
||||||
segments[1] = locale;
|
router.replace(pathname, { locale: nextLocale });
|
||||||
router.push(segments.join("/"));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { type BlogPost } from "@/lib/blog";
|
import { type BlogPost } from "@/lib/blog";
|
||||||
|
|
||||||
interface ArticleListItemProps {
|
interface ArticleListItemProps {
|
||||||
post: BlogPost;
|
post: BlogPost;
|
||||||
lang: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArticleListItem({ post, lang }: ArticleListItemProps) {
|
export function ArticleListItem({ post }: ArticleListItemProps) {
|
||||||
|
const t = useTranslations("text.blog");
|
||||||
|
const lang = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="bg-white rounded-xl shadow-lg hover:shadow-xl transition-shadow overflow-hidden">
|
<article className="bg-card rounded-xl shadow-lg hover:shadow-xl transition-shadow overflow-hidden">
|
||||||
<div className="relative h-80 w-full">
|
<div className="relative h-80 w-full">
|
||||||
<Image
|
<Image
|
||||||
src={post.frontmatter.cover}
|
src={post.frontmatter.cover}
|
||||||
@@ -22,16 +27,20 @@ export function ArticleListItem({ post, lang }: ArticleListItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-500 mb-4">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
|
||||||
<time className="font-medium">
|
<time className="font-medium">
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString()}
|
{new Date(post.frontmatter.date).toLocaleDateString(lang, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
</time>
|
</time>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{post.frontmatter.tags.map((tag) => (
|
{post.frontmatter.tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="bg-gray-100 px-3 py-1 rounded-full hover:bg-gray-200 transition-colors"
|
className="bg-muted px-3 py-1 rounded-full hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -39,21 +48,21 @@ export function ArticleListItem({ post, lang }: ArticleListItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/${lang}/blog/${post.slug}`}>
|
<Link href={`/${lang}/blog/${post.slug}`}>
|
||||||
<h2 className="text-3xl font-bold mb-4 hover:text-blue-600 transition-colors leading-tight">
|
<h2 className="text-3xl font-bold mb-4 hover:text-primary transition-colors leading-tight">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</h2>
|
</h2>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<p className="text-gray-600 mb-6 text-lg leading-relaxed line-clamp-3">
|
<p className="text-muted-foreground mb-6 text-lg leading-relaxed line-clamp-3">
|
||||||
{post.frontmatter.description}
|
{post.frontmatter.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||||
<Link
|
<Link
|
||||||
href={`/${lang}/blog/${post.slug}`}
|
href={`/${lang}/blog/${post.slug}`}
|
||||||
className="text-blue-600 hover:text-blue-800 font-medium inline-flex items-center text-lg"
|
className="text-primary hover:text-primary/80 font-medium inline-flex items-center text-lg"
|
||||||
>
|
>
|
||||||
Read more
|
{t("readMore")}
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 ml-2"
|
className="w-5 h-5 ml-2"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -71,7 +80,7 @@ export function ArticleListItem({ post, lang }: ArticleListItemProps) {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
by <span className="font-bold">{post.frontmatter.author}</span>
|
{t("by")} <span className="font-bold">{post.frontmatter.author}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export type MDXComponents = {
|
|||||||
// Custom MDX components
|
// Custom MDX components
|
||||||
export const mdxComponents: MDXComponents = {
|
export const mdxComponents: MDXComponents = {
|
||||||
p: ({ children, ...props }) => (
|
p: ({ children, ...props }) => (
|
||||||
<div className="mb-6 leading-relaxed text-gray-700" {...props}>
|
<div className="mb-6 leading-relaxed text-foreground" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -92,7 +92,7 @@ export const mdxComponents: MDXComponents = {
|
|||||||
alt={props.alt || ""}
|
alt={props.alt || ""}
|
||||||
/>
|
/>
|
||||||
{props.alt && (
|
{props.alt && (
|
||||||
<div className="text-center text-sm text-gray-600 mt-2 italic">
|
<div className="text-center text-sm text-muted-foreground mt-2 italic">
|
||||||
{props.alt}
|
{props.alt}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -101,7 +101,7 @@ export const mdxComponents: MDXComponents = {
|
|||||||
},
|
},
|
||||||
pre: ({ children, ...props }) => (
|
pre: ({ children, ...props }) => (
|
||||||
<pre
|
<pre
|
||||||
className="relative my-6 rounded-lg bg-gray-50 border border-gray-200 p-4 overflow-x-auto"
|
className="relative my-6 rounded-lg bg-muted border border-border p-4 overflow-x-auto"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -111,13 +111,13 @@ export const mdxComponents: MDXComponents = {
|
|||||||
const isInlineCode = !className;
|
const isInlineCode = !className;
|
||||||
return isInlineCode ? (
|
return isInlineCode ? (
|
||||||
<code
|
<code
|
||||||
className="bg-gray-50 rounded px-1.5 py-0.5 text-gray-800 border border-gray-200 text-sm"
|
className="bg-muted rounded px-1.5 py-0.5 text-foreground border border-border text-sm"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
) : (
|
) : (
|
||||||
<code className="block text-gray-800 text-sm" {...props}>
|
<code className="block text-foreground text-sm" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
@@ -125,7 +125,7 @@ export const mdxComponents: MDXComponents = {
|
|||||||
table: ({ children, ...props }) => (
|
table: ({ children, ...props }) => (
|
||||||
<div className="my-8 w-full overflow-x-auto">
|
<div className="my-8 w-full overflow-x-auto">
|
||||||
<table
|
<table
|
||||||
className="min-w-full divide-y divide-gray-300 border border-gray-300"
|
className="min-w-full divide-y divide-border border border-border"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -133,23 +133,23 @@ export const mdxComponents: MDXComponents = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
thead: ({ children, ...props }) => (
|
thead: ({ children, ...props }) => (
|
||||||
<thead className="bg-gray-50" {...props}>
|
<thead className="bg-muted" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</thead>
|
</thead>
|
||||||
),
|
),
|
||||||
tbody: ({ children, ...props }) => (
|
tbody: ({ children, ...props }) => (
|
||||||
<tbody className="divide-y divide-gray-200 bg-white" {...props}>
|
<tbody className="divide-y divide-border bg-card" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</tbody>
|
</tbody>
|
||||||
),
|
),
|
||||||
tr: ({ children, ...props }) => (
|
tr: ({ children, ...props }) => (
|
||||||
<tr className="hover:bg-gray-50" {...props}>
|
<tr className="hover:bg-accent" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</tr>
|
</tr>
|
||||||
),
|
),
|
||||||
th: ({ children, ...props }) => (
|
th: ({ children, ...props }) => (
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-r last:border-r-0"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider border-r last:border-r-0"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -157,7 +157,7 @@ export const mdxComponents: MDXComponents = {
|
|||||||
),
|
),
|
||||||
td: ({ children, ...props }) => (
|
td: ({ children, ...props }) => (
|
||||||
<td
|
<td
|
||||||
className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 border-r last:border-r-0"
|
className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground border-r last:border-r-0"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -165,7 +165,7 @@ export const mdxComponents: MDXComponents = {
|
|||||||
),
|
),
|
||||||
blockquote: ({ children, ...props }) => (
|
blockquote: ({ children, ...props }) => (
|
||||||
<blockquote
|
<blockquote
|
||||||
className="border-l-4 border-blue-500 pl-4 my-4 italic text-gray-600 bg-gray-50 py-2 rounded-r-lg"
|
className="border-l-4 border-primary pl-4 my-4 italic text-muted-foreground bg-muted py-2 rounded-r-lg"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -173,7 +173,7 @@ export const mdxComponents: MDXComponents = {
|
|||||||
),
|
),
|
||||||
ul: ({ children, ...props }) => (
|
ul: ({ children, ...props }) => (
|
||||||
<ul
|
<ul
|
||||||
className="list-disc list-outside ml-6 my-6 space-y-2 text-gray-700"
|
className="list-disc list-outside ml-6 my-6 space-y-2 text-foreground"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -181,7 +181,7 @@ export const mdxComponents: MDXComponents = {
|
|||||||
),
|
),
|
||||||
ol: ({ children, ...props }) => (
|
ol: ({ children, ...props }) => (
|
||||||
<ol
|
<ol
|
||||||
className="list-decimal list-outside ml-6 my-6 space-y-2 text-gray-700"
|
className="list-decimal list-outside ml-6 my-6 space-y-2 text-foreground"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ interface TocItem {
|
|||||||
|
|
||||||
interface TableOfContentsProps {
|
interface TableOfContentsProps {
|
||||||
content: string;
|
content: string;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableOfContents: React.FC<TableOfContentsProps> = ({
|
export const TableOfContents: React.FC<TableOfContentsProps> = ({
|
||||||
content,
|
content,
|
||||||
|
title = "Table of contents",
|
||||||
}) => {
|
}) => {
|
||||||
const [activeId, setActiveId] = useState<string>("");
|
const [activeId, setActiveId] = useState<string>("");
|
||||||
const [toc, setToc] = useState<TocItem[]>([]);
|
const [toc, setToc] = useState<TocItem[]>([]);
|
||||||
@@ -109,8 +111,8 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({
|
|||||||
if (toc.length === 0) return null;
|
if (toc.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="hidden lg:block sticky top-8 p-6 bg-gray-50 rounded-lg max-h-[calc(100vh-4rem)] overflow-y-auto">
|
<nav className="hidden lg:block sticky top-8 p-6 bg-muted rounded-lg max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||||
<h4 className="text-lg font-semibold mb-4">Table of contents</h4>
|
<h4 className="text-lg font-semibold mb-4">{title}</h4>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{toc.map((item) => (
|
{toc.map((item) => (
|
||||||
<li
|
<li
|
||||||
@@ -123,10 +125,10 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => scrollToHeader(item.id)}
|
onClick={() => scrollToHeader(item.id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"block w-full text-left py-1 text-sm hover:text-blue-600 transition-colors",
|
"block w-full text-left py-1 text-sm hover:text-primary transition-colors",
|
||||||
activeId === item.id
|
activeId === item.id
|
||||||
? "text-blue-600 font-medium"
|
? "text-primary font-medium"
|
||||||
: "text-gray-600"
|
: "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.text}
|
{item.text}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Play } from "lucide-react";
|
import { Play } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
interface YouTubePlayerProps {
|
interface YouTubePlayerProps {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
@@ -21,9 +22,11 @@ const YouTubePlayer: React.FC<YouTubePlayerProps> = ({
|
|||||||
<div className="relative pb-[56.25%]">
|
<div className="relative pb-[56.25%]">
|
||||||
{!isPlaying ? (
|
{!isPlaying ? (
|
||||||
<div className="absolute top-0 left-0 w-full h-full">
|
<div className="absolute top-0 left-0 w-full h-full">
|
||||||
<img
|
<Image
|
||||||
src={localThumbnail}
|
src={localThumbnail}
|
||||||
alt="Video preview"
|
alt="Video preview"
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 1024px) 100vw, 1024px"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Clipboard, FileText, Check } from "lucide-react";
|
import { Clipboard, FileText, Check } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useClipboardActions } from "@/hooks/useClipboardActions";
|
import { useClipboardActions } from "@/hooks/useClipboardActions";
|
||||||
|
|
||||||
@@ -17,19 +18,20 @@ export const WriteClipboardButton: React.FC<WriteClipboardButtonProps> = ({
|
|||||||
title,
|
title,
|
||||||
textToCopy,
|
textToCopy,
|
||||||
}) => {
|
}) => {
|
||||||
|
const tButtons = useTranslations("text.common.buttons");
|
||||||
const { copyText, isCopied, isLoadingMessages, clipboardMessages, error } =
|
const { copyText, isCopied, isLoadingMessages, clipboardMessages, error } =
|
||||||
useClipboardActions();
|
useClipboardActions();
|
||||||
|
|
||||||
const buttonText = title || clipboardMessages.copyError || "Copy"; // Fallback title
|
const buttonText = title || tButtons("copy");
|
||||||
|
|
||||||
if (isLoadingMessages && !clipboardMessages.copiedSuccess) {
|
if (isLoadingMessages && !clipboardMessages.copiedSuccess) {
|
||||||
// Only show loading if messages truly not ready
|
// Only show loading if messages truly not ready
|
||||||
return (
|
return (
|
||||||
<Button variant="outline" disabled>
|
<Button variant="outline" disabled>
|
||||||
{clipboardMessages.loading || "Loading..."}
|
{clipboardMessages.loading}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -57,6 +59,7 @@ export const ReadClipboardButton: React.FC<ReadClipboardButtonProps> = ({
|
|||||||
title,
|
title,
|
||||||
onRead,
|
onRead,
|
||||||
}) => {
|
}) => {
|
||||||
|
const tButtons = useTranslations("text.common.buttons");
|
||||||
const {
|
const {
|
||||||
readClipboard,
|
readClipboard,
|
||||||
isPasted,
|
isPasted,
|
||||||
@@ -70,16 +73,16 @@ export const ReadClipboardButton: React.FC<ReadClipboardButtonProps> = ({
|
|||||||
onRead(text); // Pass null if read failed or no suitable content
|
onRead(text); // Pass null if read failed or no suitable content
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttonText = title || clipboardMessages.readError || "Paste"; // Fallback title
|
const buttonText = title || tButtons("paste");
|
||||||
|
|
||||||
if (isLoadingMessages && !clipboardMessages.pastedSuccess) {
|
if (isLoadingMessages && !clipboardMessages.pastedSuccess) {
|
||||||
// Only show loading if messages truly not ready
|
// Only show loading if messages truly not ready
|
||||||
return (
|
return (
|
||||||
<Button variant="outline" disabled>
|
<Button variant="outline" disabled>
|
||||||
{clipboardMessages.loading || "Loading..."}
|
{clipboardMessages.loading}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type JsonLdProps = {
|
||||||
|
data: Record<string, any> | Record<string, any>[];
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function JsonLd({ data, id }: JsonLdProps) {
|
||||||
|
const blocks = Array.isArray(data) ? data : [data];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{blocks.map((item, idx) => (
|
||||||
|
<script
|
||||||
|
key={id ? `${id}-${idx}` : idx}
|
||||||
|
type="application/ld+json"
|
||||||
|
suppressHydrationWarning
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(item) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,45 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import type { Messages } from "@/types/messages";
|
|
||||||
|
|
||||||
interface FAQMessage {
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FAQ {
|
interface FAQ {
|
||||||
question: string;
|
question: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateFAQs = (messages: { text: { faqs: FAQMessage } }): FAQ[] => {
|
// Static FAQ count based on messages structure (indices 0-13)
|
||||||
const faqs: FAQ[] = [];
|
const FAQ_COUNT = 14;
|
||||||
const faqsData = messages.text.faqs;
|
|
||||||
|
|
||||||
// Get the total number of questions (by finding keys starting with question_)
|
|
||||||
const questionKeys = Object.keys(faqsData).filter((key) =>
|
|
||||||
key.startsWith("question_")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Automatically generate FAQ array based on the number of questions
|
|
||||||
questionKeys.forEach((qKey) => {
|
|
||||||
const index = qKey.split("_")[1]; // Get the numeric index
|
|
||||||
const aKey = `answer_${index}`;
|
|
||||||
|
|
||||||
if (faqsData[aKey]) {
|
|
||||||
// Ensure the corresponding answer exists
|
|
||||||
faqs.push({
|
|
||||||
question: faqsData[qKey],
|
|
||||||
answer: faqsData[aKey],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return faqs;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FAQSectionProps {
|
interface FAQSectionProps {
|
||||||
isInToolPage?: boolean; // Whether it is in the tool page (e.g. homepage)
|
isInToolPage?: boolean; // Whether it is in the tool page (e.g. homepage)
|
||||||
@@ -47,7 +22,6 @@ interface FAQSectionProps {
|
|||||||
showTitle?: boolean; // Whether to display the title
|
showTitle?: boolean; // Whether to display the title
|
||||||
titleClassName?: string; // Title style class
|
titleClassName?: string; // Title style class
|
||||||
lang?: string;
|
lang?: string;
|
||||||
messages: Messages;
|
|
||||||
}
|
}
|
||||||
// Control the level and style of the title through props, so it can be used on other pages as well as on a standalone page
|
// Control the level and style of the title through props, so it can be used on other pages as well as on a standalone page
|
||||||
export default function FAQSection({
|
export default function FAQSection({
|
||||||
@@ -55,9 +29,20 @@ export default function FAQSection({
|
|||||||
className = "",
|
className = "",
|
||||||
showTitle = true,
|
showTitle = true,
|
||||||
titleClassName = "",
|
titleClassName = "",
|
||||||
messages,
|
|
||||||
}: FAQSectionProps) {
|
}: FAQSectionProps) {
|
||||||
const faqs = generateFAQs(messages);
|
const t = useTranslations("text.faq");
|
||||||
|
|
||||||
|
// Generate FAQs using useTranslations with dynamic keys
|
||||||
|
// We use type assertion since next-intl doesn't support dynamic keys in type system
|
||||||
|
const faqs: FAQ[] = [];
|
||||||
|
for (let i = 0; i < FAQ_COUNT; i++) {
|
||||||
|
const question = t(`items.${i}.question` as never);
|
||||||
|
const answer = t(`items.${i}.answer` as never);
|
||||||
|
// Only add if both question and answer exist (not fallback keys)
|
||||||
|
if (question && answer && !question.startsWith("items.")) {
|
||||||
|
faqs.push({ question, answer });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set default styles for different scenarios
|
// Set default styles for different scenarios
|
||||||
const containerClasses = `container mx-auto px-4 py-8 ${className}`;
|
const containerClasses = `container mx-auto px-4 py-8 ${className}`;
|
||||||
@@ -68,13 +53,9 @@ export default function FAQSection({
|
|||||||
<div className={containerClasses}>
|
<div className={containerClasses}>
|
||||||
{showTitle &&
|
{showTitle &&
|
||||||
(isInToolPage ? (
|
(isInToolPage ? (
|
||||||
<h2 className={`text-3xl ${titleClasses}`}>
|
<h2 className={`text-3xl ${titleClasses}`}>{t("title")}</h2>
|
||||||
{messages.text.faqs.FAQ_dis}
|
|
||||||
</h2>
|
|
||||||
) : (
|
) : (
|
||||||
<h1 className={`text-4xl ${titleClasses}`}>
|
<h1 className={`text-4xl ${titleClasses}`}>{t("title")}</h1>
|
||||||
{messages.text.faqs.FAQ_dis}
|
|
||||||
</h1>
|
|
||||||
))}
|
))}
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<Accordion type="single" collapsible className="w-full">
|
||||||
{faqs.map((faq, index) => (
|
{faqs.map((faq, index) => (
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Messages } from "@/types/messages";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { languageDisplayNames } from "@/constants/i18n-config";
|
import { languageDisplayNames } from "@/constants/i18n-config";
|
||||||
|
|
||||||
interface FooterProps {
|
export function Footer() {
|
||||||
messages: Messages;
|
const t = useTranslations("text.footer");
|
||||||
lang: string;
|
const lang = useLocale();
|
||||||
}
|
|
||||||
|
|
||||||
export function Footer({ messages, lang }: FooterProps) {
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-background border-t mt-auto">
|
<footer className="bg-background border-t mt-auto">
|
||||||
<div className="container mx-auto px-4 py-6">
|
<div className="container mx-auto px-4 py-6">
|
||||||
@@ -24,8 +24,7 @@ export function Footer({ messages, lang }: FooterProps) {
|
|||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
© {new Date().getFullYear()}{" "}
|
© {new Date().getFullYear()} {t("copyright")}
|
||||||
{messages.text.Footer.CopyrightNotice}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ export function Footer({ messages, lang }: FooterProps) {
|
|||||||
href={`/${lang}/terms`}
|
href={`/${lang}/terms`}
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
{messages.text.Footer.Terms_dis}
|
{t("terms")}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -46,14 +45,14 @@ export function Footer({ messages, lang }: FooterProps) {
|
|||||||
href={`/${lang}/privacy`}
|
href={`/${lang}/privacy`}
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
{messages.text.Footer.Privacy_dis}
|
{t("privacy")}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{/* Entry for supported languages */}
|
{/* Entry for supported languages */}
|
||||||
<li>
|
<li>
|
||||||
<span className="text-sm text-muted-foreground font-bold">
|
<span className="text-sm text-muted-foreground font-bold">
|
||||||
{messages.text.Footer.SupportedLanguages}:
|
{t("supportedLanguages")}:
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{Object.entries(languageDisplayNames).map(([code, name]) => (
|
{Object.entries(languageDisplayNames).map(([code, name]) => (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -7,34 +8,28 @@ import { Button } from "@/components/ui/button";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Menu, X, Github } from "lucide-react";
|
import { Menu, X, Github } from "lucide-react";
|
||||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||||
import { Messages } from "@/types/messages";
|
import ThemeToggle from "@/components/web/ThemeToggle";
|
||||||
|
|
||||||
/**
|
|
||||||
* Props interface for the Header component
|
|
||||||
*/
|
|
||||||
interface HeaderProps {
|
|
||||||
messages: Messages;
|
|
||||||
lang: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header component providing navigation, language switching, and GitHub link
|
* Header component providing navigation, language switching, and GitHub link
|
||||||
* Features responsive design with mobile menu support
|
* Features responsive design with mobile menu support
|
||||||
*/
|
*/
|
||||||
const Header = ({ messages, lang }: HeaderProps) => {
|
const Header = () => {
|
||||||
|
const t = useTranslations("text.navigation");
|
||||||
|
const lang = useLocale();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
// Configuration for navigation items
|
// Configuration for navigation items
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: `/${lang}`, label: messages.text.Header.Home_dis },
|
{ href: `/${lang}`, label: t("home") },
|
||||||
{ href: `/${lang}/features`, label: messages.text.Header.Features_dis },
|
{ href: `/${lang}/features`, label: t("features") },
|
||||||
{ href: `/${lang}/blog`, label: messages.text.Header.Blog_dis },
|
{ href: `/${lang}/blog`, label: t("blog") },
|
||||||
{ href: `/${lang}/about`, label: messages.text.Header.About_dis },
|
{ href: `/${lang}/about`, label: t("about") },
|
||||||
{ href: `/${lang}/help`, label: messages.text.Header.Help_dis },
|
{ href: `/${lang}/help`, label: t("help") },
|
||||||
{ href: `/${lang}/faq`, label: messages.text.Header.FAQ_dis },
|
{ href: `/${lang}/faq`, label: t("faq") },
|
||||||
{ href: `/${lang}/terms`, label: messages.text.Header.Terms_dis },
|
{ href: `/${lang}/terms`, label: t("terms") },
|
||||||
{ href: `/${lang}/privacy`, label: messages.text.Header.Privacy_dis },
|
{ href: `/${lang}/privacy`, label: t("privacy") },
|
||||||
];
|
];
|
||||||
|
|
||||||
// GitHub repository URL
|
// GitHub repository URL
|
||||||
@@ -91,6 +86,7 @@ const Header = ({ messages, lang }: HeaderProps) => {
|
|||||||
<Github className="h-5 w-5" />
|
<Github className="h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<ThemeToggle />
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,6 +94,7 @@ const Header = ({ messages, lang }: HeaderProps) => {
|
|||||||
{/* Mobile menu controls */}
|
{/* Mobile menu controls */}
|
||||||
<div className="md:hidden flex items-center space-x-2">
|
<div className="md:hidden flex items-center space-x-2">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
<ThemeToggle />
|
||||||
<Button asChild variant="ghost" size="icon">
|
<Button asChild variant="ghost" size="icon">
|
||||||
<Link
|
<Link
|
||||||
href={githubUrl}
|
href={githubUrl}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { Messages } from "@/types/messages";
|
|
||||||
|
|
||||||
interface PageContentProps {
|
export default function HowItWorks() {
|
||||||
messages: Messages;
|
const t = useTranslations("text.howItWorks");
|
||||||
}
|
|
||||||
|
|
||||||
export default function HowItWorks({ messages }: PageContentProps) {
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
number: 1,
|
number: 1,
|
||||||
title: messages!.text.HowItWorks.step1_title,
|
title: t("step1Title"),
|
||||||
description: messages!.text.HowItWorks.step1_description,
|
description: t("step1Description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
number: 2,
|
number: 2,
|
||||||
title: messages!.text.HowItWorks.step2_title,
|
title: t("step2Title"),
|
||||||
description: messages!.text.HowItWorks.step2_description,
|
description: t("step2Description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
number: 3,
|
number: 3,
|
||||||
title: messages!.text.HowItWorks.step3_title,
|
title: t("step3Title"),
|
||||||
description: messages!.text.HowItWorks.step3_description,
|
description: t("step3Description"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -31,11 +31,11 @@ export default function HowItWorks({ messages }: PageContentProps) {
|
|||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-3xl md:text-4xl font-bold mb-6">
|
<h2 className="text-3xl md:text-4xl font-bold mb-6">
|
||||||
{messages.text.HowItWorks.h2}
|
{t("title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-8">{messages.text.HowItWorks.h2_P}</p>
|
<p className="text-muted-foreground mb-8">{t("description")}</p>
|
||||||
<Button className="bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600 text-white rounded-full px-8 py-6 text-lg">
|
<Button className="bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600 text-white rounded-full px-8 py-6 text-lg">
|
||||||
{messages.text.HowItWorks.btn_try}
|
{t("tryNow")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export default function HowItWorks({ messages }: PageContentProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-xl font-bold mb-2">{step.title}</h3>
|
<h3 className="text-xl font-bold mb-2">{step.title}</h3>
|
||||||
<p className="text-gray-600">{step.description}</p>
|
<p className="text-muted-foreground">{step.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -69,7 +69,7 @@ export default function HowItWorks({ messages }: PageContentProps) {
|
|||||||
|
|
||||||
{/* Right Side - Demo Animation */}
|
{/* Right Side - Demo Animation */}
|
||||||
<div className="w-full md:w-1/2">
|
<div className="w-full md:w-1/2">
|
||||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
<div className="bg-card rounded-lg shadow-lg overflow-hidden">
|
||||||
<video autoPlay loop muted playsInline width="1920" height="75">
|
<video autoPlay loop muted playsInline width="1920" height="75">
|
||||||
<source src="/HowItWorks.webm" type="video/webm" />
|
<source src="/HowItWorks.webm" type="video/webm" />
|
||||||
</video>
|
</video>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user