diff --git a/.gitignore b/.gitignore index f74dd75..65e945d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,17 @@ frontend/.env.local # Auth / plan user store (runtime) backend/app/data/user_store.json +backend/app/data/social_store.json +backend/app/data/community_store.json + +# Production secrets (use deploy/nomadro-api.env.example) +deploy/nomadro-api.env + +# Local screenshots / temp +book-*.png +tmp-screenshots/ +scripts/_shell_out.txt +scripts/_*.py # PocketBase pocketbase/pb_data/* diff --git a/README.md b/README.md index 035c0e0..c6de9dd 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,33 @@ NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1 NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com ``` +## Git 工作流 + +仓库:[gitea.dsx2020.com/eric/nomadweb](https://gitea.dsx2020.com/eric/nomadweb) · 主开发分支 **`dev1`** + +```bash +# 日常开发 +git checkout dev1 +git pull origin dev1 +# … 改代码 … +git add -A +git status # 确认未包含 .env、密钥、运行时 json +git commit -m "feat: 简述改动" +git push origin dev1 + +# 部署到生产(服务器 git pull + 增量构建) +python scripts/deploy_update.py +``` + +| 步骤 | 说明 | +|------|------| +| `dev1` | 功能开发、联调、上线均在此分支 | +| `main` | 稳定快照,按需从 `dev1` 合并 | +| 密钥 | `deploy/nomadro-api.env` 仅放服务器,模板见 `deploy/nomadro-api.env.example` | +| 运行时数据 | `user_store.json` / `social_store.json` / `community_store.json` 不提交 | + +**不要用 SFTP 全量同步当主流程** — `deploy_direct_sync.py` 仅作 Gitea 不可用时的应急备份(慢且易漏文件)。 + ## 部署 ### 生产(已上线) @@ -189,16 +216,16 @@ NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com 单元:`deploy/systemd/nomadro-web.service`、`nomadro-api.service`。 -**推荐更新:** +**推荐更新(Git):** ```bash -# 需能 git pull 到 origin/dev1 时 +git push origin dev1 python scripts/deploy_update.py ``` -拉取 → 按变更增量构建前后端 → `systemctl restart`;会停掉占用端口的旧 Docker Web/API 容器。 +服务器执行 `git pull` → 按变更只重建前端或后端 → `systemctl restart`;比 SFTP 全量同步快且可回滚。 -**Gitea 不可用 / 仅本地有最新提交时:** +**应急(Gitea 不可用 / 未 push 的本地提交):** ```bash python scripts/deploy_direct_sync.py diff --git a/backend/app/data/community_data.py b/backend/app/data/community_data.py new file mode 100644 index 0000000..7db8d29 --- /dev/null +++ b/backend/app/data/community_data.py @@ -0,0 +1,264 @@ +"""Community & events mock data — adapted from NomadCNA business logic.""" + +MEETUPS = [ + { + "id": "online-roundtable", + "title": "远程工作节奏圆桌", + "city": "线上", + "destination_slug": "", + "emoji": "🎙️", + "date": "2026-09-12", + "time": "20:00", + "venue": "nomadro 线上活动厅", + "description": "聊聊异步协作、时区管理和旅居城市选择。登录后可 RSVP,活动前会收到提醒。", + "mode": "online", + "access_level": "public", + "mirotalkRoom": "nomadro-online-roundtable", + "loungeChannel": "#nomadro-online", + "meetingUrl": "", + "rsvp_count": 38, + "max_attendees": 80, + "organizer": "nomadro 社区", + "tags": ["远程", "协作", "新手友好"], + "is_upcoming": True, + }, + { + "id": "chiangmai-cowork", + "title": "清迈联合办公下午茶", + "city": "清迈", + "destination_slug": "chiang-mai", + "emoji": "☕", + "date": "2026-09-18", + "time": "15:00", + "venue": "Nimman 区 Co-working", + "description": "一起办公、分享签证经验和住宿踩坑。线下为主,也开放线上旁听链接。", + "mode": "hybrid", + "access_level": "public", + "mirotalkRoom": "nomadro-chiangmai-cowork", + "loungeChannel": "#chiangmai-cowork", + "rsvp_count": 14, + "max_attendees": 24, + "organizer": "清迈游民小组", + "tags": ["线下", "社交", "东南亚"], + "is_upcoming": True, + }, + { + "id": "lisbon-sunset", + "title": "里斯本日落漫步", + "city": "里斯本", + "destination_slug": "lisbon", + "emoji": "🌅", + "date": "2026-09-22", + "time": "18:30", + "venue": "Miradouro 观景点集合", + "description": "工作一周后,和同城游民一起看日落、交换欧洲签证情报。", + "mode": "offline", + "access_level": "public", + "rsvp_count": 9, + "max_attendees": 16, + "organizer": "Lisbon Nomads", + "tags": ["欧洲", "户外", "社交"], + "is_upcoming": True, + }, + { + "id": "bali-surf-morning", + "title": "巴厘岛晨间冲浪 + 办公", + "city": "巴厘岛", + "destination_slug": "bali", + "emoji": "🏄", + "date": "2026-10-05", + "time": "07:00", + "venue": "Canggu Beach", + "description": "早起冲浪,10 点回咖啡馆集中办公。适合想体验 Work-Life 平衡的游民。", + "mode": "offline", + "access_level": "public", + "rsvp_count": 11, + "max_attendees": 20, + "organizer": "Bali Remote Crew", + "tags": ["运动", "生活方式"], + "is_upcoming": True, + }, + { + "id": "tokyo-tax-talk", + "title": "日本税务与居留线上分享", + "city": "线上", + "destination_slug": "tokyo", + "emoji": "🧾", + "date": "2026-10-12", + "time": "19:30", + "venue": "线上直播", + "description": "特邀长期旅居日本的游民分享税务申报、保险和银行开户经验(非法律建议)。", + "mode": "online", + "access_level": "members", + "rsvp_count": 52, + "max_attendees": 120, + "organizer": "nomadro 社区", + "tags": ["税务", "日本", "干货"], + "is_upcoming": True, + }, +] + +DISCUSSIONS = [ + { + "id": "visa-sea-2026", + "title": "2026 东南亚长期停留签证怎么选?", + "excerpt": "泰国 DTV、马来西亚 DE Rantau、印尼第二家园……大家最近实际办下来体验如何?", + "author": "小林", + "author_emoji": "🧳", + "category": "签证", + "reply_count": 23, + "like_count": 47, + "is_pinned": True, + "created_at": "2026-08-20", + "tags": ["签证", "东南亚"], + }, + { + "id": "async-remote", + "title": "异步团队怎么写日报才不烦人?", + "excerpt": "我们团队跨 5 个时区,想收集团队节奏模板和工具推荐。", + "author": "Marco", + "author_emoji": "💻", + "category": "远程工作", + "reply_count": 15, + "like_count": 31, + "is_pinned": False, + "created_at": "2026-08-22", + "tags": ["远程", "协作"], + }, + { + "id": "coliving-tips", + "title": "第一次租 Coliving 必问房东的 10 个问题", + "excerpt": "押金、网速、清洁、访客政策……欢迎补充你的踩坑经历。", + "author": "阿静", + "author_emoji": "🏡", + "category": "住宿", + "reply_count": 19, + "like_count": 56, + "is_pinned": False, + "created_at": "2026-08-25", + "tags": ["住宿", "经验"], + }, + { + "id": "health-insurance", + "title": "数字游民国际医疗保险对比", + "excerpt": "SafetyWing、Genki、本地险……按年龄段和目的地聊聊性价比。", + "author": "Yuki", + "author_emoji": "🏥", + "category": "安全", + "reply_count": 11, + "like_count": 28, + "is_pinned": False, + "created_at": "2026-08-27", + "tags": ["保险", "安全"], + }, + { + "id": "meetup-feedback", + "title": "你希望 nomadro 线下活动开在哪些城市?", + "excerpt": "我们在规划 Q4 活动路线,投票 + 留言告诉我们你的城市!", + "author": "nomadro", + "author_emoji": "🌍", + "category": "社区", + "reply_count": 34, + "like_count": 72, + "is_pinned": True, + "created_at": "2026-08-28", + "tags": ["活动", "投票"], + }, +] + +GIGS = [ + { + "id": "logo-design", + "title": "为游民社区设计 Logo", + "description": "需要扁平风格,含地球+代码元素,交付 SVG。", + "budget": "¥800", + "deadline": "2026-09-15", + "tags": ["设计", "远程"], + "poster": "nomadro", + "status": "open", + }, + { + "id": "meetup-host", + "title": "深圳线下活动主持人", + "description": "协助组织 20 人规模的游民交流会,有经验优先。", + "budget": "¥500/场", + "deadline": "2026-09-20", + "tags": ["活动", "深圳"], + "poster": "深圳湾区主理人", + "status": "open", + }, + { + "id": "content-translate", + "title": "签证指南英文润色", + "description": "将 3 篇中文签证攻略翻译润色为英文博客。", + "budget": "¥1200", + "deadline": "2026-10-01", + "tags": ["翻译", "内容"], + "poster": "nomadro", + "status": "open", + }, +] + +DISCUSSION_REPLIES = { + "visa-sea-2026": [ + { + "id": "r1", + "author": "阿Ken", + "author_emoji": "🇹🇭", + "content": "DTV 刚办下来,材料比想象中简单,关键是银行流水和远程工作证明。", + "created_at": "2026-08-21", + "like_count": 12, + }, + { + "id": "r2", + "author": "Sara", + "author_emoji": "🇲🇾", + "content": "DE Rantau 审批大概 3 周,适合想待吉隆坡的。", + "created_at": "2026-08-21", + "like_count": 8, + }, + ], + "async-remote": [ + { + "id": "r1", + "author": "Devon", + "author_emoji": "🕐", + "content": "我们用 Loom 录屏 + 简短文字摘要,比长日报友好很多。", + "created_at": "2026-08-23", + "like_count": 9, + }, + ], +} + +NOMAD_ROUTES = [ + { + "id": "sea-slow", + "slug": "sea-slow-trail", + "title": "东南亚慢旅三角", + "duration_days": 90, + "budget": 18000, + "description": "清迈 → 巴厘岛 → 吉隆坡,低成本 + 高速网络 + 成熟游民社区。", + "stops": ["chiang-mai", "bali", "kuala-lumpur"], + "emoji": "🌴", + }, + { + "id": "euro-culture", + "slug": "euro-culture-loop", + "title": "欧洲文化环线", + "duration_days": 60, + "budget": 35000, + "description": "里斯本 → 巴塞罗那 → 柏林,签证友好、文化丰富。", + "stops": ["lisbon", "barcelona", "berlin"], + "emoji": "🏰", + }, + { + "id": "latam-budget", + "slug": "latam-budget-run", + "title": "拉美性价比冲刺", + "duration_days": 45, + "budget": 12000, + "description": "墨西哥城 → 麦德林,贴近北美时区、生活成本低。", + "stops": ["mexico-city", "medellin"], + "emoji": "🌮", + }, +] diff --git a/backend/app/data/digital_content.py b/backend/app/data/digital_content.py new file mode 100644 index 0000000..45930bf --- /dev/null +++ b/backend/app/data/digital_content.py @@ -0,0 +1,120 @@ +"""Digital nomad academy content — nomadro /digital sub-site.""" + +SITE_ID = "nomadro-digital" + +COURSE_MODULES = [ + { + "title": "启程:远程工作基础", + "lessons": [ + {"title": "什么是数字游民", "duration": "8 分钟", "free": True}, + {"title": "异步协作入门", "duration": "12 分钟", "free": True}, + {"title": "时区与会议管理", "duration": "15 分钟", "free": False}, + ], + }, + { + "title": "签证与税务", + "lessons": [ + {"title": "东南亚长期签证概览", "duration": "18 分钟", "free": False}, + {"title": "183 天规则与税务居民", "duration": "14 分钟", "free": False}, + ], + }, + { + "title": "Homelab 与连接", + "lessons": [ + {"title": "把家装进一个机柜", "duration": "20 分钟", "free": False}, + {"title": "无论在哪都能连回家", "duration": "16 分钟", "free": False}, + ], + }, +] + +LESSONS = { + "0-0": { + "title": "什么是数字游民", + "moduleIndex": 0, + "lessonIndex": 0, + "duration": "8 分钟", + "free": True, + "content": "数字游民是一种工作方式:产出不绑定在某个地理坐标上。你可以在任何有网络的地方完成交付。", + }, + "0-1": { + "title": "异步协作入门", + "moduleIndex": 0, + "lessonIndex": 1, + "duration": "12 分钟", + "free": True, + "content": "异步沟通不是「随时在线」,而是「明确预期」:写清楚截止时间、交付物和决策人。", + }, + "0-2": { + "title": "时区与会议管理", + "moduleIndex": 0, + "lessonIndex": 2, + "duration": "15 分钟", + "free": False, + "content": "用 overlap 窗口安排会议,非重叠时段留给深度工作。工具箱里的「会议黄金时段」可以帮你算。", + }, + "1-0": { + "title": "东南亚长期签证概览", + "moduleIndex": 1, + "lessonIndex": 0, + "duration": "18 分钟", + "free": False, + "content": "泰国 DTV、马来西亚 DE Rantau、印尼第二家园……各国有不同的门槛与材料清单。", + }, + "1-1": { + "title": "183 天规则与税务居民", + "moduleIndex": 1, + "lessonIndex": 1, + "duration": "14 分钟", + "free": False, + "content": "多数国家用 183 天判定税务居民。用 nomadro 税居天数工具追踪停留。", + }, + "2-0": { + "title": "把家装进一个机柜", + "moduleIndex": 2, + "lessonIndex": 0, + "duration": "20 分钟", + "free": False, + "content": "Homelab 让你在旅途中拥有可控的 NAS、VPN 和开发环境。", + }, + "2-1": { + "title": "无论在哪都能连回家", + "moduleIndex": 2, + "lessonIndex": 1, + "duration": "16 分钟", + "free": False, + "content": "WireGuard + 动态 DNS,把家里的服务安全暴露给在外的你。", + }, +} + +JOBS = [ + { + "id": "j1", + "title": "Senior Frontend Engineer", + "company": "Remote First Co", + "location": "全球远程", + "type": "全职", + "salary": "$80k–120k", + "tags": ["React", "TypeScript", "远程"], + "url": "https://nomadro.com", + }, + { + "id": "j2", + "title": "DevOps / SRE", + "company": "Nomad Labs", + "location": "欧洲时区", + "type": "合同", + "salary": "€60–90/h", + "tags": ["K8s", "AWS", "异步"], + "url": "https://nomadro.com", + }, + { + "id": "j3", + "title": "内容运营(中文)", + "company": "nomadro", + "location": "东南亚友好", + "type": "兼职", + "salary": "面议", + "tags": ["社区", "写作", "游民"], + "url": "https://nomadro.com", + }, +] diff --git a/backend/app/data/social_profiles.py b/backend/app/data/social_profiles.py new file mode 100644 index 0000000..73a5dcc --- /dev/null +++ b/backend/app/data/social_profiles.py @@ -0,0 +1,54 @@ +"""Nomad matching candidate profiles — adapted from NomadCNA business logic.""" + +MATCH_INTENTS = ("friends", "dating", "partner", "roommate", "cofounder", "explore") + +CANDIDATE_PROFILES = [ + { + "id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiang-mai", + "gender": "女", "single": "是", "bio": "远程前端,喜欢咖啡和徒步。", + "photo": "🧳", "tags": ["远程", "咖啡", "徒步"], + "lookingFor": ["friends", "dating", "explore"], + }, + { + "id": "p2", "userId": "u2", "name": "Marco", "location": "里斯本", "citySlug": "lisbon", + "gender": "男", "single": "是", "bio": "全栈工程师,欧洲旅居第三年。", + "photo": "💻", "tags": ["全栈", "欧洲", "冲浪"], + "lookingFor": ["friends", "cofounder", "explore"], + }, + { + "id": "p3", "userId": "u3", "name": "Yuki", "location": "巴厘岛", "citySlug": "bali", + "gender": "女", "single": "否", "bio": "设计师,寻找合租室友。", + "photo": "🎨", "tags": ["设计", "瑜伽", "合租"], + "lookingFor": ["roommate", "friends"], + }, + { + "id": "p4", "userId": "u4", "name": "Alex", "location": "墨西哥城", "citySlug": "mexico-city", + "gender": "男", "single": "是", "bio": "独立开发者,拉美时区常驻。", + "photo": "🚀", "tags": ["独立开发", "拉美"], + "lookingFor": ["cofounder", "friends", "dating"], + }, + { + "id": "p5", "userId": "u5", "name": "Sara", "location": "巴塞罗那", "citySlug": "barcelona", + "gender": "女", "single": "是", "bio": "产品运营,热爱美食与市集。", + "photo": "☕", "tags": ["产品", "美食"], + "lookingFor": ["dating", "partner", "friends"], + }, + { + "id": "p6", "userId": "u6", "name": "Ken", "location": "东京", "citySlug": "tokyo", + "gender": "男", "single": "是", "bio": "数据分析师,东亚签证达人。", + "photo": "📊", "tags": ["数据", "签证"], + "lookingFor": ["friends", "explore"], + }, + { + "id": "p7", "userId": "u7", "name": "Lina", "location": "大理", "citySlug": "dali", + "gender": "女", "single": "是", "bio": "内容创作者,慢生活倡导者。", + "photo": "📷", "tags": ["内容", "慢生活"], + "lookingFor": ["friends", "dating", "roommate"], + }, + { + "id": "p8", "userId": "u8", "name": "Devon", "location": "柏林", "citySlug": "berlin", + "gender": "非二元", "single": "是", "bio": "DevOps,开源社区活跃成员。", + "photo": "🖥️", "tags": ["DevOps", "开源"], + "lookingFor": ["cofounder", "friends"], + }, +] diff --git a/backend/app/main.py b/backend/app/main.py index db20031..2d845fd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routers import api, auth +from app.routers import api, auth, community, payment, social app = FastAPI( title="nomadro API", @@ -20,6 +20,8 @@ app.add_middleware( app.include_router(api.router, prefix="/api/v1") app.include_router(auth.router, prefix="/api/v1") +app.include_router(social.router, prefix="/api/v1") +app.include_router(community.router, prefix="/api/v1") @app.get("/") diff --git a/backend/app/routers/api.py b/backend/app/routers/api.py index fa292d1..f71e5c9 100644 --- a/backend/app/routers/api.py +++ b/backend/app/routers/api.py @@ -1,12 +1,23 @@ -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException, Query, Header from app.schemas import ( BlogPost, BlogPostDetail, ChartData, CompareRequest, CostCalculatorRequest, - CostCalculatorResponse, Destination, FAQ, SearchResult, StatsResponse, + CostCalculatorResponse, Destination, Discussion, DiscussionDetail, FAQ, + Meetup, MeetupRsvpRequest, MeetupSession, NextStopRequest, NextStopResponse, NomadRoute, + DigitalLesson, DigitalJob, + RecommendedDestination, SearchResult, StatsResponse, SubscribeRequest, SubscribeResponse, Testimonial, Tool, Visa, ) from app.services.pocketbase import pb from app.data import mock_data +from app.data import community_data +from app.services.recommendations import recommend_destinations +from app.services.meetup_live import ( + build_lounge_chat_url, build_mirotalk_join_url, can_access_meetup, create_mirotalk_room_slug, +) +from app.services.auth import get_user_by_token +from app.services import community_store, social_store +from app.data import digital_content router = APIRouter() @@ -213,3 +224,135 @@ async def global_search(q: str = Query(..., min_length=1)): )) return results[:12] + + +# ── Community (ported from NomadCNA, adapted for nomadro) ── + +@router.get("/meetups", response_model=list[Meetup]) +async def list_meetups(upcoming: bool = True): + return [Meetup(**m) for m in community_store.list_meetups(upcoming)] + + +@router.get("/meetups/{meetup_id}", response_model=Meetup) +async def get_meetup(meetup_id: str): + m = community_store.get_meetup(meetup_id) + if not m: + raise HTTPException(404, "活动不存在") + return Meetup(**m) + + +@router.post("/meetups/rsvp") +async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Header(None)): + user_id = None + if authorization and authorization.startswith("Bearer "): + user = get_user_by_token(authorization[7:]) + if user: + user_id = user["id"] + res = community_store.rsvp_meetup(body.meetup_id, user_id) + if not res.get("ok"): + if res.get("error") == "not_found": + raise HTTPException(404, "活动不存在") + raise HTTPException(400, "活动已满员") + return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]} + + +@router.get("/discussions", response_model=list[Discussion]) +async def list_discussions(category: str | None = Query(None)): + return [Discussion(**d) for d in community_store.list_discussions(category)] + + +@router.get("/discussions/{discussion_id}", response_model=DiscussionDetail) +async def get_discussion(discussion_id: str): + d = community_store.get_discussion(discussion_id) + if not d: + raise HTTPException(404, "讨论不存在") + return DiscussionDetail(**d) + + +@router.get("/routes", response_model=list[NomadRoute]) +async def list_routes(): + return [NomadRoute(**r) for r in community_data.NOMAD_ROUTES] + + +@router.post("/recommendations/next-stop", response_model=NextStopResponse) +async def next_stop_recommendations(body: NextStopRequest): + dests = await pb.get_destinations() + recommended = recommend_destinations( + dests, + budget=body.budget, + internet=body.internet, + climate=body.climate, + tags=body.tags, + priority=body.priority, + limit=body.limit, + ) + return NextStopResponse( + items=[RecommendedDestination(**d) for d in recommended], + routes=[NomadRoute(**r) for r in community_data.NOMAD_ROUTES], + ) + + +@router.get("/meetups/{meetup_id}/session", response_model=MeetupSession) +async def meetup_session(meetup_id: str, authorization: str | None = Header(None)): + meetup = community_store.get_meetup(meetup_id) + if not meetup: + raise HTTPException(404, "活动不存在") + + user = None + if authorization and authorization.startswith("Bearer "): + user = get_user_by_token(authorization[7:]) + + is_vip = social_store.is_vip(user["id"]) if user else False + can_join, lock = can_access_meetup(meetup, user, is_vip) + mode = meetup.get("mode", "offline") + display = (user or {}).get("name", "访客") + + session = MeetupSession( + canJoin=can_join and mode in ("online", "hybrid"), + lockReason=lock if not can_join else "", + displayName=display, + mode=mode, + mirotalkRoom=meetup.get("mirotalkRoom") or create_mirotalk_room_slug(meetup), + loungeChannel=meetup.get("loungeChannel", ""), + ) + if session.canJoin: + session.videoUrl = build_mirotalk_join_url(meetup, display) + session.chatUrl = build_lounge_chat_url(meetup) + return session + + +# ── Digital academy content ── + +@router.get("/digital/course") +async def digital_course(): + return {"modules": digital_content.COURSE_MODULES, "siteId": digital_content.SITE_ID} + + +@router.get("/digital/lessons/{module_index}/{lesson_index}", response_model=DigitalLesson) +async def digital_lesson(module_index: int, lesson_index: int, authorization: str | None = Header(None)): + key = f"{module_index}-{lesson_index}" + lesson = digital_content.LESSONS.get(key) + if not lesson: + raise HTTPException(404, "课时不存在") + if not lesson.get("free"): + user = None + if authorization and authorization.startswith("Bearer "): + user = get_user_by_token(authorization[7:]) + if not user or not social_store.is_vip(user["id"]): + raise HTTPException(403, "需要 VIP 会员解锁") + return DigitalLesson(**lesson) + + +@router.get("/digital/jobs", response_model=list[DigitalJob]) +async def digital_jobs(): + return [DigitalJob(**j) for j in digital_content.JOBS] + + +@router.get("/digital/vip/check") +async def digital_vip_check(authorization: str | None = Header(None)): + if not authorization or not authorization.startswith("Bearer "): + return {"vip": False} + user = get_user_by_token(authorization[7:]) + if not user: + return {"vip": False} + return {"vip": social_store.is_vip(user["id"])} diff --git a/backend/app/routers/community.py b/backend/app/routers/community.py new file mode 100644 index 0000000..245856a --- /dev/null +++ b/backend/app/routers/community.py @@ -0,0 +1,121 @@ +"""Community mutations — discussions, meetups, gigs, notifications.""" + +from fastapi import APIRouter, Header, HTTPException, Query + +from app.schemas import ( + CreateDiscussionRequest, CreateMeetupRequest, CreateReplyRequest, + FeedbackRequest, GigApplyRequest, GigItem, NotificationItem, StatsOverview, +) +from app.services.auth import get_user_by_token +from app.services import community_store, social_store + +router = APIRouter(tags=["community"]) + + +def _user(authorization: str | None) -> dict: + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(401, "未登录") + user = get_user_by_token(authorization[7:]) + if not user: + raise HTTPException(401, "未登录") + return user + + +@router.post("/discussions") +async def create_discussion(body: CreateDiscussionRequest, authorization: str | None = Header(None)): + user = _user(authorization) + d = community_store.create_discussion(user["id"], user["name"], body.model_dump()) + return {"success": True, "discussion": d} + + +@router.post("/discussions/{discussion_id}/replies") +async def post_reply( + discussion_id: str, + body: CreateReplyRequest, + authorization: str | None = Header(None), +): + user = _user(authorization) + reply = community_store.add_reply(discussion_id, user["id"], user["name"], body.content, body.author_emoji) + if not reply: + raise HTTPException(404, "讨论不存在") + return {"success": True, "reply": reply} + + +@router.post("/discussions/{discussion_id}/like") +async def like_discussion(discussion_id: str, authorization: str | None = Header(None)): + user = _user(authorization) + return community_store.toggle_discussion_like(discussion_id, user["id"]) + + +@router.post("/discussions/{discussion_id}/pin") +async def pin_discussion(discussion_id: str, authorization: str | None = Header(None)): + user = _user(authorization) + d = community_store.toggle_pin(discussion_id, user["id"]) + if not d: + raise HTTPException(403, "仅作者可置顶") + return {"success": True, "discussion": d} + + +@router.post("/meetups") +async def create_meetup(body: CreateMeetupRequest, authorization: str | None = Header(None)): + user = _user(authorization) + m = community_store.create_meetup(user["id"], user["name"], body.model_dump()) + return {"success": True, "meetup": m} + + +@router.get("/meetups/my-rsvp") +async def my_rsvps(authorization: str | None = Header(None)): + user = _user(authorization) + return {"ids": list(community_store.user_rsvp_ids(user["id"]))} + + +@router.get("/gigs", response_model=list[GigItem]) +async def list_gigs(): + return [GigItem(**g) for g in community_store.list_gigs()] + + +@router.post("/gigs/{gig_id}/apply") +async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | None = Header(None)): + user = _user(authorization) + res = community_store.apply_gig(gig_id, user["id"], user["name"], body.message) + if not res.get("ok"): + raise HTTPException(400, res.get("error", "申请失败")) + return res + + +@router.get("/notifications", response_model=list[NotificationItem]) +async def list_notifications(authorization: str | None = Header(None)): + user = _user(authorization) + return [NotificationItem(**n) for n in community_store.list_notifications(user["id"])] + + +@router.post("/notifications/read") +async def mark_read(authorization: str | None = Header(None)): + user = _user(authorization) + community_store.mark_notifications_read(user["id"]) + return {"success": True} + + +@router.post("/feedback") +async def submit_feedback(body: FeedbackRequest, authorization: str | None = Header(None)): + user = None + name = body.name or "访客" + if authorization and authorization.startswith("Bearer "): + user = get_user_by_token(authorization[7:]) + if user: + name = user["name"] + item = community_store.add_feedback(user["id"] if user else None, name, body.content, body.category) + return {"success": True, "id": item["id"]} + + +@router.get("/stats/overview", response_model=StatsOverview) +async def stats_overview(): + return StatsOverview(**community_store.stats_overview()) + + +@router.get("/members/{user_id}") +async def public_member(user_id: str): + profile = social_store.get_public_profile(user_id) + if not profile: + raise HTTPException(404, "用户不存在") + return profile diff --git a/backend/app/routers/payment.py b/backend/app/routers/payment.py new file mode 100644 index 0000000..95faaaa --- /dev/null +++ b/backend/app/routers/payment.py @@ -0,0 +1,132 @@ +"""Payment + order fulfillment — ZPay/XorPay from NomadCNA.""" + +from fastapi import APIRouter, Header, HTTPException, Query, Request +from fastapi.responses import PlainTextResponse + +from app.schemas import PayCreateRequest, PayCreateResponse, PayStatusResponse +from app.services.auth import get_user_by_token +from app.services import social_store +from app.services.payment_service import ( + DEV_AUTO_PAY, + PAYMENT_DEFAULT_AMOUNT, + PAYMENT_JOIN_AMOUNT, + create_pay_redirect, + parse_notify_order_id, + query_order_paid, + verify_notify, +) + +router = APIRouter(prefix="/pay", tags=["payment"]) + + +def _token_user(authorization: str | None) -> dict: + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(401, "未登录") + user = get_user_by_token(authorization[7:]) + if not user: + raise HTTPException(401, "未登录") + return user + + +def _client_ip(request: Request) -> str: + forwarded = request.headers.get("x-forwarded-for", "") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return "" + + +@router.post("/create", response_model=PayCreateResponse) +async def create_payment( + body: PayCreateRequest, + request: Request, + authorization: str | None = Header(None), +): + user = _token_user(authorization) + amount = PAYMENT_JOIN_AMOUNT if body.pay_type == "join" else PAYMENT_DEFAULT_AMOUNT + order = social_store.create_order(user["id"], body.pay_type, amount, dev_auto_pay=DEV_AUTO_PAY) + return_url = body.return_url or "https://nomadweb.nomadro.com/join/paid" + ua = request.headers.get("user-agent", "") + device = "wechat" if "micromessenger" in ua.lower() else "pc" + redirect = await create_pay_redirect( + order["id"], + amount, + return_url, + body.provider or "", + channel="wxpay" if device == "wechat" else "alipay", + device=device, + user_agent=ua, + client_ip=_client_ip(request), + ) + return PayCreateResponse( + order_id=order["id"], + amount=amount, + redirect_url=redirect, + status=order["status"], + ) + + +@router.get("/status", response_model=PayStatusResponse) +async def pay_status(order_id: str = Query(...), authorization: str | None = Header(None)): + _token_user(authorization) + order = social_store.get_order(order_id) + if not order: + raise HTTPException(404, "订单不存在") + if order["status"] != "paid": + if DEV_AUTO_PAY or await query_order_paid(order_id): + social_store.mark_order_paid(order_id) + order = social_store.get_order(order_id) + return PayStatusResponse( + order_id=order_id, + status=order["status"], + paid=order["status"] == "paid", + ) + + +@router.post("/complete") +async def complete_order( + order_id: str = Query(...), + authorization: str | None = Header(None), +): + user = _token_user(authorization) + order = social_store.get_order(order_id) + if not order: + raise HTTPException(404, "订单不存在") + if order["userId"] != user["id"]: + raise HTTPException(403, "无权操作此订单") + if order["status"] != "paid": + if DEV_AUTO_PAY or await query_order_paid(order_id): + social_store.mark_order_paid(order_id) + else: + raise HTTPException(402, "订单未支付") + return {"success": True, "vip": social_store.is_vip(user["id"])} + + +async def _notify_handler(provider: str, request: Request) -> PlainTextResponse: + if request.method == "GET": + data = dict(request.query_params) + else: + form = await request.form() + data = dict(form) + if not verify_notify(provider, data): + raise HTTPException(400, "验签失败") + oid = parse_notify_order_id(provider, data) + if oid: + social_store.mark_order_paid(oid) + return PlainTextResponse("success" if provider == "zpay" else "ok") + + +@router.api_route("/zpay_notify", methods=["GET", "POST"]) +async def zpay_notify(request: Request): + return await _notify_handler("zpay", request) + + +@router.api_route("/xorpay_notify", methods=["GET", "POST"]) +async def xorpay_notify(request: Request): + return await _notify_handler("xorpay", request) + + +@router.api_route("/notify", methods=["GET", "POST"]) +async def pay_notify_legacy(request: Request, provider: str = Query("zpay")): + return await _notify_handler(provider, request) diff --git a/backend/app/routers/social.py b/backend/app/routers/social.py new file mode 100644 index 0000000..08f0b81 --- /dev/null +++ b/backend/app/routers/social.py @@ -0,0 +1,134 @@ +"""Matching, chat, join, VIP — adapted from NomadCNA.""" + +from fastapi import APIRouter, Header, HTTPException, Query + +from app.schemas import ( + ChatMessage, ConversationItem, JoinMemberRequest, MatchProfile, MatchQuota, + SendMessageRequest, SwipeRequest, SwipeResponse, VipStatus, +) +from app.services.auth import get_user_by_token +from app.services import social_store + +router = APIRouter(prefix="/social", tags=["social"]) + + +def _token_user(authorization: str | None) -> dict: + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(401, "未登录") + user = get_user_by_token(authorization[7:]) + if not user: + raise HTTPException(401, "未登录") + return user + + +@router.post("/join") +async def join_member(body: JoinMemberRequest, authorization: str | None = Header(None)): + user = _token_user(authorization) + profile = social_store.join_member(user["id"], user["name"], body.model_dump()) + return {"success": True, "profile": profile} + + +@router.get("/join/status") +async def join_status(authorization: str | None = Header(None)): + user = _token_user(authorization) + profile = social_store._profiles.get(user["id"]) + return {"complete": bool(profile), "profile": profile} + + +@router.get("/matches/candidates", response_model=list[MatchProfile]) +async def match_candidates( + authorization: str | None = Header(None), + intent: str = Query("friends"), + city: str = Query(""), + gender: str = Query(""), + single: str = Query(""), + exclude_swiped: bool = Query(True), +): + user = _token_user(authorization) + if user["id"] not in social_store._profiles: + raise HTTPException(400, "请先完成加入资料") + return [MatchProfile(**p) for p in social_store.list_candidates( + user["id"], intent=intent, city=city, gender=gender, single=single, exclude_swiped=exclude_swiped + )] + + +@router.get("/matches/quota", response_model=MatchQuota) +async def match_quota(authorization: str | None = Header(None)): + user = _token_user(authorization) + return MatchQuota(**social_store.get_quota(user["id"])) + + +@router.post("/matches/swipes/undo") +async def undo_swipe(authorization: str | None = Header(None)): + user = _token_user(authorization) + res = social_store.undo_last_swipe(user["id"]) + if not res.get("ok"): + raise HTTPException(400, "没有可撤销的滑动") + return res + + +@router.get("/matches/likes", response_model=list[MatchProfile]) +async def match_likes(authorization: str | None = Header(None)): + user = _token_user(authorization) + return [MatchProfile(**p) for p in social_store.list_likes(user["id"])] + + +@router.get("/matches/mutual") +async def mutual_matches(authorization: str | None = Header(None)): + user = _token_user(authorization) + return {"items": social_store.list_mutual(user["id"])} + + +@router.post("/matches/swipes", response_model=SwipeResponse) +async def record_swipe(body: SwipeRequest, authorization: str | None = Header(None)): + user = _token_user(authorization) + result = social_store.record_swipe(user["id"], body.profileId, body.action, body.intent) + if not result.get("ok"): + err = result.get("error") + if err == "quota_exceeded": + raise HTTPException(429, "今日滑动次数已用完") + if err == "vip_required": + raise HTTPException(403, "超级喜欢需要 VIP") + if err == "duplicate": + raise HTTPException(409, "已滑动过该用户") + raise HTTPException(400, err or "滑动失败") + return SwipeResponse(**result) + + +@router.get("/conversations", response_model=list[ConversationItem]) +async def list_conversations(authorization: str | None = Header(None)): + user = _token_user(authorization) + items = social_store.list_conversations(user["id"]) + return [ConversationItem(**c) for c in items] + + +@router.get("/conversations/{conv_id}") +async def get_conversation(conv_id: str, authorization: str | None = Header(None)): + user = _token_user(authorization) + conv = social_store.get_conversation(conv_id, user["id"]) + if not conv: + raise HTTPException(404, "会话不存在") + return conv + + +@router.get("/conversations/{conv_id}/messages", response_model=list[ChatMessage]) +async def get_messages(conv_id: str, authorization: str | None = Header(None), limit: int = 50): + user = _token_user(authorization) + return [ChatMessage(**m) for m in social_store.list_messages(conv_id, user["id"], limit)] + + +@router.post("/conversations/{conv_id}/messages", response_model=ChatMessage) +async def post_message(conv_id: str, body: SendMessageRequest, authorization: str | None = Header(None)): + user = _token_user(authorization) + msg = social_store.send_message(conv_id, user["id"], body.body) + if not msg: + raise HTTPException(400, "发送失败") + return ChatMessage(**msg) + + +@router.get("/vip/check", response_model=VipStatus) +async def vip_check(authorization: str | None = Header(None)): + user = _token_user(authorization) + vip = social_store.is_vip(user["id"]) + exp = (social_store._memberships.get(user["id"]) or {}).get("expires_at", 0) + return VipStatus(vip=vip, expires_at=exp) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6b1e412..03d6efd 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -184,4 +184,289 @@ class SearchResult(BaseModel): title: str subtitle: str emoji: str - url: str \ No newline at end of file + url: str + + +class Meetup(BaseModel): + id: str + title: str + city: str + destination_slug: str = "" + emoji: str + date: str + time: str + venue: str + description: str + mode: str # online | offline | hybrid + access_level: str = "public" + mirotalkRoom: str = "" + loungeChannel: str = "" + rsvp_count: int = 0 + max_attendees: int = 50 + organizer: str = "" + tags: list[str] = [] + is_upcoming: bool = True + + +class Discussion(BaseModel): + id: str + title: str + excerpt: str + author: str + author_emoji: str = "🧑‍💻" + category: str + reply_count: int = 0 + like_count: int = 0 + is_pinned: bool = False + created_at: str + tags: list[str] = [] + + +class DiscussionReply(BaseModel): + id: str + author: str + author_emoji: str = "🧑‍💻" + content: str + created_at: str + like_count: int = 0 + + +class DiscussionDetail(Discussion): + replies: list[DiscussionReply] = [] + + +class NomadRoute(BaseModel): + id: str + slug: str + title: str + duration_days: int + budget: int + description: str + stops: list[str] + emoji: str = "🗺️" + + +class NextStopRequest(BaseModel): + budget: int = Field(default=8000, ge=2000, le=50000) + internet: int = Field(default=50, ge=10, le=500) + climate: str = "mild" # warm | mild | cool + tags: list[str] = Field(default_factory=list) + priority: str = "balanced" # cost | speed | community | balanced + limit: int = Field(default=12, ge=1, le=24) + + +class RecommendedDestination(Destination): + match_score: float = 0 + match_reasons: list[str] = [] + + +class NextStopResponse(BaseModel): + items: list[RecommendedDestination] + routes: list[NomadRoute] = [] + + +class MeetupRsvpRequest(BaseModel): + meetup_id: str + name: str = Field(default="访客", min_length=1) + + +# ── Social / matching / chat ── + +class MatchProfile(BaseModel): + id: str + userId: str = "" + name: str + location: str = "" + citySlug: str = "" + gender: str = "" + single: str = "" + bio: str = "" + photo: str = "🧑‍💻" + tags: list[str] = [] + lookingFor: list[str] = [] + intent: str = "" + + +class SwipeRequest(BaseModel): + profileId: str + action: str = "like" # like | dislike | superlike + intent: str = "friends" + + +class SwipeResponse(BaseModel): + ok: bool + matched: bool = False + match: dict | None = None + error: str | None = None + + +class MatchQuota(BaseModel): + vip: bool + limit: int + used: int + remaining: int + + +class JoinMemberRequest(BaseModel): + city: str = "" + citySlug: str = "" + gender: str = "" + single: str = "" + bio: str = "" + lookingFor: list[str] = Field(default_factory=lambda: ["friends", "explore"]) + photo: str = "🧑‍💻" + + +class ChatPeer(BaseModel): + id: str = "" + userId: str = "" + name: str = "" + photo: str = "🧑‍💻" + location: str = "" + + +class ConversationItem(BaseModel): + id: str + type: str = "direct" + intent: str = "" + lastMessageAt: str = "" + lastMessagePreview: str = "" + unreadCount: int = 0 + peer: MatchProfile | None = None + + +class ChatMessage(BaseModel): + id: str + conversationId: str = "" + senderId: str + body: str + createdAt: str = "" + mine: bool = False + + +class SendMessageRequest(BaseModel): + body: str = Field(min_length=1, max_length=2000) + + +class VipStatus(BaseModel): + vip: bool + expires_at: int = 0 + + +class PayCreateRequest(BaseModel): + pay_type: str = "join" # join | vip | meetup + return_url: str = "" + provider: str = "zpay" + + +class PayCreateResponse(BaseModel): + order_id: str + amount: int + redirect_url: str + status: str + + +class PayStatusResponse(BaseModel): + order_id: str + status: str + paid: bool + + +class MeetupSession(BaseModel): + canJoin: bool + lockReason: str = "" + displayName: str = "" + videoUrl: str = "" + chatUrl: str = "" + loungeChannel: str = "" + mirotalkRoom: str = "" + meetingProvider: str = "mirotalk" + mode: str = "" + + +class DigitalLesson(BaseModel): + title: str + moduleIndex: int + lessonIndex: int + duration: str = "" + free: bool = False + content: str = "" + + +class DigitalJob(BaseModel): + id: str + title: str + company: str + location: str + type: str + salary: str + tags: list[str] = [] + url: str = "" + + +class CreateDiscussionRequest(BaseModel): + title: str = Field(min_length=4, max_length=120) + excerpt: str = "" + content: str = "" + category: str = "社区" + tags: list[str] = Field(default_factory=list) + author_emoji: str = "🧑‍💻" + + +class CreateReplyRequest(BaseModel): + content: str = Field(min_length=1, max_length=4000) + author_emoji: str = "🧑‍💻" + + +class CreateMeetupRequest(BaseModel): + title: str = Field(min_length=4, max_length=120) + city: str = "线上" + destination_slug: str = "" + emoji: str = "🎉" + date: str = "" + time: str = "19:00" + venue: str = "" + description: str = "" + mode: str = "offline" + access_level: str = "public" + max_attendees: int = 30 + tags: list[str] = Field(default_factory=list) + mirotalkRoom: str = "" + loungeChannel: str = "" + + +class GigItem(BaseModel): + id: str + title: str + description: str + budget: str + deadline: str + tags: list[str] = [] + poster: str = "" + status: str = "open" + + +class GigApplyRequest(BaseModel): + message: str = Field(min_length=4, max_length=2000) + + +class NotificationItem(BaseModel): + id: str + title: str + body: str = "" + link: str = "" + read: bool = False + created_at: str = "" + + +class FeedbackRequest(BaseModel): + content: str = Field(min_length=4, max_length=4000) + category: str = "general" + name: str = "" + + +class StatsOverview(BaseModel): + meetups: int = 0 + discussions: int = 0 + gigs: int = 0 + members_active: int = 0 \ No newline at end of file diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index f3b581c..6923767 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -109,8 +109,22 @@ def login_demo() -> dict[str, Any] | None: return {"token": token, "user": _user_profile(user)} +def _reload_sessions() -> None: + if not STORE_PATH.exists(): + return + try: + data = json.loads(STORE_PATH.read_text(encoding="utf-8")) + global _sessions + _sessions.update(data.get("sessions") or {}) + except (OSError, json.JSONDecodeError): + return + + def get_user_by_token(token: str) -> dict | None: uid = _sessions.get(token) + if not uid: + _reload_sessions() + uid = _sessions.get(token) if not uid: return None for user in _users.values(): diff --git a/backend/app/services/community_store.py b/backend/app/services/community_store.py new file mode 100644 index 0000000..dc4a3e5 --- /dev/null +++ b/backend/app/services/community_store.py @@ -0,0 +1,394 @@ +"""Persistent community data: discussions, meetups, gigs, notifications.""" + +from __future__ import annotations + +import json +import re +import secrets +import time +from copy import deepcopy +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from app.data import community_data + +STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "community_store.json" + +_meetups: list[dict] = [] +_discussions: list[dict] = [] +_replies: dict[str, list[dict]] = {} +_discussion_likes: dict[str, set[str]] = {} # discussion_id -> user_ids +_reply_likes: dict[str, set[str]] = {} +_rsvps: dict[str, set[str]] = {} # meetup_id -> user_ids +_gigs: list[dict] = [] +_gig_apps: list[dict] = [] +_notifications: dict[str, list[dict]] = {} # user_id -> items +_feedback: list[dict] = [] +_seeded = False + + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +def _slugify(text: str) -> str: + s = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", text.lower()).strip("-") + return (s[:48] or secrets.token_hex(4)) + + +def _persist() -> None: + STORE_PATH.parent.mkdir(parents=True, exist_ok=True) + STORE_PATH.write_text( + json.dumps({ + "meetups": _meetups, + "discussions": _discussions, + "replies": _replies, + "discussion_likes": {k: list(v) for k, v in _discussion_likes.items()}, + "reply_likes": {k: list(v) for k, v in _reply_likes.items()}, + "rsvps": {k: list(v) for k, v in _rsvps.items()}, + "gigs": _gigs, + "gig_apps": _gig_apps, + "notifications": _notifications, + "feedback": _feedback, + "seeded": _seeded, + }, ensure_ascii=False), + encoding="utf-8", + ) + + +def _restore() -> None: + global _meetups, _discussions, _replies, _discussion_likes, _reply_likes + global _rsvps, _gigs, _gig_apps, _notifications, _feedback, _seeded + if not STORE_PATH.exists(): + return + try: + data = json.loads(STORE_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + _meetups = data.get("meetups") or [] + _discussions = data.get("discussions") or [] + _replies = data.get("replies") or {} + _discussion_likes = {k: set(v) for k, v in (data.get("discussion_likes") or {}).items()} + _reply_likes = {k: set(v) for k, v in (data.get("reply_likes") or {}).items()} + _rsvps = {k: set(v) for k, v in (data.get("rsvps") or {}).items()} + _gigs = data.get("gigs") or [] + _gig_apps = data.get("gig_apps") or [] + _notifications = data.get("notifications") or {} + _feedback = data.get("feedback") or [] + _seeded = bool(data.get("seeded")) + + +def _reload() -> None: + _restore() + + +def _seed_if_needed() -> None: + global _seeded + _reload() + if _seeded: + return + _meetups = deepcopy(community_data.MEETUPS) + _discussions = deepcopy(community_data.DISCUSSIONS) + _replies = deepcopy(community_data.DISCUSSION_REPLIES) + _gigs = deepcopy(getattr(community_data, "GIGS", _default_gigs())) + _seeded = True + _persist() + + +def _default_gigs() -> list[dict]: + return [ + { + "id": "logo-design", + "title": "为游民社区设计 Logo", + "description": "需要扁平风格,含地球+代码元素,交付 SVG。", + "budget": "¥800", + "deadline": "2026-09-15", + "tags": ["设计", "远程"], + "poster": "nomadro", + "status": "open", + }, + { + "id": "meetup-host", + "title": "深圳线下活动主持人", + "description": "协助组织 20 人规模的游民交流会,有经验优先。", + "budget": "¥500/场", + "deadline": "2026-09-20", + "tags": ["活动", "深圳"], + "poster": "深圳湾区主理人", + "status": "open", + }, + { + "id": "content-translate", + "title": "签证指南英文润色", + "description": "将 3 篇中文签证攻略翻译润色为英文博客。", + "budget": "¥1200", + "deadline": "2026-10-01", + "tags": ["翻译", "内容"], + "poster": "nomadro", + "status": "open", + }, + ] + + +_seed_if_needed() + + +def _discussion_like_count(did: str) -> int: + return len(_discussion_likes.get(did, set())) + + +def _reply_like_count(rid: str) -> int: + return len(_reply_likes.get(rid, set())) + + +def _enrich_discussion(d: dict) -> dict: + did = d["id"] + replies = _replies.get(did, []) + base_likes = d.get("like_count", 0) + stored = _discussion_like_count(did) + return { + **d, + "reply_count": len(replies), + "like_count": max(base_likes, stored) if stored == 0 else base_likes + stored, + } + + +def list_meetups(upcoming: bool = True) -> list[dict]: + _seed_if_needed() + items = list(_meetups) + if upcoming: + items = [m for m in items if m.get("is_upcoming", True)] + for m in items: + mid = m["id"] + m["rsvp_count"] = max(m.get("rsvp_count", 0), len(_rsvps.get(mid, set()))) + return items + + +def get_meetup(meetup_id: str) -> dict | None: + _seed_if_needed() + for m in _meetups: + if m["id"] == meetup_id: + out = dict(m) + out["rsvp_count"] = max(out.get("rsvp_count", 0), len(_rsvps.get(meetup_id, set()))) + return out + return None + + +def create_meetup(user_id: str, user_name: str, payload: dict) -> dict: + _seed_if_needed() + mid = _slugify(payload.get("title", "meetup")) + "-" + secrets.token_hex(3) + mode = payload.get("mode", "offline") + room = payload.get("mirotalkRoom") or "" + if mode in ("online", "hybrid") and not room: + room = f"nomadro-{_slugify(payload.get('city', 'online'))}-{secrets.token_hex(2)}" + lounge = payload.get("loungeChannel") or (f"#{room}" if room else "") + meetup = { + "id": mid, + "title": payload["title"], + "city": payload.get("city", "线上"), + "destination_slug": payload.get("destination_slug", ""), + "emoji": payload.get("emoji", "🎉"), + "date": payload.get("date", _now_iso()), + "time": payload.get("time", "19:00"), + "venue": payload.get("venue", "待定"), + "description": payload.get("description", ""), + "mode": mode, + "access_level": payload.get("access_level", "public"), + "mirotalkRoom": room, + "loungeChannel": lounge, + "meetingUrl": "", + "rsvp_count": 0, + "max_attendees": int(payload.get("max_attendees", 30)), + "organizer": user_name, + "organizer_id": user_id, + "tags": payload.get("tags", []), + "is_upcoming": True, + } + _meetups.insert(0, meetup) + _persist() + return meetup + + +def rsvp_meetup(meetup_id: str, user_id: str | None = None) -> dict: + _seed_if_needed() + m = get_meetup(meetup_id) + if not m: + return {"ok": False, "error": "not_found"} + uid = user_id or f"guest-{secrets.token_hex(4)}" + rset = _rsvps.setdefault(meetup_id, set()) + if uid in rset: + return {"ok": True, "message": "已报名", "rsvp_count": len(rset)} + if len(rset) >= m["max_attendees"]: + return {"ok": False, "error": "full"} + rset.add(uid) + _persist() + return {"ok": True, "message": f"已报名「{m['title']}」", "rsvp_count": len(rset)} + + +def user_rsvp_ids(user_id: str) -> set[str]: + _seed_if_needed() + return {mid for mid, uids in _rsvps.items() if user_id in uids} + + +def list_discussions(category: str | None = None) -> list[dict]: + _seed_if_needed() + items = [_enrich_discussion(d) for d in _discussions] + if category: + items = [d for d in items if d["category"] == category] + pinned = sorted([d for d in items if d.get("is_pinned")], key=lambda x: x["created_at"], reverse=True) + rest = sorted([d for d in items if not d.get("is_pinned")], key=lambda x: x["created_at"], reverse=True) + return pinned + rest + + +def get_discussion(discussion_id: str) -> dict | None: + _seed_if_needed() + for d in _discussions: + if d["id"] == discussion_id: + enriched = _enrich_discussion(d) + replies = [] + for r in _replies.get(discussion_id, []): + replies.append({**r, "like_count": r.get("like_count", 0) + _reply_like_count(r["id"])}) + return {**enriched, "replies": replies} + return None + + +def create_discussion(user_id: str, user_name: str, payload: dict) -> dict: + _seed_if_needed() + did = _slugify(payload["title"]) + "-" + secrets.token_hex(3) + d = { + "id": did, + "title": payload["title"], + "excerpt": payload.get("excerpt") or payload.get("content", "")[:200], + "author": user_name, + "author_id": user_id, + "author_emoji": payload.get("author_emoji", "🧑‍💻"), + "category": payload.get("category", "社区"), + "reply_count": 0, + "like_count": 0, + "is_pinned": False, + "created_at": _now_iso(), + "tags": payload.get("tags", []), + } + _discussions.insert(0, d) + _replies[did] = [] + _persist() + return _enrich_discussion(d) + + +def add_reply(discussion_id: str, user_id: str, user_name: str, content: str, emoji: str = "🧑‍💻") -> dict | None: + _seed_if_needed() + if not any(d["id"] == discussion_id for d in _discussions): + return None + rid = secrets.token_hex(6) + reply = { + "id": rid, + "author": user_name, + "author_id": user_id, + "author_emoji": emoji, + "content": content, + "created_at": _now_iso(), + "like_count": 0, + } + _replies.setdefault(discussion_id, []).append(reply) + _persist() + return reply + + +def toggle_discussion_like(discussion_id: str, user_id: str) -> dict: + _seed_if_needed() + likes = _discussion_likes.setdefault(discussion_id, set()) + if user_id in likes: + likes.discard(user_id) + liked = False + else: + likes.add(user_id) + liked = True + _persist() + return {"liked": liked, "like_count": _discussion_like_count(discussion_id)} + + +def toggle_pin(discussion_id: str, user_id: str) -> dict | None: + _seed_if_needed() + for d in _discussions: + if d["id"] == discussion_id and d.get("author_id") == user_id: + d["is_pinned"] = not d.get("is_pinned", False) + _persist() + return _enrich_discussion(d) + return None + + +def list_gigs() -> list[dict]: + _seed_if_needed() + return [g for g in _gigs if g.get("status", "open") == "open"] + + +def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict: + _seed_if_needed() + gig = next((g for g in _gigs if g["id"] == gig_id), None) + if not gig: + return {"ok": False, "error": "not_found"} + for a in _gig_apps: + if a["gig_id"] == gig_id and a["user_id"] == user_id: + return {"ok": False, "error": "duplicate"} + _gig_apps.append({ + "id": secrets.token_hex(6), + "gig_id": gig_id, + "user_id": user_id, + "user_name": user_name, + "message": message, + "created_at": _now_iso(), + }) + _persist() + return {"ok": True, "message": "申请已提交"} + + +def add_notification(user_id: str, title: str, body: str, link: str = "") -> None: + items = _notifications.setdefault(user_id, []) + items.insert(0, { + "id": secrets.token_hex(6), + "title": title, + "body": body, + "link": link, + "read": False, + "created_at": _now_iso(), + }) + items[:] = items[:50] + _persist() + + +def list_notifications(user_id: str) -> list[dict]: + _reload() + return _notifications.get(user_id, []) + + +def mark_notifications_read(user_id: str) -> None: + _reload() + for n in _notifications.get(user_id, []): + n["read"] = True + _persist() + + +def add_feedback(user_id: str | None, user_name: str, content: str, category: str = "general") -> dict: + _seed_if_needed() + item = { + "id": secrets.token_hex(6), + "user_id": user_id or "", + "user_name": user_name, + "content": content, + "category": category, + "created_at": _now_iso(), + } + _feedback.append(item) + _persist() + return item + + +def stats_overview() -> dict: + _seed_if_needed() + return { + "meetups": len(_meetups), + "discussions": len(_discussions), + "gigs": len(_gigs), + "members_active": len(_rsvps) + len(_gig_apps), + } diff --git a/backend/app/services/meetup_live.py b/backend/app/services/meetup_live.py new file mode 100644 index 0000000..1ebc758 --- /dev/null +++ b/backend/app/services/meetup_live.py @@ -0,0 +1,68 @@ +"""Meetup live room URL builders — ported from NomadCNA.""" + +from __future__ import annotations + +import os +import re +from urllib.parse import urlencode + +MIROTALK_URL = os.getenv("MIROTALK_URL", "https://mirotalk.nomadro.com") +LOUNGE_URL = os.getenv("LOUNGE_URL", "https://lounge.nomadro.com") + +VIP_ROLES = frozenset({"admin", "owner", "host", "organizer", "moderator"}) + + +def normalize_room(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-") + return slug[:64] or "nomadro-live" + + +def create_mirotalk_room_slug(meetup: dict) -> str: + city = normalize_room(meetup.get("city") or "online") + date = (meetup.get("date") or "").replace("-", "")[:8] + time_part = (meetup.get("time") or "0000").replace(":", "")[:4] + mid = normalize_room(meetup.get("id") or "room") + return f"nomadro-{city}-{date}-{time_part}-{mid}" + + +def create_lounge_channel(meetup: dict) -> str: + if meetup.get("loungeChannel"): + ch = meetup["loungeChannel"].strip() + return ch if ch.startswith("#") else f"#{ch}" + if meetup.get("mirotalkRoom"): + return f"#{normalize_room(meetup['mirotalkRoom'])}" + return f"#{create_mirotalk_room_slug(meetup)}" + + +def build_mirotalk_join_url(meetup: dict, display_name: str = "nomadro") -> str: + explicit = (meetup.get("meetingUrl") or "").strip() + if explicit.startswith("http"): + return explicit + room = meetup.get("mirotalkRoom") or create_mirotalk_room_slug(meetup) + return f"{MIROTALK_URL.rstrip('/')}/join?{urlencode({'room': room, 'name': display_name})}" + + +def build_lounge_chat_url(meetup: dict) -> str: + channel = create_lounge_channel(meetup).lstrip("#") + return f"{LOUNGE_URL.rstrip('/')}/#/chan-{channel}" + + +def can_access_meetup(meetup: dict, user: dict | None, is_vip: bool = False) -> tuple[bool, str]: + level = meetup.get("access_level") or "public" + if level == "public": + return True, "" + if not user: + return False, "login_required" + if level == "members": + return True, "" + if level == "vip": + role = (user.get("role") or "").lower() + if is_vip or role in VIP_ROLES: + return True, "" + return False, "vip_required" + if level == "hosts": + role = (user.get("role") or "").lower() + if role in VIP_ROLES: + return True, "" + return False, "host_required" + return True, "" diff --git a/backend/app/services/payment_providers.py b/backend/app/services/payment_providers.py new file mode 100644 index 0000000..50459ef --- /dev/null +++ b/backend/app/services/payment_providers.py @@ -0,0 +1,237 @@ +"""ZPay / XorPay providers — ported from NomadCNA backend/payment.py.""" + +from __future__ import annotations + +import hashlib +import json +import os +from typing import Any + +import httpx + + +def _md5(value: str) -> str: + return hashlib.md5(value.encode("utf-8")).hexdigest().lower() + + +def _is_internal_ip(ip: str | None) -> bool: + if not ip: + return True + value = ip.strip().lower() + if value in {"127.0.0.1", "localhost", "::1"}: + return True + if value.startswith("10.") or value.startswith("192.168."): + return True + if value.startswith("172."): + parts = value.split(".") + if len(parts) > 1 and parts[1].isdigit(): + return 16 <= int(parts[1]) <= 31 + return False + + +def resolve_channel(channel: str | None) -> str: + return "wxpay" if str(channel or "").lower() in {"", "wx", "wechat", "wxpay"} else "alipay" + + +class ZPayProvider: + name = "zpay" + + def __init__(self) -> None: + self.pid = os.getenv("ZPAY_PID", "2025121809351743") + self.key = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89") + self.submit_url = os.getenv("ZPAY_SUBMIT_URL", "https://zpayz.cn/submit.php") + self.mapi_url = os.getenv("ZPAY_MAPI_URL", "https://zpayz.cn/mapi.php") + self.query_url = os.getenv("ZPAY_QUERY_URL", "https://zpayz.cn/api.php") + + def sign(self, params: dict[str, Any]) -> str: + filtered = { + key: value + for key, value in params.items() + if key not in {"sign", "sign_type"} and value is not None and str(value) != "" + } + raw = "&".join(f"{key}={filtered[key]}" for key in sorted(filtered)) + self.key + return _md5(raw) + + def create_order( + self, + *, + order_id: str, + name: str, + amount_yuan: str, + channel: str, + notify_url: str, + return_url: str, + client_ip: str = "", + ) -> dict[str, Any]: + params: dict[str, Any] = { + "pid": self.pid, + "type": resolve_channel(channel), + "out_trade_no": order_id, + "notify_url": notify_url, + "return_url": return_url, + "name": name, + "money": amount_yuan, + "sign_type": "MD5", + } + if client_ip and not _is_internal_ip(client_ip): + params["clientip"] = client_ip + params["sign"] = self.sign(params) + return { + "status": "ok", + "provider": self.name, + "params": params, + "pay_url": self.submit_url, + "submit_method": "POST", + } + + async def create_order_api( + self, + *, + order_id: str, + name: str, + amount_yuan: str, + channel: str, + notify_url: str, + return_url: str, + client_ip: str = "", + device: str = "pc", + ) -> dict[str, Any]: + params: dict[str, Any] = { + "pid": self.pid, + "type": resolve_channel(channel), + "out_trade_no": order_id, + "notify_url": notify_url, + "return_url": return_url, + "name": name, + "money": amount_yuan, + "device": device, + "sign_type": "MD5", + } + if client_ip and not _is_internal_ip(client_ip): + params["clientip"] = client_ip + params["sign"] = self.sign(params) + + try: + async with httpx.AsyncClient(timeout=15.0, trust_env=False) as client: + res = await client.post(self.mapi_url, data=params) + except Exception as exc: + return {"status": "error", "provider": self.name, "msg": str(exc)} + + result: Any + try: + result = res.json() + except Exception: + result = {} + if isinstance(result, str): + try: + result = json.loads(result) + except Exception: + result = {} + if not isinstance(result, dict): + result = {} + msg = result.get("msg") + if isinstance(msg, str) and msg.strip().startswith("{"): + try: + inner = json.loads(msg) + if isinstance(inner, dict): + result = inner + except Exception: + pass + try: + code = int(float(result.get("code", 0))) + except (TypeError, ValueError): + code = 0 + if code == 1: + return { + "status": "ok", + "provider": self.name, + "payurl": result.get("payurl"), + "payurl2": result.get("payurl2"), + "qrcode": result.get("qrcode"), + "img": result.get("img"), + "trade_no": result.get("trade_no"), + } + return {"status": "error", "provider": self.name, "msg": result.get("msg") or res.text[:500]} + + def verify_notify(self, data: dict[str, Any]) -> bool: + return self.sign(data) == str(data.get("sign", "")) + + async def query_order_status(self, order_id: str, timeout: float = 8.0) -> dict[str, Any]: + try: + async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: + res = await client.get( + self.query_url, + params={"act": "order", "pid": self.pid, "key": self.key, "out_trade_no": order_id}, + ) + data = res.json() if res.is_success else {} + except Exception as exc: + return {"paid": False, "error": str(exc)} + try: + code = int(float(data.get("code", 0))) + status = int(float(data.get("status", 0))) + except (TypeError, ValueError): + return {"paid": False, "msg": data.get("msg", "查询失败")} + pay_price = data.get("money") or data.get("pay_price") or "" + return {"paid": code == 1 and status == 1, "status": status, "pay_price": str(pay_price)} + + +class XorPayProvider: + name = "xorpay" + + def __init__(self) -> None: + self.aid = os.getenv("XORPAY_AID", "8220") + self.secret = os.getenv("XORPAY_SECRET", "afcacd99570945f88de62624aaa3578e") + self.cashier_url = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier").rstrip("/") + self.query_url = os.getenv("XORPAY_QUERY_URL", "https://xorpay.com/api/query2").rstrip("/") + + def create_order(self, *, order_id: str, name: str, amount_yuan: str, notify_url: str) -> dict[str, Any]: + params = { + "name": name, + "pay_type": "jsapi", + "price": amount_yuan, + "order_id": order_id, + "notify_url": notify_url, + } + params["sign"] = _md5( + params["name"] + params["pay_type"] + params["price"] + params["order_id"] + params["notify_url"] + self.secret + ) + return {"status": "ok", "provider": self.name, "params": params, "pay_url": f"{self.cashier_url}/{self.aid}"} + + def verify_notify(self, data: dict[str, Any]) -> bool: + raw = ( + str(data.get("aoid", "")) + + str(data.get("order_id", "")) + + str(data.get("pay_price", "")) + + str(data.get("pay_time", "")) + + self.secret + ) + return _md5(raw) == str(data.get("sign", "")) + + async def query_order_status(self, order_id: str, timeout: float = 8.0) -> dict[str, Any]: + sign = _md5(order_id + self.secret) + try: + async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: + res = await client.get(f"{self.query_url}/{self.aid}", params={"order_id": order_id, "sign": sign}) + data = res.json() if res.is_success else {} + status = str(data.get("status", "")).lower() + return { + "paid": status in {"payed", "success"}, + "status": status, + "pay_price": data.get("pay_price") or data.get("price") or "", + } + except Exception as exc: + return {"paid": False, "error": str(exc)} + + +def provider_for(device: str = "pc", requested: str = "", user_agent: str = "") -> ZPayProvider | XorPayProvider: + req = requested.strip().lower() + default = os.getenv("PAYMENT_PROVIDER", "zpay").lower() + if req == "xorpay": + return XorPayProvider() + if req == "zpay": + return ZPayProvider() + if default == "xorpay": + return XorPayProvider() + if device.lower() == "wechat" or "micromessenger" in user_agent.lower(): + return XorPayProvider() + return ZPayProvider() diff --git a/backend/app/services/payment_service.py b/backend/app/services/payment_service.py new file mode 100644 index 0000000..288aba2 --- /dev/null +++ b/backend/app/services/payment_service.py @@ -0,0 +1,96 @@ +"""Payment helpers — ZPay/XorPay from NomadCNA + DEV_AUTO_PAY toggle.""" + +from __future__ import annotations + +import os +from urllib.parse import urlencode + +from app.services.payment_providers import XorPayProvider, ZPayProvider, provider_for + +DEV_AUTO_PAY = os.getenv("DEV_AUTO_PAY", "false").lower() in ("1", "true", "yes") +PAYMENT_JOIN_AMOUNT = int(os.getenv("PAYMENT_JOIN_AMOUNT", "10000")) # fen (100 yuan) +PAYMENT_DEFAULT_AMOUNT = int(os.getenv("PAYMENT_DEFAULT_AMOUNT", "10000")) +SITE_BASE = os.getenv("SITE_BASE_URL", "https://nomadweb.nomadro.com").rstrip("/") +PAY_NOTIFY_URL = os.getenv("PAY_NOTIFY_URL", f"{SITE_BASE}/api/v1/pay/zpay_notify") + + +def amount_yuan(amount_fen: int) -> str: + return f"{amount_fen / 100:.2f}" + + +def notify_url_for(provider_name: str) -> str: + if provider_name == "xorpay": + return os.getenv("XORPAY_NOTIFY_URL", f"{SITE_BASE}/api/v1/pay/xorpay_notify") + return os.getenv("ZPAY_NOTIFY_URL", PAY_NOTIFY_URL) + + +async def create_pay_redirect( + order_id: str, + amount: int, + return_url: str, + provider: str = "", + *, + channel: str = "alipay", + device: str = "pc", + user_agent: str = "", + client_ip: str = "", +) -> str: + if DEV_AUTO_PAY: + sep = "&" if "?" in return_url else "?" + return f"{return_url}{sep}order_id={order_id}&status=paid" + + pay_provider = provider_for(device, provider or os.getenv("PAYMENT_PROVIDER", "zpay"), user_agent) + yuan = amount_yuan(amount) + name = "nomadro会员" + notify = notify_url_for(pay_provider.name) + + if isinstance(pay_provider, XorPayProvider): + created = pay_provider.create_order( + order_id=order_id, name=name, amount_yuan=yuan, notify_url=notify, + ) + return f"{created['pay_url']}?{urlencode(created['params'])}" + + api_result = await pay_provider.create_order_api( + order_id=order_id, + name=name, + amount_yuan=yuan, + channel=channel, + notify_url=notify, + return_url=return_url, + client_ip=client_ip, + device=device, + ) + if api_result.get("status") == "ok": + pay_url = api_result.get("payurl2") if device == "h5" and api_result.get("payurl2") else api_result.get("payurl") + if pay_url: + return str(pay_url) + + created = pay_provider.create_order( + order_id=order_id, + name=name, + amount_yuan=yuan, + channel=channel, + notify_url=notify, + return_url=return_url, + client_ip=client_ip, + ) + return f"{created['pay_url']}?{urlencode(created['params'])}" + + +async def query_order_paid(order_id: str) -> bool: + zpay, xorpay = await ZPayProvider().query_order_status(order_id), await XorPayProvider().query_order_status(order_id) + return bool(zpay.get("paid") or xorpay.get("paid")) + + +def verify_notify(provider: str, data: dict) -> bool: + if DEV_AUTO_PAY: + return True + if provider == "xorpay": + return XorPayProvider().verify_notify(data) + return ZPayProvider().verify_notify(data) + + +def parse_notify_order_id(provider: str, data: dict) -> str: + if provider == "xorpay": + return str(data.get("order_id") or "") + return str(data.get("out_trade_no") or data.get("order_id") or "") diff --git a/backend/app/services/recommendations.py b/backend/app/services/recommendations.py new file mode 100644 index 0000000..83e509d --- /dev/null +++ b/backend/app/services/recommendations.py @@ -0,0 +1,125 @@ +"""Next-stop recommendation scoring — ported from NomadCNA logic, adapted to nomadro destinations.""" + +from __future__ import annotations + +from typing import Any + + +def _parse_nomads_count(value: str) -> int: + digits = "".join(ch for ch in str(value) if ch.isdigit()) + return int(digits) if digits else 0 + + +def score_destination( + dest: dict[str, Any], + *, + budget: int, + internet: int, + climate: str, + tags: list[str], + priority: str = "balanced", +) -> tuple[float, list[str]]: + """Return (score, match_reasons) for a destination dict.""" + cost = int(dest.get("cost") or 0) + speed = int(dest.get("speed") or 0) + temp = int(dest.get("temperature") or 20) + rating = float(dest.get("rating") or 0) + nomads = _parse_nomads_count(dest.get("nomads_count") or "0") + highlights = dest.get("highlights") or [] + tag = str(dest.get("tag") or "") + reasons: list[str] = [] + + score = rating * 8 + + # Budget fit (monthly cost in CNY-ish units) + if cost <= budget: + score += 28 + reasons.append("预算内") + elif cost <= budget * 1.2: + score += 14 + reasons.append("略超预算") + else: + score += max(0, 10 - (cost - budget) / 800) + + # Internet + if speed >= internet: + score += 22 + reasons.append("网络达标") + else: + score += max(0, 22 - (internet - speed) / 4) + reasons.append("需确认住处网络") + + # Climate preference + if climate == "warm": + score += 18 if temp >= 24 else max(0, 18 - (24 - temp) * 2) + if temp >= 24: + reasons.append("气候偏暖") + elif climate == "cool": + score += 18 if temp <= 18 else max(0, 18 - (temp - 18) * 2) + if temp <= 18: + reasons.append("气候清爽") + else: # mild + score += 18 if 18 <= temp <= 26 else max(0, 18 - abs(temp - 22) * 2) + if 18 <= temp <= 26: + reasons.append("气候温和") + + # Tag overlap (NomadCNA-style lifestyle tags) + dest_tags = set(highlights + [tag]) + overlap = [t for t in tags if any(t in dt or dt in t for dt in dest_tags)] + if overlap: + score += min(15, len(overlap) * 5) + reasons.append(f"标签匹配:{overlap[0]}") + + # Community density + if nomads >= 8000: + score += 12 + reasons.append("社区活跃") + elif nomads >= 3000: + score += 6 + + # Priority boost + if priority == "cost": + score += (14000 - cost) / 120 + elif priority == "speed": + score += speed / 5 + elif priority == "community": + score += nomads / 400 + + if not reasons: + reasons.append("综合评分推荐") + + return round(score, 1), reasons[:3] + + +def recommend_destinations( + destinations: list[dict[str, Any]], + *, + budget: int = 8000, + internet: int = 50, + climate: str = "mild", + tags: list[str] | None = None, + priority: str = "balanced", + limit: int = 12, +) -> list[dict[str, Any]]: + tags = tags or [] + scored: list[tuple[float, dict[str, Any], list[str]]] = [] + for dest in destinations: + s, reasons = score_destination( + dest, + budget=budget, + internet=internet, + climate=climate, + tags=tags, + priority=priority, + ) + scored.append((s, dest, reasons)) + + scored.sort(key=lambda x: x[0], reverse=True) + items = [] + for s, dest, reasons in scored[:limit]: + items.append({ + **dest, + "match_score": s, + "match_reasons": reasons, + }) + return items diff --git a/backend/app/services/social_store.py b/backend/app/services/social_store.py new file mode 100644 index 0000000..13d6e21 --- /dev/null +++ b/backend/app/services/social_store.py @@ -0,0 +1,431 @@ +"""Social graph: profiles, swipes, matches, DMs — file-backed store.""" + +from __future__ import annotations + +import json +import secrets +import time +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import Any + +from app.data.social_profiles import CANDIDATE_PROFILES, MATCH_INTENTS + +STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "social_store.json" + +LIKE_ACTIONS = frozenset({"like", "right", "superlike"}) +FREE_SWIPE_DAILY_LIMIT = 25 +VIP_SWIPE_LIMIT = 9999 + +_profiles: dict[str, dict] = {} # user_id -> profile +_swipes: list[dict] = [] +_matches: list[dict] = [] +_conversations: list[dict] = [] +_messages: list[dict] = [] +_memberships: dict[str, dict] = {} # user_id -> {expires_at, plan} +_orders: dict[str, dict] = {} + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _today_key() -> str: + return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d") + + +def _persist() -> None: + STORE_PATH.parent.mkdir(parents=True, exist_ok=True) + STORE_PATH.write_text( + json.dumps({ + "profiles": _profiles, + "swipes": _swipes, + "matches": _matches, + "conversations": _conversations, + "messages": _messages, + "memberships": _memberships, + "orders": _orders, + }, ensure_ascii=False), + encoding="utf-8", + ) + + +def _restore() -> None: + global _profiles, _swipes, _matches, _conversations, _messages, _memberships, _orders + if not STORE_PATH.exists(): + return + try: + data = json.loads(STORE_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + _profiles = data.get("profiles") or {} + _swipes = data.get("swipes") or [] + _matches = data.get("matches") or [] + _conversations = data.get("conversations") or [] + _messages = data.get("messages") or [] + _memberships = data.get("memberships") or {} + _orders = data.get("orders") or {} + + +_restore() + + +def _reload() -> None: + _restore() + + +def is_vip(user_id: str) -> bool: + _reload() + m = _memberships.get(user_id) + if not m: + return False + exp = m.get("expires_at") or 0 + return exp > int(time.time()) + + +def ensure_membership(user_id: str, days: int = 365) -> None: + _memberships[user_id] = { + "plan": "vip", + "expires_at": int(time.time()) + days * 86400, + "updated_at": _now_iso(), + } + _persist() + + +def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict: + if user_id not in _profiles: + _profiles[user_id] = { + "userId": user_id, + "name": name, + "location": extra.get("location", "全球"), + "citySlug": extra.get("citySlug", ""), + "gender": extra.get("gender", ""), + "single": extra.get("single", ""), + "bio": extra.get("bio", ""), + "photo": extra.get("photo", "🧑‍💻"), + "tags": extra.get("tags", []), + "lookingFor": extra.get("lookingFor", ["friends", "explore"]), + "createdAt": _now_iso(), + } + _persist() + return _profiles[user_id] + + +def join_member(user_id: str, name: str, payload: dict) -> dict: + _reload() + profile = get_or_create_profile( + user_id, + name, + location=payload.get("city", "全球"), + citySlug=payload.get("citySlug", ""), + gender=payload.get("gender", ""), + single=payload.get("single", ""), + bio=payload.get("bio", ""), + lookingFor=payload.get("lookingFor", ["friends", "explore"]), + photo=payload.get("photo", "🧑‍💻"), + ) + _persist() + return profile + + +def _profile_looking_for(record: dict) -> list[str]: + raw = record.get("lookingFor") or [] + cleaned = [x for x in raw if x in MATCH_INTENTS] + return cleaned or ["friends", "explore"] + + +def list_candidates( + user_id: str, + intent: str = "friends", + city: str = "", + gender: str = "", + single: str = "", + exclude_swiped: bool = True, +) -> list[dict]: + _reload() + my_profile = _profiles.get(user_id) + swiped_ids = set() + if exclude_swiped: + for s in _swipes: + if s.get("userId") == user_id: + swiped_ids.add(s.get("profileId")) + + pool = list(CANDIDATE_PROFILES) + if my_profile: + pool = [p for p in pool if p.get("userId") != user_id] + + results = [] + for p in pool: + if p["id"] in swiped_ids: + continue + if intent and intent not in _profile_looking_for(p): + continue + if city and city not in (p.get("location") or "") and city != p.get("citySlug"): + continue + if gender and is_vip(user_id) and gender and p.get("gender") != gender: + continue + if single and is_vip(user_id) and single and p.get("single") != single: + continue + results.append({**p, "intent": intent}) + return results + + +def swipe_count_today(user_id: str) -> int: + today = _today_key() + return sum(1 for s in _swipes if s.get("userId") == user_id and s.get("date") == today) + + +def get_quota(user_id: str) -> dict: + _reload() + vip = is_vip(user_id) + limit = VIP_SWIPE_LIMIT if vip else FREE_SWIPE_DAILY_LIMIT + used = swipe_count_today(user_id) + return {"vip": vip, "limit": limit, "used": used, "remaining": max(0, limit - used)} + + +def _pair_key(a: str, b: str) -> str: + return "|".join(sorted([a, b])) + + +def _has_reciprocal_like(target_user_id: str, my_profile_id: str) -> bool: + target_profile = next((p for p in CANDIDATE_PROFILES if p.get("userId") == target_user_id), None) + if not target_profile: + return False + target_pid = target_profile["id"] + for s in _swipes: + if s.get("userId") == target_user_id and s.get("profileId") == my_profile_id: + if s.get("action") in LIKE_ACTIONS: + return True + return False + + +def _ensure_match(user_id: str, profile: dict, intent: str) -> dict | None: + target_uid = profile.get("userId") + if not target_uid: + return None + pk = _pair_key(user_id, target_uid) + for m in _matches: + if m.get("pairKey") == pk: + return m + conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True) + match = { + "id": secrets.token_hex(6), + "pairKey": pk, + "userAId": user_id, + "userBId": target_uid, + "profileAId": _profiles.get(user_id, {}).get("id", ""), + "profileBId": profile.get("id"), + "intent": intent, + "conversationId": conv["id"], + "matchedAt": _now_iso(), + } + _matches.append(match) + _persist() + return match + + +def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", match: bool = False) -> dict: + pk = _pair_key(user_a, user_b) + for c in _conversations: + if c.get("pairKey") == pk: + return c + conv = { + "id": secrets.token_hex(8), + "pairKey": pk, + "type": "match" if match else "direct", + "userAId": user_a, + "userBId": user_b, + "intent": intent, + "lastMessageAt": _now_iso(), + "lastMessagePreview": "", + "readState": {user_a: _now_iso(), user_b: _now_iso()}, + } + _conversations.append(conv) + _persist() + return conv + + +def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dict: + _reload() + if action not in LIKE_ACTIONS and action != "dislike": + action = "dislike" + if action == "superlike" and not is_vip(user_id): + return {"ok": False, "error": "vip_required"} + q = get_quota(user_id) + if not q["vip"] and q["remaining"] <= 0 and action in LIKE_ACTIONS: + return {"ok": False, "error": "quota_exceeded"} + + for s in _swipes: + if s.get("userId") == user_id and s.get("profileId") == profile_id: + return {"ok": False, "error": "duplicate"} + + profile = next((p for p in CANDIDATE_PROFILES if p["id"] == profile_id), None) + if not profile: + return {"ok": False, "error": "not_found"} + + _swipes.append({ + "id": secrets.token_hex(6), + "userId": user_id, + "profileId": profile_id, + "action": action, + "intent": intent, + "date": _today_key(), + "createdAt": _now_iso(), + }) + _persist() + + matched = None + if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id): + matched = _ensure_match(user_id, profile, intent) + if matched: + from app.services import community_store + + my_name = _profiles.get(user_id, {}).get("name", "游民") + peer_name = profile.get("name", "游民") + conv_id = matched.get("conversationId", "") + link = f"/chat/{conv_id}" if conv_id else "/chat" + community_store.add_notification( + user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link + ) + peer_uid = profile.get("userId") + if peer_uid: + community_store.add_notification( + peer_uid, "匹配成功 🎉", f"你和 {my_name} 互相喜欢了,快去聊天吧", link + ) + + return { + "ok": True, + "matched": bool(matched), + "match": matched, + } + + +def get_public_profile(user_id: str) -> dict | None: + _reload() + p = _profiles.get(user_id) + if not p: + return None + return {**p, "vip": is_vip(user_id)} + + +def undo_last_swipe(user_id: str) -> dict: + _reload() + for i in range(len(_swipes) - 1, -1, -1): + if _swipes[i].get("userId") == user_id: + removed = _swipes.pop(i) + _persist() + return {"ok": True, "removed": removed} + return {"ok": False, "error": "nothing_to_undo"} + + +def list_likes(user_id: str) -> list[dict]: + _reload() + liked_ids = [s["profileId"] for s in _swipes if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS] + return [p for p in CANDIDATE_PROFILES if p["id"] in liked_ids] + + +def list_mutual(user_id: str) -> list[dict]: + _reload() + items = [] + for m in _matches: + if user_id in (m.get("userAId"), m.get("userBId")): + peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"] + peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None) + items.append({**m, "peer": peer, "conversationId": m.get("conversationId")}) + return items + + +def list_conversations(user_id: str) -> list[dict]: + _reload() + items = [] + for c in _conversations: + if user_id not in (c.get("userAId"), c.get("userBId")): + continue + peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] + peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None) + read_at = (c.get("readState") or {}).get(user_id, "") + unread = 1 if c.get("lastMessageAt", "") > read_at else 0 + items.append({ + **c, + "peer": peer, + "unreadCount": unread, + }) + items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True) + return items + + +def get_conversation(conv_id: str, user_id: str) -> dict | None: + for c in _conversations: + if c["id"] == conv_id and user_id in (c.get("userAId"), c.get("userBId")): + peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] + peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None) + return {**c, "peer": peer} + return None + + +def list_messages(conv_id: str, user_id: str, limit: int = 50) -> list[dict]: + _reload() + conv = get_conversation(conv_id, user_id) + if not conv: + return [] + msgs = [m for m in _messages if m.get("conversationId") == conv_id] + msgs.sort(key=lambda x: x.get("createdAt", "")) + if conv.get("readState") is not None: + conv["readState"][user_id] = _now_iso() + _persist() + return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]] + + +def send_message(conv_id: str, user_id: str, body: str) -> dict | None: + conv = get_conversation(conv_id, user_id) + if not conv or not body.strip(): + return None + body = body.strip()[:2000] + msg = { + "id": secrets.token_hex(8), + "conversationId": conv_id, + "senderId": user_id, + "body": body, + "createdAt": _now_iso(), + } + _messages.append(msg) + for c in _conversations: + if c["id"] == conv_id: + c["lastMessageAt"] = _now_iso() + c["lastMessagePreview"] = body[:80] + break + _persist() + return {**msg, "mine": True} + + +def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool = False) -> dict: + order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{secrets.token_hex(2)}" + status = "paid" if dev_auto_pay else "pending" + order = { + "id": order_id, + "userId": user_id, + "payType": pay_type, + "amount": amount, + "status": status, + "createdAt": _now_iso(), + } + _orders[order_id] = order + if dev_auto_pay: + ensure_membership(user_id) + _persist() + return order + + +def get_order(order_id: str) -> dict | None: + _reload() + return _orders.get(order_id) + + +def mark_order_paid(order_id: str) -> dict | None: + order = _orders.get(order_id) + if not order: + return None + order["status"] = "paid" + ensure_membership(order["userId"]) + _persist() + return order diff --git a/deploy/caddy/lounge.nomadro.com.caddy b/deploy/caddy/lounge.nomadro.com.caddy new file mode 100644 index 0000000..f8c84f4 --- /dev/null +++ b/deploy/caddy/lounge.nomadro.com.caddy @@ -0,0 +1,12 @@ +lounge.nomadro.com { + encode gzip + reverse_proxy https://lounge.nomadro.cn { + header_up Host lounge.nomadro.cn + transport http { + tls_insecure_skip_verify + } + } + header { + Content-Security-Policy "frame-ancestors 'self' https://nomadweb.nomadro.com https://nomadro.cn https://www.nomadro.cn http://localhost:3001 http://127.0.0.1:3001" + } +} diff --git a/deploy/caddy/mirotalk.nomadro.com.caddy b/deploy/caddy/mirotalk.nomadro.com.caddy new file mode 100644 index 0000000..2d18c5f --- /dev/null +++ b/deploy/caddy/mirotalk.nomadro.com.caddy @@ -0,0 +1,12 @@ +mirotalk.nomadro.com { + encode gzip + reverse_proxy https://mirotalk.nomadro.cn { + header_up Host mirotalk.nomadro.cn + transport http { + tls_insecure_skip_verify + } + } + header { + Content-Security-Policy "frame-ancestors 'self' https://nomadweb.nomadro.com https://nomadro.cn https://www.nomadro.cn http://localhost:3001 http://127.0.0.1:3001" + } +} diff --git a/deploy/nomadro-api.env.example b/deploy/nomadro-api.env.example new file mode 100644 index 0000000..c1e2ae2 --- /dev/null +++ b/deploy/nomadro-api.env.example @@ -0,0 +1,22 @@ +# Copy to nomadro-api.env on the server — DO NOT commit real keys +DEV_AUTO_PAY=false +PAYMENT_PROVIDER=zpay +PAYMENT_JOIN_AMOUNT=10000 +PAYMENT_DEFAULT_AMOUNT=10000 +SITE_BASE_URL=https://nomadweb.nomadro.com + +ZPAY_PID= +ZPAY_KEY= +ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php +ZPAY_MAPI_URL=https://zpayz.cn/mapi.php +ZPAY_QUERY_URL=https://zpayz.cn/api.php +ZPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/zpay_notify + +XORPAY_AID= +XORPAY_SECRET= +XORPAY_CASHIER_URL=https://xorpay.com/api/cashier +XORPAY_QUERY_URL=https://xorpay.com/api/query2 +XORPAY_NOTIFY_URL=https://nomadweb.nomadro.com/api/v1/pay/xorpay_notify + +MIROTALK_URL=https://mirotalk.nomadro.com +LOUNGE_URL=https://lounge.nomadro.com diff --git a/deploy/systemd/nomadro-api.service b/deploy/systemd/nomadro-api.service index 022e5d6..f235a44 100644 --- a/deploy/systemd/nomadro-api.service +++ b/deploy/systemd/nomadro-api.service @@ -5,6 +5,7 @@ After=network.target [Service] Type=simple WorkingDirectory=/opt/nomadweb/backend +EnvironmentFile=/opt/nomadweb/deploy/nomadro-api.env Environment=POCKETBASE_URL=http://127.0.0.1:8090 Environment=POCKETBASE_ADMIN_EMAIL=admin@nomadro.com Environment=POCKETBASE_ADMIN_PASSWORD=admin123456 diff --git a/frontend/content/book/book.json b/frontend/content/book/book.json new file mode 100644 index 0000000..33d3b8e --- /dev/null +++ b/frontend/content/book/book.json @@ -0,0 +1,73 @@ +{ + "title": "游牧代码", + "subtitle": "数字游民与 Homelab 生存指南", + "author": "林远", + "description": "一个程序员离开工位、带上电脑环游世界,同时把「家」装进机柜的故事。", + "tagline": "在任意城市,过你想要的生活。", + "coverGradient": "from-violet-950 via-fuchsia-900 to-orange-900", + "readingMinutes": 45, + "viralTags": ["数字游民", "Homelab", "远程办公", "Tailscale", "自由职业"], + "stats": [ + { "emoji": "🌍", "value": "7", "label": "章精华" }, + { "emoji": "⏱️", "value": "45", "label": "分钟读完" }, + { "emoji": "📍", "value": "3+", "label": "城市灵感" }, + { "emoji": "🔥", "value": "12", "label": "书摘片段" } + ], + "hooks": [ + { "emoji": "💻", "text": "远程办公的第一周,效率奇高。第二周,你开始分不清「工作结束」和「生活开始」。" }, + { "emoji": "🌴", "text": "清迈的周二早晨:flat white + VS Code,旁边有人在写旅行博客。" }, + { "emoji": "🗄️", "text": "Homelab 给你的锚,不是地理上的,是数字上的——文件在,服务在,随时等你回来。" }, + { "emoji": "🔑", "text": "Tailscale:无论 IP 怎么变,你的设备永远在「同一个局域网」。" } + ], + "chapters": [ + { + "slug": "00-preface", + "title": "序:我为什么开始游牧", + "emoji": "✈️", + "hook": "我想要的不是更好的工位,而是可以选择坐在哪里。", + "shareQuote": "数字游民是一种工作方式:产出不绑定在某个地理坐标上。" + }, + { + "slug": "01-first-remote", + "title": "第一次远程办公的那个周五", + "emoji": "🏠", + "hook": "穿着睡衣开 Zoom,可能是当代最魔幻的上班方式。", + "shareQuote": "异步沟通:不是「随时在线」,而是「明确预期」。" + }, + { + "slug": "02-chiangmai", + "title": "清迈的周二早晨", + "emoji": "☕", + "hook": "游牧不是一个人流浪,是在流动中找到同类。", + "shareQuote": "在这里,旅游和工作本来就不矛盾。" + }, + { + "slug": "03-gear", + "title": "行囊里真正重要的东西", + "emoji": "🎒", + "hook": "带多了是负担,带少了会后悔——这是游牧者的永恒命题。", + "shareQuote": "网络,比护照更重要。" + }, + { + "slug": "04-homelab", + "title": "Homelab:把家装进一个机柜", + "emoji": "🖥️", + "hook": "几百块的旧电脑,可以成为整个数字生活的中枢。", + "shareQuote": "完美是优秀的敌人。先跑起来,再迭代。" + }, + { + "slug": "05-network", + "title": "无论在哪,都能连回家", + "emoji": "🔗", + "hook": "人在巴厘岛,手在家里。", + "shareQuote": "Homelab 服务默认不暴露公网——这是安全感的前提。" + }, + { + "slug": "06-epilogue", + "title": "终章:自由是一种日常", + "emoji": "🌅", + "hook": "自由不是不用工作,而是可以选择在哪里、以什么节奏工作。", + "shareQuote": "小步验证,比宏大计划更可靠。" + } + ] +} diff --git a/frontend/content/book/chapters/00-preface.md b/frontend/content/book/chapters/00-preface.md new file mode 100644 index 0000000..88402d4 --- /dev/null +++ b/frontend/content/book/chapters/00-preface.md @@ -0,0 +1,31 @@ +# 序:我为什么开始游牧 + +二〇二三年秋天,我在公司工位上坐满了三年。 + +显示器左边是 Jira,右边是 Slack,中间是我已经改过十七遍的需求文档。某个加班的深夜,我刷到一张清迈咖啡馆的照片——阳光从百叶窗缝里漏进来,一台 MacBook 和一杯冰美式,背景里有人在用泰语点单。 + +那一刻我突然意识到:**我想要的不是更好的工位,而是可以选择坐在哪里。** + +--- + +## 这不是逃离 + +很多人把数字游民理解成「辞职、环游世界、发 Instagram」。不是的。 + +数字游民是一种**工作方式**:你的产出不绑定在某个地理坐标上。你可以在上海写代码,也可以在里斯本写代码——只要网络稳定、时区能协调、交付不掉链子。 + +Homelab 则是另一种自由:无论你在哪个时区醒来,你的文件、你的服务、你的数据,都在一个你完全掌控的地方等着你。 + +这本书写的,就是这两件事如何交织在一起。 + +--- + +## 你会读到什么 + +这不是技术文档,也不是旅行攻略。这是一个普通程序员,从第一次远程办公,到在东南亚租下月付公寓,再到把家里那台旧服务器变成「随时能连回去的数字基地」的真实过程。 + +有踩过的坑,也有意外的好运。 + +如果你也在想:**能不能不辞职,但换一种活法?** + +那就从下一章开始吧。 diff --git a/frontend/content/book/chapters/01-first-remote.md b/frontend/content/book/chapters/01-first-remote.md new file mode 100644 index 0000000..ac28075 --- /dev/null +++ b/frontend/content/book/chapters/01-first-remote.md @@ -0,0 +1,39 @@ +# 第一次远程办公的那个周五 + +周五下午三点,Team Leader 在群里发了一条消息: + +> 「下周开始,技术组全员 remote,有问题的私聊 HR。」 + +我盯着这条消息看了很久。不是担心,是一种奇怪的安静——像考试结束铃响之前,你已经写完了最后一道题。 + +--- + +## 第一天 + +周一早上九点,我穿着睡衣坐在餐桌前,打开 Zoom。 + +同事们的脸出现在格子里,有人还在刷牙,有人背景是乱糟糟的卧室。会议结束后,我泡了杯咖啡,走到阳台。 + +楼下有人在遛狗,阳光很好。 + +**原来工作日也可以是这样。** + +--- + +## 效率的幻觉 + +远程的第一周,我效率奇高。没有通勤,没有工位旁的闲聊,没有「开完这个会再写代码」的拖延。 + +第二周,我开始分不清「工作结束」和「生活开始」的边界。晚上十一点,Slack 还亮着绿点。周末,邮箱里躺着三条「不急,周一前看就行」的消息。 + +我学到一个词:**异步沟通**。不是「随时在线」,而是「明确预期」——什么时候回复、什么算紧急、什么可以等到下一个工作日。 + +--- + +## 那个周五的后续 + +三个月后,HR 发邮件说办公室租约到期,技术组永久 remote。 + +我没有回「太好了」,也没有回「怎么办」。我只是打开地图,搜索了「清迈 月租 公寓 WiFi」。 + +有些改变,不是突然发生的。是你某天回头看,发现路已经走了一半。 diff --git a/frontend/content/book/chapters/02-chiangmai.md b/frontend/content/book/chapters/02-chiangmai.md new file mode 100644 index 0000000..c45d3e6 --- /dev/null +++ b/frontend/content/book/chapters/02-chiangmai.md @@ -0,0 +1,44 @@ +# 清迈的周二早晨 + +七点半,我被摩托车声叫醒。 + +不是闹钟——是楼下送菜的小贩,喇叭里循环播放泰语,我一个字也听不懂,但已经习惯了。这是我在清迈的第三周。 + +--- + +## 咖啡馆即办公室 + +我住的公寓步行十分钟有一家叫 **Graph Table** 的咖啡馆。二层靠窗的位置,能看到一条安静的小巷。WiFi 密码写在收银台的小黑板上,下载速度稳定在 80 Mbps 左右。 + +我点一杯 flat white,打开 VS Code。 + +旁边的荷兰设计师在画 Figma,日本背包客在写旅行博客。没有人问「你是来旅游的还是来工作的」——在这里,这两件事本来就不矛盾。 + +--- + +## 时区的艺术 + +我在东七区,团队大部分人在东八区。这意味着: + +- 我的「早上十点 standup」,是清迈的九点 +- 他们的「下班前 review」,是我的下午五点——刚好收工去夜市 + +我学会把深度工作放在本地上午,把会议和沟通压缩到下午。不是妥协,是**设计**。 + +--- + +## 孤独与社群 + +数字游民最容易被忽视的问题,不是签证,不是网络,是**孤独**。 + +我在 Nomad List 的 Discord 里认识了几个常驻清迈的开发者。我们每周三晚上在 Nimman 区聚餐,聊技术、聊签证、聊哪个 co-working 空间的空调更冷。 + +**游牧不是一个人流浪,是在流动中找到同类。** + +--- + +## 周二的结尾 + +晚上八点,我骑摩托车回公寓。路过塔佩门,游客正在拍夜景。我停了一分钟,然后继续往前。 + +明天还有 standup。但此刻,清迈的风是温的。 diff --git a/frontend/content/book/chapters/03-gear.md b/frontend/content/book/chapters/03-gear.md new file mode 100644 index 0000000..0405063 --- /dev/null +++ b/frontend/content/book/chapters/03-gear.md @@ -0,0 +1,45 @@ +# 行囊里真正重要的东西 + +出发前,我在 Reddit 的 r/digitalnomad 翻了三天装备帖。最后发现:**带多了是负担,带少了会后悔。** + +这是我在路上一年后,精简再精简的清单。 + +--- + +## 电脑:唯一不能妥协的 + +一台 14 寸 MacBook Pro,M 系列芯片,16GB 内存。不是最新款,但足够跑 Docker、开十几个 Chrome 标签、同时 Zoom 和写代码。 + +外接显示器?我试过,最后卖了。游牧的核心是**移动**,不是把工位复制到每一个城市。 + +--- + +## 网络:比护照更重要 + +我随身带三样东西: + +1. **本地 SIM 卡** — 落地当天买,流量包月 +2. **便携路由器** — GL.iNet 那类,支持 4G 备用 +3. **Tailscale** — 后面会细讲,这是连回 Homelab 的钥匙 + +在清迈、巴厘岛、里斯本,我学会的第一件事永远是:先测 WiFi,再 unpack。 + +--- + +## 其他:能省则省 + +- 降噪耳机:飞机、咖啡馆、合租房必备 +- 万能转换插头:一个就够 +- 折叠键盘:可选,我最后只用笔记本自带 + +衣服?一个 40L 背包,七天的量,到哪里都能洗。 + +--- + +## 真正重要的 + +装备可以列清单,但有三样东西清单上写不下: + +**稳定的收入、可预期的工作节奏、以及一个随时可以回去的地方。** + +第三个,Homelab 会帮你解决。 diff --git a/frontend/content/book/chapters/04-homelab.md b/frontend/content/book/chapters/04-homelab.md new file mode 100644 index 0000000..2f19016 --- /dev/null +++ b/frontend/content/book/chapters/04-homelab.md @@ -0,0 +1,48 @@ +# Homelab:把家装进一个机柜 + +二〇二二年,我还住在租来的单间里,床底下藏着一台 Dell OptiPlex 小主机。 + +当时只是想跑个 Plex,把电影串流到手机。没想到,这台几百块的机器,后来成了我整个数字生活的中枢。 + +--- + +## 什么是 Homelab + +Homelab,字面意思是「家庭实验室」。简单说,就是**你自己搭的、自己管的服务器环境**。 + +不是云服务那种「按月订阅、数据在别人机房」。是你的 NAS、你的 Git 仓库、你的笔记同步、你的自动化脚本——物理上可能就在你国内那个出租屋里,逻辑上却跟着你走遍世界。 + +--- + +## 我的第一套配置 + +| 组件 | 选择 | 用途 | +|------|------|------| +| 主机 | Dell OptiPlex 7040 | 7×24 低功耗运行 | +| 系统 | Proxmox | 虚拟机管理 | +| 存储 | 4TB 机械 + 256GB SSD | 文件 + 系统 | +| 服务 | Jellyfin, Nextcloud, Gitea | 媒体、同步、代码 | + +总花费不到三千块。比一台新 iPhone 便宜,但价值在于:**它是你的。** + +--- + +## 为什么游牧者需要 Homelab + +人在清迈,文件在上海的硬盘里——听起来很蠢,对吧? + +但云盘有容量限制,有审查风险,有「服务条款变更」的隐患。Homelab 给你的,是**数据主权**和**不受地点限制的访问权**。 + +配合 Tailscale 或 WireGuard,你在任何 WiFi 下,都能像坐在家里一样访问自己的服务。 + +--- + +## 从一台旧电脑开始 + +你不需要一上来就买机柜、交换机、UPS。 + +找一台闲置笔记本,装 Linux,跑一个 Nextcloud 或 FileBrowser。能远程访问,就算 Homelab 入门了。 + +**完美是优秀的敌人。先跑起来,再迭代。** + +下一章,我们聊怎么「连回家」。 diff --git a/frontend/content/book/chapters/05-network.md b/frontend/content/book/chapters/05-network.md new file mode 100644 index 0000000..590396c --- /dev/null +++ b/frontend/content/book/chapters/05-network.md @@ -0,0 +1,59 @@ +# 无论在哪,都能连回家 + +清迈咖啡馆的 WiFi 突然断了。 + +我切换到手机热点,打开终端,输入: + +```bash +ssh nas +``` + +三秒后,我出现在家里那台 Proxmox 主机的 shell 里。文件在,服务在,就像从未离开过。 + +靠的是 **Tailscale**。 + +--- + +## 虚拟局域网 + +Tailscale 基于 WireGuard,把你在不同网络的设备,组成一个加密的虚拟局域网(Tailnet)。 + +家里跑 Tailscale 的 NAS,笔记本跑 Tailscale 客户端——无论 IP 怎么变,它们永远在「同一个局域网」里。 + +配置过程: + +1. 在 Homelab 主机安装 Tailscale +2. 笔记本、手机同样安装并登录同一账号 +3. 用 MagicDNS 给设备起名字,比如 `nas.tail-xxxxx.ts.net` + +不需要公网 IP,不需要端口转发,不需要折腾路由器。 + +--- + +## 我常用的远程场景 + +**同步文件** — Nextcloud 客户端走 Tailscale 内网,速度比公网快,也不暴露服务到互联网。 + +**看代码** — Gitea 跑在内网,浏览器打开 `http://gitea:3000`,只有 Tailnet 里的设备能访问。 + +**紧急修复** — 生产环境告警,SSH 进去,改配置,重启服务。人在巴厘岛,手在家里。 + +--- + +## 安全习惯 + +- Homelab 服务**默认不暴露公网** +- 敏感操作走 SSH 密钥,禁用密码登录 +- 定期备份到另一块硬盘,异地再备一份 + +网络自由的前提,是**安全意识**不能掉线。 + +--- + +## 连回家,意味着什么 + +游牧者容易有一种漂流感:今天这条街,明天那座城,没有锚。 + +Homelab 给我的锚,不是地理上的,是**数字上的**——我知道有一个地方,存着我的东西,跑着我的服务,随时等我回来。 + +这种确定感,和清迈的咖啡一样,是/nomad 生活的基础设施。 diff --git a/frontend/content/book/chapters/06-epilogue.md b/frontend/content/book/chapters/06-epilogue.md new file mode 100644 index 0000000..00410bd --- /dev/null +++ b/frontend/content/book/chapters/06-epilogue.md @@ -0,0 +1,49 @@ +# 终章:自由是一种日常 + +写这本书时,我人在里斯本。 + +公寓窗外是电车轨道,偶尔有铃声。桌上有半杯 bica,电脑屏幕里是未提交的 commit。 + +--- + +## 自由不是假期 + +很多人把数字游民想象成「永远在度假」。实际上,周二依然是周二,需求依然会改,bug 依然会在上线前出现。 + +**自由不是不用工作,而是可以选择在哪里、以什么节奏工作。** + +--- + +## Homelab 与游牧的共生 + +这两件事看起来方向相反: + +- 游牧是**流动** +- Homelab 是**固定** + +但组合在一起,逻辑就通了:你在物理世界移动,数字世界的「家」保持不动。像蜗牛的壳,不是负担,是随身携带的秩序。 + +--- + +## 给正在考虑的你 + +如果你还在工位上读这本书,不必立刻辞职,不必立刻买票。 + +你可以: + +1. 先和团队谈一次 remote 的可能性 +2. 用一台旧电脑搭一个最小 Homelab +3. 选一个周末,带着电脑去邻城住两晚,测试「在别处工作」的感觉 + +**小步验证,比宏大计划更可靠。** + +--- + +## 最后 + +游牧代码,写的不是「如何逃离」,而是「如何建造」——建造一种你真正想要的生活,并用技术和纪律去支撑它。 + +书到这里结束。你的下一章,在自己手里。 + +—— 林远 +写于里斯本,一个普通的周二 diff --git a/frontend/content/book/en/book.json b/frontend/content/book/en/book.json new file mode 100644 index 0000000..8defa7e --- /dev/null +++ b/frontend/content/book/en/book.json @@ -0,0 +1,73 @@ +{ + "title": "Nomad Code", + "subtitle": "A Digital Nomad & Homelab Survival Guide", + "author": "Lin Yuan", + "description": "A programmer leaves the office, travels with a laptop, and packs \"home\" into a rack.", + "tagline": "Live the life you want — from any city.", + "coverGradient": "from-violet-950 via-fuchsia-900 to-orange-900", + "readingMinutes": 45, + "viralTags": ["DigitalNomad", "Homelab", "RemoteWork", "Tailscale", "Freelance"], + "stats": [ + { "emoji": "🌍", "value": "7", "label": "Chapters" }, + { "emoji": "⏱️", "value": "45", "label": "Min read" }, + { "emoji": "📍", "value": "3+", "label": "City ideas" }, + { "emoji": "🔥", "value": "12", "label": "Quotes" } + ], + "hooks": [ + { "emoji": "💻", "text": "Week one of remote work: productivity soars. Week two: you can't tell when work ends and life begins." }, + { "emoji": "🌴", "text": "Tuesday morning in Chiang Mai: flat white + VS Code, someone writing a travel blog nearby." }, + { "emoji": "🗄️", "text": "Homelab is your anchor — not geographic, but digital. Files stay, services stay, waiting for you." }, + { "emoji": "🔑", "text": "Tailscale: no matter how IPs change, your devices stay on the same LAN." } + ], + "chapters": [ + { + "slug": "00-preface", + "title": "Preface: Why I Started Nomading", + "emoji": "✈️", + "hook": "I didn't want a better desk — I wanted to choose where I sit.", + "shareQuote": "Digital nomadism is a way of working: output isn't tied to a place." + }, + { + "slug": "01-first-remote", + "title": "That First Remote Friday", + "emoji": "🏠", + "hook": "Zoom in pajamas might be the most surreal way to work today.", + "shareQuote": "Async communication: not \"always online\", but \"clear expectations\"." + }, + { + "slug": "02-chiangmai", + "title": "Tuesday Morning in Chiang Mai", + "emoji": "☕", + "hook": "Nomading isn't wandering alone — it's finding your people on the move.", + "shareQuote": "Here, travel and work were never at odds." + }, + { + "slug": "03-gear", + "title": "What Actually Matters in Your Bag", + "emoji": "🎒", + "hook": "Pack too much and you're burdened; too little and you'll regret it.", + "shareQuote": "Connectivity matters more than passports." + }, + { + "slug": "04-homelab", + "title": "Homelab: Home in a Rack", + "emoji": "🖥️", + "hook": "An old PC can become the hub of your digital life.", + "shareQuote": "Perfect is the enemy of good. Ship first, iterate later." + }, + { + "slug": "05-network", + "title": "Connect Home from Anywhere", + "emoji": "🔗", + "hook": "Body in Bali, hands at home.", + "shareQuote": "Homelab services shouldn't face the public internet — that's the baseline for safety." + }, + { + "slug": "06-epilogue", + "title": "Epilogue: Freedom as a Daily Practice", + "emoji": "🌅", + "hook": "Freedom isn't not working — it's choosing where and at what pace you work.", + "shareQuote": "Small experiments beat grand plans." + } + ] +} diff --git a/frontend/content/book/features.json b/frontend/content/book/features.json new file mode 100644 index 0000000..7211bb3 --- /dev/null +++ b/frontend/content/book/features.json @@ -0,0 +1,94 @@ +{ + "$schema": "./features.schema.json", + "$comment": "功能模块总开关 — 关闭任何模块不影响其余功能", + + "analytics": { + "enabled": true, + "provider": "ga4", + "measurementId": "" + }, + + "ads": { + "enabled": false, + "provider": "adsense", + "clientId": "", + "slots": { + "cover": "", + "readerMid": "", + "readerEnd": "" + } + }, + + "payment": { + "crypto": { + "enabled": false, + "provider": "nowpayments", + "tiers": [ + { "amount": 5, "label": "Coffee" }, + { "amount": 10, "label": "Lunch" }, + { "amount": 20, "label": "Sponsor" } + ] + }, + "checkout": { + "enabled": false, + "provider": "creem", + "buttonText": "Buy Now — $29", + "productName": "Download Edition (PDF & EPUB)" + } + }, + + "feedback": { + "enabled": false, + "title": "这本书对你有帮助吗?", + "subtitle": "欢迎提建议、纠错或合作联系,我会认真看每一条。" + }, + + "affiliate": { + "enabled": true, + "rules": [ + { + "hosts": ["amazon.com", "amzn.to", "amazon.cn"], + "param": "tag=your-amazon-tag-20" + }, + { + "hosts": ["taobao.com", "tmall.com"], + "rewrite": "https://s.click.taobao.com/t?eurl={encoded}&union_lens=lensId" + } + ] + }, + + "share": { + "enabled": true, + "campaign": "nomad-code", + "defaultHashtags": ["数字游民", "Homelab", "远程办公"], + "viralPrompt": { + "enabled": true, + "triggerPercent": 42 + } + }, + + "reader": { + "bookmarks": { "enabled": true }, + "highlights": { "enabled": true }, + "search": { "enabled": true }, + "progress": { "enabled": true }, + "zen": { "enabled": true }, + "shortcuts": { "enabled": true }, + "themeToggle": { "enabled": true }, + "fontControl": { "enabled": true } + }, + + "userdata": { + "enabled": true, + "syncToServer": false, + "$comment": "syncToServer=true 时书签/划线/进度同步到 PocketBase,否则仅 localStorage" + }, + + "backend": { + "pocketbase": { "enabled": true }, + "email": { + "enabled": false, + "$comment": "需要配置 SMTP 环境变量才能发送购买邮件" + } + } +} diff --git a/frontend/content/book/site.json b/frontend/content/book/site.json new file mode 100644 index 0000000..fdf00b2 --- /dev/null +++ b/frontend/content/book/site.json @@ -0,0 +1,8 @@ +{ + "$comment": "品牌身份信息 — 模块开关和配置见 features.json", + "brand": { + "name": "游牧代码", + "xHandle": "@linyuan", + "contactEmail": "hello@example.com" + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0ec971c..95a41d6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,21 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@ant-design/icons": "^6.3.2", + "@ant-design/nextjs-registry": "^1.3.0", + "@tailwindcss/postcss": "^4.0.0", + "antd": "^6.6.1", + "github-slugger": "^2.0.0", + "highlight.js": "^11.9.0", "next": "16.3.3", + "next-themes": "^0.4.4", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "react-markdown": "^9.0.3", + "rehype-highlight": "^7.0.1", + "rehype-slug": "^6.0.0", + "remark-gfm": "^4.0.0", + "tailwindcss": "^4.0.0" }, "devDependencies": { "@types/node": "^20", @@ -21,6 +33,124 @@ "typescript": "^5" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.2.tgz", + "integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.5.0", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", + "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", + "license": "MIT" + }, + "node_modules/@ant-design/nextjs-registry": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ant-design/nextjs-registry/-/nextjs-registry-1.3.0.tgz", + "integrity": "sha512-lHlg5bnj3LPYHUD+iZsO1k11NhIrx1mLWh2DXQ0m3Ly3OSaYfgeHX0WoVXxdVjEGjjYnPTe+OUY7YvU/p1aPZw==", + "license": "MIT", + "peerDependencies": { + "@ant-design/cssinjs": ">=1.0.0", + "antd": ">=5.0.0", + "next": ">=14.0.0", + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/react-slick": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", + "json2mq": "^0.2.0", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -213,6 +343,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -265,7 +404,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -287,13 +425,24 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -1057,7 +1206,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1068,7 +1216,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1079,7 +1226,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1089,14 +1235,12 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1107,7 +1251,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1362,6 +1505,733 @@ "node": ">=12.4.0" } }, + "node_modules/@rc-component/async-validator": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-6.0.0.tgz", + "integrity": "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.22.0.tgz", + "integrity": "sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.10.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.2.tgz", + "integrity": "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.10.0.tgz", + "integrity": "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.3.tgz", + "integrity": "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.6.tgz", + "integrity": "sha512-1EXVsSKPZC6kGrIqxVck7kAWM6T65b6et/n5wIcyiaIa41VrDshD0nVLHoBDEIZr2ATQsOP6icb4XQkNFMLBAw==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^6.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.10.0.tgz", + "integrity": "sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.3.1.tgz", + "integrity": "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==", + "license": "MIT", + "dependencies": { + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/listy": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rc-component/listy/-/listy-1.2.3.tgz", + "integrity": "sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.1", + "@rc-component/virtual-list": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.12.0.tgz", + "integrity": "sha512-v5MBx8zDcqCT+uybJgKpcx3Fgrvj0m3u+A/15RlSBaMx12lLoJZTTYggC3pbYYfohcKujWWUyBz6O6Gp2vGiNA==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.3.1", + "@rc-component/menu": "~1.5.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.5.0.tgz", + "integrity": "sha512-RjvzPsZkwVEg3xu+/jDDqDeUCBJAnWylONFlGeQgg5enlNAnlpybqhaMGE/UPEIvr6hL90PGhjFBguJqj0Qb2Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/motion": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.3.3.tgz", + "integrity": "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-2.0.8.tgz", + "integrity": "sha512-MTRA3b8eHE14gh5R9nlXzbWZEDEBFVkEHj/FI1VOXw2zrSnSxqbuuU3drsq+k53e6MHwtegoFay1tnNYhtl61Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.1.tgz", + "integrity": "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.4.0.tgz", + "integrity": "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.12.0.tgz", + "integrity": "sha512-0FZGgDiDZFMm2hcfGv4jyjm7yWIj2MiwfeTzLL0vWkO+8cCSKdiM9XhWpkn1aAsoI2EwUy48Oz2cGfvL8ZKrfw==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@rc-component/portal": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-2.2.1.tgz", + "integrity": "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/progress/-/progress-1.0.3.tgz", + "integrity": "sha512-Q1nPLIvKr95el/q76yaBh5nv1hqCsWaHQb8U7vXEDMefEhHgM7n3CPoN+o4LHnTyANQHmMFgoS/naAT4Bhxuaw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-2.0.0.tgz", + "integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.1.tgz", + "integrity": "sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/slider/-/slider-1.1.1.tgz", + "integrity": "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rc-component/steps/-/steps-1.2.3.tgz", + "integrity": "sha512-/b9gHcvDGjTDWJZW7+1kKFvzs0jrEeQ8mFvvIgX+h7xNT0FsOYODJYKjyJR4fPbvMBCfCOEqaBK66HBxov5BQA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.11.1.tgz", + "integrity": "sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.13.0.tgz", + "integrity": "sha512-3FXr+9ZII8tFLWuDWrgjRaxZd2mazkKJAGRFEfVmp4r1qnBglOlxQ78l54Td1aj6/iFaaEReKSAF2HToztU5uw==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.5.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.5.2.tgz", + "integrity": "sha512-VLq4BclU3LgRe46g2hTr2bdsM8tFYs7dVwpuD88rPNNg4lCGe8mBSS4FiRzYzS+ffnZ46y4SI8KYKo9K7qBnhw==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.10.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.4.0.tgz", + "integrity": "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.4.0.tgz", + "integrity": "sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.16.1.tgz", + "integrity": "sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.10.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.10.1.tgz", + "integrity": "sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.2.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.1.tgz", + "integrity": "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.12.0.tgz", + "integrity": "sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^19.2.7" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/util/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.5.1.tgz", + "integrity": "sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^8.0.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list/node_modules/@babel/runtime": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.0.tgz", + "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1378,24 +2248,317 @@ "tslib": "^2.8.0" } }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1410,6 +2573,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -1424,7 +2602,6 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1440,6 +2617,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", @@ -1735,6 +2918,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", @@ -2145,6 +3334,70 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/antd": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.6.2.tgz", + "integrity": "sha512-aTOPWXsqfWrlSiI0a1qR9UWR4jBCTJ8FNZfZmYQP9/aGGLJSrw9/c5uE+4vSVSt3riQlIPAwR3BcAgEHmE7GMg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.3.2", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.29.2", + "@rc-component/cascader": "~1.22.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.10.0", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.3", + "@rc-component/form": "~1.8.6", + "@rc-component/image": "~1.10.0", + "@rc-component/input": "~1.3.1", + "@rc-component/input-number": "~1.6.2", + "@rc-component/listy": "~1.2.3", + "@rc-component/mentions": "~1.12.0", + "@rc-component/menu": "~1.5.0", + "@rc-component/motion": "^1.3.3", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~2.0.8", + "@rc-component/pagination": "~1.4.0", + "@rc-component/picker": "~1.12.0", + "@rc-component/progress": "~1.0.3", + "@rc-component/qrcode": "~2.0.0", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.10.1", + "@rc-component/slider": "~1.1.1", + "@rc-component/steps": "~1.2.3", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.11.1", + "@rc-component/tabs": "~1.13.0", + "@rc-component/tooltip": "~1.5.2", + "@rc-component/tour": "~2.4.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/tree-select": "~1.16.1", + "@rc-component/trigger": "^3.10.1", + "@rc-component/upload": "~1.1.1", + "@rc-component/util": "^1.12.0", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2375,6 +3628,16 @@ "node": ">= 0.4" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2532,6 +3795,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2549,12 +3822,61 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2575,6 +3897,22 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2608,7 +3946,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2672,11 +4009,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2690,6 +4032,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2733,16 +4088,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -2785,6 +4161,19 @@ "dev": true, "license": "MIT" }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -3404,6 +4793,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3414,6 +4813,12 @@ "node": ">=0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3707,6 +5112,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3763,6 +5174,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -3857,6 +5274,101 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-heading-rank": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", + "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -3874,6 +5386,25 @@ "hermes-estree": "0.25.1" } }, + "node_modules/highlight.js": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3911,6 +5442,12 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3926,6 +5463,30 @@ "node": ">= 0.4" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4084,6 +5645,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -4159,6 +5730,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -4172,6 +5753,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, "node_modules/is-negative-zero": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", @@ -4212,6 +5799,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4389,6 +5988,15 @@ "node": ">= 0.4" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4453,6 +6061,15 @@ "dev": true, "license": "MIT" }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4526,6 +6143,267 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4549,6 +6427,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4562,6 +6450,30 @@ "loose-envify": "cli.js" } }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lowlight/node_modules/highlight.js": { + "version": "11.11.2", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz", + "integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4572,6 +6484,25 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4582,6 +6513,288 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4592,6 +6805,569 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4606,6 +7382,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4633,7 +7422,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -4730,6 +7518,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -4964,6 +7762,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4998,13 +7821,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -5070,6 +7893,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5129,6 +7962,33 @@ "dev": true, "license": "MIT" }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5173,6 +8033,106 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", + "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "github-slugger": "^2.0.0", + "hast-util-heading-rank": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -5313,6 +8273,15 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -5543,6 +8512,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -5564,6 +8543,12 @@ "node": ">= 0.4" } }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -5678,6 +8663,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -5701,6 +8700,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -5724,6 +8741,12 @@ } } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5750,6 +8773,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -5767,19 +8818,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5793,6 +8831,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -5993,6 +9051,107 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -6072,6 +9231,34 @@ "punycode": "^2.1.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6229,6 +9416,16 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 23c7eb7..52affbc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,14 +4,26 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "next build --webpack", "start": "next start", "lint": "eslint" }, "dependencies": { + "@ant-design/icons": "^6.3.2", + "@ant-design/nextjs-registry": "^1.3.0", + "antd": "^6.6.1", + "github-slugger": "^2.0.0", + "highlight.js": "^11.9.0", "next": "16.3.3", + "next-themes": "^0.4.4", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "react-markdown": "^9.0.3", + "rehype-highlight": "^7.0.1", + "rehype-slug": "^6.0.0", + "remark-gfm": "^4.0.0", + "@tailwindcss/postcss": "^4.0.0", + "tailwindcss": "^4.0.0" }, "devDependencies": { "@types/node": "^20", diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/public/icons/apple-touch-icon.png b/frontend/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..c4059bd Binary files /dev/null and b/frontend/public/icons/apple-touch-icon.png differ diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000..02ac52e Binary files /dev/null and b/frontend/public/icons/icon-192.png differ diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000..d58e36a Binary files /dev/null and b/frontend/public/icons/icon-512.png differ diff --git a/frontend/public/icons/icon.svg b/frontend/public/icons/icon.svg new file mode 100644 index 0000000..1f5c755 --- /dev/null +++ b/frontend/public/icons/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..efa2229 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,56 @@ +/* Simple offline-capable shell for nomadro PWA */ +const CACHE = "nomadro-shell-v1"; +const PRECACHE = ["/", "/plan", "/compare", "/tools", "/manifest.webmanifest", "/icons/icon.svg"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + + // API: network only + if (url.pathname.startsWith("/api/")) return; + + // Navigations: network first, fall back to cache / home + if (req.mode === "navigate") { + event.respondWith( + fetch(req) + .then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)); + return res; + }) + .catch(() => caches.match(req).then((r) => r || caches.match("/"))) + ); + return; + } + + // Static assets: cache first + event.respondWith( + caches.match(req).then( + (cached) => + cached || + fetch(req).then((res) => { + if (res.ok && (url.pathname.startsWith("/_next/") || url.pathname.startsWith("/icons/"))) { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)); + } + return res; + }) + ) + ); +}); diff --git a/frontend/src/app/book/ebook-globals.css b/frontend/src/app/book/ebook-globals.css new file mode 100644 index 0000000..1e11602 --- /dev/null +++ b/frontend/src/app/book/ebook-globals.css @@ -0,0 +1,3187 @@ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;600;700&family=Noto+Serif+SC:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); +@import 'tailwindcss'; +@import 'highlight.js/styles/github.min.css'; + +@custom-variant dark (&:where(.dark, .dark *)); + +:root { + --book-bg: #120f1d; + --book-bg-secondary: #1b1630; + --book-surface: rgba(255, 255, 255, 0.72); + --book-surface-strong: rgba(255, 255, 255, 0.86); + --book-soft: rgba(255, 255, 255, 0.08); + --book-border: rgba(255, 255, 255, 0.14); + --book-text: #1e1630; + --book-muted: #6d6286; + --book-accent: #f05a9b; + --book-accent-2: #8b5cf6; + --book-accent-soft: rgba(240, 90, 155, 0.14); + --book-font-size: 16px; + --book-line-height: 1.7; + --book-font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; + --reader-max-width: 56rem; +} + +.dark { + --book-bg: #0f0b18; + --book-bg-secondary: #171126; + --book-surface: rgba(28, 22, 45, 0.8); + --book-surface-strong: rgba(33, 26, 53, 0.92); + --book-soft: rgba(255, 255, 255, 0.06); + --book-border: rgba(255, 255, 255, 0.1); + --book-text: #f6efff; + --book-muted: #b4a9c8; + --book-accent: #ff7ab6; + --book-accent-2: #9f7aea; + --book-accent-soft: rgba(255, 122, 182, 0.14); +} + +.sepia { + --book-bg: #f3e9d7; + --book-bg-secondary: #e8dcc4; + --book-surface: rgba(255, 250, 240, 0.82); + --book-surface-strong: rgba(255, 252, 245, 0.94); + --book-soft: rgba(120, 80, 40, 0.08); + --book-border: rgba(120, 80, 40, 0.16); + --book-text: #3d2f1f; + --book-muted: #8a7358; + --book-accent: #c2410c; + --book-accent-2: #b45309; + --book-accent-soft: rgba(194, 65, 12, 0.12); +} + +html { + scroll-behavior: smooth; +} + +.ebook-section { + margin: 0; + min-height: 100vh; + background: var(--book-bg); + color: var(--book-text); + font-family: var(--book-font-family); + -webkit-font-smoothing: antialiased; + background-image: + radial-gradient(circle at top, rgba(167, 139, 250, 0.2), transparent 26%), + radial-gradient(circle at 80% 20%, rgba(244, 114, 182, 0.22), transparent 22%), + linear-gradient(180deg, var(--book-bg), var(--book-bg-secondary)); + isolation: isolate; +} + +/* Kill main-site typography bleed into the book */ +.ebook-section h1, +.ebook-section h2, +.ebook-section h3, +.ebook-section h4, +.ebook-section p, +.ebook-section ul, +.ebook-section ol, +.ebook-section li { + font-family: inherit; +} + +/* Let component link colors win over main-site `a { color: inherit }` */ +.ebook-section a { + text-decoration: none; +} + +.ebook-section h1 { + font-size: inherit; + margin: 0; +} + +.book-cover { + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.18), transparent 28%), + linear-gradient(155deg, #25153c 0%, #4c1d95 36%, #db2777 100%); + box-shadow: + 0 30px 80px rgba(6, 4, 12, 0.45), + 0 8px 20px rgba(219, 39, 119, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.14); +} + +.glass-panel { + background: var(--book-surface); + border: 1px solid var(--book-border); + box-shadow: + 0 18px 45px rgba(10, 6, 20, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.28); + backdrop-filter: blur(18px); +} + +.hero-orb { + position: absolute; + border-radius: 999px; + filter: blur(60px); + opacity: 0.55; + animation: orb-float 8s ease-in-out infinite; +} + +.hero-orb:nth-child(2) { + animation-delay: -2s; +} + +.hero-orb:nth-child(3) { + animation-delay: -4s; +} + +@keyframes orb-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-18px); } +} + +.reader-paper { + background: var(--book-surface-strong); + border: 1px solid var(--book-border); + box-shadow: + 0 24px 60px rgba(10, 6, 20, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.35); + backdrop-filter: blur(12px); +} + +.focus-reader .reader-chrome { + transition: transform 0.28s ease, opacity 0.28s ease; +} + +.focus-reader.chrome-hidden .reader-chrome { + transform: translateY(-100%); + opacity: 0; + pointer-events: none; +} + +.prose .chapter-title { + position: relative; + padding-bottom: 1rem; + margin-bottom: 2rem; +} + +.prose .chapter-title::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 4rem; + height: 3px; + border-radius: 999px; + background: linear-gradient(90deg, var(--book-accent), var(--book-accent-2)); +} + +.prose .drop-cap::first-letter { + float: left; + margin: 0.08em 0.12em 0 0; + font-size: 3.4em; + line-height: 0.82; + font-weight: 700; + color: var(--book-accent); +} + +.search-hit { + background: rgba(250, 204, 21, 0.35); + border-radius: 4px; + box-shadow: 0 0 0 2px rgba(250, 204, 21, 0.2); +} + +.reading-mode-toggle { + display: inline-flex; + align-items: center; + border-radius: 999px; + border: 1px solid var(--book-border); + background: var(--book-surface-strong); + padding: 2px; +} + +.reading-mode-toggle button { + border: 0; + background: transparent; + color: var(--book-muted); + font-size: 11px; + padding: 0.2rem 0.45rem; + border-radius: 999px; + cursor: pointer; +} + +.reading-mode-toggle button.is-active { + background: var(--book-accent-soft); + color: var(--book-accent); + font-weight: 600; +} + +.bookmark-group { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.chapter-menu-trigger.is-active { + color: #f59e0b; + border-color: rgba(245, 158, 11, 0.35); +} + +.bookmark-jump { + border: 0; + background: var(--book-accent-soft); + color: var(--book-accent); + font-size: 11px; + padding: 0.25rem 0.5rem; + border-radius: 999px; + cursor: pointer; +} + +.chapter-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} + +.in-chapter-search { + position: relative; +} + +.in-chapter-search-panel { + position: absolute; + right: 0; + top: calc(100% + 0.5rem); + width: min(90vw, 18rem); + padding: 0.75rem; + border-radius: 1rem; + border: 1px solid var(--book-border); + background: var(--book-surface-strong); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.18); + z-index: 20; +} + +.in-chapter-search-panel input { + width: 100%; + border: 1px solid var(--book-border); + background: var(--book-soft); + color: var(--book-text); + border-radius: 0.6rem; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + outline: none; +} + +.in-chapter-search-panel ul { + margin: 0.75rem 0 0; + padding: 0; + list-style: none; +} + +.in-chapter-search-panel li { + font-size: 0.8rem; + color: var(--book-muted); + padding: 0.35rem 0; + border-top: 1px solid var(--book-border); +} + +.in-chapter-search-empty { + margin: 0.75rem 0 0; + font-size: 0.8rem; + color: var(--book-muted); +} + +.chapter-drawer-search-wrap { + padding: 0 0.25rem 0.75rem; +} + +.chapter-drawer-search { + width: 100%; + border: 1px solid var(--book-border); + background: var(--book-soft); + color: var(--book-text); + border-radius: 0.75rem; + padding: 0.6rem 0.75rem; + font-size: 0.875rem; + outline: none; +} + +.chapter-drawer-search::placeholder { + color: var(--book-muted); +} + +.mobile-chapter-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 40; + display: none; + gap: 0.5rem; + padding: 0.65rem 0.75rem calc(0.65rem + env(safe-area-inset-bottom)); + background: var(--book-surface-strong); + border-top: 1px solid var(--book-border); + backdrop-filter: blur(12px); +} + +.mobile-chapter-btn { + flex: 1; + min-width: 0; + padding: 0.65rem 0.75rem; + border-radius: 999px; + background: var(--book-soft); + color: var(--book-text); + text-decoration: none; + font-size: 0.78rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mobile-chapter-btn.is-next { + text-align: right; +} + +@media (min-width: 641px) { + .reading-mode-toggle button { + padding: 0.2rem 0.55rem; + } +} + +@media (max-width: 640px) { + .mobile-chapter-bar { + display: flex; + } + + .reader-main { + padding-bottom: 5.5rem; + } +} + +.prose { + font-size: var(--book-font-size); + line-height: var(--book-line-height); + color: var(--book-text); + letter-spacing: 0.02em; + animation: chapter-in 0.45s ease; +} + +@keyframes chapter-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.prose h1 { + font-size: calc(var(--book-font-size) * 1.6); + font-weight: 700; + line-height: 1.35; + margin: 0 0 1.5rem; + letter-spacing: 0.04em; + color: var(--book-text); + text-wrap: balance; +} + +.prose h2 { + font-size: calc(var(--book-font-size) * 1.15); + font-weight: 600; + margin: 2.5rem 0 1rem; + color: var(--book-text); + border-top: 1px solid var(--book-border); + padding: 0; + padding-top: 1.15rem; + scroll-margin-top: 5.5rem; +} + +.prose h3 { + font-size: calc(var(--book-font-size) * 1.05); + font-weight: 600; + margin: 2rem 0 0.75rem; + scroll-margin-top: 5.5rem; +} + +.prose p { + margin: 1.25rem 0; + text-align: justify; + text-justify: inter-ideograph; +} + +.prose hr { + border: none; + text-align: center; + margin: 2.5rem 0; +} + +.prose hr::before { + content: '· · ·'; + letter-spacing: 0.5em; + color: var(--book-muted); + font-size: 0.85em; +} + +.prose ul, +.prose ol { + margin: 1.25rem 0; + padding-left: 1.5rem; +} + +.prose li + li { + margin-top: 0.5rem; +} + +.prose blockquote { + margin: 1.5rem 0; + padding: 1rem 1.25rem; + border-left: 3px solid var(--book-accent); + color: var(--book-muted); + font-style: italic; + background: linear-gradient(90deg, var(--book-accent-soft), transparent); + border-radius: 0 16px 16px 0; +} + +.prose strong { + font-weight: 600; + color: var(--book-text); +} + +.prose a { + color: var(--book-accent); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 0.15s; +} + +.prose a:hover { + border-bottom-color: var(--book-accent); +} + +.prose pre { + margin: 0; + padding: 1rem 1.25rem; + border-radius: 18px; + overflow-x: auto; + background: var(--book-surface-strong) !important; + border: 1px solid var(--book-border); + font-size: 0.85em; + line-height: 1.6; + text-align: left; + box-shadow: 0 10px 30px rgba(20, 12, 35, 0.12); +} + +.prose code { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.85em; +} + +.prose :not(pre) > code { + padding: 0.15em 0.35em; + border-radius: 999px; + background: var(--book-accent-soft); + border: 1px solid var(--book-border); +} + +.prose table { + width: 100%; + border-collapse: collapse; + margin: 1.5rem 0; + font-size: 0.9em; + line-height: 1.5; +} + +.prose th, +.prose td { + border: 1px solid var(--book-border); + padding: 0.6rem 0.85rem; + text-align: left; +} + +.prose th { + background: var(--book-accent-soft); + font-weight: 600; +} + +.dark .hljs { + background: transparent !important; +} + +.drawer-lock { + overflow: hidden !important; +} + +.chapter-menu-trigger { + display: inline-flex; + align-items: center; + gap: 0.375rem; + height: 2.25rem; + padding: 0 0.75rem; + border-radius: 999px; + border: 1px solid var(--book-border); + background: var(--book-surface-strong); + color: var(--book-text); + font-size: 0.875rem; + cursor: pointer; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); +} + +.chapter-drawer-root { + position: fixed; + inset: 0; + z-index: 1000; + pointer-events: none; +} + +.chapter-drawer-root.is-open { + pointer-events: auto; +} + +.chapter-drawer-backdrop { + position: absolute; + inset: 0; + border: 0; + background: rgba(8, 6, 14, 0.55); + backdrop-filter: blur(4px); + opacity: 0; + transition: opacity 0.2s ease; + cursor: pointer; +} + +.chapter-drawer-root.is-open .chapter-drawer-backdrop { + opacity: 1; +} + +.chapter-drawer-panel { + position: absolute; + top: 0; + right: 0; + display: flex; + flex-direction: column; + width: min(100vw, 22rem); + height: 100%; + background: var(--book-surface-strong); + color: var(--book-text); + border-left: 1px solid var(--book-border); + box-shadow: -20px 0 60px rgba(20, 10, 35, 0.28); + transform: translateX(100%); + transition: transform 0.24s ease; +} + +.sepia .chapter-drawer-panel { + background: #faf3e8; + color: #3d2f1f; + border-left-color: rgba(194, 65, 12, 0.18); +} + +.sepia .chapter-drawer-item { + color: #5c4a38; +} + +.sepia .chapter-drawer-item.is-active { + color: #c2410c; +} + +.chapter-drawer-root.is-open .chapter-drawer-panel { + transform: translateX(0); +} + +.chapter-drawer-header, +.chapter-drawer-footer { + flex-shrink: 0; + padding: 1.1rem 1.25rem; + border-bottom: 1px solid var(--book-border); +} + +.chapter-drawer-footer { + border-bottom: 0; + border-top: 1px solid var(--book-border); +} + +.chapter-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.chapter-drawer-label { + margin: 0; + font-size: 0.75rem; + color: var(--book-muted); +} + +.chapter-drawer-title { + margin: 0.25rem 0 0; + font-size: 1rem; + font-weight: 600; +} + +.chapter-drawer-close { + width: 2rem; + height: 2rem; + border: 0; + border-radius: 999px; + background: rgba(240, 90, 155, 0.12); + color: inherit; + font-size: 1.25rem; + line-height: 1; + cursor: pointer; +} + +.chapter-drawer-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.75rem; +} + +.chapter-drawer-item { + display: flex; + gap: 0.75rem; + align-items: flex-start; + margin-bottom: 0.5rem; + padding: 0.85rem 1rem; + border-radius: 1rem; + text-decoration: none; + color: var(--book-text); + background: transparent; + transition: background 0.15s ease, color 0.15s ease; +} + +.chapter-drawer-item:hover { + background: var(--book-accent-soft); + color: var(--book-text); +} + +.chapter-drawer-item.is-active { + background: var(--book-accent-soft); + color: var(--book-accent); + font-weight: 600; +} + +.chapter-drawer-index { + flex-shrink: 0; + font-size: 0.75rem; + opacity: 0.55; + font-variant-numeric: tabular-nums; +} + +.chapter-drawer-name { + line-height: 1.45; + font-size: 0.92rem; +} + +.chapter-drawer-back { + color: var(--book-muted); + text-decoration: none; + font-size: 0.875rem; +} + +.chapter-drawer-back:hover { + color: var(--book-accent); +} + +.reader-hint { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + margin: 0; + padding: 0.35rem 0.75rem; + border-radius: 999px; + background: var(--book-surface-strong); + border: 1px solid var(--book-border); + color: var(--book-muted); + font-size: 0.7rem; + letter-spacing: 0.08em; + pointer-events: none; + opacity: 0.7; +} + +@media (max-width: 640px) { + .reader-hint { + display: none; + } +} + +@media (max-width: 640px) { + .prose { + font-size: calc(var(--book-font-size) - 1px); + } + + .prose p { + text-align: left; + } +} + +/* ── Viral cover & share UI ── */ + +.viral-cover { + color: #f8f4ff; +} + +.viral-cover-bg { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; +} + +.viral-cover-gradient { + position: absolute; + inset: 0; + opacity: 0.92; +} + +.viral-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(circle at center, black 20%, transparent 75%); +} + +.viral-cover .orb-1 { + width: 280px; + height: 280px; + top: -60px; + left: -40px; + background: #a855f7; +} + +.viral-cover .orb-2 { + width: 220px; + height: 220px; + top: 20%; + right: -30px; + background: #ec4899; +} + +.viral-cover .orb-3 { + width: 180px; + height: 180px; + bottom: 10%; + left: 30%; + background: #f97316; +} + +.viral-tag { + display: inline-flex; + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.75rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.16); + backdrop-filter: blur(8px); + animation: tag-pop 0.5s ease backwards; +} + +@keyframes tag-pop { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } +} + +.viral-badge { + display: inline-flex; + margin: 0 0 1rem; + padding: 0.4rem 0.85rem; + border-radius: 999px; + font-size: 0.78rem; + letter-spacing: 0.06em; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.18); +} + +.viral-title { + margin: 0; + font-size: clamp(2.4rem, 8vw, 4.2rem); + font-weight: 700; + line-height: 1.08; + letter-spacing: 0.06em; + text-wrap: balance; + background: linear-gradient(135deg, #fff 0%, #fbcfe8 45%, #c4b5fd 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.viral-subtitle { + margin: 0.75rem 0 0; + font-size: clamp(1rem, 3vw, 1.25rem); + color: rgba(255, 255, 255, 0.78); +} + +.viral-desc { + margin: 1rem 0 0; + max-width: 36rem; + font-size: 0.95rem; + line-height: 1.75; + color: rgba(255, 255, 255, 0.62); +} + +.viral-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin-top: 1.75rem; +} + +@media (min-width: 640px) { + .viral-stats { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.viral-stat-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + padding: 0.85rem 0.5rem; + border-radius: 1.1rem; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + backdrop-filter: blur(10px); + transition: transform 0.2s ease, border-color 0.2s ease; +} + +.viral-stat-card:hover { + transform: translateY(-3px); + border-color: rgba(255, 255, 255, 0.28); +} + +.viral-stat-emoji { + font-size: 1.25rem; +} + +.stat-counter { + font-size: 1.5rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} + +.viral-stat-label { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.55); +} + +.viral-tagline { + margin: 2rem 0 0; + font-size: 1.05rem; + font-style: italic; + color: rgba(255, 255, 255, 0.72); +} + +.viral-book-card { + position: relative; + border-radius: 1.5rem; + overflow: hidden; +} + +.viral-book-card-glow { + position: absolute; + inset: -20%; + background: radial-gradient(circle, rgba(236, 72, 153, 0.35), transparent 60%); + animation: glow-pulse 4s ease-in-out infinite; +} + +@keyframes glow-pulse { + 0%, 100% { opacity: 0.6; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.05); } +} + +.viral-book-card-inner { + position: relative; + min-height: 22rem; + padding: 1.75rem; + border-radius: 1.5rem; + background: linear-gradient(155deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.04)); + border: 1px solid rgba(255, 255, 255, 0.18); + backdrop-filter: blur(16px); + display: flex; + flex-direction: column; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.28); +} + +.insight-carousel { + margin-top: 3rem; +} + +.insight-carousel-label { + margin: 0 0 0.85rem; + font-size: 0.82rem; + color: rgba(255, 255, 255, 0.55); +} + +.insight-carousel-track { + display: flex; + gap: 0.85rem; + overflow-x: auto; + padding-bottom: 0.5rem; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} + +.insight-carousel-track::-webkit-scrollbar { + display: none; +} + +.insight-card { + flex: 0 0 min(85vw, 18rem); + scroll-snap-align: start; + padding: 1.1rem 1.15rem; + border-radius: 1.15rem; + background: rgba(255, 255, 255, 0.09); + border: 1px solid rgba(255, 255, 255, 0.14); + animation: card-in 0.5s ease backwards; +} + +@keyframes card-in { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.insight-card-emoji { + display: block; + font-size: 1.4rem; + margin-bottom: 0.5rem; +} + +.insight-card p { + margin: 0; + font-size: 0.9rem; + line-height: 1.65; + color: rgba(255, 255, 255, 0.82); +} + +.viral-chapter-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.75rem; + margin-top: 3rem; + padding-bottom: 3rem; +} + +@media (min-width: 640px) { + .viral-chapter-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .viral-chapter-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.viral-chapter-card { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 1.1rem 1.15rem; + border-radius: 1.15rem; + text-decoration: none; + color: inherit; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.12); + transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease; +} + +.viral-chapter-card:hover { + transform: translateY(-4px); + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.24); +} + +.viral-chapter-num { + font-size: 0.7rem; + letter-spacing: 0.2em; + color: rgba(255, 255, 255, 0.4); +} + +.viral-chapter-emoji { + font-size: 1.5rem; +} + +.viral-chapter-title { + font-size: 0.95rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.92); +} + +.viral-chapter-hook { + font-size: 0.78rem; + line-height: 1.5; + color: rgba(255, 255, 255, 0.55); +} + +.scroll-reveal { + opacity: 0; + transform: translateY(18px); + transition: opacity 0.55s ease, transform 0.55s ease; +} + +.scroll-reveal.is-visible { + opacity: 1; + transform: translateY(0); +} + +.share-x-btn { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.65rem 1.1rem; + border: 0; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 600; + color: #fff; + background: linear-gradient(135deg, #111 0%, #2d2d2d 100%); + cursor: pointer; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.share-x-btn:hover { + transform: translateY(-2px); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32); +} + +.share-x-btn.is-compact { + padding: 0.45rem 0.65rem; + font-size: 0.75rem; +} + +.share-x-label-compact { + font-size: 0.72rem; + font-weight: 700; +} + +.skill-radar { + padding: 1rem; + border-radius: 1.25rem; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.14); + backdrop-filter: blur(10px); +} + +.skill-radar-label { + margin: 0 0 0.5rem; + font-size: 0.82rem; + color: rgba(255, 255, 255, 0.65); + text-align: center; +} + +.skill-radar-svg { + width: 100%; + height: auto; + display: block; +} + +.skill-radar-text { + font-size: 9px; + fill: rgba(255, 255, 255, 0.55); +} + +.skill-radar-area { + animation: radar-in 1.2s ease forwards; + transform-origin: center; +} + +@keyframes radar-in { + from { opacity: 0; transform: scale(0.6); } + to { opacity: 1; transform: scale(1); } +} + +/* ── Chapter hero & shareable quotes ── */ + +.chapter-hero { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px dashed var(--book-border); +} + +.chapter-hero-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.chapter-hero-emoji { + font-size: 2.5rem; + animation: emoji-bounce 2s ease-in-out infinite; +} + +@keyframes emoji-bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} + +.chapter-hero-hook { + margin: 1rem 0 0; + font-size: 1.05rem; + font-weight: 500; + color: var(--book-muted); + line-height: 1.65; + font-style: italic; +} + +.progress-ring-fill { + transition: stroke-dashoffset 0.8s ease; +} + +.progress-ring-text { + fill: var(--book-muted); + font-family: ui-sans-serif, system-ui, sans-serif; + font-weight: 600; +} + +.reader-share-group { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.reader-copy-btn { + min-width: 2.25rem; + justify-content: center; + padding: 0 0.55rem; +} + +.chapter-drawer-emoji { + margin-right: 0.35rem; +} + +/* ── Continuous scroll reader ── */ + +.scroll-reader-layout { + display: flex; + align-items: stretch; +} + +.scroll-reader-content { + flex: 1; + min-width: 0; +} + +.scroll-reader-main { + padding-bottom: 4rem; +} + +/* ── Left chapter sidebar ── */ + +.chapter-sidebar { + display: none; + flex-direction: column; + width: 15.5rem; + flex-shrink: 0; + position: sticky; + top: 0; + height: 100vh; + overflow: hidden; + background: var(--book-surface-strong); + color: var(--book-text); + border-right: 1px solid var(--book-border); +} + +@media (min-width: 1024px) { + .chapter-sidebar { + display: flex; + } + + .chapter-sidebar-toggle { + display: none !important; + } +} + +.chapter-sidebar-header { + flex-shrink: 0; + padding: 1.25rem 1.15rem 1rem; + border-bottom: 1px solid var(--book-border); +} + +.chapter-sidebar-label { + margin: 0; + font-size: 0.72rem; + color: var(--book-muted); +} + +.chapter-sidebar-title { + margin: 0.3rem 0 0; + font-size: 1rem; + font-weight: 600; + color: var(--book-text); +} + +.chapter-sidebar-search-wrap { + flex-shrink: 0; + padding: 0.75rem 1rem; +} + +.chapter-sidebar-search { + width: 100%; + border: 1px solid var(--book-border); + background: var(--book-soft); + color: var(--book-text); + border-radius: 0.65rem; + padding: 0.55rem 0.7rem; + font-size: 0.82rem; + outline: none; +} + +.chapter-sidebar-search::placeholder { + color: var(--book-muted); +} + +.chapter-sidebar-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.25rem 0.65rem 0.75rem; +} + +.chapter-sidebar-item { + display: flex; + gap: 0.65rem; + align-items: flex-start; + flex: 1; + min-width: 0; + margin-bottom: 0; + padding: 0.7rem 0.5rem 0.7rem 0.75rem; + border-radius: 0.75rem; + text-decoration: none; + color: var(--book-text); + transition: background 0.15s ease, color 0.15s ease; +} + +.chapter-sidebar-item:hover { + background: var(--book-accent-soft); +} + +.chapter-sidebar-item.is-active { + background: var(--book-accent-soft); + color: var(--book-accent); + font-weight: 600; +} + +.chapter-sidebar-index { + flex-shrink: 0; + font-size: 0.72rem; + color: var(--book-muted); + font-variant-numeric: tabular-nums; + padding-top: 0.15rem; +} + +.chapter-sidebar-item.is-active .chapter-sidebar-index { + color: var(--book-accent); + opacity: 0.75; +} + +.chapter-sidebar-name { + line-height: 1.45; + font-size: 0.86rem; +} + +.chapter-sidebar-emoji { + margin-right: 0.3rem; +} + +.chapter-sidebar-footer { + flex-shrink: 0; + padding: 0.85rem 1.15rem; + border-top: 1px solid var(--book-border); +} + +.chapter-sidebar-back { + color: var(--book-muted); + text-decoration: none; + font-size: 0.82rem; +} + +.chapter-sidebar-back:hover { + color: var(--book-accent); +} + +.chapter-sidebar-toggle { + display: inline-flex; + align-items: center; + gap: 0.35rem; + height: 2.25rem; + padding: 0 0.65rem; + border-radius: 999px; + border: 1px solid var(--book-border); + background: var(--book-surface-strong); + color: var(--book-text); + font-size: 0.82rem; + cursor: pointer; + flex-shrink: 0; +} + +/* Mobile drawer — slides from left */ + +.chapter-sidebar-mobile { + position: fixed; + inset: 0; + z-index: 1000; + pointer-events: none; +} + +.chapter-sidebar-mobile.is-open { + pointer-events: auto; +} + +.chapter-sidebar-backdrop { + position: absolute; + inset: 0; + border: 0; + background: rgba(8, 6, 14, 0.55); + backdrop-filter: blur(4px); + opacity: 0; + transition: opacity 0.2s ease; + cursor: pointer; +} + +.chapter-sidebar-mobile.is-open .chapter-sidebar-backdrop { + opacity: 1; +} + +.chapter-sidebar-panel { + position: absolute; + top: 0; + left: 0; + display: flex; + flex-direction: column; + width: min(88vw, 20rem); + height: 100%; + background: var(--book-surface-strong); + color: var(--book-text); + border-right: 1px solid var(--book-border); + box-shadow: 8px 0 40px rgba(0, 0, 0, 0.2); + transform: translateX(-100%); + transition: transform 0.24s ease; +} + +.chapter-sidebar-mobile.is-open .chapter-sidebar-panel { + transform: translateX(0); +} + +.chapter-sidebar-close { + position: absolute; + top: 0.85rem; + right: 0.85rem; + z-index: 2; + width: 2rem; + height: 2rem; + border: 0; + border-radius: 999px; + background: var(--book-accent-soft); + color: var(--book-text); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; +} + +.chapter-sidebar-panel .chapter-sidebar-header { + padding-right: 2.5rem; +} + +.chapter-section { + scroll-margin-top: 5.5rem; +} + +.chapter-section + .chapter-section { + margin-top: 3.5rem; +} + +.chapter-section-divider { + display: flex; + justify-content: center; + margin-bottom: 2.5rem; + color: var(--book-muted); + font-size: 0.85rem; + letter-spacing: 0.5em; + opacity: 0.6; +} + +.book-end { + margin-top: 4rem; + padding: 3rem 1rem 2rem; + text-align: center; + border-top: 1px dashed var(--book-border); +} + +.book-end-emoji { + margin: 0; + font-size: 2rem; +} + +.book-end-text { + margin: 0.75rem 0 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--book-text); +} + +.book-end-sub { + margin: 0.35rem 0 0; + font-size: 0.9rem; + color: var(--book-muted); + font-style: italic; +} + +.scroll-nav-hint { + position: fixed; + bottom: 1rem; + left: 50%; + z-index: 25; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.85rem; + border-radius: 999px; + background: var(--book-surface-strong); + border: 1px solid var(--book-border); + color: var(--book-muted); + font-size: 0.72rem; + letter-spacing: 0.06em; + backdrop-filter: blur(10px); +} + +.scroll-nav-home { + color: var(--book-accent); + text-decoration: none; +} + +.scroll-nav-home:hover { + text-decoration: underline; +} + +.scroll-nav-divider { + opacity: 0.4; +} + +@media (max-width: 640px) { + .scroll-nav-hint { + display: none; + } + + .scroll-reader-main { + padding-bottom: 2rem; + } +} + +.chapter-hero-meta { + margin: 0.65rem 0 0; + font-size: 0.78rem; + color: var(--book-muted); + letter-spacing: 0.06em; +} + +.viral-chapter-card { + position: relative; +} + +.viral-chapter-read { + position: absolute; + top: 1rem; + right: 1rem; + font-size: 0.68rem; + padding: 0.15rem 0.45rem; + border-radius: 999px; + background: rgba(74, 222, 128, 0.18); + color: #bbf7d0; + letter-spacing: 0.08em; +} + +.viral-chapter-card.is-read { + border-color: rgba(74, 222, 128, 0.28); +} + +.chapter-sidebar-progress { + margin-top: 0.85rem; +} + +.chapter-sidebar-progress p { + margin: 0; + font-size: 0.72rem; + color: var(--book-muted); +} + +.chapter-sidebar-progress-track { + margin-top: 0.45rem; + height: 4px; + border-radius: 999px; + background: var(--book-soft); + overflow: hidden; +} + +.chapter-sidebar-progress-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--book-accent), var(--book-accent-2)); + transition: width 0.3s ease; +} + +.chapter-sidebar-headings { + margin: -0.15rem 0 0.65rem 2.4rem; + padding: 0; + list-style: none; +} + +.chapter-sidebar-row { + display: flex; + align-items: flex-start; + gap: 0.1rem; + margin-bottom: 0.35rem; +} + +.chapter-sidebar-expand { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 1.7rem; + height: 1.7rem; + margin-top: 0.55rem; + margin-right: 0.35rem; + border: none; + border-radius: 0.45rem; + background: transparent; + color: var(--book-muted); + cursor: pointer; +} + +.chapter-sidebar-expand:hover { + background: var(--book-accent-soft); + color: var(--book-accent); +} + +.chapter-sidebar-expand svg { + transition: transform 0.15s ease; +} + +.chapter-sidebar-expand.is-open svg { + transform: rotate(90deg); +} + +.chapter-sidebar-headings a { + display: block; + padding: 0.28rem 0.2rem; + font-size: 0.75rem; + color: var(--book-muted); + text-decoration: none; + line-height: 1.4; +} + +.chapter-sidebar-headings a:hover { + color: var(--book-accent); +} + +.chapter-sidebar-bookmarks { + flex-shrink: 0; + max-height: 8.5rem; + overflow-y: auto; + padding: 0.65rem 1rem 0.25rem; + border-top: 1px solid var(--book-border); +} + +.chapter-sidebar-bookmarks-label { + margin: 0 0 0.4rem; + font-size: 0.7rem; + color: var(--book-muted); + letter-spacing: 0.08em; +} + +.chapter-sidebar-bookmark { + display: block; + width: 100%; + margin: 0 0 0.25rem; + padding: 0.35rem 0; + border: 0; + background: transparent; + color: var(--book-text); + font-size: 0.78rem; + text-align: left; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chapter-sidebar-bookmark:hover { + color: var(--book-accent); +} + +.reader-search-btn, +.reader-settings-btn, +.reader-copy-btn { + min-width: 2.25rem; + justify-content: center; + padding: 0 0.55rem; +} + +.reader-settings { + position: relative; +} + +.reader-settings-panel { + position: absolute; + right: 0; + top: calc(100% + 0.55rem); + width: min(18rem, 78vw); + padding: 0.9rem; + border-radius: 1.1rem; + border: 1px solid var(--book-border); + background: var(--book-surface-strong); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.18); + z-index: 40; +} + +.reader-settings-label { + margin: 0.65rem 0 0.4rem; + font-size: 0.72rem; + color: var(--book-muted); +} + +.reader-settings-label:first-child { + margin-top: 0; +} + +.reader-settings-pills { + display: flex; + gap: 0.3rem; +} + +.reader-settings-pills button { + flex: 1; + border: 1px solid var(--book-border); + background: transparent; + color: var(--book-muted); + font-size: 0.75rem; + padding: 0.3rem 0.2rem; + border-radius: 999px; + cursor: pointer; +} + +.reader-settings-pills button.is-active { + background: var(--book-accent-soft); + color: var(--book-accent); + border-color: transparent; + font-weight: 600; +} + +.reader-settings-tip { + margin: 0.85rem 0 0; + font-size: 0.7rem; + color: var(--book-muted); +} + +.book-search-root, +.shortcut-help-root { + position: fixed; + inset: 0; + z-index: 1100; +} + +.book-search-backdrop { + position: absolute; + inset: 0; + border: 0; + background: rgba(8, 6, 14, 0.55); + backdrop-filter: blur(6px); + cursor: pointer; +} + +.book-search-panel, +.shortcut-help-panel { + position: relative; + z-index: 1; + width: min(36rem, calc(100vw - 1.5rem)); + margin: 12vh auto 0; + border-radius: 1.25rem; + border: 1px solid var(--book-border); + background: var(--book-surface-strong); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.28); + overflow: hidden; +} + +.book-search-bar { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--book-border); +} + +.book-search-bar input { + flex: 1; + border: 0; + background: transparent; + color: var(--book-text); + font-size: 1rem; + outline: none; +} + +.book-search-bar kbd, +.shortcut-help-panel kbd { + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + border-radius: 0.4rem; + border: 1px solid var(--book-border); + color: var(--book-muted); +} + +.book-search-results { + max-height: min(50vh, 22rem); + overflow-y: auto; + padding: 0.4rem; +} + +.book-search-hint { + margin: 0; + padding: 1.25rem 0.85rem; + font-size: 0.85rem; + color: var(--book-muted); + text-align: center; +} + +.book-search-item { + display: flex; + flex-direction: column; + gap: 0.2rem; + width: 100%; + padding: 0.7rem 0.8rem; + border: 0; + border-radius: 0.85rem; + background: transparent; + color: var(--book-text); + text-align: left; + cursor: pointer; +} + +.book-search-item.is-active, +.book-search-item:hover { + background: var(--book-accent-soft); +} + +.book-search-meta { + font-size: 0.78rem; + color: var(--book-muted); +} + +.book-search-snippet { + font-size: 0.88rem; + line-height: 1.5; +} + +.shortcut-help-panel { + padding: 1.25rem 1.35rem 1.1rem; +} + +.shortcut-help-title { + margin: 0 0 0.85rem; + font-size: 1rem; + font-weight: 600; +} + +.shortcut-help-panel ul { + margin: 0; + padding: 0; + list-style: none; +} + +.shortcut-help-panel li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.45rem 0; + border-top: 1px solid var(--book-border); + font-size: 0.88rem; +} + +.shortcut-help-panel li:first-of-type { + border-top: 0; +} + +.selection-share { + position: fixed; + z-index: 80; + display: flex; + gap: 0.25rem; + padding: 0.25rem; + border-radius: 999px; + border: 1px solid var(--book-border); + background: var(--book-surface-strong); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2); + transform: translate(-50%, calc(-100% - 10px)); +} + +.selection-share button { + border: 0; + background: transparent; + color: var(--book-text); + font-size: 0.75rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + cursor: pointer; +} + +.selection-share button:hover { + background: var(--book-accent-soft); + color: var(--book-accent); +} + +.code-block-wrap { + position: relative; + margin: 1.5rem 0; +} + +.code-copy-btn { + position: absolute; + top: 0.55rem; + right: 0.55rem; + z-index: 1; + border: 1px solid var(--book-border); + background: var(--book-surface-strong); + color: var(--book-muted); + font-size: 0.7rem; + padding: 0.2rem 0.5rem; + border-radius: 999px; + cursor: pointer; +} + +.code-copy-btn:hover { + color: var(--book-accent); +} + +.book-end-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.65rem; + margin-top: 1.35rem; +} + +.book-end-btn { + display: inline-flex; + align-items: center; + padding: 0.65rem 1.1rem; + border-radius: 999px; + border: 1px solid var(--book-border); + background: var(--book-soft); + color: var(--book-text); + font-size: 0.82rem; + text-decoration: none; + cursor: pointer; +} + +.book-end-btn:hover { + border-color: var(--book-accent); + color: var(--book-accent); +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + .hero-orb, + .skill-radar-area, + .chapter-hero-emoji, + .viral-book-card-glow, + .scroll-reveal, + .prose { + animation: none !important; + transition: none !important; + } +} + +.reader-measure { + width: 100%; + max-width: var(--reader-max-width, 56rem); +} + +.reader-settings-panel { + max-height: min(72vh, 32rem); + overflow-y: auto; +} + +.reader-settings-zen { + width: 100%; + margin-top: 0.85rem; + padding: 0.5rem 0.7rem; + border-radius: 0.75rem; + border: 1px solid var(--book-border); + background: var(--book-soft); + color: var(--book-text); + font-size: 0.8rem; + cursor: pointer; +} + +.reader-settings-zen.is-active { + background: var(--book-accent-soft); + color: var(--book-accent); + border-color: transparent; +} + +.reader-settings-danger { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.reader-settings-danger button { + flex: 1; + border: 0; + background: transparent; + color: var(--book-muted); + font-size: 0.72rem; + cursor: pointer; + padding: 0.3rem 0; +} + +.reader-settings-danger button:hover { + color: var(--book-accent); +} + +.zen-mode .chapter-sidebar { + display: none !important; +} + +.zen-mode .chapter-sidebar-toggle { + display: none !important; +} + +.zen-exit { + position: fixed; + top: 0.85rem; + left: 50%; + z-index: 60; + transform: translateX(-50%); + padding: 0.4rem 0.9rem; + border-radius: 999px; + border: 1px solid var(--book-border); + background: var(--book-surface-strong); + color: var(--book-muted); + font-size: 0.75rem; + cursor: pointer; + opacity: 0.35; + transition: opacity 0.2s ease; +} + +.zen-exit:hover, +.zen-exit:focus { + opacity: 1; + color: var(--book-text); +} + +mark.text-highlight, +.prose mark.text-highlight { + background: rgba(250, 204, 21, 0.38); + color: inherit; + border-radius: 0.15em; + padding: 0.05em 0.12em; + box-shadow: inset 0 -0.12em 0 rgba(245, 158, 11, 0.35); +} + +.sepia mark.text-highlight, +.sepia .prose mark.text-highlight { + background: rgba(234, 179, 8, 0.35); +} + +.chapter-pull-quote { + margin: 1.35rem 0 0; + padding: 1rem 1.15rem 0.85rem; + border-radius: 1.1rem; + background: linear-gradient(135deg, var(--book-accent-soft), transparent 70%); + border: 1px solid var(--book-border); +} + +.chapter-pull-quote blockquote { + margin: 0; + padding: 0; + border: 0; + background: none; + font-style: italic; + color: var(--book-text); + font-size: 1.02rem; + line-height: 1.7; +} + +.chapter-pull-quote-actions { + display: flex; + align-items: center; + gap: 0.45rem; + margin-top: 0.75rem; +} + +.chapter-pull-quote-actions button { + border: 0; + background: var(--book-soft); + color: var(--book-muted); + font-size: 0.75rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + cursor: pointer; +} + +.chapter-pull-quote-actions button:hover { + color: var(--book-accent); +} + +.chapter-next-peek { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin-top: 2.5rem; + padding: 1rem 1.1rem; + border-radius: 1.1rem; + border: 1px dashed var(--book-border); + text-decoration: none; + color: var(--book-text); + transition: border-color 0.15s ease, background 0.15s ease; +} + +.chapter-next-peek span { + font-size: 0.72rem; + letter-spacing: 0.12em; + color: var(--book-muted); +} + +.chapter-next-peek strong { + font-weight: 600; +} + +.chapter-next-peek:hover { + border-color: var(--book-accent); + background: var(--book-accent-soft); +} + +.heading-with-link .heading-link-btn { + margin-left: 0.4rem; + border: 0; + background: transparent; + color: var(--book-muted); + font-size: 0.75em; + opacity: 0; + cursor: pointer; + vertical-align: middle; +} + +.heading-with-link:hover .heading-link-btn, +.heading-link-btn:focus { + opacity: 1; +} + +.chapter-sidebar-highlight { + display: flex; + align-items: flex-start; + gap: 0.25rem; +} + +.chapter-sidebar-highlight .chapter-sidebar-bookmark { + white-space: normal; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.chapter-sidebar-highlight-del { + flex-shrink: 0; + width: 1.4rem; + height: 1.4rem; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--book-muted); + cursor: pointer; +} + +.chapter-sidebar-highlight-del:hover { + color: var(--book-accent); +} + +.viral-chapter-mins { + margin-top: 0.35rem; + font-size: 0.7rem; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.42); +} + +.viral-free-badge { + display: inline-flex; + padding: 0.35rem 0.85rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(251, 207, 232, 0.95)); + color: #4c1d95; +} + +.cover-sticky-cta { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom)); + background: rgba(18, 15, 29, 0.92); + border-top: 1px solid rgba(255, 255, 255, 0.12); + backdrop-filter: blur(12px); +} + +@media (min-width: 768px) { + .cover-sticky-cta { + display: none; + } +} + +.cover-sticky-read { + flex: 1; + text-align: center; + padding: 0.75rem 1rem; + border-radius: 999px; + background: #fff; + color: #4c1d95; + font-size: 0.88rem; + font-weight: 700; + text-decoration: none; +} + +.cover-sticky-meta { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.55); + white-space: nowrap; +} + +.site-footer { + margin-top: 3rem; + padding: 2rem 0 5rem; + text-align: center; + border-top: 1px solid rgba(255, 255, 255, 0.12); +} + +.site-footer-title { + margin: 0; + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.82); +} + +.site-footer-sub { + margin: 0.45rem 0 0; + font-size: 0.78rem; + color: rgba(255, 255, 255, 0.48); +} + +.site-footer-links { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1rem; + margin-top: 1rem; +} + +.site-footer-links a { + font-size: 0.82rem; + color: rgba(255, 255, 255, 0.72); + text-decoration: none; +} + +.site-footer-links a:hover { + color: #fbcfe8; +} + +.feedback-fab { + position: fixed; + right: 1rem; + bottom: 5.5rem; + z-index: 45; + width: 2.75rem; + height: 2.75rem; + border: 1px solid var(--book-border); + border-radius: 999px; + background: var(--book-surface-strong); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18); + cursor: pointer; + font-size: 1.1rem; +} + +@media (min-width: 768px) { + .feedback-fab { + bottom: 1.25rem; + } +} + +.feedback-modal-root { + position: fixed; + inset: 0; + z-index: 1200; +} + +.feedback-modal { + position: relative; + z-index: 1; + width: min(26rem, calc(100vw - 2rem)); + margin: 18vh auto 0; + padding: 1.25rem 1.35rem; + border-radius: 1.25rem; + border: 1px solid var(--book-border); + background: var(--book-surface-strong); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.28); +} + +.feedback-modal h3 { + margin: 0; + font-size: 1.05rem; +} + +.feedback-modal p { + margin: 0.45rem 0 0; + font-size: 0.85rem; + color: var(--book-muted); + line-height: 1.55; +} + +.feedback-modal textarea, +.feedback-modal input { + width: 100%; + margin-top: 0.85rem; + border: 1px solid var(--book-border); + background: var(--book-soft); + color: var(--book-text); + border-radius: 0.75rem; + padding: 0.65rem 0.75rem; + font-size: 0.88rem; + outline: none; +} + +.feedback-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.85rem; + align-items: center; +} + +.feedback-submit { + border: 0; + border-radius: 999px; + padding: 0.55rem 1rem; + background: var(--book-accent); + color: #fff; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; +} + +.feedback-link { + font-size: 0.78rem; + color: var(--book-muted); + text-decoration: none; +} + +.feedback-success { + margin-top: 1rem !important; + color: var(--book-accent) !important; + font-weight: 600; +} + +.viral-share-prompt { + position: fixed; + left: 50%; + bottom: 1rem; + z-index: 55; + transform: translateX(-50%); + width: min(24rem, calc(100vw - 1.5rem)); + padding: 0.85rem 1rem; + border-radius: 1.1rem; + border: 1px solid var(--book-border); + background: var(--book-surface-strong); + box-shadow: 0 18px 45px rgba(0, 0, 0, 0.22); +} + +.viral-share-prompt p { + margin: 0; + font-size: 0.82rem; + color: var(--book-text); +} + +.viral-share-prompt-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.65rem; + align-items: center; +} + +.viral-share-dismiss { + border: 0; + background: transparent; + color: var(--book-muted); + font-size: 0.78rem; + cursor: pointer; +} + +.ad-slot { + margin: 1.5rem 0; + padding: 0.75rem; + border-radius: 1rem; + border: 1px dashed var(--book-border); + background: var(--book-soft); + min-height: 90px; +} + +.ad-slot-label { + margin: 0 0 0.5rem; + font-size: 0.68rem; + letter-spacing: 0.1em; + color: var(--book-muted); + text-align: center; +} + +.cover-ad-slot { + border-color: rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.06); +} + +.cover-ad-slot .ad-slot-label { + color: rgba(255, 255, 255, 0.45); +} + +.crypto-support { + margin: 2.5rem auto 0; + max-width: 42rem; + padding: 1.4rem 1.35rem 1.5rem; + border-radius: 1.4rem; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(12, 8, 22, 0.45); + backdrop-filter: blur(16px); + color: #fff; +} + +.crypto-support.is-compact { + margin-top: 2rem; + max-width: 36rem; + background: var(--book-soft); + border-color: var(--book-border); + color: var(--book-text); +} + +.crypto-support-kicker { + margin: 0; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + opacity: 0.62; +} + +.crypto-support-title { + margin: 0.35rem 0 0; + font-size: 1.35rem; + font-weight: 700; +} + +.crypto-support-sub { + margin: 0.45rem 0 0; + font-size: 0.92rem; + line-height: 1.5; + opacity: 0.78; +} + +.crypto-support-tiers { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.65rem; + margin-top: 1.1rem; +} + +.crypto-support-tier { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.75rem 0.5rem; + border-radius: 1rem; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); + color: inherit; + cursor: pointer; +} + +.crypto-support.is-compact .crypto-support-tier { + border-color: var(--book-border); + background: var(--book-surface); +} + +.crypto-support-tier:hover:not(:disabled) { + border-color: var(--book-accent); + transform: translateY(-1px); +} + +.crypto-support-tier:disabled { + opacity: 0.6; + cursor: wait; +} + +.crypto-support-label { + font-size: 0.72rem; + opacity: 0.7; +} + +.crypto-support-amount { + font-size: 1.05rem; + font-weight: 700; +} + +.crypto-support-error { + margin: 0.8rem 0 0; + font-size: 0.82rem; + color: #ff8aa0; +} + +.support-thanks { + min-height: 70vh; + display: grid; + place-content: center; + gap: 0.75rem; + padding: 2.5rem 1.25rem; + text-align: center; + max-width: 32rem; + margin: 0 auto; +} + +.support-thanks-kicker { + margin: 0; + letter-spacing: 0.16em; + text-transform: uppercase; + font-size: 0.72rem; + color: var(--book-muted); +} + +.support-thanks h1 { + margin: 0; + font-size: 2.2rem; +} + +.support-thanks p { + margin: 0; + color: var(--book-muted); + line-height: 1.6; +} + +.support-thanks-actions { + display: flex; + justify-content: center; + gap: 0.8rem; + margin-top: 0.6rem; +} + +.support-thanks-actions a { + color: var(--book-accent); +} + +@media (max-width: 640px) { + .crypto-support-tiers { + grid-template-columns: 1fr; + } +} + +.buy-ebook { + margin: 2.5rem auto 0; + max-width: 42rem; + padding: 1.6rem 1.35rem 1.5rem; + border-radius: 1.4rem; + border: 1px solid rgba(139, 92, 246, 0.3); + background: linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(240, 90, 155, 0.06)); + backdrop-filter: blur(16px); + color: #fff; + text-align: center; +} + +.buy-ebook.is-compact { + margin-top: 2rem; + max-width: 36rem; + background: var(--book-soft); + border-color: var(--book-border); + color: var(--book-text); +} + +.buy-ebook-title { + margin: 0; + font-size: 1.35rem; + font-weight: 700; +} + +.buy-ebook-sub { + margin: 0.5rem 0 0; + font-size: 0.92rem; + line-height: 1.5; + opacity: 0.78; +} + +.buy-ebook-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-top: 1.1rem; + padding: 0.85rem 2.2rem; + border-radius: 999px; + border: none; + background: linear-gradient(135deg, var(--book-accent), var(--book-accent-2)); + color: #fff; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + box-shadow: 0 8px 25px rgba(240, 90, 155, 0.25); + transition: transform 0.15s, box-shadow 0.15s; +} + +.buy-ebook-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 12px 35px rgba(240, 90, 155, 0.35); +} + +.buy-ebook-btn:disabled { + opacity: 0.6; + cursor: wait; +} + +.buy-ebook-error { + margin: 0.8rem 0 0; + font-size: 0.82rem; + color: #ff8aa0; +} + +.buy-ebook { + margin: 2rem auto 0; + max-width: 42rem; + padding: 1.4rem 1.35rem 1.5rem; + border-radius: 1.4rem; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(12, 8, 22, 0.45); + backdrop-filter: blur(16px); + color: #fff; + text-align: center; +} + +.buy-ebook.is-compact { + margin-top: 1.5rem; + max-width: 36rem; + background: var(--book-soft); + border-color: var(--book-border); + color: var(--book-text); +} + +.buy-ebook-title { + margin: 0; + font-size: 1.35rem; + font-weight: 700; +} + +.buy-ebook-sub { + margin: 0.45rem 0 0; + font-size: 0.92rem; + line-height: 1.5; + opacity: 0.78; +} + +.buy-ebook-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; + padding: 0.75rem 2rem; + border-radius: 999px; + border: none; + background: linear-gradient(135deg, var(--book-accent), var(--book-accent-2)); + color: #fff; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; +} + +.buy-ebook-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 8px 24px rgba(240, 90, 155, 0.3); +} + +.buy-ebook-btn:disabled { + opacity: 0.6; + cursor: wait; +} + +.buy-ebook-error { + margin: 0.8rem 0 0; + font-size: 0.82rem; + color: #ff8aa0; +} + +.locale-switch { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + height: 2.4rem; + padding: 0 0.95rem 0 0.75rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.4); + background: rgba(10, 8, 22, 0.62); + color: #fff; + font-size: 0.84rem; + font-weight: 700; + letter-spacing: 0.04em; + cursor: pointer; + backdrop-filter: blur(14px); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28); +} + +.locale-switch:hover { + background: rgba(18, 12, 36, 0.78); + border-color: rgba(255, 255, 255, 0.55); +} + +.viral-cover > .locale-switch { + position: absolute; + top: 1.15rem; + right: 1.15rem; + z-index: 50; +} + +.reader-chrome .locale-switch { + height: 2.05rem; + padding: 0 0.7rem 0 0.55rem; + background: var(--book-soft); + border-color: var(--book-border); + color: var(--book-text); + box-shadow: none; + backdrop-filter: none; +} + +.ant-card .locale-switch { + height: 2rem; + background: #111827; + border-color: #4b5563; + color: #fff; + box-shadow: none; +} + +/* ── Nomadweb ↔ ebook fusion: cover layout locked in CSS (not Tailwind) ── */ + +.ebook-section { + /* neutralize main-site scroll offset for fixed navbar */ + scroll-padding-top: 0; +} + +.ebook-section .cover-root { + position: relative; + min-height: 100vh; + overflow: hidden; + color: #f8f4ff; +} + +.ebook-section .viral-cover-bg { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; +} + +.ebook-section .cover-shell { + position: relative; + z-index: 10; + width: 100%; + max-width: 72rem; + margin: 0 auto; + padding: 2.5rem 1rem 4rem; +} + +@media (min-width: 640px) { + .ebook-section .cover-shell { + padding: 3.5rem 1.5rem 5rem; + } +} + +@media (min-width: 1024px) { + .ebook-section .cover-shell { + padding: 4rem 1.5rem 5rem; + } +} + +.ebook-section .cover-back-home { + position: fixed; + top: 18px; + left: 18px; + z-index: 90; + display: inline-flex; + align-items: center; + height: 40px; + padding: 0 14px; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.02em; + color: #ffe66d !important; + background: rgba(10, 8, 22, 0.72); + border: 1px solid rgba(255, 255, 255, 0.28); + backdrop-filter: blur(12px); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35); +} + +.ebook-section .cover-back-home:hover { + border-color: rgba(255, 255, 255, 0.5); +} + +.ebook-section .cover-tags { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +@media (min-width: 1024px) { + .ebook-section .cover-tags { + justify-content: flex-start; + } +} + +.ebook-section .cover-hero { + display: grid; + gap: 2.5rem; + align-items: center; + margin-top: 2rem; +} + +@media (min-width: 1024px) { + .ebook-section .cover-hero { + grid-template-columns: 1.1fr 0.9fr; + gap: 3.5rem; + } +} + +.ebook-section .cover-main { + text-align: center; +} + +@media (min-width: 1024px) { + .ebook-section .cover-main { + text-align: left; + } +} + +.ebook-section .cover-aside { + width: 100%; + max-width: 24rem; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +@media (min-width: 1024px) { + .ebook-section .cover-aside { + max-width: 28rem; + margin: 0; + } +} + +.ebook-section .cover-cta-row { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + margin-top: 2.5rem; +} + +@media (min-width: 640px) { + .ebook-section .cover-cta-row { + flex-direction: row; + justify-content: flex-start; + align-items: stretch; + } +} + +.ebook-section .cover-main .cover-cta-row { + justify-content: center; +} + +@media (min-width: 1024px) { + .ebook-section .cover-main .cover-cta-row { + justify-content: flex-start; + } +} + +.ebook-section a.cover-primary-cta, +.ebook-section .cover-primary-cta { + display: inline-flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 2rem; + border-radius: 999px; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.2; + color: #4a044e !important; + background: #fff !important; + box-shadow: 0 18px 45px rgba(0, 0, 0, 0.24); + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.ebook-section .cover-primary-cta:hover { + transform: translateY(-2px); + box-shadow: 0 24px 55px rgba(0, 0, 0, 0.3); +} + +.ebook-section .cover-primary-cta-chapter { + max-width: 10rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.8; +} + +.ebook-section .cover-progress-card { + text-align: left; + padding: 0.75rem 1.25rem; + border-radius: 1rem; + color: rgba(255, 255, 255, 0.85); + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.14); + backdrop-filter: blur(12px); +} + +.ebook-section .cover-progress-label { + margin: 0; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.6); +} + +.ebook-section .cover-progress-detail { + margin: 0.25rem 0 0; + font-size: 0.875rem; + font-weight: 500; +} + +.ebook-section .cover-progress-track { + margin-top: 0.5rem; + width: 10rem; + height: 0.375rem; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.15); +} + +.ebook-section .cover-progress-fill { + height: 100%; + border-radius: 999px; + background: rgba(255, 255, 255, 0.85); + transition: width 0.3s ease; +} + +.ebook-section .cover-share-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-top: 1rem; +} + +@media (min-width: 1024px) { + .ebook-section .cover-share-row { + justify-content: flex-start; + } +} + +.ebook-section .cover-book-kicker { + margin: 0; + font-size: 0.75rem; + letter-spacing: 0.3em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.5); +} + +.ebook-section .cover-book-body { + margin-top: auto; + padding-top: 4rem; +} + +.ebook-section .cover-book-author { + margin: 0; + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.6); +} + +.ebook-section .cover-book-title { + margin: 0.5rem 0 0; + font-family: 'Noto Serif SC', serif; + font-size: 2.25rem; + font-weight: 700; + color: #fff; + line-height: 1.15; +} + +.ebook-section .cover-book-sub { + margin: 0.75rem 0 0; + color: rgba(255, 255, 255, 0.75); +} + +.ebook-section .cover-book-chapters { + list-style: none; + margin: 1.5rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ebook-section .cover-book-chapters li { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.7); + overflow: hidden; +} + +.ebook-section .cover-book-chapters li span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ebook-section .cover-book-more { + color: rgba(255, 255, 255, 0.45) !important; +} + +.ebook-section .scroll-reveal.is-visible { + opacity: 1 !important; + transform: translateY(0) !important; +} + +.ebook-section .viral-title { + font-family: var(--book-font-family) !important; + font-size: clamp(2.4rem, 8vw, 4.2rem) !important; + margin: 0 !important; + line-height: 1.08 !important; + font-weight: 700 !important; + letter-spacing: 0.06em; + color: transparent !important; + background: linear-gradient(135deg, #fff 0%, #fbcfe8 45%, #c4b5fd 100%); + -webkit-background-clip: text; + background-clip: text; +} + +/* ── Reader fusion: match book.nomadro.com, isolate main-site CSS bleed ── */ + +.ebook-section .scroll-reader-layout { + min-height: 100vh; + display: flex; + align-items: stretch; + background: var(--book-bg); +} + +.ebook-section .reader-chrome { + position: sticky; + top: 0; + z-index: 30; + background: color-mix(in srgb, var(--book-surface) 92%, transparent); + backdrop-filter: blur(16px); +} + +.ebook-section .chapter-sidebar { + position: sticky; + top: 0; + align-self: flex-start; + height: 100vh; + z-index: 20; + flex-shrink: 0; + width: 15.5rem; + background: var(--book-surface-strong); + border-right: 1px solid var(--book-border); +} + +.ebook-section .scroll-reader-content { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + align-items: stretch; + background: var(--book-bg); +} + +/* 2/4 = book.nomadro.com 对称居中;1/3 = 贴左右边空。用 grid 强制,别靠 margin:auto */ +.ebook-section .reader-center-wrap { + display: grid !important; + grid-template-columns: 1fr min(var(--reader-max-width, 56rem), 100%) 1fr !important; + width: 100% !important; + box-sizing: border-box !important; + padding-left: 0 !important; + padding-right: 0 !important; +} + +.ebook-section .reader-center-wrap > .scroll-reader-main { + grid-column: 2 !important; + width: 100% !important; + max-width: none !important; + margin: 0 !important; + /* match book: px-4 / sm:px-6 */ + padding: 1.5rem 1rem !important; + box-sizing: border-box !important; + flex: none !important; + display: block !important; +} + +@media (min-width: 640px) { + .ebook-section .reader-center-wrap > .scroll-reader-main { + padding: 2.5rem 1.5rem !important; + } +} + +@media (min-width: 1024px) { + .ebook-section .reader-center-wrap > .scroll-reader-main { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } +} + +.ebook-section .reader-chrome .reader-measure { + width: min(100%, var(--reader-max-width, 56rem)) !important; + max-width: var(--reader-max-width, 56rem) !important; + margin-left: auto !important; + margin-right: auto !important; +} + +.ebook-section .reader-paper { + background: var(--book-surface-strong); + border: 1px solid var(--book-border); + box-shadow: + 0 24px 60px rgba(10, 6, 20, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.35); + backdrop-filter: blur(12px); + overflow: visible; + border-radius: 1.5rem !important; + width: 100% !important; + margin: 0 !important; + box-sizing: border-box !important; + /* match book.nomadro.com: px-4 py-7 (Tailwind was dead under unlayered reset) */ + padding: 1.75rem 1rem !important; +} + +@media (min-width: 640px) { + .ebook-section .reader-paper { + border-radius: 2rem !important; + /* sm:px-8 sm:py-10 */ + padding: 2.5rem 2rem !important; + } +} + +@media (min-width: 1024px) { + .ebook-section .reader-paper { + /* lg:px-12 lg:py-14 */ + padding: 3.5rem 3rem !important; + } +} + +@media (min-width: 1024px) { + .ebook-section .reader-center-wrap { + grid-template-columns: 1fr min(var(--reader-max-width, 56rem), calc(100% - 3rem)) 1fr !important; + } +} + +/* Neutralize main-site bleed that paints pink bars / wrong borders on prose */ +.ebook-section .prose p, +.ebook-section .prose li, +.ebook-section .markdown-body p, +.ebook-section .markdown-body li { + border: none !important; + border-left: none !important; + border-bottom: none !important; + box-shadow: none !important; + background-image: none; +} + +.ebook-section .prose p::before, +.ebook-section .prose p::after, +.ebook-section .prose li::before, +.ebook-section .markdown-body p::before, +.ebook-section .markdown-body p::after { + content: none !important; + display: none !important; +} + +.ebook-section .prose h2, +.ebook-section .prose h3, +.ebook-section .heading-with-link { + border-bottom: none !important; + text-decoration: none !important; +} + +.ebook-section .prose h2::after, +.ebook-section .prose h3::after, +.ebook-section .heading-with-link::after { + display: none !important; +} + +/* Keep intentional chapter title underline only */ +.ebook-section .prose .chapter-title::after { + display: block !important; + content: "" !important; + position: absolute; + left: 0; + bottom: 0; + width: 4rem; + height: 3px; + border-radius: 999px; + background: linear-gradient(90deg, var(--book-accent), var(--book-accent-2)); +} + +.ebook-section a.scroll-nav-home { + color: var(--book-accent) !important; +} + +.ebook-section a.book-end-btn, +.ebook-section button.book-end-btn { + color: var(--book-text) !important; +} + +.ebook-section a.book-end-btn:hover, +.ebook-section button.book-end-btn:hover { + color: var(--book-accent) !important; +} + +.ebook-section a.chapter-next-peek { + color: var(--book-text) !important; + text-decoration: none; +} + +.ebook-section a.chapter-next-peek:hover { + border-color: var(--book-accent); + color: var(--book-accent) !important; +} + +.ebook-section .prose a, +.ebook-section .markdown-body a { + color: var(--book-accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.ebook-section .prose blockquote, +.ebook-section blockquote { + border-left-width: 3px; + margin-left: 0; + padding-left: 1rem; +} + +/* Kill payment blocks when flags are off but stale HTML remains */ +.ebook-section .buy-ebook[hidden], +.ebook-section .crypto-support[hidden] { + display: none !important; +} + diff --git a/frontend/src/app/book/layout.tsx b/frontend/src/app/book/layout.tsx new file mode 100644 index 0000000..6439402 --- /dev/null +++ b/frontend/src/app/book/layout.tsx @@ -0,0 +1,64 @@ +import type { Metadata } from "next"; +import { AntdRegistry } from "@ant-design/nextjs-registry"; +import { Providers } from "@/components/ebook/Providers"; +import { buildBookMetadata } from "@/lib/ebook/metadata"; +import { getSiteConfig } from "@/lib/ebook/site-config.server"; +import { getServerFeatures } from "@/lib/ebook/features.server"; +import { getLocale } from "@/lib/ebook/i18n/server"; +import "./ebook-globals.css"; + +export async function generateMetadata(): Promise { + const locale = await getLocale(); + return buildBookMetadata(locale); +} + +/** + * Book section is a self-contained surface inside nomadweb: + * - Own visual system (ebook-globals), isolated under .ebook-section + * - Locale bridged with main-site cookie/localStorage via getLocale + LocaleProvider + * - Theme driven by ThemeProvider on (not a forced .dark wrapper) + * - No main Navbar / SiteShell — immersive like book.nomadro.com + * - Entry from main nav `/book`; escape via cover "← nomadro" + */ +export default async function BookLayout({ + children, +}: { + children: React.ReactNode; +}) { + const locale = await getLocale(); + const site = getSiteConfig(); + const f = getServerFeatures(); + + return ( +
+ + + ); +} diff --git a/frontend/src/components/ebook/AnimatedCounter.tsx b/frontend/src/components/ebook/AnimatedCounter.tsx new file mode 100644 index 0000000..da0a303 --- /dev/null +++ b/frontend/src/components/ebook/AnimatedCounter.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +export function AnimatedCounter({ value }: { value: string }) { + const [display, setDisplay] = useState('0'); + const ref = useRef(null); + const started = useRef(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting || started.current) return; + started.current = true; + const match = value.match(/^(\d+)(.*)$/); + if (!match) { + setDisplay(value); + return; + } + const target = parseInt(match[1], 10); + const trailing = match[2]; + let frame = 0; + const total = 30; + const timer = setInterval(() => { + frame += 1; + setDisplay(String(Math.round((target * frame) / total)) + trailing); + if (frame >= total) clearInterval(timer); + }, 30); + }, + { threshold: 0.3 } + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [value]); + + return ( + + {display} + + ); +} diff --git a/frontend/src/components/ebook/BookEnd.tsx b/frontend/src/components/ebook/BookEnd.tsx new file mode 100644 index 0000000..f4415cf --- /dev/null +++ b/frontend/src/components/ebook/BookEnd.tsx @@ -0,0 +1,59 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { ShareToX } from './ShareToX'; +import { getReadingProgress } from '@/lib/ebook/reading-progress'; +import { getBookShareText, getReadShareUrl } from '@/lib/ebook/share-text'; +import { trackEvent } from '@/lib/ebook/analytics'; +import { useT } from '@/components/ebook/LocaleProvider'; +import type { Book } from '@/lib/ebook/book-types'; + +export function BookEnd({ book }: { book: Book }) { + const t = useT(); + const [readCount, setReadCount] = useState(0); + + useEffect(() => { + const saved = getReadingProgress(); + setReadCount(saved?.readChapters.length ?? 0); + if (saved && saved.readChapters.length >= book.chapters.length) { + trackEvent('book_complete'); + } + }, [book.chapters.length]); + + const shareText = `${t('cover.finishedNow', { title: book.title })} 📖\n${book.tagline}\n\n${getBookShareText(book).split('\n').slice(2).join('\n')}`; + const done = readCount >= book.chapters.length; + + return ( +
+

+ {done ? '🌅' : '📖'} +

+

{done ? t('reader.bookFinished') : t('reader.lastChapter')}

+

+ {done + ? t('cover.shareDone', { tagline: book.tagline }) + : t('cover.flipBack', { read: readCount, total: book.chapters.length })} +

+
+ + + + {t('thanks.backCover')} + +
+
+ ); +} diff --git a/frontend/src/components/ebook/BookSearch.tsx b/frontend/src/components/ebook/BookSearch.tsx new file mode 100644 index 0000000..7ab581f --- /dev/null +++ b/frontend/src/components/ebook/BookSearch.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import type { LoadedChapter } from '@/lib/ebook/book-types'; +import { clearHighlights, highlightQueryIn, searchChapters } from '@/lib/ebook/search'; +import { useT } from '@/components/ebook/LocaleProvider'; + +function jumpToHit(slug: string, query: string) { + const section = document.getElementById(slug); + section?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + history.replaceState(null, '', `/book/read#${slug}`); + + window.setTimeout(() => { + const root = document.getElementById(slug); + if (!root) return; + const first = highlightQueryIn(root, query); + first?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 420); +} + +export function BookSearch({ + chapters, + open, + onClose, +}: { + chapters: LoadedChapter[]; + open: boolean; + onClose: () => void; +}) { + const t = useT(); + const [query, setQuery] = useState(''); + const [active, setActive] = useState(0); + const [mounted, setMounted] = useState(false); + const inputRef = useRef(null); + + const hits = useMemo(() => searchChapters(chapters, query), [chapters, query]); + + useEffect(() => setMounted(true), []); + + useEffect(() => { + setActive(0); + }, [query]); + + useEffect(() => { + if (!open) { + clearHighlights(); + return; + } + const id = window.setTimeout(() => inputRef.current?.focus(), 30); + return () => window.clearTimeout(id); + }, [open]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActive((i) => Math.min(i + 1, Math.max(hits.length - 1, 0))); + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setActive((i) => Math.max(i - 1, 0)); + } + if (e.key === 'Enter' && hits[active]) { + e.preventDefault(); + jumpToHit(hits[active].slug, query); + onClose(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, hits, active, query, onClose]); + + if (!mounted || !open) return null; + + return createPortal( +
+ + ))} +
+
+ , + document.body + ); +} diff --git a/frontend/src/components/ebook/BookmarkButton.tsx b/frontend/src/components/ebook/BookmarkButton.tsx new file mode 100644 index 0000000..7a9848d --- /dev/null +++ b/frontend/src/components/ebook/BookmarkButton.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { + getBookmark, + removeBookmark, + saveBookmark, + TOGGLE_BOOKMARK_EVENT, + type Bookmark, +} from '@/lib/ebook/bookmarks'; +import { useT } from '@/components/ebook/LocaleProvider'; + +export function BookmarkButton({ + slug, + title, +}: { + slug: string; + title: string; +}) { + const t = useT(); + const [saved, setSaved] = useState(null); + + useEffect(() => { + setSaved(getBookmark(slug)); + }, [slug]); + + const toggle = useCallback(() => { + if (getBookmark(slug)) { + removeBookmark(slug); + setSaved(null); + return; + } + const bookmark: Bookmark = { + slug, + scrollY: window.scrollY, + title, + createdAt: Date.now(), + }; + saveBookmark(bookmark); + setSaved(bookmark); + }, [slug, title]); + + useEffect(() => { + window.addEventListener(TOGGLE_BOOKMARK_EVENT, toggle); + return () => window.removeEventListener(TOGGLE_BOOKMARK_EVENT, toggle); + }, [toggle]); + + return ( + + ); +} diff --git a/frontend/src/components/ebook/BuyEbook.tsx b/frontend/src/components/ebook/BuyEbook.tsx new file mode 100644 index 0000000..6c1cb14 --- /dev/null +++ b/frontend/src/components/ebook/BuyEbook.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useState } from 'react'; +import { trackEvent } from '@/lib/ebook/analytics'; +import { getFeatures } from '@/lib/ebook/features'; +import { useT } from '@/components/ebook/LocaleProvider'; + +export function BuyEbook({ compact = false }: { compact?: boolean }) { + const t = useT(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const productName = getFeatures().payment.checkout.productName; + const buttonText = getFeatures().payment.checkout.buttonText; + + const buy = async () => { + setError(''); + setBusy(true); + trackEvent('buy_click', { from: compact ? 'reader' : 'cover' }); + try { + const res = await fetch('/api/creem/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const data = (await res.json()) as { ok?: boolean; checkoutUrl?: string; error?: string }; + if (!res.ok || !data.checkoutUrl) { + throw new Error(data.error || t('payment.checkoutError')); + } + window.location.href = data.checkoutUrl; + } catch (err) { + setError(err instanceof Error ? err.message : t('payment.checkoutFailed')); + setBusy(false); + } + }; + + return ( +
+

{t('payment.buyGet', { name: productName })}

+

{t('payment.buySub')}

+ + {error ?

{error}

: null} +
+ ); +} diff --git a/frontend/src/components/ebook/ChapterHero.tsx b/frontend/src/components/ebook/ChapterHero.tsx new file mode 100644 index 0000000..8d24641 --- /dev/null +++ b/frontend/src/components/ebook/ChapterHero.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { ProgressRing } from './ProgressRing'; +import { ShareQuoteCard } from './ShareQuoteCard'; +import { useT } from '@/components/ebook/LocaleProvider'; +import type { Book, Chapter } from '@/lib/ebook/book-types'; + +export function ChapterHero({ + book, + chapter, + index, + total, + readingMinutes, +}: { + book?: Book; + chapter: Chapter; + index: number; + total: number; + readingMinutes?: number; +}) { + const t = useT(); + const progress = Math.round(((index + 1) / total) * 100); + + return ( +
+
+ {chapter.emoji} + +
+ {chapter.hook &&

{chapter.hook}

} + {readingMinutes ? ( +

+ {t('reader.aboutMinsChapter', { mins: readingMinutes, index: index + 1, total })} +

+ ) : null} + {chapter.shareQuote && book ? ( + + ) : null} +
+ ); +} diff --git a/frontend/src/components/ebook/ChapterMenu.tsx b/frontend/src/components/ebook/ChapterMenu.tsx new file mode 100644 index 0000000..a8047a8 --- /dev/null +++ b/frontend/src/components/ebook/ChapterMenu.tsx @@ -0,0 +1,175 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import type { Book } from '@/lib/ebook/book-types'; + +function scrollToChapterSlug(slug: string) { + document.getElementById(slug)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + history.replaceState(null, '', `/book/read#${slug}`); +} + +export function ChapterMenu({ + book, + scrollMode = false, + activeSlug, +}: { + book: Book; + scrollMode?: boolean; + activeSlug?: string; +}) { + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const [filter, setFilter] = useState(''); + const pathname = usePathname(); + const isScroll = scrollMode || pathname === '/book/read'; + + useEffect(() => setMounted(true), []); + + useEffect(() => { + setOpen(false); + }, [pathname]); + + useEffect(() => { + if (!open) return; + document.documentElement.classList.add('drawer-lock'); + document.body.classList.add('drawer-lock'); + return () => { + document.documentElement.classList.remove('drawer-lock'); + document.body.classList.remove('drawer-lock'); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [open]); + + const drawer = + mounted && + createPortal( +
+ +
+ + + +
+ setOpen(false)} className="chapter-drawer-back"> + ← 返回封面 + +
+ + , + document.body + ); + + return ( + <> + + {drawer} + + ); +} diff --git a/frontend/src/components/ebook/ChapterNav.tsx b/frontend/src/components/ebook/ChapterNav.tsx new file mode 100644 index 0000000..1a4ea4b --- /dev/null +++ b/frontend/src/components/ebook/ChapterNav.tsx @@ -0,0 +1,57 @@ +'use client'; + +import Link from 'next/link'; +import type { Chapter } from '@/lib/ebook/book-types'; +import { useT } from '@/components/ebook/LocaleProvider'; + +export function ChapterNav({ + prev, + next, +}: { + prev: Chapter | null; + next: Chapter | null; +}) { + const t = useT(); + + return ( + + ); +} diff --git a/frontend/src/components/ebook/ChapterReader.tsx b/frontend/src/components/ebook/ChapterReader.tsx new file mode 100644 index 0000000..2b005be --- /dev/null +++ b/frontend/src/components/ebook/ChapterReader.tsx @@ -0,0 +1,32 @@ +import { MarkdownContent } from './MarkdownContent'; +import { ChapterNav } from './ChapterNav'; +import { InChapterSearch } from './InChapterSearch'; +import { ChapterHero } from './ChapterHero'; +import type { Chapter } from '@/lib/ebook/book-types'; + +export function ChapterReader({ + content, + chapter, + index, + total, + prev, + next, +}: { + content: string; + chapter: Chapter; + index: number; + total: number; + prev: Chapter | null; + next: Chapter | null; +}) { + return ( + <> + +
+ +
+ + + + ); +} diff --git a/frontend/src/components/ebook/ChapterSidebar.tsx b/frontend/src/components/ebook/ChapterSidebar.tsx new file mode 100644 index 0000000..1046dfc --- /dev/null +++ b/frontend/src/components/ebook/ChapterSidebar.tsx @@ -0,0 +1,446 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import type { Book, LoadedChapter } from '@/lib/ebook/book-types'; +import { calcBookProgress, getReadingProgress } from '@/lib/ebook/reading-progress'; +import { BOOKMARKS_EVENT, getBookmarks, type Bookmark } from '@/lib/ebook/bookmarks'; +import { + getHighlights, + HIGHLIGHTS_EVENT, + removeHighlight, + type Highlight, +} from '@/lib/ebook/highlights'; +import { extractHeadings } from '@/lib/ebook/headings'; +import { useT } from '@/components/ebook/LocaleProvider'; + +function scrollToId(id: string) { + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); +} + +function scrollToChapterSlug(slug: string) { + scrollToId(slug); + history.replaceState(null, '', `/book/read#${slug}`); +} + +function ChapterNavList({ + book, + chapters, + activeSlug, + filter, + onNavigate, +}: { + book: Book; + chapters: LoadedChapter[]; + activeSlug: string; + filter: string; + onNavigate?: () => void; +}) { + const t = useT(); + const [openSlugs, setOpenSlugs] = useState>(() => new Set()); + + function toggle(slug: string) { + setOpenSlugs((prev) => { + const next = new Set(prev); + if (next.has(slug)) next.delete(slug); + else next.add(slug); + return next; + }); + } + + return ( + <> + {book.chapters + .map((chapter, i) => ({ chapter, i })) + .filter( + ({ chapter }) => + !filter.trim() || + chapter.title.toLowerCase().includes(filter.trim().toLowerCase()) + ) + .map(({ chapter, i }) => { + const active = activeSlug === chapter.slug; + const loaded = chapters.find((c) => c.slug === chapter.slug); + const headings = loaded + ? extractHeadings(loaded.content).filter((h) => h.level === 2) + : []; + const expanded = openSlugs.has(chapter.slug); + + return ( +
+
+ {headings.length > 0 ? ( + + ) : ( + + )} + { + e.preventDefault(); + scrollToChapterSlug(chapter.slug); + onNavigate?.(); + }} + className={`chapter-sidebar-item ${active ? 'is-active' : ''}`} + style={{ flex: 1, minWidth: 0, marginBottom: 0, padding: '8px 8px 8px 6px' }} + > + + {String(i + 1).padStart(2, '0')} + + + + {chapter.emoji} + + + {chapter.title} + + + +
+ {expanded && headings.length > 0 && ( + + )} +
+ ); + })} + + ); +} + +function SidebarContent({ + book, + chapters, + activeSlug, + readChapters, + remainingMins, + bookmarks, + highlights, + filter, + setFilter, + onNavigate, +}: { + book: Book; + chapters: LoadedChapter[]; + activeSlug: string; + readChapters: string[]; + remainingMins: number; + bookmarks: Bookmark[]; + highlights: Highlight[]; + filter: string; + setFilter: (v: string) => void; + onNavigate?: () => void; +}) { + const t = useT(); + const percent = calcBookProgress(readChapters.length, book.chapters.length); + + return ( + <> +
+

《{book.title}》

+

{t('reader.toc')}

+
+

+ {t('reader.readProgress', { + read: readChapters.length, + total: book.chapters.length, + mins: remainingMins, + })} +

+
+
+
+
+
+ +
+ setFilter(e.target.value)} + placeholder={t('reader.filterChapters')} + className="chapter-sidebar-search" + /> +
+ + + + {bookmarks.length > 0 && ( +
+

{t('reader.bookmarks')}

+ {bookmarks.map((bm) => ( + + ))} +
+ )} + + {highlights.length > 0 && ( +
+

{t('reader.highlights')}

+ {highlights.map((hl) => ( +
+ + +
+ ))} +
+ )} + +
+ + {t('reader.backCover')} + +
+ + ); +} + +export function ChapterSidebarToggle({ onOpen }: { onOpen: () => void }) { + const t = useT(); + return ( + + ); +} + +export function ChapterSidebar({ + book, + chapters, + activeSlug, + remainingMins, + mobileOpen, + onMobileClose, +}: { + book: Book; + chapters: LoadedChapter[]; + activeSlug: string; + remainingMins: number; + mobileOpen: boolean; + onMobileClose: () => void; +}) { + const t = useT(); + const [readChapters, setReadChapters] = useState([]); + const [bookmarks, setBookmarks] = useState([]); + const [highlights, setHighlights] = useState([]); + const [filter, setFilter] = useState(''); + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + + useEffect(() => { + const saved = getReadingProgress(); + setReadChapters(saved?.readChapters ?? []); + }, [activeSlug]); + + useEffect(() => { + const refresh = () => setBookmarks(getBookmarks()); + refresh(); + window.addEventListener(BOOKMARKS_EVENT, refresh); + return () => window.removeEventListener(BOOKMARKS_EVENT, refresh); + }, []); + + useEffect(() => { + const refresh = () => setHighlights(getHighlights()); + refresh(); + window.addEventListener(HIGHLIGHTS_EVENT, refresh); + return () => window.removeEventListener(HIGHLIGHTS_EVENT, refresh); + }, []); + + useEffect(() => { + if (!mobileOpen) return; + document.documentElement.classList.add('drawer-lock'); + document.body.classList.add('drawer-lock'); + return () => { + document.documentElement.classList.remove('drawer-lock'); + document.body.classList.remove('drawer-lock'); + }; + }, [mobileOpen]); + + useEffect(() => { + if (!mobileOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onMobileClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [mobileOpen, onMobileClose]); + + const contentProps = { + book, + chapters, + activeSlug, + readChapters, + remainingMins, + bookmarks, + highlights, + filter, + setFilter, + }; + + const mobileDrawer = + mounted && + createPortal( +
+ + + +
, + document.body + ); + + return ( + <> + + {mobileDrawer} + + ); +} diff --git a/frontend/src/components/ebook/ContinueReading.tsx b/frontend/src/components/ebook/ContinueReading.tsx new file mode 100644 index 0000000..d6400cf --- /dev/null +++ b/frontend/src/components/ebook/ContinueReading.tsx @@ -0,0 +1,97 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { + calcBookProgress, + getReadingProgress, + type ReadingProgress, +} from "@/lib/ebook/reading-progress"; +import { formatAgo } from "@/lib/ebook/time"; +import { trackEvent } from "@/lib/ebook/analytics"; +import { useLocale, useT } from "@/components/ebook/LocaleProvider"; +import type { Book } from "@/lib/ebook/book-types"; + +export function ContinueReading({ + book, + chapterMinutes, +}: { + book: Book; + chapterMinutes?: Record; +}) { + const t = useT(); + const { locale } = useLocale(); + const [progress, setProgress] = useState(null); + + useEffect(() => { + setProgress(getReadingProgress()); + }, []); + + const readCount = progress?.readChapters.length ?? 0; + const percent = calcBookProgress(readCount, book.chapters.length); + const continueSlug = progress?.slug; + const continueHref = continueSlug ? `/book/read#${continueSlug}` : "/book/read"; + + const continueChapter = continueSlug + ? book.chapters.find((c) => c.slug === continueSlug) + : null; + + const unread = book.chapters.filter((ch) => !progress?.readChapters.includes(ch.slug)); + const minsLeft = unread.reduce( + (sum, ch) => + sum + (chapterMinutes?.[ch.slug] ?? Math.round(book.readingMinutes / book.chapters.length)), + 0 + ); + + return ( +
+ {continueChapter ? ( + trackEvent("continue_read", { slug: continueSlug ?? "" })} + className="cover-primary-cta" + > + {t("cover.continueReading")} + {continueChapter.title} + ↓ + + ) : ( + trackEvent("start_read", { from: "cover_cta" })} + className="cover-primary-cta" + > + {t("cover.startRead")} + ↓ + + )} + + {readCount > 0 && ( +
+

+ {progress?.updatedAt + ? t("cover.progressWithTime", { ago: formatAgo(progress.updatedAt, locale) }) + : t("cover.readProgress")} +

+

+ {minsLeft > 0 + ? t("cover.progressDetailLeft", { + read: readCount, + total: book.chapters.length, + percent, + n: minsLeft, + }) + : t("cover.progressDetail", { + read: readCount, + total: book.chapters.length, + percent, + })} +

+
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ebook/ContinuousReader.tsx b/frontend/src/components/ebook/ContinuousReader.tsx new file mode 100644 index 0000000..c8c2985 --- /dev/null +++ b/frontend/src/components/ebook/ContinuousReader.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { ChapterHero } from './ChapterHero'; +import { MarkdownContent } from './MarkdownContent'; +import { BookEnd } from './BookEnd'; +import { AdSlot } from './AdSlot'; +import { useT } from '@/components/ebook/LocaleProvider'; +import type { Book, LoadedChapter } from '@/lib/ebook/book-types'; +import type { AffiliateRule } from '@/lib/ebook/site-types'; + +export type { LoadedChapter }; + +export function ContinuousReader({ + book, + chapters, + affiliateRules, + adSlots, +}: { + book: Book; + chapters: LoadedChapter[]; + affiliateRules: AffiliateRule[]; + adSlots: { mid: string; end: string }; +}) { + const t = useT(); + const midIndex = Math.floor(chapters.length / 2); + + return ( +
+ {chapters.map((chapter, i) => { + const next = chapters[i + 1]; + return ( +
+ {i > 0 && ( +
+ · · · +
+ )} + {i === midIndex && adSlots.mid && ( + + )} + + + {next && ( + + {t('reader.nextChapter')} + + {next.emoji} {next.title} + + + )} +
+ ); + })} + {adSlots.end && } + +
+ ); +} diff --git a/frontend/src/components/ebook/CoverChapterGrid.tsx b/frontend/src/components/ebook/CoverChapterGrid.tsx new file mode 100644 index 0000000..3004f17 --- /dev/null +++ b/frontend/src/components/ebook/CoverChapterGrid.tsx @@ -0,0 +1,45 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { getReadingProgress } from '@/lib/ebook/reading-progress'; +import { useT } from '@/components/ebook/LocaleProvider'; +import type { Book } from '@/lib/ebook/book-types'; + +export function CoverChapterGrid({ + book, + chapterMinutes, +}: { + book: Book; + chapterMinutes?: Record; +}) { + const t = useT(); + const [read, setRead] = useState([]); + + useEffect(() => { + setRead(getReadingProgress()?.readChapters ?? []); + }, []); + + return ( + + ); +} diff --git a/frontend/src/components/ebook/CoverPage.tsx b/frontend/src/components/ebook/CoverPage.tsx new file mode 100644 index 0000000..a3a39d0 --- /dev/null +++ b/frontend/src/components/ebook/CoverPage.tsx @@ -0,0 +1,154 @@ +"use client"; + +import type { Book } from "@/lib/ebook/book-types"; +import type { SiteConfig } from "@/lib/ebook/site-types"; +import { ContinueReading } from "./ContinueReading"; +import { InsightCarousel } from "./InsightCarousel"; +import { AnimatedCounter } from "./AnimatedCounter"; +import { ShareToX } from "./ShareToX"; +import { ScrollReveal } from "./ScrollReveal"; +import { SkillRadar } from "./SkillRadar"; +import { CoverChapterGrid } from "./CoverChapterGrid"; +import { CoverStickyCta, SiteFooter } from "./GrowthKit"; +import { AdSlot } from "./AdSlot"; +import { getBookShareText, getBookShareUrl } from "@/lib/ebook/share-text"; +import { trackEvent } from "@/lib/ebook/analytics"; +import { useEffect } from "react"; +import { isEnabled } from "@/lib/ebook/features"; +import { useT } from "@/components/ebook/LocaleProvider"; +import { LocaleSwitcher } from "@/components/ebook/LocaleSwitcher"; +import Link from "next/link"; + +/** + * Cover layout uses dedicated CSS (cover-*) so nomadweb global styles + * and Tailwind specificity fights cannot collapse the original book.nomadro.com composition. + */ +export function CoverPage({ + book, + site, + chapterMinutes, + coverAdSlot = "", +}: { + book: Book; + site: SiteConfig; + chapterMinutes?: Record; + coverAdSlot?: string; +}) { + const t = useT(); + const shareText = getBookShareText(book); + + useEffect(() => { + if (isEnabled.analytics()) trackEvent("cover_view"); + }, []); + + return ( +
+ + ← nomadro + + + +
+
+
+
+
+
+
+ +
+ +
+ {t("cover.freeBadge")} + {book.viralTags.map((tag) => ( + + #{tag} + + ))} +
+
+ +
+
+ +

{book.title}

+

{book.subtitle}

+

{book.description}

+
+ + +
+ {book.stats.map((stat) => ( +
+ {stat.emoji} + + {stat.label} +
+ ))} +
+
+ + + +
+ +
+
+ + +

「{book.tagline}」

+
+
+ + + +
+
+
+

Digital Nomad × Homelab

+
+

by {book.author}

+

{book.title}

+

{book.subtitle}

+
    + {book.chapters.slice(0, 4).map((ch) => ( +
  • + {ch.emoji} + {ch.title} +
  • + ))} +
  • + {t("cover.moreChapters", { n: book.chapters.length - 4 })} +
  • +
+
+
+
+ +
+ + + + + + + + + + {isEnabled.ads() && coverAdSlot && ( + + + + )} + + +
+ + +
+ ); +} diff --git a/frontend/src/components/ebook/CryptoSupport.tsx b/frontend/src/components/ebook/CryptoSupport.tsx new file mode 100644 index 0000000..f2c3a53 --- /dev/null +++ b/frontend/src/components/ebook/CryptoSupport.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useState } from 'react'; +import { trackEvent } from '@/lib/ebook/analytics'; +import { getFeatures } from '@/lib/ebook/features'; +import { useT } from '@/components/ebook/LocaleProvider'; + +export function CryptoSupport({ compact = false }: { compact?: boolean }) { + const t = useT(); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(''); + + const pay = async (amount: number) => { + setError(''); + setBusy(amount); + trackEvent('pay_click', { amount, from: compact ? 'reader' : 'cover' }); + try { + const res = await fetch('/api/pay/invoice', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount }), + }); + const data = (await res.json()) as { ok?: boolean; invoiceUrl?: string; error?: string }; + if (!res.ok || !data.invoiceUrl) { + throw new Error(data.error || t('payment.payFailed')); + } + window.location.href = data.invoiceUrl; + } catch (err) { + setError(err instanceof Error ? err.message : t('payment.payFailed')); + setBusy(null); + } + }; + + return ( +
+

{t('payment.cryptoKicker')}

+

{t('payment.cryptoTitle')}

+

{t('payment.cryptoSub')}

+
+ {getFeatures().payment.crypto.tiers.map((tier) => ( + + ))} +
+ {error ?

{error}

: null} +
+ ); +} diff --git a/frontend/src/components/ebook/FeedbackPanel.tsx b/frontend/src/components/ebook/FeedbackPanel.tsx new file mode 100644 index 0000000..8a8340d --- /dev/null +++ b/frontend/src/components/ebook/FeedbackPanel.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { trackEvent } from '@/lib/ebook/analytics'; +import { useT } from '@/components/ebook/LocaleProvider'; + +export function FeedbackPanel({ + title, + subtitle, + contactEmail, + xHandle, +}: { + title: string; + subtitle: string; + contactEmail: string; + xHandle: string; +}) { + const t = useT(); + const [open, setOpen] = useState(false); + const [sent, setSent] = useState(false); + const [loading, setLoading] = useState(false); + const [form, setForm] = useState({ email: '', message: '' }); + + useEffect(() => { + if (!open) return; + trackEvent('feedback_open'); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open]); + + const submit = async () => { + if (!form.message.trim()) return; + setLoading(true); + try { + await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + trackEvent('feedback_submit'); + setSent(true); + } catch { + if (contactEmail) { + window.location.href = `mailto:${contactEmail}?subject=${encodeURIComponent(t('feedback.subject'))}&body=${encodeURIComponent(form.message)}`; + } + } finally { + setLoading(false); + } + }; + + return ( + <> + + + {open && ( +
+