setShowToc(false)}
+ aria-hidden="true"
+ />
+
+ 目录
+ {toc.map((item, i) => (
+ handleTocJump(i)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") handleTocJump(i);
+ }}
+ >
+ {item.text}
+
+ ))}
+
+
+ {showBackTop && (
+ <>
+ {toc.length > 0 && (
+
setShowToc(true)}
+ aria-label="打开目录"
+ >
+
+
+
+
+
+
+ )}
+
window.scrollTo({ top: 0, behavior: "smooth" })}
+ aria-label="回到顶部"
+ >
+
+
+
+
+ >
+ )}
+ >
+ );
+}
diff --git a/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/lesson-markdown.module.css b/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/lesson-markdown.module.css
new file mode 100644
index 0000000..2909096
--- /dev/null
+++ b/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/lesson-markdown.module.css
@@ -0,0 +1,349 @@
+/* 参考 nomadlms,适配日间/夜间主题 */
+
+.readingProgress {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 3px;
+ background: linear-gradient(90deg, var(--accent) 0%, #fbbf24 100%);
+ z-index: 100;
+ transition: width 0.15s linear;
+ border-radius: 0 3px 3px 0;
+}
+
+.backToTop {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ width: 48px;
+ height: 48px;
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ cursor: pointer;
+ z-index: 50;
+ color: var(--accent);
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.backToTop:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
+}
+
+.tocButton {
+ position: fixed;
+ bottom: 24px;
+ right: 84px;
+ width: 48px;
+ height: 48px;
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ cursor: pointer;
+ z-index: 50;
+ color: var(--accent);
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.tocButton:hover {
+ transform: translateY(-2px);
+}
+
+.drawerMask {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.4);
+ z-index: 140;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.3s ease;
+}
+
+.drawerMaskOpen {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.drawer {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 280px;
+ max-width: 85vw;
+ background: var(--background);
+ border-left: 1px solid var(--border);
+ box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
+ z-index: 150;
+ padding: 80px 0 24px;
+ overflow-y: auto;
+ transform: translateX(100%);
+ transition: transform 0.3s ease;
+}
+
+.drawerOpen {
+ transform: translateX(0);
+}
+
+.drawerTitle {
+ padding: 0 20px 12px;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--muted-foreground);
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 8px;
+}
+
+.drawerItem {
+ padding: 10px 20px;
+ font-size: 14px;
+ color: var(--foreground);
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.drawerItem:hover {
+ background: var(--muted);
+}
+
+.drawerItemH2 {
+ padding-left: 28px;
+ font-size: 13px;
+ color: var(--muted-foreground);
+}
+
+.markdownBody {
+ font-size: 16px;
+ line-height: 1.85;
+ color: var(--foreground);
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+}
+
+.markdownBody h1 {
+ font-size: 1.5rem;
+ font-weight: 800;
+ color: var(--foreground);
+ margin: 28px 0 12px;
+ padding-bottom: 0;
+ border-bottom: none;
+ line-height: 1.3;
+}
+
+.markdownBody h1:first-child {
+ margin-top: 0;
+}
+
+.markdownBody h1::after {
+ content: "";
+ display: block;
+ width: 40px;
+ height: 3px;
+ background: linear-gradient(90deg, var(--accent), #fbbf24);
+ border-radius: 3px;
+ margin-top: 8px;
+}
+
+.markdownBody h2 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--foreground);
+ margin: 24px 0 10px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+ line-height: 1.35;
+}
+
+.markdownBody h3 {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--foreground);
+ margin: 20px 0 8px;
+}
+
+.markdownBody h4,
+.markdownBody h5,
+.markdownBody h6 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--foreground);
+ margin: 16px 0 6px;
+}
+
+.markdownBody p {
+ font-size: 1rem;
+ margin-bottom: 14px;
+ line-height: 1.9;
+}
+
+.markdownBody blockquote {
+ position: relative;
+ margin: 24px 0;
+ padding: 16px 20px 16px 24px;
+ border-left: none;
+ background: var(--muted);
+ border-radius: 0 12px 12px 0;
+ color: var(--muted-foreground);
+}
+
+.markdownBody blockquote::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 4px;
+ background: var(--accent);
+ border-radius: 0 2px 2px 0;
+}
+
+.markdownBody pre.codeBlock {
+ margin: 24px 0;
+ padding: 0;
+ overflow-x: auto;
+ border-radius: 8px;
+ background: transparent;
+}
+
+.markdownBody pre.codeBlock code {
+ display: block;
+ padding: 16px;
+ font-size: 0.875em;
+ line-height: 1.6;
+}
+
+.markdownBody p code,
+.markdownBody li code {
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 0.9em;
+ padding: 0.2em 0.4em;
+ background: var(--muted);
+ color: var(--foreground);
+ border-radius: 6px;
+}
+
+.markdownBody a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.markdownBody a:hover {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.markdownBody img {
+ display: block;
+ margin: 28px auto;
+ max-width: 100%;
+ height: auto;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.markdownBody ul,
+.markdownBody ol {
+ margin: 16px 0;
+ padding-left: 28px;
+}
+
+.markdownBody li {
+ margin-bottom: 6px;
+}
+
+.markdownBody li > ul,
+.markdownBody li > ol {
+ margin-top: 6px;
+ margin-bottom: 0;
+}
+
+.markdownBody table {
+ width: 100%;
+ margin: 24px 0;
+ border-collapse: collapse;
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.markdownBody th {
+ background: var(--muted);
+ font-weight: 600;
+ padding: 12px 16px;
+ text-align: left;
+ border: 1px solid var(--border);
+}
+
+.markdownBody td {
+ padding: 12px 16px;
+ border: 1px solid var(--border);
+}
+
+.markdownBody tr:nth-child(even) {
+ background: var(--muted);
+}
+
+.markdownBody hr {
+ border: none;
+ height: 1px;
+ background: var(--border);
+ margin: 28px 0;
+}
+
+.markdownBody strong {
+ font-weight: 700;
+}
+
+.markdownBody input[type="checkbox"] {
+ margin-right: 8px;
+}
+
+/* 响应式 */
+@media (min-width: 768px) {
+ .markdownBody {
+ font-size: 17px;
+ line-height: 1.8;
+ }
+
+ .markdownBody h1 {
+ font-size: 1.75rem;
+ margin: 32px 0 14px;
+ }
+
+ .markdownBody h1::after {
+ width: 50px;
+ margin-top: 10px;
+ }
+
+ .markdownBody h2 {
+ font-size: 1.35rem;
+ margin: 28px 0 12px;
+ }
+
+ .markdownBody h3 {
+ font-size: 1.2rem;
+ margin: 24px 0 10px;
+ }
+
+ .markdownBody p {
+ margin-bottom: 16px;
+ }
+
+ .backToTop,
+ .tocButton {
+ bottom: 32px;
+ right: 32px;
+ }
+
+ .tocButton {
+ right: 92px;
+ }
+}
diff --git a/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/page.tsx b/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/page.tsx
new file mode 100644
index 0000000..c6d0815
--- /dev/null
+++ b/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/page.tsx
@@ -0,0 +1,237 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { Link, useTranslation } from "@/app/digital/i18n/navigation";
+import { VideoPlayer } from "@/app/digital/components/course/VideoPlayer";
+import { LessonMarkdown } from "./LessonMarkdown";
+import { getLesson } from "@/config/digital/lessons";
+import { canWatchLesson, isFreeTrial } from "@/app/digital/lib/course-payment";
+import { useAuthAndVip } from "@/app/digital/lib/useAuthAndVip";
+import { getStoredToken, getStoredUser } from "@/app/digital/lib/pocketbase";
+import { setLastWatched, markCompleted } from "@/app/digital/lib/learning";
+import { DOWNLOAD_CONFIG } from "@/config/digital/course";
+import Header from "@/app/digital/components/Header";
+import AuthModal from "@/app/digital/components/AuthModal";
+import {
+ getPayEnv,
+ redirectToPay,
+ getDeviceFromEnv,
+} from "@/app/digital/lib/payment";
+export default function LessonPage() {
+ const params = useParams();
+ const router = useRouter();
+ const { t } = useTranslation("community");
+ const { user, vip, refetch } = useAuthAndVip();
+ const [toast, setToast] = useState(false);
+ const [authOpen, setAuthOpen] = useState(false);
+ const [payRedirecting, setPayRedirecting] = useState(false);
+ const moduleIndex = Number(params.moduleIndex);
+ const lessonIndex = Number(params.lessonIndex);
+
+ const lesson = getLesson(moduleIndex, lessonIndex);
+ const unlocked = canWatchLesson(moduleIndex, lessonIndex, vip);
+
+ useEffect(() => {
+ if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name);
+ }, [moduleIndex, lessonIndex, lesson?.name, unlocked]);
+
+ const startPay = (userId: string) => {
+ const env = getPayEnv();
+ const channel = "wxpay";
+ const device = getDeviceFromEnv(env);
+ const returnUrl = `${window.location.origin}/`;
+
+ redirectToPay({
+ user_id: userId,
+ return_url: returnUrl,
+ channel,
+ device,
+ type: "video",
+ useJoinConfig: true,
+ });
+ };
+
+ const handleEnroll = async () => {
+ if (payRedirecting) return;
+ setPayRedirecting(true);
+
+ try {
+ // 先验证登录态
+ let meRes = await fetch("/api/digital/auth/me", { credentials: "include" });
+ let meData = await meRes.json().catch(() => ({}));
+ if (meData?.user?.id) {
+ // 已登录:先校验 VIP,已是 VIP 则无需支付
+ const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
+ const vipData = await vipRes.json().catch(() => ({}));
+ if (vipData?.vip) {
+ await refetch();
+ return;
+ }
+ startPay(meData.user.id);
+ return;
+ }
+
+ const storedUser = getStoredUser();
+ const storedToken = getStoredToken();
+ if (storedUser?.id && storedToken) {
+ await fetch("/api/digital/auth/sync-session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: storedToken, record: storedUser }),
+ credentials: "include",
+ }).catch(() => null);
+ meRes = await fetch("/api/digital/auth/me", { credentials: "include" });
+ meData = await meRes.json().catch(() => ({}));
+ if (meData?.user?.id) {
+ const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
+ const vipData = await vipRes.json().catch(() => ({}));
+ if (vipData?.vip) {
+ await refetch();
+ return;
+ }
+ startPay(meData.user.id);
+ return;
+ }
+ }
+
+ setAuthOpen(true);
+ } finally {
+ setPayRedirecting(false);
+ }
+ };
+
+ if (!lesson || isNaN(moduleIndex) || isNaN(lessonIndex)) {
+ return (
+
+
+
+
+
章节不存在
+
请检查链接或返回课程首页
+
+ 返回首页
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ router.back()}
+ className="flex items-center gap-1 text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
+ >
+
+
+
+ 返回
+
+ /
+
+ 课程首页
+
+
+
+
+ {lesson.name}
+ {isFreeTrial(moduleIndex, lessonIndex) && (
+
+ 试看
+
+ )}
+
+
时长 {lesson.duration}
+
+
+ {unlocked ? (
+
markCompleted(moduleIndex, lessonIndex)}
+ />
+ ) : (
+
+
+
🔒
+
报名解锁本章节
+
+ {payRedirecting ? "跳转支付中..." : "立即报名"}
+
+
+
+ )}
+
+
+ {unlocked && (
+
+ )}
+
+ {DOWNLOAD_CONFIG.enabled && unlocked && (
+
+ )}
+
+
+ {toast && (
+
+ {t("updating")}
+
+ )}
+
setAuthOpen(false)}
+ onSuccess={() => {
+ setAuthOpen(false);
+ void refetch();
+ }}
+ />
+
+ );
+}
diff --git a/app/[locale]/digital/course/page.tsx b/app/[locale]/digital/course/page.tsx
new file mode 100644
index 0000000..373ee94
--- /dev/null
+++ b/app/[locale]/digital/course/page.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import Header from "@/app/digital/components/Header";
+import Footer from "@/app/digital/components/Footer";
+import {
+ CourseHero,
+ AudienceSection,
+ BenefitsSection,
+ CurriculumSection,
+ InstructorsSection,
+ FAQSection,
+ CTASection,
+} from "@/app/digital/components/course";
+
+export default function CoursePage() {
+ return (
+
+ );
+}
diff --git a/app/[locale]/digital/day/[id]/page.tsx b/app/[locale]/digital/day/[id]/page.tsx
new file mode 100644
index 0000000..5babeea
--- /dev/null
+++ b/app/[locale]/digital/day/[id]/page.tsx
@@ -0,0 +1,127 @@
+import { notFound } from "next/navigation";
+import { Link } from "@/app/digital/i18n/navigation";
+import { getDayData, daysData } from "../../../../digital/data/days";
+import DayContent from "../../../../digital/components/DayContent";
+
+interface PageProps {
+ params: Promise<{ id: string }>;
+}
+
+export function generateStaticParams() {
+ return daysData.map((d) => ({ id: String(d.day) }));
+}
+
+export default async function DayPage({ params }: PageProps) {
+ const { id } = await params;
+ const dayId = parseInt(id, 10);
+ const data = getDayData(dayId);
+
+ if (!data) return notFound();
+
+ const prevDay = getDayData(dayId - 1);
+ const nextDay = getDayData(dayId + 1);
+
+ return (
+
+ {/* Top bar */}
+
+
+
+
+
+
+ 返回首页
+
+
+ Day {data.day} / 7
+
+
+
+
+ {/* Article header */}
+
+
+
+ 阅读时间{data.readingTime}
+
+
+ 第 {data.day} 天
+
+
+
+
+ {data.icon}
+
+
+
+ {data.title}
+
+
+
+
+
+
+
+
+
+ {/* Markdown body */}
+
+
+
+
+ {/* Prev / Next navigation */}
+
+
+ {prevDay ? (
+
+
+
+
+
+
上一天
+
+ Day {prevDay.day}: {prevDay.title}
+
+
+
+ ) : (
+
+ )}
+ {nextDay ? (
+
+
+
下一天
+
+ Day {nextDay.day}: {nextDay.title}
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {/* Footer */}
+
+
+ 🌍 数字游民指南
+
+ ·
+ 精选内容 · 持续更新
+
+
+ );
+}
diff --git a/app/[locale]/digital/ebook/EbookClient.tsx b/app/[locale]/digital/ebook/EbookClient.tsx
new file mode 100644
index 0000000..7616c20
--- /dev/null
+++ b/app/[locale]/digital/ebook/EbookClient.tsx
@@ -0,0 +1,222 @@
+"use client";
+
+import { useEffect, useState, useRef, useCallback } from "react";
+import { Link } from "@/app/digital/i18n/navigation";
+import type { EbookData } from "@/app/digital/lib/ebook";
+import "github-markdown-css/github-markdown.css";
+import styles from "./ebook.module.css";
+
+function LazyChunk({
+ html,
+ estimatedHeight,
+ forceVisible,
+}: {
+ html: string;
+ estimatedHeight: number;
+ forceVisible: boolean;
+}) {
+ const [visible, setVisible] = useState(forceVisible);
+ const ref = useRef
(null);
+ const show = forceVisible || visible;
+
+ useEffect(() => {
+ if (forceVisible || visible) return;
+ const el = ref.current;
+ if (!el) return;
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setVisible(true);
+ observer.disconnect();
+ }
+ },
+ { rootMargin: "1200px 0px" }
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [forceVisible, visible]);
+
+ if (show) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+export default function EbookClient({ data }: { data: EbookData }) {
+ const [showDrawer, setShowDrawer] = useState(false);
+ const [readProgress, setReadProgress] = useState(0);
+ const [showBackTop, setShowBackTop] = useState(false);
+ const [forceShowAll, setForceShowAll] = useState(false);
+ const contentRef = useRef(null);
+
+ const { chunks, headers } = data;
+
+ useEffect(() => {
+ let ticking = false;
+ const onScroll = () => {
+ if (ticking) return;
+ ticking = true;
+ requestAnimationFrame(() => {
+ const scrollTop =
+ window.scrollY || document.documentElement.scrollTop;
+ const docHeight =
+ document.documentElement.scrollHeight - window.innerHeight;
+ setReadProgress(
+ docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0
+ );
+ setShowBackTop(scrollTop > 600);
+ ticking = false;
+ });
+ };
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
+ const handleJump = useCallback(
+ (index: number) => {
+ setForceShowAll(true);
+ setShowDrawer(false);
+
+ const run = () => {
+ const root = contentRef.current;
+ if (!root) return;
+ const h1s = root.querySelectorAll("h1");
+ const h2s = root.querySelectorAll("h2");
+ const targets = h1s.length > 0 ? h1s : h2s;
+ const el = targets[index] as HTMLElement;
+ if (el) {
+ try {
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
+ } catch {
+ const top =
+ el.getBoundingClientRect().top +
+ (window.pageYOffset || document.documentElement.scrollTop) -
+ 60;
+ window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
+ }
+ }
+ };
+
+ run();
+ setTimeout(run, 100);
+ setTimeout(run, 300);
+ setTimeout(run, 600);
+ },
+ []
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ 数字游民
+ setShowDrawer(!showDrawer)}
+ aria-label="目录"
+ >
+
+
+
+
+
+ 目录
+
+
+
+
+
+ {chunks.map((chunk, i) => (
+
+ ))}
+
+
+
+ {/* 抽屉目录 */}
+ setShowDrawer(false)}
+ aria-hidden="true"
+ />
+
+ {headers.map((title, index) => (
+ handleJump(index)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") handleJump(index);
+ }}
+ >
+ {title}
+
+ ))}
+
+
+ {showBackTop && (
+
window.scrollTo({ top: 0, behavior: "smooth" })}
+ aria-label="回到顶部"
+ >
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/app/[locale]/digital/ebook/ebook.module.css b/app/[locale]/digital/ebook/ebook.module.css
new file mode 100644
index 0000000..85f40ba
--- /dev/null
+++ b/app/[locale]/digital/ebook/ebook.module.css
@@ -0,0 +1,493 @@
+/* 复刻 nomadbook 电子书样式 */
+
+.readingProgress {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 4px;
+ background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
+ z-index: 201;
+ transition: width 0.15s linear;
+ border-radius: 0 4px 4px 0;
+}
+
+.headerSpacer {
+ width: 100%;
+ height: 88px;
+}
+
+.header {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 100;
+ height: 88px;
+ background: rgba(255, 255, 255, 0.92);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.headerLeft {
+ width: 100px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #1a1a2e;
+ cursor: pointer;
+}
+
+.headerMiddle {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ font-weight: 600;
+ color: #1a1a2e;
+ letter-spacing: 2px;
+}
+
+.headerRight {
+ width: 72px;
+ height: 72px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #4facfe;
+ cursor: pointer;
+ margin-right: 10px;
+ background: #f0f7ff;
+ border-radius: 50%;
+ transition: all 0.2s ease;
+}
+
+.headerRight:hover {
+ background: #dceeff;
+}
+
+.headerRightActive {
+ background: #4facfe !important;
+ color: #fff !important;
+}
+
+.chunkEnter {
+ animation: chunkAppear 0.5s ease-out both;
+}
+
+@keyframes chunkAppear {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.chunkPlaceholder {
+ background: linear-gradient(90deg, #f6f7f8 25%, #edeef1 37%, #f6f7f8 63%);
+ background-size: 400% 100%;
+ animation: shimmer 1.8s ease infinite;
+ border-radius: 12px;
+ margin: 24px 0;
+}
+
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+.loadingSpinner {
+ width: 56px;
+ height: 56px;
+ border: 5px solid #f0f0f0;
+ border-top-color: #4facfe;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.backToTop {
+ position: fixed;
+ bottom: 120px;
+ right: 30px;
+ width: 80px;
+ height: 80px;
+ background: rgba(255, 255, 255, 0.92);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+ cursor: pointer;
+ z-index: 50;
+ animation: fadeInUp 0.3s ease;
+ color: #4facfe;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.backToTop:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
+}
+
+@keyframes fadeInUp {
+ from { opacity: 0; transform: translateY(16px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.book {
+ padding: 30px;
+ max-width: 100%;
+}
+
+.markdownBody {
+ font-size: 16px !important;
+ line-height: 1.85 !important;
+ color: #2c3e50 !important;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+}
+
+.markdownBody h1 {
+ font-size: 24px !important;
+ font-weight: 800;
+ color: #1a1a2e;
+ margin: 28px 0 10px;
+ padding-bottom: 0;
+ border-bottom: none;
+ line-height: 1.3;
+}
+
+.markdownBody h1::after {
+ content: "";
+ display: block;
+ width: 40px;
+ height: 3px;
+ background: linear-gradient(90deg, #4facfe, #00f2fe);
+ border-radius: 3px;
+ margin-top: 8px;
+}
+
+.markdownBody h2 {
+ font-size: 21px !important;
+ font-weight: 700;
+ color: #2c3e50;
+ margin: 22px 0 9px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid #f0f0f0;
+ line-height: 1.3;
+}
+
+.markdownBody h3 {
+ font-size: 18px !important;
+ font-weight: 600;
+ color: #34495e;
+ margin: 18px 0 7px;
+}
+
+.markdownBody h4,
+.markdownBody h5,
+.markdownBody h6 {
+ font-size: 16px !important;
+ font-weight: 600;
+ color: #4a5568;
+ margin: 14px 0 6px;
+}
+
+.markdownBody p {
+ font-size: 16px !important;
+ margin-bottom: 10px;
+ line-height: 1.9;
+}
+
+.markdownBody blockquote {
+ position: relative;
+ margin: 28px 0;
+ padding: 20px 24px 20px 30px;
+ border-left: none;
+ background: linear-gradient(135deg, #f8fbff 0%, #f0f7ff 100%);
+ border-radius: 0 12px 12px 0;
+ color: #4a5568;
+}
+
+.markdownBody blockquote::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 6px;
+ background: linear-gradient(180deg, #4facfe, #00f2fe);
+ border-radius: 3px;
+}
+
+.markdownBody pre {
+ position: relative;
+ margin: 28px 0;
+ padding: 28px 24px 24px;
+ background: #1e1e2e;
+ border-radius: 12px;
+ overflow-x: auto;
+}
+
+.markdownBody pre::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 4px;
+ background: linear-gradient(90deg, #4facfe, #00f2fe);
+ border-radius: 12px 12px 0 0;
+}
+
+.markdownBody pre code {
+ font-family: "SF Mono", "Fira Code", Menlo, Monaco, Consolas, monospace;
+ font-size: 13px !important;
+ line-height: 1.6;
+ color: #e2e8f0;
+ background: none !important;
+ padding: 0;
+}
+
+.markdownBody code {
+ font-family: "SF Mono", "Fira Code", Menlo, Monaco, Consolas, monospace;
+ font-size: 14px !important;
+ padding: 2px 6px;
+ background: #f1f5f9;
+ color: #e53e3e;
+ border-radius: 6px;
+}
+
+.markdownBody a {
+ color: #4facfe;
+ text-decoration: none;
+}
+
+.markdownBody a:hover {
+ color: #2b8de3;
+ border-bottom: 1px solid #4facfe;
+}
+
+.markdownBody img {
+ display: block;
+ margin: 28px auto;
+ max-width: 100%;
+ height: auto;
+ border-radius: 8px;
+}
+
+.markdownBody ul,
+.markdownBody ol {
+ margin: 16px 0;
+ padding-left: 40px;
+}
+
+.markdownBody table {
+ width: 100%;
+ margin: 28px 0;
+ border-collapse: collapse;
+ border-radius: 8px;
+}
+
+.markdownBody th {
+ background: linear-gradient(135deg, #f8fafc, #edf2f7);
+ font-weight: 600;
+ padding: 16px 20px;
+ text-align: left;
+ border: 1px solid #e2e8f0;
+}
+
+.markdownBody td {
+ padding: 14px 20px;
+ border: 1px solid #e2e8f0;
+}
+
+.markdownBody tr:nth-child(even) {
+ background: #f8fafc;
+}
+
+/* 抽屉目录 */
+.drawer {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 280px;
+ max-width: 85vw;
+ background: #fff;
+ box-shadow: -4px 0 24px rgba(0, 0, 0, 0.1);
+ z-index: 150;
+ padding-top: 88px;
+ overflow-y: auto;
+ transform: translateX(100%);
+ transition: transform 0.3s ease;
+}
+
+.drawerOpen {
+ transform: translateX(0);
+}
+
+.drawerMask {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.35);
+ z-index: 140;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.3s ease;
+}
+
+.drawerMaskOpen {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.drawerItem {
+ padding: 16px 20px;
+ border-bottom: 1px solid #f5f5f5;
+ color: #2c3e50;
+ cursor: pointer;
+ font-size: 15px;
+ transition: background 0.15s;
+}
+
+.drawerItem:hover {
+ background: #f0f7ff;
+}
+
+/* PC 适配 */
+@media (min-width: 768px) {
+ .headerSpacer {
+ height: 56px;
+ }
+
+ .header {
+ height: 56px;
+ }
+
+ .headerMiddle {
+ font-size: 18px;
+ }
+
+ .headerRight {
+ width: auto;
+ height: 34px;
+ padding: 0 14px;
+ margin-right: 16px;
+ gap: 5px;
+ border-radius: 17px;
+ font-size: 13px;
+ }
+
+ .book {
+ padding: 30px 60px;
+ }
+
+ .markdownBody {
+ font-size: 16px !important;
+ line-height: 1.8 !important;
+ max-width: 800px;
+ margin: 0 auto;
+ }
+
+ .markdownBody h1 {
+ font-size: 28px !important;
+ margin: 36px 0 14px;
+ }
+
+ .markdownBody h1::after {
+ width: 50px;
+ height: 3px;
+ margin-top: 10px;
+ }
+
+ .markdownBody h2 {
+ font-size: 23px !important;
+ margin: 28px 0 12px;
+ padding-bottom: 8px;
+ }
+
+ .markdownBody h3 {
+ font-size: 19px !important;
+ margin: 22px 0 10px;
+ }
+
+ .markdownBody p {
+ font-size: 16px !important;
+ margin-bottom: 14px;
+ }
+
+ .markdownBody pre code {
+ font-size: 14px !important;
+ }
+
+ .markdownBody code {
+ font-size: 14px !important;
+ padding: 2px 6px;
+ }
+
+ .backToTop {
+ width: 44px;
+ height: 44px;
+ bottom: 40px;
+ right: 40px;
+ }
+
+ .drawer {
+ padding-top: 56px;
+ width: 300px;
+ }
+
+ .drawerItem {
+ font-size: 14px;
+ padding: 14px 18px;
+ }
+}
+
+@media (min-width: 1024px) {
+ .book {
+ padding: 30px 80px;
+ }
+
+ .markdownBody {
+ width: 720px;
+ font-size: 17px !important;
+ line-height: 1.85 !important;
+ }
+
+ .markdownBody h1 {
+ font-size: 30px !important;
+ }
+
+ .markdownBody h2 {
+ font-size: 25px !important;
+ }
+
+ .markdownBody h3 {
+ font-size: 20px !important;
+ }
+
+ .markdownBody p {
+ font-size: 17px !important;
+ }
+
+ .markdownBody img.loaded:hover {
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
+ transform: translateY(-2px);
+ }
+}
diff --git a/app/[locale]/digital/ebook/page.tsx b/app/[locale]/digital/ebook/page.tsx
new file mode 100644
index 0000000..68fcbbb
--- /dev/null
+++ b/app/[locale]/digital/ebook/page.tsx
@@ -0,0 +1,17 @@
+import { readFileSync } from "fs";
+import path from "path";
+import { parseEbookMarkdown } from "../../../digital/lib/ebook";
+import EbookClient from "./EbookClient";
+
+export const metadata = {
+ title: "电子书 | 数字游民",
+ description: "数字游民:地理套利与自动化杠杆,低成本工作室、云手机、无人直播、出海掘金。",
+};
+
+export default function EbookPage() {
+ const mdPath = path.join(process.cwd(), "app/digital/data/ebook.md");
+ const source = readFileSync(mdPath, "utf-8");
+ const data = parseEbookMarkdown(source);
+
+ return
;
+}
diff --git a/app/[locale]/digital/jobs/JobsContent.tsx b/app/[locale]/digital/jobs/JobsContent.tsx
new file mode 100644
index 0000000..a10eaee
--- /dev/null
+++ b/app/[locale]/digital/jobs/JobsContent.tsx
@@ -0,0 +1,291 @@
+"use client";
+
+import { useState, useRef, type FormEvent, type ChangeEvent } from "react";
+import { Link, useLocale, useTranslation } from "@/app/digital/i18n/navigation";
+
+const positionOptions = [
+ { value: "content", labelZh: "内容运营", labelEn: "Content Operations" },
+ { value: "dev", labelZh: "前端/全栈开发", labelEn: "Frontend / Full-stack Dev" },
+ { value: "design", labelZh: "UI/UX 设计", labelEn: "UI/UX Design" },
+ { value: "marketing", labelZh: "市场推广", labelEn: "Marketing" },
+ { value: "other", labelZh: "其他", labelEn: "Other" },
+];
+
+interface FormData {
+ position: string;
+ name: string;
+ email: string;
+ phone: string;
+ intro: string;
+}
+
+function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+
{children}
+
+ );
+}
+
+function ChevronDown() {
+ return (
+
+
+
+ );
+}
+
+export default function JobsContent() {
+ const { t } = useTranslation("jobs");
+ const locale = useLocale();
+ const [form, setForm] = useState
({
+ position: "",
+ name: "",
+ email: "",
+ phone: "",
+ intro: "",
+ });
+ const [resume, setResume] = useState(null);
+ const [resumeError, setResumeError] = useState(null);
+ const fileRef = useRef(null);
+
+ const set = (key: keyof FormData, value: string) =>
+ setForm((prev) => ({ ...prev, [key]: value }));
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ setResumeError(null);
+ if (!resume) {
+ setResumeError(locale === "zh" ? "请上传简历(PDF 或 Word)" : "Please upload your resume (PDF or Word)");
+ return;
+ }
+ // 暂不提交,无业务逻辑
+ if (locale === "zh") {
+ alert("表单已填写完成,提交功能即将上线。");
+ } else {
+ alert("Form completed. Submit feature coming soon.");
+ }
+ };
+
+ const handleFileChange = (e: ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ const ext = file.name.split(".").pop()?.toLowerCase();
+ const allowed = ["pdf", "doc", "docx"];
+ if (!ext || !allowed.includes(ext)) {
+ setResumeError(locale === "zh" ? "请上传 PDF、DOC 或 DOCX 格式" : "Please upload PDF, DOC or DOCX");
+ return;
+ }
+ if (file.size > 10 * 1024 * 1024) {
+ setResumeError(locale === "zh" ? "文件大小不能超过 10MB" : "File size must be under 10MB");
+ return;
+ }
+ setResumeError(null);
+ setResume(file);
+ };
+
+ const removeResume = () => {
+ setResume(null);
+ setResumeError(null);
+ if (fileRef.current) fileRef.current.value = "";
+ };
+
+ const getLabel = (opt: (typeof positionOptions)[0]) =>
+ locale === "zh" ? opt.labelZh : opt.labelEn;
+
+ return (
+
+
+ {/* Banner */}
+
+
+
+
💼
+
+ {locale === "zh" ? "加入我们" : "Join Us"}
+
+
+
+
+
+
+
+ {t("title")}
+
+
+ {t("subtitle")}
+
+
+
+
+ {/* Form */}
+
+
+
+
+ ← {locale === "zh" ? "返回首页" : "Back to Home"}
+
+
+
+
+ );
+}
diff --git a/app/[locale]/digital/jobs/page.tsx b/app/[locale]/digital/jobs/page.tsx
new file mode 100644
index 0000000..6806698
--- /dev/null
+++ b/app/[locale]/digital/jobs/page.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from "next";
+import Header from "../../../digital/components/Header";
+import Footer from "../../../digital/components/Footer";
+import JobsContent from "./JobsContent";
+
+export const metadata: Metadata = {
+ title: "招聘 | Careers",
+ description: "加入数字游民指南团队,我们正在招聘。",
+};
+
+export default function JobsPage() {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/[locale]/digital/join/page.tsx b/app/[locale]/digital/join/page.tsx
new file mode 100644
index 0000000..80213ea
--- /dev/null
+++ b/app/[locale]/digital/join/page.tsx
@@ -0,0 +1,557 @@
+"use client";
+
+import { useState, useRef, useEffect, useCallback, type FormEvent, type ChangeEvent } from "react";
+import { Link } from "@/app/digital/i18n/navigation";
+import {
+ getPayEnv,
+ redirectToPay,
+ getDeviceFromEnv,
+} from "@/app/digital/lib/payment";
+import type { PayEnv } from "@/app/digital/lib/payment";
+import { getStoredToken } from "@/app/digital/lib/pocketbase";
+import AuthModal from "@/app/digital/components/AuthModal";
+
+const genderOptions = ["男", "女"];
+const singleOptions = ["单身", "恋爱中", "已婚"];
+const educationOptions = ["高中及以下", "大专", "本科", "硕士", "博士"];
+const graduationYears = Array.from({ length: 41 }, (_, i) => String(2026 - i));
+
+interface FormData {
+ nickname: string;
+ gender: string;
+ single: string;
+ profession: string;
+ intro: string;
+ wechat: string;
+ education: string;
+ graduationYear: string;
+ phone: string;
+}
+
+interface MediaInfo {
+ preview: string;
+ url: string;
+ type: "image" | "video";
+}
+
+export default function JoinPage() {
+ const [form, setForm] = useState({
+ nickname: "",
+ gender: "",
+ single: "",
+ profession: "",
+ intro: "",
+ wechat: "",
+ education: "",
+ graduationYear: "",
+ phone: "",
+ });
+ const [media, setMedia] = useState(null);
+ const [uploading, setUploading] = useState(false);
+ const fileRef = useRef(null);
+ const [submitted, setSubmitted] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [payRedirecting, setPayRedirecting] = useState(false);
+ const [payEnv, setPayEnv] = useState(null);
+ const [authOpen, setAuthOpen] = useState(false);
+
+ const checkAuth = useCallback(async () => {
+ if (getStoredToken()) return true;
+ try {
+ const res = await fetch("/api/digital/auth/me", { credentials: "include" });
+ const data = await res.json();
+ return !!(data?.user && data?.token);
+ } catch {
+ return false;
+ }
+ }, []);
+
+ useEffect(() => {
+ if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") {
+ setSubmitted(true);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ const env = getPayEnv();
+ setPayEnv(env);
+ }, []);
+
+ const set = (key: keyof FormData, value: string) =>
+ setForm((prev) => ({ ...prev, [key]: value }));
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ const loggedIn = await checkAuth();
+ if (!loggedIn) {
+ setAuthOpen(true);
+ setError("请先登录或注册后再提交申请");
+ return;
+ }
+ setSubmitting(true);
+ try {
+ const res = await fetch("/api/digital/join", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...form, media: media?.url }),
+ });
+ const data = await res.json();
+ if (!res.ok || !data?.ok) {
+ throw new Error(data?.error || "提交失败,请稍后重试");
+ }
+ const userId = data.user_id;
+ if (!userId) {
+ setSubmitted(true);
+ return;
+ }
+ setSubmitting(false);
+ setPayRedirecting(true);
+ // Z-Pay return_url 不支持带参数,使用 /join/paid 专用路径
+ const returnUrl = `${window.location.origin}/`;
+ const env = getPayEnv();
+ const channel = "wxpay";
+ const device = getDeviceFromEnv(env);
+ redirectToPay({
+ user_id: userId,
+ return_url: returnUrl,
+ channel,
+ device,
+ useJoinConfig: true,
+ });
+ return;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "网络错误,请重试");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (submitted) {
+ return (
+
+
+
+ ✅
+
+
申请已提交
+
+ 我们会尽快审核你的信息
+
+ 审核通过后将通过微信邀请你入群
+
+
+ ← 返回首页
+
+
+
+ );
+ }
+
+ return (
+
+ {/* ── PC: center column / H5: full width ── */}
+
+ {/* ── Banner ── */}
+
+ {/* Hero image area */}
+
+
+
🌍
+
+ Digital Nomad Community
+
+
+ {/* Decorative blobs */}
+
+
+
+
+ {/* Title block */}
+
+
+ 🌍 数字游民社区报名
+
+
+ {"💼 结识志同道合的伙伴\n📍 线下聚会 + 线上分享\n🤝 互助成长,资源共享"}
+
+
+
+
+ {/* ── Form card ── */}
+
+ {/* ---- Row group: nickname + gender (PC side-by-side) ---- */}
+
+
+ set("nickname", e.target.value)}
+ required
+ className="form-input"
+ />
+
+
+
+ set("gender", e.target.value)}
+ required
+ className={`form-input appearance-none ${
+ form.gender ? "text-slate-800" : "text-slate-400"
+ }`}
+ >
+
+ 请选择
+
+ {genderOptions.map((o) => (
+
+ {o}
+
+ ))}
+
+
+
+
+
+ {/* ---- Row group: single + profession ---- */}
+
+
+ set("single", e.target.value)}
+ required
+ className={`form-input appearance-none ${
+ form.single ? "text-slate-800" : "text-slate-400"
+ }`}
+ >
+
+ 请选择
+
+ {singleOptions.map((o) => (
+
+ {o}
+
+ ))}
+
+
+
+
+
+ set("profession", e.target.value)}
+ required
+ className="form-input"
+ />
+
+
+
+ {/* ---- wechat + phone ---- */}
+
+
+ set("wechat", e.target.value)}
+ required
+ className="form-input"
+ />
+
+
+
+ {
+ const v = e.target.value.replace(/\D/g, "").slice(0, 11);
+ set("phone", v);
+ }}
+ className="form-input"
+ />
+
+
+
+ {/* ---- Intro (full width) ---- */}
+
+
+ 📝 自我介绍
+
+
+ set("intro", e.target.value)}
+ className="form-input min-h-[120px] resize-none sm:min-h-[140px]"
+ />
+
+ {form.intro.length}/500
+
+
+
+
+ {/* ---- Row group: education + graduation ---- */}
+
+
+ set("education", e.target.value)}
+ className={`form-input appearance-none ${
+ form.education ? "text-slate-800" : "text-slate-400"
+ }`}
+ >
+
+ 请选择
+
+ {educationOptions.map((o) => (
+
+ {o}
+
+ ))}
+
+
+
+
+
+ set("graduationYear", e.target.value)}
+ className={`form-input appearance-none ${
+ form.graduationYear ? "text-slate-800" : "text-slate-400"
+ }`}
+ >
+
+ 请选择
+
+ {graduationYears.map((y) => (
+
+ {y}
+
+ ))}
+
+
+
+
+
+ {/* ---- Media upload (optional) ---- */}
+
+
+ 📷 头像 / 自拍视频
+
+ (可选)
+
+
+
+ 支持图片(JPG/PNG)或短视频(MP4),最大 20MB
+
+
+
) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ if (file.size > 20 * 1024 * 1024) {
+ alert("文件大小不能超过 20MB");
+ return;
+ }
+ const isVideo = file.type.startsWith("video/");
+ const preview = URL.createObjectURL(file);
+ setMedia({ preview, url: "", type: isVideo ? "video" : "image" });
+ setUploading(true);
+ try {
+ const fd = new FormData();
+ fd.append("file", file);
+ const res = await fetch("/api/digital/upload-media", {
+ method: "POST",
+ body: fd,
+ });
+ const data = await res.json();
+ if (!res.ok || !data?.ok) {
+ throw new Error(data?.error || "上传失败");
+ }
+ setMedia((m) => (m ? { ...m, url: data.url } : null));
+ } catch (err) {
+ alert(err instanceof Error ? err.message : "上传失败");
+ URL.revokeObjectURL(preview);
+ setMedia(null);
+ if (fileRef.current) fileRef.current.value = "";
+ } finally {
+ setUploading(false);
+ }
+ }}
+ />
+
+ {media ? (
+
+ {uploading && (
+
+ 上传中…
+
+ )}
+ {media.type === "image" ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
{
+ URL.revokeObjectURL(media.preview);
+ setMedia(null);
+ if (fileRef.current) fileRef.current.value = "";
+ }}
+ className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white shadow-md transition-colors hover:bg-red-600 disabled:opacity-50"
+ >
+ ✕
+
+
+ ) : (
+
fileRef.current?.click()}
+ className="flex h-32 w-32 flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-slate-200 text-slate-400 transition-colors hover:border-sky-300 hover:text-sky-500 disabled:opacity-50 sm:h-40 sm:w-40"
+ >
+
+
+
+ {uploading ? "上传中…" : "点击上传"}
+
+ )}
+
+
+ {/* ── 支付方式:默认微信支付,支付宝暂不提供 ── */}
+
+
+ {/* ── Submit ── */}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {uploading ? "媒体上传中…" : payRedirecting ? "跳转支付中…" : submitting ? "提交中…" : "提交申请"}
+
+
+
+
+
+ {/* ── Back link (PC) ── */}
+
+
+ ← 返回首页
+
+
+
+
+
+ ← 返回首页
+
+
+
+
+
setAuthOpen(false)}
+ onSuccess={() => {
+ setAuthOpen(false);
+ setError(null);
+ }}
+ />
+
+ );
+}
+
+/* ─── Reusable field wrapper ─── */
+function Field({
+ label,
+ children,
+ noBorder,
+}: {
+ label: string;
+ children: React.ReactNode;
+ noBorder?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+
{children}
+
+ );
+}
+
+/* ─── Chevron icon for selects ─── */
+function ChevronDown() {
+ return (
+
+
+
+ );
+}
diff --git a/app/[locale]/digital/join/paid/page.tsx b/app/[locale]/digital/join/paid/page.tsx
new file mode 100644
index 0000000..2b68762
--- /dev/null
+++ b/app/[locale]/digital/join/paid/page.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { useEffect } from "react";
+import { Link } from "@/app/digital/i18n/navigation";
+
+/**
+ * 支付完成回跳页
+ * Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
+ */
+export default function JoinPaidPage() {
+ useEffect(() => {
+ try {
+ localStorage.removeItem("join_pay_order");
+ } catch {}
+ document.cookie = "join_pay_order=; path=/; max-age=0";
+ }, []);
+
+ return (
+
+
+
+ ✅
+
+
申请已提交
+
+ 我们会尽快审核你的信息
+
+ 审核通过后将通过微信邀请你入群
+
+
+ ← 返回首页
+
+
+
+ );
+}
diff --git a/app/[locale]/digital/layout.tsx b/app/[locale]/digital/layout.tsx
new file mode 100644
index 0000000..26fdb5f
--- /dev/null
+++ b/app/[locale]/digital/layout.tsx
@@ -0,0 +1,44 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { LocaleProvider } from "../../digital/context/LocaleContext";
+import type { Locale } from "../../digital/context/LocaleContext";
+import { getThemeConfig } from "@/config/digital";
+import { getThemeMessages } from "@/app/digital/lib/theme-data";
+import DigitalProviders from "@/app/digital/DigitalProviders";
+
+const LOCALES: Locale[] = ["zh", "en"];
+
+export const metadata: Metadata = (() => {
+ const theme = getThemeConfig();
+ return {
+ title: theme.meta.title,
+ description: theme.meta.description,
+ };
+})();
+
+export function generateStaticParams() {
+ return LOCALES.map((locale) => ({ locale }));
+}
+
+export default async function LocaleLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ if (!LOCALES.includes(locale as Locale)) {
+ notFound();
+ }
+
+ const messages = await getThemeMessages(locale as Locale);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/[locale]/digital/page.tsx b/app/[locale]/digital/page.tsx
new file mode 100644
index 0000000..279265f
--- /dev/null
+++ b/app/[locale]/digital/page.tsx
@@ -0,0 +1,29 @@
+import Header from "../../digital/components/Header";
+import Hero from "../../digital/components/Hero";
+import Features from "../../digital/components/Features";
+import Roadmap from "../../digital/components/Roadmap";
+import Tools from "../../digital/components/Tools";
+import Community from "../../digital/components/Community";
+import Footer from "../../digital/components/Footer";
+import { getThemeToolsData, getResourceData } from "../../digital/lib/theme-data";
+
+export default async function HomePage() {
+ const [toolsData, resourceData] = await Promise.all([
+ getThemeToolsData(),
+ getResourceData(),
+ ]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/[locale]/digital/privacy/PrivacyContent.tsx b/app/[locale]/digital/privacy/PrivacyContent.tsx
new file mode 100644
index 0000000..054b33f
--- /dev/null
+++ b/app/[locale]/digital/privacy/PrivacyContent.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+import { useLocale } from "@/app/digital/i18n/navigation";
+
+function renderWithLinks(text: string) {
+ const urlRegex = /(https?:\/\/[^\s)]+)/g;
+ const parts = text.split(urlRegex);
+ return parts.map((part, i) =>
+ part.match(urlRegex) ? (
+
+ {part}
+
+ ) : (
+ part
+ )
+ );
+}
+
+const PRIVACY_ZH = {
+ title: "隐私政策",
+ lastUpdated: "最后更新",
+ sections: [
+ {
+ title: "一、适用范围",
+ content: `本隐私政策适用于「数字游民指南」(以下简称本网站)在您使用网站服务时收集的个人信息。本政策不适用于本网站以外的第三方链接网站,也不适用于非本网站委托或参与管理的人员。`,
+ },
+ {
+ title: "二、个人资料的收集、处理及利用",
+ content: `当您访问本网站或使用本网站提供的功能服务时,我们将根据服务性质请您提供必要的个人资料,并在特定目的范围内处理及利用;未经您书面同意,本网站不会将个人资料用于其他用途。
+
+本网站在您使用申请表单、问卷、支付等互动功能时,会保留您提供的姓名、电子邮箱、联系方式及使用时间等。
+
+在一般浏览时,服务器会自动记录相关行为,包括您使用设备的 IP 地址、使用时间、浏览器类型、浏览及点击记录等,作为我们改进网站服务的参考,此记录仅供内部使用,不会对外公布。
+
+您可以随时向我们提出请求,以更正或删除您的账户或本网站所收集的个人资料。联系方式请见文末。`,
+ },
+ {
+ title: "三、支付处理与 Stripe",
+ content: `本网站使用 Stripe 作为支付服务提供商处理付款。在您进行支付时:
+
+• 数据控制者:本网站为个人数据的控制者,Stripe 作为数据处理者代表我们处理支付相关数据。
+• Stripe 隐私政策:您可查阅 Stripe 的隐私政策 https://stripe.com/privacy
+• 收集的数据类别:Stripe 直接收集的数据包括卡号、有效期等支付信息及设备数据;我们向 Stripe 共享的数据包括邮箱、姓名、账单地址等完成交易所需信息。
+• 国际数据传输:支付数据可能传输至美国等地区,Stripe 采用标准合同条款及欧盟-美国数据隐私框架等机制保障合规。
+• 法律依据:支付处理基于合同履行(完成支付义务)、法律义务(税务与会计要求)及合法利益(欺诈防范)。
+• 数据保留:交易记录通常保留 7–10 年以满足税务及会计要求;支付失败日志可能保留 90 天至 1 年。`,
+ },
+ {
+ title: "四、资料保护",
+ content: `本网站主机设有防火墙、防毒系统等安全设备及必要防护措施,保护网站及您的个人资料。个人资料仅由经授权人员接触,相关处理人员均签署保密协议,违反者将承担法律责任。
+
+如因业务需要委托其他单位提供服务,本网站将严格要求其遵守保密义务,并采取必要检查程序确保其切实遵守。`,
+ },
+ {
+ title: "五、外部链接",
+ content: `本网站提供其他网站的链接,您可经由本网站提供的链接进入其他网站。该等链接网站不适用本网站的隐私政策,您须参考该链接网站自身的隐私政策。`,
+ },
+ {
+ title: "六、与第三人共用个人资料",
+ content: `本网站不会提供、交换、出租或出售您的个人资料给其他个人、团体、私人企业或公务机关,但有法律依据或合约义务者不在此限,包括但不限于:经您书面同意、法律明文规定、为免除您生命身体自由或财产上之危险、与公务机关或学术研究机构基于公共利益进行统计或学术研究且资料经处理后无法识别特定当事人、或为辨识联络或采取法律行动所必要者。
+
+本网站委托厂商协助收集、处理或利用您的个人资料时,将对委外厂商善尽监督管理之责。`,
+ },
+ {
+ title: "七、Cookie 使用",
+ content: `为提供您最佳服务,本网站会在您的设备中放置并读取 Cookie。若您不愿接受 Cookie,可在浏览器中设定较高的隐私等级以拒绝 Cookie,但可能导致网站部分功能无法正常执行。`,
+ },
+ {
+ title: "八、政策修订",
+ content: `本隐私政策将因应需求随时修正,修正后的条款将刊登于本网站。`,
+ },
+ {
+ title: "九、联系管道",
+ content: `若对本隐私政策有任何疑问,或欲提出变更、删除个人资料之请求,请前往本网站「联系我们」页面提交表单,或通过以下方式联系我们:
+
+• 网站:数字游民指南
+• 邮箱:请通过网站联系我们页面获取
+• 地址:请通过网站联系我们页面获取`,
+ },
+ ],
+};
+
+const PRIVACY_EN = {
+ title: "Privacy Policy",
+ lastUpdated: "Last updated",
+ sections: [
+ {
+ title: "1. Scope",
+ content: `This Privacy Policy applies to personal information collected by "Digital Nomad Guide" (the "Site") when you use our services. This policy does not apply to third-party websites linked from this Site, nor to persons not entrusted or involved in the management of this Site.`,
+ },
+ {
+ title: "2. Collection, Processing and Use of Personal Data",
+ content: `When you visit the Site or use our services, we may ask you to provide necessary personal data for specific purposes. We will not use your personal data for other purposes without your written consent.
+
+When you use application forms, surveys, payments, or other interactive features, we retain information you provide, such as name, email, contact details, and usage time.
+
+During general browsing, our servers automatically record relevant activity, including your IP address, access time, browser type, and browsing/click records, for improving our services. Such records are for internal use only and are not disclosed to the public.
+
+You may request correction or deletion of your account or personal data collected by the Site at any time. Contact details are provided at the end of this policy.`,
+ },
+ {
+ title: "3. Payment Processing and Stripe",
+ content: `We use Stripe as our payment service provider. When you make a payment:
+
+• Data Controller: We are the data controller; Stripe acts as a data processor on our behalf.
+• Stripe Privacy Policy: https://stripe.com/privacy
+• Data Categories: Stripe directly collects payment card details and device data; we share email, name, billing address, and other information necessary to complete the transaction.
+• International Transfers: Payment data may be transferred to the US and other regions. Stripe uses Standard Contractual Clauses and the EU-US Data Privacy Framework to ensure compliance.
+• Legal Basis: Payment processing is based on contract performance (fulfilling payment obligations), legal obligation (tax and accounting requirements), and legitimate interest (fraud prevention).
+• Data Retention: Transaction records are typically retained for 7–10 years for tax and accounting purposes; payment failure logs may be retained for 90 days to 1 year.`,
+ },
+ {
+ title: "4. Data Protection",
+ content: `Our servers are protected by firewalls, antivirus systems, and other security measures. Personal data is accessible only to authorized personnel who have signed confidentiality agreements. Violations will result in legal liability.
+
+When we entrust third parties to process personal data, we require them to comply with confidentiality obligations and conduct necessary audits.`,
+ },
+ {
+ title: "5. External Links",
+ content: `The Site provides links to other websites. Those linked sites are not governed by this Privacy Policy. Please refer to the privacy policy of each linked site.`,
+ },
+ {
+ title: "6. Sharing Personal Data with Third Parties",
+ content: `We will not provide, exchange, rent, or sell your personal data to other individuals, groups, or entities, except where required by law or contract, including but not limited to: your written consent, legal requirements, protection of life or property, statistical or academic research with anonymized data, or identification/contact/legal action as necessary.
+
+When we entrust third parties to collect, process, or use your personal data, we will supervise them appropriately.`,
+ },
+ {
+ title: "7. Use of Cookies",
+ content: `To provide you with the best service, we place and read cookies on your device. You may set a higher privacy level in your browser to refuse cookies, but some features of the Site may not function properly.`,
+ },
+ {
+ title: "8. Policy Amendments",
+ content: `We may amend this Privacy Policy from time to time. Amended terms will be posted on the Site.`,
+ },
+ {
+ title: "9. Contact",
+ content: `If you have questions about this Privacy Policy or wish to request correction or deletion of your personal data, please submit a form via our "Contact Us" page, or contact us through:
+
+• Site: Digital Nomad Guide
+• Email: Available via the Contact Us page
+• Address: Available via the Contact Us page`,
+ },
+ ],
+};
+
+export default function PrivacyContent() {
+ const locale = useLocale();
+ const content = locale === "zh" ? PRIVACY_ZH : PRIVACY_EN;
+
+ return (
+
+
+
+ {content.title}
+
+
+ {content.lastUpdated}: 2025-03
+
+
+
+ {content.sections.map((section) => (
+
+
+ {section.title}
+
+
+ {renderWithLinks(section.content)}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/[locale]/digital/privacy/page.tsx b/app/[locale]/digital/privacy/page.tsx
new file mode 100644
index 0000000..81d3ffe
--- /dev/null
+++ b/app/[locale]/digital/privacy/page.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from "next";
+import Header from "../../../digital/components/Header";
+import Footer from "../../../digital/components/Footer";
+import PrivacyContent from "./PrivacyContent";
+
+export const metadata: Metadata = {
+ title: "隐私政策 | Privacy Policy",
+ description: "数字游民指南隐私政策,说明我们如何收集、使用和保护您的个人信息。",
+};
+
+export default function PrivacyPage() {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx
index f9ef49f..9f9e3de 100644
--- a/app/[locale]/layout.tsx
+++ b/app/[locale]/layout.tsx
@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
import { LocaleProvider } from "../context/LocaleContext";
import type { Locale } from "../context/LocaleContext";
import { getThemeMessages } from "@/app/lib/theme-data";
-import Navbar from "../components/Navbar";
+import RouteAwareNavbar from "../components/RouteAwareNavbar";
import SetLangAttr from "../components/SetLangAttr";
const LOCALES: Locale[] = ["zh", "en"];
@@ -35,7 +35,7 @@ export default async function LocaleLayout({
return (
-
+
{children}
);
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index d942e88..2ae1275 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -82,7 +82,7 @@ const FALLBACK_HOME_CONTENT: ContentItem[] = [
subtitle: "完整方法论、工具、案例和长期更新",
subtitleEn: "Methods, tools, cases, and long-term updates.",
coverImage: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200",
- targetUrl: "https://digital.hackrobot.cn/zh",
+ targetUrl: "/digital",
ctaLabel: "打开指南",
ctaLabelEn: "Open guide",
},
@@ -336,7 +336,6 @@ export default function Home() {
{homeContent.slice(0, 4).map((item, index) => {
- const isExternal = item.targetUrl.startsWith("http");
const title = locale === "zh" ? item.title : item.titleEn || item.title;
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
const cta = locale === "zh" ? item.ctaLabel : item.ctaLabelEn || item.ctaLabel;
@@ -344,9 +343,10 @@ export default function Home() {
ebook: { header: "电子书", icon: "📘", href: `/ebooks/${item.slug}` },
video: { header: "访谈视频", icon: "▶️", href: `/videos/${item.slug}` },
app: { header: "App", icon: "📱", href: "/app" },
- guide: { header: "数字游民指南", icon: "🧭", href: item.targetUrl },
+ guide: { header: "数字游民指南", icon: "🧭", href: "/digital" },
};
const meta = typeMeta[item.type] || typeMeta.guide;
+ const isExternal = meta.href.startsWith("http");
const content = (
<>
{meta.header}
diff --git a/app/api/digital/auth/login/route.ts b/app/api/digital/auth/login/route.ts
new file mode 100644
index 0000000..d17d975
--- /dev/null
+++ b/app/api/digital/auth/login/route.ts
@@ -0,0 +1,45 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getPocketBaseConfig } from "@/config/digital/services.config";
+import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/digital/lib/auth-cookie";
+
+export async function POST(request: NextRequest) {
+ const body = await request.json().catch(() => ({}));
+ const identity = String(body?.identity || body?.email || "").trim();
+ const password = String(body?.password || "");
+
+ if (!identity || !password) {
+ return NextResponse.json({ ok: false, error: "Missing identity or password" }, { status: 400 });
+ }
+
+ const pb = getPocketBaseConfig();
+ const pbRes = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/auth-with-password`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ identity, password }),
+ });
+
+ const pbData = await pbRes.json().catch(() => ({}));
+ if (!pbRes.ok || !pbData?.token || !pbData?.record?.id) {
+ return NextResponse.json(
+ { ok: false, error: pbData?.message || "Login failed" },
+ { status: pbRes.status || 401 }
+ );
+ }
+
+ const payload = JSON.stringify({
+ token: pbData.token,
+ userId: pbData.record.id,
+ email: pbData.record.email ?? identity,
+ });
+ const encoded = Buffer.from(payload, "utf-8").toString("base64url");
+
+ const res = NextResponse.json({
+ ok: true,
+ token: pbData.token,
+ record: pbData.record,
+ user: { id: pbData.record.id, email: pbData.record.email ?? identity },
+ });
+ res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record
);
+ return res;
+}
+
diff --git a/app/api/digital/auth/logout/route.ts b/app/api/digital/auth/logout/route.ts
new file mode 100644
index 0000000..a6e67b7
--- /dev/null
+++ b/app/api/digital/auth/logout/route.ts
@@ -0,0 +1,8 @@
+import { NextRequest, NextResponse } from "next/server";
+import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/digital/lib/auth-cookie";
+
+export async function POST(request: NextRequest) {
+ const res = NextResponse.json({ ok: true });
+ res.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record);
+ return res;
+}
diff --git a/app/api/digital/auth/me/route.ts b/app/api/digital/auth/me/route.ts
new file mode 100644
index 0000000..8684164
--- /dev/null
+++ b/app/api/digital/auth/me/route.ts
@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getPocketBaseConfig } from "@/config/digital/services.config";
+import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/digital/lib/auth-cookie";
+
+export async function GET(request: NextRequest) {
+ const cookie = request.cookies.get(COOKIE_NAME)?.value;
+ if (!cookie) {
+ return NextResponse.json({ ok: true, user: null });
+ }
+ try {
+ const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
+ const { token, userId, email } = payload;
+ if (!token || !userId) return NextResponse.json({ ok: true, user: null });
+
+ const pb = getPocketBaseConfig();
+ const res = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/auth-refresh`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) {
+ const r = NextResponse.json({ ok: true, user: null });
+ r.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record);
+ return r;
+ }
+ const data = await res.json();
+ const newToken = data.token;
+ const newRecord = data.record;
+ if (newToken && newRecord?.id) {
+ const newPayload = JSON.stringify({
+ token: newToken,
+ userId: newRecord.id,
+ email: newRecord.email ?? email,
+ });
+ const encoded = Buffer.from(newPayload, "utf-8").toString("base64url");
+ const r = NextResponse.json({
+ ok: true,
+ user: { id: newRecord.id, email: newRecord.email ?? email },
+ token: newToken,
+ });
+ r.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record);
+ return r;
+ }
+ return NextResponse.json({
+ ok: true,
+ user: { id: data.record?.id ?? userId, email: data.record?.email ?? email },
+ token: data.token,
+ });
+ } catch {
+ return NextResponse.json({ ok: true, user: null });
+ }
+}
diff --git a/app/api/digital/auth/register/route.ts b/app/api/digital/auth/register/route.ts
new file mode 100644
index 0000000..a41e3b7
--- /dev/null
+++ b/app/api/digital/auth/register/route.ts
@@ -0,0 +1,62 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getPocketBaseConfig } from "@/config/digital/services.config";
+import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/digital/lib/auth-cookie";
+
+export async function POST(request: NextRequest) {
+ const body = await request.json().catch(() => ({}));
+ const email = String(body?.email || "").trim();
+ const password = String(body?.password || "");
+ const passwordConfirm = String(body?.passwordConfirm || "");
+
+ if (!email || !password || !passwordConfirm) {
+ return NextResponse.json(
+ { ok: false, error: "Missing email/password/passwordConfirm" },
+ { status: 400 }
+ );
+ }
+
+ const pb = getPocketBaseConfig();
+ const createRes = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/records`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, password, passwordConfirm }),
+ });
+
+ const createData = await createRes.json().catch(() => ({}));
+ if (!createRes.ok) {
+ return NextResponse.json(
+ { ok: false, error: createData?.message || "Register failed" },
+ { status: createRes.status || 400 }
+ );
+ }
+
+ const loginRes = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/auth-with-password`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ identity: email, password }),
+ });
+ const loginData = await loginRes.json().catch(() => ({}));
+ if (!loginRes.ok || !loginData?.token || !loginData?.record?.id) {
+ return NextResponse.json(
+ { ok: false, error: loginData?.message || "Auto login failed after register" },
+ { status: loginRes.status || 401 }
+ );
+ }
+
+ const payload = JSON.stringify({
+ token: loginData.token,
+ userId: loginData.record.id,
+ email: loginData.record.email ?? email,
+ });
+ const encoded = Buffer.from(payload, "utf-8").toString("base64url");
+
+ const res = NextResponse.json({
+ ok: true,
+ token: loginData.token,
+ record: loginData.record,
+ user: { id: loginData.record.id, email: loginData.record.email ?? email },
+ });
+ res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record);
+ return res;
+}
+
diff --git a/app/api/digital/auth/sync-session/route.ts b/app/api/digital/auth/sync-session/route.ts
new file mode 100644
index 0000000..d105875
--- /dev/null
+++ b/app/api/digital/auth/sync-session/route.ts
@@ -0,0 +1,22 @@
+import { NextRequest, NextResponse } from "next/server";
+import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/digital/lib/auth-cookie";
+
+export async function POST(request: NextRequest) {
+ const body = await request.json().catch(() => ({}));
+ const token = body?.token;
+ const record = body?.record;
+ if (!token || !record?.id) {
+ return NextResponse.json({ ok: false, error: "缺少 token 或 record" }, { status: 400 });
+ }
+
+ const payload = JSON.stringify({
+ token,
+ userId: record.id,
+ email: record.email ?? "",
+ });
+ const encoded = Buffer.from(payload, "utf-8").toString("base64url");
+
+ const res = NextResponse.json({ ok: true });
+ res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record);
+ return res;
+}
diff --git a/app/api/digital/join/route.ts b/app/api/digital/join/route.ts
new file mode 100644
index 0000000..e1c99fa
--- /dev/null
+++ b/app/api/digital/join/route.ts
@@ -0,0 +1,130 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getPocketBaseConfig } from "@/config/digital/services.config";
+import { getThemeConfig } from "@/config/digital";
+
+const COOKIE_NAME = "pb_session";
+
+function getOrCreateUserId(request: NextRequest): string {
+ const cookie = request.cookies.get(COOKIE_NAME)?.value;
+ if (cookie) {
+ try {
+ const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
+ if (payload?.userId) return payload.userId;
+ } catch {
+ /* ignore */
+ }
+ }
+ return `user${Date.now()}`;
+}
+
+async function getAdminToken(): Promise {
+ const email = process.env.POCKETBASE_EMAIL;
+ const password = process.env.POCKETBASE_PASSWORD;
+ if (!email || !password) return null;
+
+ const pb = getPocketBaseConfig();
+ const res = await fetch(`${pb.url}/api/admins/auth-with-password`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ identity: email, password }),
+ });
+ if (!res.ok) return null;
+ const data = await res.json();
+ return data?.token ?? null;
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const userId = getOrCreateUserId(request);
+ const body = await request.json();
+ const formData = body && typeof body === "object" ? body : {};
+
+ const {
+ nickname,
+ gender,
+ single,
+ profession,
+ intro,
+ wechat,
+ education,
+ graduationYear,
+ phone,
+ media: mediaUrl,
+ } = formData;
+
+ // reason=自我介绍, single=感情状态, media=MinIO 上传后的 URL
+ const record: Record = {
+ nickname: nickname || "",
+ occupation: profession || "",
+ reason: intro || "",
+ single: single || "",
+ wechatId: wechat || "",
+ gender: gender || "",
+ education: education || "",
+ gradYear: graduationYear || "",
+ phone: phone || "",
+ user_id: userId,
+ amount: 0,
+ order_id: "",
+ type: "",
+ };
+
+ if (mediaUrl && typeof mediaUrl === "string") {
+ (record as Record)["media"] = mediaUrl;
+ }
+
+ const theme = getThemeConfig();
+ const pb = getPocketBaseConfig({
+ joinCollection: theme.services?.pocketbaseJoinCollection,
+ });
+ const collection = pb.joinCollection;
+
+ const doCreate = async (authToken?: string) => {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ if (authToken) headers["Authorization"] = authToken;
+
+ return fetch(`${pb.url}/api/collections/${collection}/records`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(record),
+ });
+ };
+
+ let res = await doCreate();
+
+ if (res.status === 403) {
+ const token = await getAdminToken();
+ if (token) res = await doCreate(token);
+ }
+
+ if (!res.ok) {
+ const errText = await res.text();
+ console.error("PocketBase create error:", res.status, errText);
+ let errMsg = "提交失败,请稍后重试";
+ try {
+ const errJson = JSON.parse(errText);
+ if (errJson?.message) errMsg = errJson.message;
+ if (errJson?.data && typeof errJson.data === "object") {
+ const details = Object.entries(errJson.data)
+ .map(([k, v]) => `${k}: ${(v as { message?: string })?.message || v}`)
+ .join("; ");
+ if (details) errMsg += ` (${details})`;
+ }
+ } catch {
+ if (errText.length < 200) errMsg = errText;
+ }
+ return NextResponse.json({ ok: false, error: errMsg }, { status: 500 });
+ }
+
+ return NextResponse.json({ ok: true, user_id: userId });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.error("Join API error:", e);
+ return NextResponse.json(
+ { ok: false, error: `提交失败: ${msg}` },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/digital/pay/confirm/route.ts b/app/api/digital/pay/confirm/route.ts
new file mode 100644
index 0000000..d59a723
--- /dev/null
+++ b/app/api/digital/pay/confirm/route.ts
@@ -0,0 +1,169 @@
+/**
+ * 支付确认:对齐 nomadvip 标准。payjsapi 异步通知未到达时,由前端直接写入 PocketBase site_vip
+ */
+import { NextRequest, NextResponse } from "next/server";
+import {
+ getApiUrlFromRequest,
+ getPocketBaseConfig,
+ getPaymentConfig,
+ getJoinPaymentConfig,
+ SITE_ID,
+} from "@/config/digital/services.config";
+import { queryXorPayOrderStatus } from "@/app/digital/lib/payment/xorpayStatus";
+import { queryZPayOrderStatus } from "@/app/digital/lib/payment/zpayStatus";
+
+function parseUserIdFromOrderId(orderId: string): string | null {
+ if (!orderId || !orderId.includes("_order_")) return null;
+ const prefix = orderId.split("_order_")[0];
+ if (!prefix || !prefix.includes("_")) return prefix || null;
+ const parts = prefix.split("_");
+ const type = parts[parts.length - 1];
+ if (["vip", "video", "meetup", "book"].includes(type?.toLowerCase() || "")) {
+ return parts.slice(0, -1).join("_") || null;
+ }
+ return prefix;
+}
+
+async function getAdminToken(): Promise {
+ const email = process.env.POCKETBASE_EMAIL || process.env.PB_ADMIN_EMAIL;
+ const password = process.env.POCKETBASE_PASSWORD || process.env.PB_ADMIN_PASSWORD;
+ if (!email || !password) return null;
+ const pb = getPocketBaseConfig();
+ const paths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"];
+ for (const path of paths) {
+ const res = await fetch(`${pb.url}${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ identity: email, password }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ if (data?.token) return data.token;
+ }
+ }
+ return null;
+}
+
+function resolvePayApiUrl(request: NextRequest): string {
+ const payConfig = getPaymentConfig();
+ const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
+ return (
+ (hostHeader ? getApiUrlFromRequest(hostHeader) : null) ||
+ payConfig.apiUrl
+ ).replace(/\/$/, "");
+}
+
+async function verifyOrderPaid(request: NextRequest, orderId: string): Promise {
+ const apiUrl = resolvePayApiUrl(request);
+ const url = `${apiUrl}/digital/order_status?order_id=${encodeURIComponent(orderId)}`;
+ try {
+ const res = await fetch(url, { cache: "no-store" });
+ const data = await res.json().catch(() => ({}));
+ if (data?.paid) return true;
+ } catch (e) {
+ console.error("[pay/confirm] verifyOrderPaid fetch error:", e);
+ }
+ if (await queryXorPayOrderStatus(orderId, 5000).then((r) => r?.paid)) return true;
+ if (await queryZPayOrderStatus(orderId, 5000).then((r) => r?.paid)) return true;
+ return false;
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json().catch(() => ({}));
+ const orderId = String(body?.order_id ?? body?.orderId ?? "").trim();
+ let userId = String(body?.user_id ?? body?.userId ?? "").trim();
+
+ if (!orderId) {
+ return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
+ }
+ if (!userId) {
+ userId = parseUserIdFromOrderId(orderId) || "";
+ }
+ if (!userId) {
+ return NextResponse.json({ ok: false, error: "缺少 user_id 且无法从 order_id 解析" }, { status: 400 });
+ }
+
+ const paid = await verifyOrderPaid(request, orderId);
+ if (!paid) {
+ return NextResponse.json({ ok: false, error: "订单未支付" }, { status: 400 });
+ }
+
+ const adminToken = await getAdminToken();
+ if (!adminToken) {
+ return NextResponse.json({ ok: false, error: "PocketBase 未配置" }, { status: 500 });
+ }
+
+ const pb = getPocketBaseConfig();
+ const joinConfig = getJoinPaymentConfig();
+ const totalFee = Number(joinConfig?.amount ?? 8800);
+
+ const checkPayments = await fetch(
+ `${pb.url}/api/collections/payments/records?filter=${encodeURIComponent(`order_id="${orderId}"`)}&perPage=1`,
+ { headers: { Authorization: `Bearer ${adminToken}` } }
+ );
+ const paymentsData = await checkPayments.json().catch(() => ({}));
+ if ((paymentsData?.items?.length ?? 0) === 0) {
+ await fetch(`${pb.url}/api/collections/payments/records`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${adminToken}`,
+ },
+ body: JSON.stringify({
+ user_id: userId,
+ type: "video",
+ order_id: orderId,
+ total_fee: totalFee,
+ amount: totalFee,
+ channel: "wxpay",
+ }),
+ });
+ }
+
+ const siteVipCheck = await fetch(
+ `${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(`user_id="${userId}" && site_id="${SITE_ID}"`)}&perPage=1`,
+ { headers: { Authorization: `Bearer ${adminToken}` } }
+ );
+ const siteVipData = await siteVipCheck.json().catch(() => ({}));
+ const siteVipItems = siteVipData?.items ?? [];
+
+ if (siteVipItems.length > 0) {
+ await fetch(`${pb.url}/api/collections/site_vip/records/${siteVipItems[0].id}`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${adminToken}`,
+ },
+ body: JSON.stringify({ order_id: orderId }),
+ });
+ } else {
+ const siteVipRes = await fetch(`${pb.url}/api/collections/site_vip/records`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${adminToken}`,
+ },
+ body: JSON.stringify({
+ user_id: userId,
+ site_id: SITE_ID,
+ order_id: orderId,
+ }),
+ });
+ if (!siteVipRes.ok) {
+ const err = await siteVipRes.json().catch(() => ({}));
+ if (!err?.data?.order_id?.message?.includes("unique") && !err?.message?.includes("unique")) {
+ console.error("[pay/confirm] site_vip create failed:", await siteVipRes.text());
+ }
+ }
+ }
+
+ return NextResponse.json({ ok: true });
+ } catch (e) {
+ console.error("[pay/confirm] error:", e);
+ return NextResponse.json(
+ { ok: false, error: e instanceof Error ? e.message : "确认失败" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/digital/pay/route.ts b/app/api/digital/pay/route.ts
new file mode 100644
index 0000000..782a982
--- /dev/null
+++ b/app/api/digital/pay/route.ts
@@ -0,0 +1,465 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ getApiUrlFromRequest,
+ getJoinPaymentConfig,
+ getPaymentConfig,
+ SITE_ID,
+} from "@/config/digital/services.config";
+import { SITE_URL } from "@/app/digital/lib/env";
+
+const ORDER_COOKIE = "join_pay_order";
+
+function resolvePayApiUrl(request: NextRequest): string {
+ const config = getPaymentConfig();
+ const hostHeader =
+ request.headers.get("host") || request.headers.get("x-forwarded-host");
+ return (getApiUrlFromRequest(hostHeader) || config.apiUrl).replace(/\/$/, "");
+}
+
+function setPayOrderCookie(response: NextResponse, orderId: string) {
+ response.cookies.set(ORDER_COOKIE, `${orderId}|${Date.now()}`, {
+ path: "/",
+ maxAge: 900,
+ });
+}
+
+function escapeAttr(s: string): string {
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(/"/g, """)
+ .replace(//g, ">");
+}
+
+function buildPayHtml(
+ payUrl: string,
+ params: Record,
+ options?: { directToCashier?: boolean; orderId?: string }
+): string {
+ const inputs = Object.entries(params)
+ .filter(([, value]) => value != null && String(value).trim() !== "")
+ .map(
+ ([key, value]) =>
+ ` `
+ )
+ .join("\n");
+
+ const pendingOrderScript = options?.orderId
+ ? ``
+ : "";
+
+ return options?.directToCashier
+ ? `支付 ${inputs} ${pendingOrderScript}`
+ : `跳转支付 正在跳转到支付页面...
${inputs} ${pendingOrderScript}`;
+}
+
+function buildQrHtml(
+ imgSrc: string,
+ returnUrl: string,
+ orderId: string,
+ opts?: { channel?: string; successDesc?: string; confirmUrl?: string }
+): string {
+ const ch = (opts?.channel || "wxpay").toLowerCase();
+ const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
+ const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
+ const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
+ const successDesc = opts?.successDesc || "支付成功,即将跳转";
+ const confirmUrl = opts?.confirmUrl || "/api/digital/pay/confirm";
+ const esc = (s: string) =>
+ s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
+ return `
+
+${esc(title)}
+
+
+
+
+
${esc(title)}
+
${esc(hint)}
+
+
+
+
+
+
+
等待支付中...
+
+
+
✅
+
支付成功
+
${esc(successDesc)}
+
3 秒后跳转
+
+
+
+`;
+}
+
+async function createPayOrder(
+ apiUrl: string,
+ userId: string,
+ returnUrl: string,
+ totalFee: number,
+ type: string,
+ channel: string,
+ provider?: "xorpay",
+ clientIp?: string
+) {
+ const payload: Record = {
+ user_id: userId,
+ site_id: SITE_ID,
+ total_fee: totalFee,
+ type,
+ channel,
+ return_url: returnUrl,
+ ...(provider ? { provider } : {}),
+ ...(clientIp ? { client_ip: clientIp } : {}),
+ };
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10000);
+ const res = await fetch(`${apiUrl}/digital/payh5`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ signal: controller.signal,
+ }).finally(() => clearTimeout(timeout));
+
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok || data?.status !== "ok") {
+ console.error("[pay] createPayOrder failed status=%s body=%j", res.status, data);
+ throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败");
+ }
+
+ return {
+ params: (data.params ?? data.xorpay_params ?? {}) as Record,
+ payUrl:
+ data.pay_url || data.xorpay_url || getPaymentConfig().zpaySubmitUrl,
+ provider: String(data.provider || (provider ? "xorpay" : "zpay")).trim(),
+ orderId: String(
+ data.order_id ||
+ data.params?.out_trade_no ||
+ data.xorpay_params?.order_id ||
+ ""
+ ).trim(),
+ };
+}
+
+async function requestRedirect(
+ apiUrl: string,
+ path: string,
+ payload: Record
+) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 12000);
+ try {
+ return await fetch(`${apiUrl}${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ redirect: "manual",
+ signal: controller.signal,
+ });
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ let body: Record;
+ const contentType = request.headers.get("content-type") || "";
+ if (contentType.includes("application/json")) {
+ body = (await request.json()) as Record;
+ } else {
+ const form = await request.formData();
+ body = Object.fromEntries(form.entries()) as Record;
+ }
+
+ const joinConfig = getJoinPaymentConfig();
+ const payConfig = getPaymentConfig();
+ const hostHeader =
+ request.headers.get("host") || request.headers.get("x-forwarded-host");
+ const apiUrl = resolvePayApiUrl(request);
+ const userId = String(body?.user_id || "").trim();
+ const totalFee = Number(body?.total_fee) || joinConfig.amount;
+ const type = String(body?.type || joinConfig.type);
+ const channel = String(body?.channel || "wxpay");
+ const device = String(body?.device || "pc");
+ const forcedProvider = device === "wechat" ? "xorpay" : undefined;
+
+ if (!userId) {
+ return NextResponse.json(
+ { ok: false, error: "缺少 user_id" },
+ { status: 400 }
+ );
+ }
+
+ const origin =
+ request.headers.get("x-forwarded-host")
+ ? `${request.headers.get("x-forwarded-proto") || "https"}://${request.headers.get("x-forwarded-host")}`
+ : request.headers.get("origin") || SITE_URL;
+ const returnUrl =
+ String(body?.return_url || "").trim() || `${origin}/zh/digital/join/paid`;
+ const clientIp =
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ request.headers.get("x-real-ip") ||
+ "";
+ const wantHtml =
+ String(body?.html) === "1" ||
+ (request.headers.get("accept") || "").includes("text/html");
+
+ console.log(
+ "[pay] digital apiUrl=%s host=%s provider=%s device=%s",
+ apiUrl,
+ hostHeader,
+ forcedProvider || "default",
+ device
+ );
+
+ const created = await createPayOrder(
+ apiUrl,
+ userId,
+ returnUrl,
+ totalFee,
+ type,
+ channel,
+ forcedProvider,
+ clientIp
+ );
+
+ if (!wantHtml) {
+ const response = NextResponse.json({
+ ok: true,
+ params: created.params,
+ payUrl: created.payUrl,
+ provider: created.provider,
+ order_id: created.orderId,
+ });
+ if (created.orderId) setPayOrderCookie(response, created.orderId);
+ return response;
+ }
+
+ if (created.provider === "xorpay") {
+ const html = buildPayHtml(created.payUrl, created.params, {
+ directToCashier: true,
+ orderId: created.orderId,
+ });
+ const response = new NextResponse(html, {
+ status: 200,
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ });
+ if (created.orderId) setPayOrderCookie(response, created.orderId);
+ return response;
+ }
+
+ const redirectPayload: Record = {
+ user_id: userId,
+ site_id: SITE_ID,
+ total_fee: totalFee,
+ type,
+ channel,
+ return_url: returnUrl,
+ device,
+ ...(clientIp ? { client_ip: clientIp } : {}),
+ };
+
+ try {
+ const redirectRes = await requestRedirect(
+ apiUrl,
+ "/digital/payh5/redirect",
+ redirectPayload
+ );
+
+ if (redirectRes.status >= 301 && redirectRes.status <= 308) {
+ const location = redirectRes.headers.get("location");
+ if (location) {
+ const response = NextResponse.redirect(location);
+ const orderId =
+ redirectRes.headers.get("x-pay-order-id")?.trim() ||
+ created.orderId;
+ if (orderId) setPayOrderCookie(response, orderId);
+ return response;
+ }
+ }
+
+ const resData = await redirectRes.json().catch(() => ({}));
+ if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
+ const safeReturnUrl = String(resData.return_url || returnUrl).replace(
+ /[&<>"']/g,
+ (c) =>
+ ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
+ c
+ ] || c)
+ );
+ const imgSrc = String(resData.img).replace(
+ /[&<>"']/g,
+ (c) =>
+ ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
+ c
+ ] || c)
+ );
+ const rawOrderId = String(resData.order_id || created.orderId).trim();
+ const html = buildQrHtml(imgSrc, safeReturnUrl, rawOrderId, {
+ channel: resData.channel || "wxpay",
+ successDesc: "支付成功,课程已解锁",
+ });
+ const response = new NextResponse(html, {
+ status: 200,
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ });
+ if (rawOrderId) setPayOrderCookie(response, rawOrderId);
+ return response;
+ }
+
+ if (redirectRes.status === 200 && resData?.use_form) {
+ const html = buildPayHtml(payConfig.zpaySubmitUrl, created.params, {
+ directToCashier: true,
+ orderId: String(resData.order_id || created.orderId).trim(),
+ });
+ const response = new NextResponse(html, {
+ status: 200,
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ });
+ const orderId = String(resData.order_id || created.orderId).trim();
+ if (orderId) setPayOrderCookie(response, orderId);
+ return response;
+ }
+
+ if (redirectRes.status >= 400) {
+ console.warn(
+ "[pay] redirect fallback status=%s body=%j",
+ redirectRes.status,
+ resData
+ );
+ }
+ } catch (error) {
+ console.warn(
+ "[pay] redirect fallback error=%s",
+ error instanceof Error ? error.message : String(error)
+ );
+ }
+
+ const html = buildPayHtml(created.payUrl, created.params, {
+ orderId: created.orderId,
+ });
+ const response = new NextResponse(html, {
+ status: 200,
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ });
+ if (created.orderId) setPayOrderCookie(response, created.orderId);
+ return response;
+ } catch (error) {
+ const err = error instanceof Error ? error : new Error(String(error));
+ const msg = err.message || "";
+ const hint =
+ msg.includes("fetch") || msg.includes("ECONNREFUSED")
+ ? "请确认 payjsapi 已启动:cd C:\\project\\payjsapi && python run.py"
+ : err.name === "AbortError"
+ ? "请求超时,请检查 payjsapi 是否正常运行"
+ : msg;
+ console.error("Pay API error:", err);
+ return NextResponse.json(
+ { ok: false, error: `支付请求失败: ${hint}` },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/digital/pay/status/route.ts b/app/api/digital/pay/status/route.ts
new file mode 100644
index 0000000..72df815
--- /dev/null
+++ b/app/api/digital/pay/status/route.ts
@@ -0,0 +1,84 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ getApiUrlFromRequest,
+ getPaymentConfig,
+} from "@/config/digital/services.config";
+import { queryXorPayOrderStatus } from "@/app/digital/lib/payment/xorpayStatus";
+import { queryZPayOrderStatus } from "@/app/digital/lib/payment/zpayStatus";
+
+function resolvePayApiUrl(request: NextRequest): string {
+ const config = getPaymentConfig();
+ const hostHeader =
+ request.headers.get("host") || request.headers.get("x-forwarded-host");
+ return (getApiUrlFromRequest(hostHeader) || config.apiUrl).replace(/\/$/, "");
+}
+
+export async function GET(request: NextRequest) {
+ const orderId = request.nextUrl.searchParams.get("order_id");
+ if (!orderId) {
+ return NextResponse.json(
+ { ok: false, error: "missing order_id" },
+ { status: 400 }
+ );
+ }
+
+ const hostHeader =
+ request.headers.get("host") || request.headers.get("x-forwarded-host");
+ const apiUrl = resolvePayApiUrl(request);
+ const url = `${apiUrl}/digital/order_status?order_id=${encodeURIComponent(orderId)}`;
+ console.log("[pay/status] request order_id=%s host=%s url=%s", orderId, hostHeader, url);
+
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 3000);
+ const res = await fetch(url, {
+ cache: "no-store",
+ signal: controller.signal,
+ }).finally(() => clearTimeout(timeout));
+ const data = await res.json().catch(() => ({}));
+ const paid = !!data?.paid;
+ console.log(
+ "[pay/status] result order_id=%s paid=%s status=%s",
+ orderId,
+ paid,
+ res.status
+ );
+ if (paid) {
+ return NextResponse.json({ ok: true, paid: true });
+ }
+ } catch (error) {
+ console.log(
+ "[pay/status] error order_id=%s error=%s",
+ orderId,
+ error instanceof Error ? error.message : String(error)
+ );
+ }
+
+ const xorpay = await queryXorPayOrderStatus(orderId, 5000);
+ console.log(
+ "[pay/status] xorpay fallback order_id=%s paid=%s status=%s error=%s url=%s",
+ orderId,
+ !!xorpay?.paid,
+ xorpay?.status || "",
+ xorpay?.error || "",
+ xorpay?.url || ""
+ );
+ if (xorpay?.paid) {
+ return NextResponse.json({ ok: true, paid: true });
+ }
+
+ const zpay = await queryZPayOrderStatus(orderId, 5000);
+ console.log(
+ "[pay/status] zpay fallback order_id=%s paid=%s status=%s error=%s url=%s",
+ orderId,
+ !!zpay?.paid,
+ zpay?.status || "",
+ zpay?.error || "",
+ zpay?.url || ""
+ );
+ if (zpay?.paid) {
+ return NextResponse.json({ ok: true, paid: true });
+ }
+
+ return NextResponse.json({ ok: true, paid: false });
+}
diff --git a/app/api/digital/upload-media/route.ts b/app/api/digital/upload-media/route.ts
new file mode 100644
index 0000000..ad64587
--- /dev/null
+++ b/app/api/digital/upload-media/route.ts
@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from "next/server";
+import { uploadBuffer, generateObjectKey } from "@/app/digital/lib/minio";
+
+const MAX_SIZE = 20 * 1024 * 1024; // 20MB
+
+export async function POST(request: NextRequest) {
+ try {
+ const form = await request.formData();
+ const file = form.get("file");
+ const fileObj =
+ file != null && typeof file === "object" && "size" in file
+ ? (file as File | Blob)
+ : null;
+
+ if (!fileObj || fileObj.size === 0) {
+ return NextResponse.json(
+ { ok: false, error: "请选择文件" },
+ { status: 400 }
+ );
+ }
+
+ if (fileObj.size > MAX_SIZE) {
+ return NextResponse.json(
+ { ok: false, error: "文件大小不能超过 20MB" },
+ { status: 400 }
+ );
+ }
+
+ const ext =
+ (fileObj instanceof File ? fileObj.name : "").split(".").pop() ||
+ (fileObj.type?.startsWith("video/") ? "mp4" : "jpg");
+ const objectKey = generateObjectKey(ext);
+
+ const buffer = Buffer.from(await fileObj.arrayBuffer());
+ const contentType =
+ (fileObj as File).type || "application/octet-stream";
+
+ const { url } = await uploadBuffer(buffer, objectKey, contentType);
+
+ return NextResponse.json({ ok: true, url });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.error("Upload media error:", e);
+ return NextResponse.json(
+ { ok: false, error: `上传失败: ${msg}` },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/digital/vip/check/route.ts b/app/api/digital/vip/check/route.ts
new file mode 100644
index 0000000..3788985
--- /dev/null
+++ b/app/api/digital/vip/check/route.ts
@@ -0,0 +1,93 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getPocketBaseConfig, SITE_ID, VIP_MASTER_SITE_ID } from "@/config/digital/services.config";
+
+const COOKIE_NAME = "pb_session";
+
+async function getAdminToken(): Promise {
+ const email = process.env.POCKETBASE_EMAIL || process.env.PB_ADMIN_EMAIL;
+ const password = process.env.POCKETBASE_PASSWORD || process.env.PB_ADMIN_PASSWORD;
+ if (!email || !password) return null;
+ const pb = getPocketBaseConfig();
+ const paths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"];
+ for (const path of paths) {
+ const res = await fetch(`${pb.url}${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ identity: email, password }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ if (data?.token) return data.token;
+ }
+ }
+ return null;
+}
+
+/** 检查单条 site_vip 是否有效 */
+function isValidVip(record: { expires_at?: string | null } | null): boolean {
+ if (!record) return false;
+ const now = new Date().toISOString();
+ return !(record.expires_at && record.expires_at < now);
+}
+
+export async function GET(request: NextRequest) {
+ const token = await getAdminToken();
+ if (!token) return NextResponse.json({ ok: true, vip: false });
+
+ let userId: string | null = null;
+
+ // 优先用邮箱查(Header 传 email,更可靠)
+ const email = request.nextUrl.searchParams.get("email")?.trim();
+ if (email) {
+ const pb = getPocketBaseConfig();
+ const filter = `email="${email.replace(/"/g, '\\"')}"`;
+ const collections = [pb.usersCollection, "_pb_users_auth_"];
+ for (const coll of collections) {
+ const userRes = await fetch(
+ `${pb.url}/api/collections/${coll}/records?filter=${encodeURIComponent(filter)}&perPage=1`,
+ { headers: { Authorization: `Bearer ${token}` } }
+ );
+ if (userRes.ok) {
+ const userData = await userRes.json();
+ userId = userData?.items?.[0]?.id ?? null;
+ if (userId) break;
+ }
+ }
+ }
+
+ // 无邮箱或未查到:回退到 cookie 中的 userId
+ if (!userId) {
+ const cookie = request.cookies.get(COOKIE_NAME)?.value;
+ if (!cookie) return NextResponse.json({ ok: true, vip: false });
+ try {
+ const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
+ userId = payload?.userId ?? null;
+ } catch {
+ return NextResponse.json({ ok: true, vip: false });
+ }
+ }
+
+ if (!userId) return NextResponse.json({ ok: true, vip: false });
+
+ try {
+ const pb = getPocketBaseConfig();
+ // 1) 本专题站 VIP(site_id = SITE_ID)2) vip 站 master VIP(site_id = vip)可解锁所有 digital 专题站
+ const filter = `user_id = "${userId}" && (site_id = "${SITE_ID}" || site_id = "${VIP_MASTER_SITE_ID}")`;
+ const res = await fetch(
+ `${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=2&sort=-expires_at`,
+ { headers: { Authorization: `Bearer ${token}` } }
+ );
+ if (!res.ok) return NextResponse.json({ ok: true, vip: false });
+ const data = await res.json();
+ const items = data?.items ?? [];
+ const record = items.find((r: { expires_at?: string | null }) => isValidVip(r));
+ const vip = !!record;
+ return NextResponse.json({
+ ok: true,
+ vip,
+ expires_at: record?.expires_at ?? null,
+ });
+ } catch {
+ return NextResponse.json({ ok: true, vip: false });
+ }
+}
diff --git a/app/components/CityModal.tsx b/app/components/CityModal.tsx
index 9341928..b58aef9 100644
--- a/app/components/CityModal.tsx
+++ b/app/components/CityModal.tsx
@@ -566,7 +566,7 @@ function RelatedContent({ items, compact = false }: { items: Array
diff --git a/app/components/RouteAwareNavbar.tsx b/app/components/RouteAwareNavbar.tsx
new file mode 100644
index 0000000..63e268b
--- /dev/null
+++ b/app/components/RouteAwareNavbar.tsx
@@ -0,0 +1,15 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import Navbar from "./Navbar";
+
+export default function RouteAwareNavbar() {
+ const pathname = usePathname();
+ const isDigitalRoute = /^\/(zh|en)\/digital(?:\/|$)/.test(pathname);
+
+ if (isDigitalRoute) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/app/components/SiteMenu.tsx b/app/components/SiteMenu.tsx
index 629c3b0..e035d69 100644
--- a/app/components/SiteMenu.tsx
+++ b/app/components/SiteMenu.tsx
@@ -86,7 +86,7 @@ export default memo(function SiteMenu({
{
titleKey: "other",
items: [
- { labelKey: "digitalNomadGuide", href: process.env.NEXT_PUBLIC_DIGITAL_URL || "https://digital.hackrobot.cn/", icon: "📖" },
+ { labelKey: "digitalNomadGuide", href: "/digital", icon: "📖" },
{ labelKey: "remoteJobs", href: "#", icon: "💼" },
{ labelKey: "coworking", href: "#", icon: "🏢" },
],
diff --git a/app/digital/DigitalProviders.tsx b/app/digital/DigitalProviders.tsx
new file mode 100644
index 0000000..1e32c1a
--- /dev/null
+++ b/app/digital/DigitalProviders.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { ThemeProvider } from "@/app/digital/context/ThemeContext";
+import { PayStatusPollProvider } from "@/app/digital/components/PayStatusPollProvider";
+
+export default function DigitalProviders({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/app/digital/components/AuthModal.tsx b/app/digital/components/AuthModal.tsx
new file mode 100644
index 0000000..091ba12
--- /dev/null
+++ b/app/digital/components/AuthModal.tsx
@@ -0,0 +1,183 @@
+"use client";
+
+import { useState, type FormEvent } from "react";
+import { pbSaveAuth } from "@/app/digital/lib/pocketbase";
+
+interface AuthModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: (email: string) => void;
+}
+
+type AuthResult = {
+ ok: boolean;
+ token?: string;
+ record?: { id: string; email?: string; [key: string]: unknown };
+ error?: string;
+ message?: string;
+};
+
+export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps) {
+ const [mode, setMode] = useState<"login" | "register">("login");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [passwordConfirm, setPasswordConfirm] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setLoading(true);
+
+ try {
+ if (mode === "register") {
+ if (password !== passwordConfirm) {
+ setError("Passwords do not match");
+ setLoading(false);
+ return;
+ }
+ if (password.length < 8) {
+ setError("Password must be at least 8 characters");
+ setLoading(false);
+ return;
+ }
+ }
+
+ const endpoint = mode === "login" ? "/api/digital/auth/login" : "/api/digital/auth/register";
+ const payload =
+ mode === "login"
+ ? { identity: email, password }
+ : { email, password, passwordConfirm };
+
+ const res = await fetch(endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify(payload),
+ });
+ const data = (await res.json().catch(() => ({}))) as AuthResult;
+
+ if (!res.ok || !data?.ok || !data?.token || !data?.record) {
+ throw new Error(data?.error || data?.message || "Authentication failed");
+ }
+
+ pbSaveAuth(data.token, { id: data.record.id, email: data.record.email ?? email });
+ onSuccess(email);
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(new CustomEvent("auth:success"));
+ }
+ onClose();
+ setEmail("");
+ setPassword("");
+ setPasswordConfirm("");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Authentication failed");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
{mode === "login" ? "Login" : "Register"}
+
+ {mode === "login" ? "Use email and password" : "Create a new account"}
+
+
+
+
+ Email
+ setEmail(e.target.value)}
+ placeholder="your@email.com"
+ required
+ className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
+ />
+
+
+ Password
+ setPassword(e.target.value)}
+ placeholder="At least 8 characters"
+ required
+ minLength={mode === "register" ? 8 : 1}
+ className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
+ />
+
+ {mode === "register" && (
+
+ Confirm Password
+ setPasswordConfirm(e.target.value)}
+ placeholder="Enter password again"
+ required
+ className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
+ />
+
+ )}
+ {error && {error}
}
+
+ {loading ? "Processing..." : mode === "login" ? "Login" : "Register"}
+
+
+
+
+ {mode === "login" ? (
+ <>
+ No account?{" "}
+ {
+ setMode("register");
+ setError(null);
+ }}
+ className="font-medium text-sky-500 hover:text-sky-600"
+ >
+ Register
+
+ >
+ ) : (
+ <>
+ Already have an account?{" "}
+ {
+ setMode("login");
+ setError(null);
+ }}
+ className="font-medium text-sky-500 hover:text-sky-600"
+ >
+ Login
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/app/digital/components/Community.tsx b/app/digital/components/Community.tsx
new file mode 100644
index 0000000..41736a6
--- /dev/null
+++ b/app/digital/components/Community.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { useState } from "react";
+import { Link, useTranslation } from "@/app/digital/i18n/navigation";
+import { TOPIC_IDS } from "@/config/digital";
+import type { ResourceData } from "@/app/digital/lib/theme-data";
+import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
+import ResourceNavigation from "./ResourceNavigation";
+
+const mainModuleKeys = ["destinations", "vipEntry", "appDownload"] as const;
+const topicKeys = ["indieDev", "aiAgent", "cloudPhone", "unmannedLive"] as const;
+
+const linkIcons: Record = {
+ destinations: "🗺️",
+ vipEntry: "👑",
+ appDownload: "📱",
+ indieDev: "🛠️",
+ aiAgent: "🤖",
+ cloudPhone: "☁️",
+ unmannedLive: "📺",
+};
+
+type CommunityProps = {
+ resourceData?: ResourceData;
+};
+
+export default function Community({ resourceData }: CommunityProps) {
+ const { t } = useTranslation("community");
+ const [toast, setToast] = useState(false);
+ const { meetupUrl, vipUrl } = useCrossSiteUrls();
+ const linkHrefs: Record = {
+ destinations: { href: meetupUrl, newTab: true },
+ vipEntry: { href: vipUrl, newTab: true },
+ appDownload: { href: "/app" },
+ };
+
+ const showUpdating = () => {
+ setToast(true);
+ setTimeout(() => setToast(false), 2000);
+ };
+
+ const renderCardContent = (key: string) => (
+ <>
+
+ {linkIcons[key]}
+
+
+
{t(`links.${key}.title`)}
+
{t(`links.${key}.desc`)}
+
{t(`links.${key}.label`)}
+
+ >
+ );
+
+ return (
+
+ );
+}
diff --git a/app/digital/components/DayContent.tsx b/app/digital/components/DayContent.tsx
new file mode 100644
index 0000000..131defa
--- /dev/null
+++ b/app/digital/components/DayContent.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+
+export default function DayContent({ content }: { content: string }) {
+ return (
+
+ {content}
+
+ );
+}
diff --git a/app/digital/components/Features.tsx b/app/digital/components/Features.tsx
new file mode 100644
index 0000000..235d4db
--- /dev/null
+++ b/app/digital/components/Features.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useTranslation } from "@/app/digital/i18n/navigation";
+
+const featureKeys = ["location", "skill", "balance"] as const;
+
+export default function Features() {
+ const { t } = useTranslation("features");
+ const features = featureKeys.map((key) => ({
+ icon: key === "location" ? "🌏" : key === "skill" ? "⚡" : "🔄",
+ key,
+ }));
+ return (
+
+
+
+
+ {t("title")} {t("titleHighlight")} {t("titleSuffix")}
+
+
+ {t("subtitle")}
+
+ {t("subtitle2")}
+
+
+
+
+ {features.map((f) => (
+
+
+ {f.icon}
+
+
+ {t(`items.${f.key}.title`)}
+
+
{t(`items.${f.key}.desc`)}
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/digital/components/Footer.tsx b/app/digital/components/Footer.tsx
new file mode 100644
index 0000000..d0ce1ef
--- /dev/null
+++ b/app/digital/components/Footer.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { Link, useTranslation } from "@/app/digital/i18n/navigation";
+import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
+
+export default function Footer() {
+ const { t } = useTranslation("footer");
+ const { meetupUrl, vipUrl } = useCrossSiteUrls();
+ const footerSections = [
+ {
+ titleKey: "guide" as const,
+ links: [
+ { labelKey: "roadmap" as const, href: "#roadmap" },
+ { labelKey: "tools" as const, href: "#tools" },
+ { labelKey: "destinations" as const, href: meetupUrl, openInNewTab: true },
+ ],
+ },
+ {
+ titleKey: "resources" as const,
+ links: [
+ { labelKey: "remoteJobs" as const, href: "#" },
+ { labelKey: "visa" as const, href: "#" },
+ { labelKey: "coliving" as const, href: "#" },
+ { labelKey: "insurance" as const, href: "#" },
+ ],
+ },
+ {
+ titleKey: "community" as const,
+ links: [
+ { labelKey: "discord" as const, href: "#" },
+ { labelKey: "wechat" as const, href: vipUrl, openInNewTab: true },
+ { labelKey: "jike" as const, href: "#" },
+ ],
+ },
+ {
+ titleKey: "about" as const,
+ links: [
+ { labelKey: "aboutUs" as const, href: "/about", internal: true },
+ { labelKey: "recruit" as const, href: "/jobs", internal: true },
+ { labelKey: "privacy" as const, href: "/privacy", internal: true, openInNewTab: true },
+ { labelKey: "contact" as const, href: "#" },
+ ],
+ },
+ ];
+ return (
+
+
+
+
+
+
🌍
+
{t("siteName")}
+
+
+ {t("tagline")}
+
+ {t("tagline2")}
+
+
+
+ {footerSections.map((section) => (
+
+
+ {t(`sections.${section.titleKey}`)}
+
+
+ {section.links.map((link) => (
+
+ {"internal" in link && link.internal ? (
+
+ {t(`links.${link.labelKey}`)}
+
+ ) : (
+
+ {t(`links.${link.labelKey}`)}
+
+ )}
+
+ ))}
+
+
+ ))}
+
+
+
+
+ {t("madeBy")}
+
+
{t("openSource")}
+
+
+
+ );
+}
diff --git a/app/digital/components/Header.tsx b/app/digital/components/Header.tsx
new file mode 100644
index 0000000..f3e85a5
--- /dev/null
+++ b/app/digital/components/Header.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/app/digital/i18n/navigation";
+import { getStoredUserEmail } from "@/app/digital/lib/pocketbase";
+import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
+import AuthModal from "./AuthModal";
+import ThemeToggle from "./ThemeToggle";
+import UserAvatar from "./UserAvatar";
+
+export default function Header() {
+ const { t } = useTranslation("common");
+ const { t: tNav } = useTranslation("nav");
+ const locale = useLocale();
+ const pathname = usePathname();
+ const router = useRouter();
+ const [mobileOpen, setMobileOpen] = useState(false);
+ const [scrolled, setScrolled] = useState(false);
+ const [authOpen, setAuthOpen] = useState(false);
+ const [userEmail, setUserEmail] = useState(null);
+ const [vip, setVip] = useState(false);
+ const { vipUrl } = useCrossSiteUrls();
+ const navLinks = [
+ { labelKey: "roadmap" as const, href: "#roadmap" },
+ { labelKey: "tools" as const, href: "#tools" },
+ { labelKey: "community" as const, href: vipUrl, external: true },
+ { labelKey: "resources" as const, href: "#resources" },
+ ];
+
+ const fetchAuthAndVip = useCallback(async () => {
+ try {
+ const meRes = await fetch("/api/digital/auth/me", { credentials: "include" });
+ const meData = await meRes.json();
+ if (meData?.user && meData?.token) {
+ const { pbSaveAuth } = await import("@/app/digital/lib/pocketbase");
+ pbSaveAuth(meData.token, { id: meData.user.id, email: meData.user.email });
+ setUserEmail(meData.user.email);
+ // 用邮箱查 VIP,比 cookie userId 更可靠
+ const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
+ const vipData = await vipRes.json();
+ setVip(vipData?.vip === true);
+ } else {
+ setUserEmail(getStoredUserEmail());
+ setVip(false);
+ }
+ } catch {
+ /* ignore */
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchAuthAndVip();
+ }, [fetchAuthAndVip]);
+
+ useEffect(() => {
+ const onVipUpdated = () => fetchAuthAndVip();
+ const onAuthSuccess = () => fetchAuthAndVip();
+ window.addEventListener("vip:updated", onVipUpdated);
+ window.addEventListener("auth:success", onAuthSuccess);
+ return () => {
+ window.removeEventListener("vip:updated", onVipUpdated);
+ window.removeEventListener("auth:success", onAuthSuccess);
+ };
+ }, [fetchAuthAndVip]);
+
+ return (
+
+
+
+
🌍
+
{t("siteName")}
+
{t("siteNameShort")}
+
+
+
+ {navLinks.map((link) => (
+
+ {tNav(link.labelKey)}
+
+ ))}
+
+
+
+ {userEmail ? (
+
{ setUserEmail(null); setVip(false); router.replace("/"); }} />
+ ) : (
+ setAuthOpen(true)}
+ className="hidden rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 sm:inline-flex"
+ >
+ {t("loginRegister")}
+
+ )}
+ router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
+ className="inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
+ aria-label={locale === "zh" ? "Switch to English" : "切换到中文"}
+ title={locale === "zh" ? "EN" : "中文"}
+ >
+ {locale === "zh" ? "EN" : "中文"}
+
+
+ setMobileOpen(!mobileOpen)}
+ className="inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 md:hidden"
+ aria-label={t("toggleMenu")}
+ >
+ {mobileOpen ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {t("explore")}
+
+
+
+
+ {mobileOpen && (
+
+ )}
+
+ setAuthOpen(false)}
+ onSuccess={() => fetchAuthAndVip()}
+ />
+
+ );
+}
diff --git a/app/digital/components/Hero.tsx b/app/digital/components/Hero.tsx
new file mode 100644
index 0000000..0fe3263
--- /dev/null
+++ b/app/digital/components/Hero.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useTranslation } from "@/app/digital/i18n/navigation";
+
+export default function Hero() {
+ const { t } = useTranslation("hero");
+ return (
+
+
+
+
+
+
+
+ 🎒
+ {t("badge")}
+
+
+
+ {t("title")}
+
+ {t("titleSuffix")}
+
+
+
+ {t("subtitle")}
+
+
+ {t("subtitleEn")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/digital/components/PayStatusPollProvider.tsx b/app/digital/components/PayStatusPollProvider.tsx
new file mode 100644
index 0000000..6edb17c
--- /dev/null
+++ b/app/digital/components/PayStatusPollProvider.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import { usePayStatusPoll } from "@/app/digital/lib/payment/usePayStatusPoll";
+
+/**
+ * 全局支付轮询:支付返回任意页面时检测 paid,调用 confirm 后跳转首页
+ */
+export function PayStatusPollProvider() {
+ usePayStatusPoll((_orderId) => {
+ window.location.href = "/";
+ });
+ return null;
+}
diff --git a/app/digital/components/ResourceNavigation.tsx b/app/digital/components/ResourceNavigation.tsx
new file mode 100644
index 0000000..31df2fa
--- /dev/null
+++ b/app/digital/components/ResourceNavigation.tsx
@@ -0,0 +1,274 @@
+"use client";
+
+import { useState, useMemo, useSyncExternalStore } from "react";
+import { useTranslation } from "@/app/digital/i18n/navigation";
+import type { ResourceData } from "@/app/digital/lib/theme-data";
+
+const PER_PAGE_MOBILE = 4;
+const PER_PAGE_PC = 8;
+const BREAKPOINT = 768;
+
+function useIsPC() {
+ return useSyncExternalStore(
+ (callback) => {
+ if (typeof window === "undefined") return () => {};
+ const mq = window.matchMedia(`(min-width: ${BREAKPOINT}px)`);
+ const fn = () => callback();
+ mq.addEventListener("change", fn);
+ return () => mq.removeEventListener("change", fn);
+ },
+ () =>
+ typeof window !== "undefined" &&
+ window.matchMedia(`(min-width: ${BREAKPOINT}px)`).matches,
+ () => false
+ );
+}
+
+/** 每页条数:H5=4,PC=8;总数不足则全显 */
+function getPerPage(total: number, isPC: boolean): number {
+ const cap = isPC ? PER_PAGE_PC : PER_PAGE_MOBILE;
+ return total <= cap ? total : cap;
+}
+
+type ResourceNavigationProps = {
+ data: ResourceData;
+};
+
+export default function ResourceNavigation({ data }: ResourceNavigationProps) {
+ const { t } = useTranslation("community");
+ const isPC = useIsPC();
+ const firstCategoryId = data.pan.categories[0]?.id ?? "";
+ const [activePanCategory, setActivePanCategory] = useState(firstCategoryId);
+ const [booksPage, setBooksPage] = useState(0);
+ const [productsPage, setProductsPage] = useState(0);
+ const [toast, setToast] = useState(false);
+
+ const showUpdating = () => {
+ setToast(true);
+ setTimeout(() => setToast(false), 2000);
+ };
+
+ const activeCategory = data.pan.categories.find((c) => c.id === activePanCategory);
+
+ const booksPerPage = getPerPage(data.books.length, isPC);
+ const productsPerPage = getPerPage(data.products.length, isPC);
+ const booksTotalPages = Math.ceil(data.books.length / booksPerPage) || 1;
+ const productsTotalPages = Math.ceil(data.products.length / productsPerPage) || 1;
+ const safeBooksPage = Math.max(0, Math.min(booksPage, booksTotalPages - 1));
+ const safeProductsPage = Math.max(0, Math.min(productsPage, productsTotalPages - 1));
+
+ const paginatedBooks = useMemo(() => {
+ const start = safeBooksPage * booksPerPage;
+ return data.books.slice(start, start + booksPerPage);
+ }, [data.books, safeBooksPage, booksPerPage]);
+
+ const paginatedProducts = useMemo(() => {
+ const start = safeProductsPage * productsPerPage;
+ return data.products.slice(start, start + productsPerPage);
+ }, [data.products, safeProductsPage, productsPerPage]);
+
+ return (
+
+ {/* 图书 - 封面+书名,数量少则大封面,多则分页 */}
+
+
+ 📚
+ {t("resourceSections.books")}
+ {data.books.length > 0 && (
+
+ ({t("resourceSections.totalBooks").replace("{count}", String(data.books.length))})
+
+ )}
+
+
+ {paginatedBooks.map((book, i) => (
+
+
+
+
+
+ {book.title}
+
+
+ ))}
+
+ {booksTotalPages > 1 && (
+
+
setBooksPage((p) => Math.max(0, p - 1))}
+ disabled={safeBooksPage === 0}
+ className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
+ aria-label="上一页"
+ >
+
+
+
+
+
+ {safeBooksPage + 1} / {booksTotalPages}
+
+
setBooksPage((p) => Math.min(booksTotalPages - 1, p + 1))}
+ disabled={safeBooksPage === booksTotalPages - 1}
+ className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
+ aria-label="下一页"
+ >
+
+
+
+
+
+ )}
+
+
+ {/* 商业数码 - 商品图+跳转电商 */}
+
+
+ 🛒
+ {t("resourceSections.products")}
+ {data.products.length > 0 && (
+
+ ({t("resourceSections.totalProducts").replace("{count}", String(data.products.length))})
+
+ )}
+
+
+ {paginatedProducts.map((product, i) => (
+
+
+
+
+
+ {product.name}
+
+
+ ))}
+
+ {productsTotalPages > 1 && (
+
+
setProductsPage((p) => Math.max(0, p - 1))}
+ disabled={safeProductsPage === 0}
+ className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
+ aria-label="上一页"
+ >
+
+
+
+
+
+ {safeProductsPage + 1} / {productsTotalPages}
+
+
setProductsPage((p) => Math.min(productsTotalPages - 1, p + 1))}
+ disabled={safeProductsPage === productsTotalPages - 1}
+ className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
+ aria-label="下一页"
+ >
+
+
+
+
+
+ )}
+
+
+ {/* 网盘 - 分类 Tab */}
+
+
+ ☁️
+ {t("resourceSections.pan")}
+
+
+
+ {data.pan.categories.map((cat) => (
+ setActivePanCategory(cat.id)}
+ className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
+ activePanCategory === cat.id
+ ? "bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300"
+ : "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
+ }`}
+ >
+ {cat.icon}
+ {cat.title}
+
+ ))}
+
+
+ {activeCategory && (
+
+ )}
+
+
+
+
+ {toast && (
+
+ {t("updating")}
+
+ )}
+
+ );
+}
diff --git a/app/digital/components/Resources.tsx b/app/digital/components/Resources.tsx
new file mode 100644
index 0000000..63c7fb9
--- /dev/null
+++ b/app/digital/components/Resources.tsx
@@ -0,0 +1,175 @@
+const resources = [
+ {
+ lang: "中文",
+ tag: "📖 入门指南",
+ title: "数字游民完全入门手册",
+ desc: "从概念到实践,系统化的数字游民入门指南,含远程工作、签证、财务全攻略",
+ source: "少数派",
+ color: "bg-sky-50 text-sky-700 border-sky-200",
+ },
+ {
+ lang: "EN",
+ tag: "📖 经典书籍",
+ title: "The 4-Hour Workweek — Tim Ferriss",
+ desc: "数字游民圣经,重新定义工作与生活的关系,构建「新富族」生活方式",
+ source: "Amazon",
+ color: "bg-amber-50 text-amber-700 border-amber-200",
+ },
+ {
+ lang: "中文",
+ tag: "☁️ 远程求职",
+ title: "国内远程工作机会汇总 — 电鸭社区",
+ desc: "最大的中文远程工作社区,收录数千个远程岗位,涵盖开发、设计、运营等领域",
+ source: "电鸭社区",
+ color: "bg-emerald-50 text-emerald-700 border-emerald-200",
+ },
+ {
+ lang: "EN",
+ tag: "🗺️ 目的地",
+ title: "Nomad List — Best Cities for Digital Nomads",
+ desc: "全球数字游民城市数据库,含生活成本、网速、安全指数、社区活跃度等指标",
+ source: "Nomad List",
+ color: "bg-violet-50 text-violet-700 border-violet-200",
+ },
+ {
+ lang: "中文",
+ tag: "📹 视频教程",
+ title: "从程序员到数字游民 — 我的转型之路",
+ desc: "真实经历分享:如何从 996 转型为自由的数字游民,含收入、签证、生活细节",
+ source: "Bilibili",
+ color: "bg-rose-50 text-rose-700 border-rose-200",
+ },
+ {
+ lang: "EN",
+ tag: "💰 财务规划",
+ title: "Tax Planning for Digital Nomads — Complete Guide",
+ desc: "数字游民税务规划权威指南:税务居民身份、双重征税协定、合规架构搭建",
+ source: "SafetyWing",
+ color: "bg-teal-50 text-teal-700 border-teal-200",
+ },
+ {
+ lang: "中文",
+ tag: "🛠️ 工具评测",
+ title: "远程工作者必备工具清单 2026",
+ desc: "50+ 工具横评:协作、会议、设计、开发、生产力工具深度体验与推荐",
+ source: "即刻",
+ color: "bg-orange-50 text-orange-700 border-orange-200",
+ },
+ {
+ lang: "EN",
+ tag: "🔬 深度文章",
+ title: "State of Digital Nomads 2026 — Annual Report",
+ desc: "2026年度数字游民状态报告:3500万全球数字游民的收入、工作、生活数据洞察",
+ source: "A Brother Abroad",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+ {
+ lang: "中文",
+ tag: "📱 社区",
+ title: "数字游民部落 — 中文社区指南",
+ desc: "连接全球数字游民:经验分享、城市攻略、合租搭伴、线下 Meetup",
+ source: "微信公众号",
+ color: "bg-cyan-50 text-cyan-700 border-cyan-200",
+ },
+ {
+ lang: "EN",
+ tag: "📋 签证",
+ title: "Digital Nomad Visa Guide — 50+ Countries",
+ desc: "全球 50+ 国家数字游民签证政策汇总,含申请条件、费用、停留期限对比",
+ source: "VisaGuide.World",
+ color: "bg-lime-50 text-lime-700 border-lime-200",
+ },
+ {
+ lang: "中文",
+ tag: "🏠 共居空间",
+ title: "全球最佳 Coliving 空间推荐",
+ desc: "精选 30+ 数字游民共居空间:巴厘岛、清迈、里斯本、麦德林……含价格与评测",
+ source: "数字游民部落",
+ color: "bg-pink-50 text-pink-700 border-pink-200",
+ },
+ {
+ lang: "EN",
+ tag: "💼 自由职业",
+ title: "Freelancing 101 — From Zero to First Client",
+ desc: "从零开始接单指南:Upwork/Fiverr/Toptal 平台对比、定价策略、客户沟通技巧",
+ source: "freeCodeCamp",
+ color: "bg-sky-50 text-sky-700 border-sky-200",
+ },
+];
+
+const sourceStats = [
+ { value: "200+", label: "篇资源" },
+ { value: "80", label: "中文资源" },
+ { value: "120+", label: "英文资源" },
+ { value: "12", label: "大分类" },
+];
+
+export default function Resources() {
+ return (
+
+
+
+
+ 📚 精选收录
+
+
+ 全网优质资源 聚合
+
+
+ 从少数派到 Nomad List,从 B站 到 freeCodeCamp
+ —— 一站式获取数字游民最佳资源。
+
+
+
+ {/* Stats */}
+
+ {sourceStats.map((s) => (
+
+ {s.value}
+ {s.label}
+
+ ))}
+
+
+ {/* Resource grid */}
+
+ {resources.map((r, i) => (
+
+
+
+ {r.lang}
+
+ {r.tag}
+
+
+ {r.title}
+
+
+ {r.desc}
+
+
+
+ {r.source}
+
+
+ 访问 ↗
+
+
+
+ ))}
+
+
+
+
+ 查看全部 200+ 篇资源 →
+
+
+
+
+ );
+}
diff --git a/app/digital/components/Roadmap.tsx b/app/digital/components/Roadmap.tsx
new file mode 100644
index 0000000..771c523
--- /dev/null
+++ b/app/digital/components/Roadmap.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { Link, useTranslation } from "@/app/digital/i18n/navigation";
+
+const days = [
+ { day: 1, icon: "👋", color: "bg-sky-500" },
+ { day: 2, icon: "💡", color: "bg-cyan-500" },
+ { day: 3, icon: "💰", color: "bg-teal-500" },
+ { day: 4, icon: "🛠️", color: "bg-emerald-500" },
+ { day: 5, icon: "📋", color: "bg-amber-500" },
+ { day: 6, icon: "🗺️", color: "bg-orange-500" },
+ { day: 7, icon: "🚀", color: "bg-rose-500" },
+];
+
+export default function Roadmap() {
+ const { t } = useTranslation("roadmap");
+ return (
+
+
+
+
+ {t("title")} {t("titleHighlight")}
+
+
+ {t("subtitle")}
+
+
+
+ {/* 7天入门文档 - 独立区块 */}
+
+
+ {t("daysSection")}
+
+
+ {days.map((d) => (
+
+
+
+ DAY {d.day}
+
+
{d.icon}
+
+ {t(`days.${d.day}.title`)}
+
+
+
+ {t(`days.${d.day}.desc`)}
+
+
+ {t("viewDetails")}
+
+
+ ))}
+
+
+
+ {/* 电子书 + 实战课 - 平级展示 */}
+
+
+ {t("productsSection")}
+
+
+
+
+ 📖
+
+
+ {t("ebook.title")}
+
+
+ {t("ebook.desc")}
+
+
+ {t("ebook.cta")}
+
+
+
+
+
+ 🎓
+
+
+ {t("course.tag")}
+
+
+ {t("course.title")}
+
+
+ {t("course.desc")}
+
+
+ {t("course.cta")}
+
+
+
+
+
+
+ );
+}
diff --git a/app/digital/components/ShopCard.tsx b/app/digital/components/ShopCard.tsx
new file mode 100644
index 0000000..5834696
--- /dev/null
+++ b/app/digital/components/ShopCard.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { useTranslation } from "@/app/digital/i18n/navigation";
+import { getThemeConfig } from "@/config/digital";
+
+export default function ShopCard() {
+ const { t } = useTranslation("shop");
+ const config = getThemeConfig();
+ const shopUrl = config.services?.shopUrl;
+
+ if (!shopUrl) return null;
+
+ return (
+
+ );
+}
diff --git a/app/digital/components/SocialPlatformIcon.tsx b/app/digital/components/SocialPlatformIcon.tsx
new file mode 100644
index 0000000..0760ee4
--- /dev/null
+++ b/app/digital/components/SocialPlatformIcon.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+const PLATFORM_ICONS: Record = {
+ // 国内平台
+ douyin: (
+
+
+
+ ),
+ "wechat-official": (
+
+
+
+ ),
+ xiaohongshu: (
+
+
+
+ ),
+ // 海外平台
+ tiktok: (
+
+
+
+ ),
+ youtube: (
+
+
+
+ ),
+ twitter: (
+
+
+
+ ),
+};
+
+export default function SocialPlatformIcon({ platform }: { platform: string }) {
+ return (
+
+ {PLATFORM_ICONS[platform] ?? (
+
+
+
+ )}
+
+ );
+}
diff --git a/app/digital/components/ThemeToggle.tsx b/app/digital/components/ThemeToggle.tsx
new file mode 100644
index 0000000..68282eb
--- /dev/null
+++ b/app/digital/components/ThemeToggle.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { useTheme } from "../context/ThemeContext";
+
+export default function ThemeToggle() {
+ const { theme, toggleTheme } = useTheme();
+
+ return (
+
+ {theme === "dark" ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/app/digital/components/Tools.tsx b/app/digital/components/Tools.tsx
new file mode 100644
index 0000000..d096b6a
--- /dev/null
+++ b/app/digital/components/Tools.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useTranslation } from "@/app/digital/i18n/navigation";
+import type { ToolsCategory } from "@/app/digital/lib/theme-data";
+
+type ToolsProps = {
+ data: { categories: ToolsCategory[] };
+};
+
+export default function Tools({ data }: ToolsProps) {
+ const categories = data.categories;
+ const { t } = useTranslation("tools");
+ return (
+
+ );
+}
diff --git a/app/digital/components/UserAvatar.tsx b/app/digital/components/UserAvatar.tsx
new file mode 100644
index 0000000..2a18514
--- /dev/null
+++ b/app/digital/components/UserAvatar.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { useTranslation } from "@/app/digital/i18n/navigation";
+import { pbLogout } from "@/app/digital/lib/pocketbase";
+
+function getAvatarLetter(email: string): string {
+ const local = email.split("@")[0];
+ if (!local) return "?";
+ return local.slice(0, 1).toUpperCase();
+}
+
+interface UserAvatarProps {
+ email: string;
+ vip?: boolean;
+ onLogout?: () => void;
+ onRefreshVip?: () => void;
+}
+
+/** 圆形头像 + VIP 标识 + 点击展开退出按钮,供 digital / meetup / vip 三站复用 */
+export default function UserAvatar({ email, vip, onLogout }: UserAvatarProps) {
+ const { t } = useTranslation("common");
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ };
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ const handleLogout = async () => {
+ await pbLogout();
+ setOpen(false);
+ onLogout?.();
+ };
+
+ return (
+
+
setOpen((o) => !o)}
+ className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-sky-500 text-sm font-semibold text-white shadow-sm ring-2 ring-white dark:ring-slate-900 hover:bg-sky-600"
+ aria-label={t("logout")}
+ >
+ {getAvatarLetter(email)}
+ {vip && (
+
+ 👑
+
+ )}
+
+ {open && (
+
+ {vip && (
+
+ 👑 VIP
+
+ )}
+
+ {t("logout")}
+
+
+ )}
+
+ );
+}
diff --git a/app/digital/components/course/AudienceSection.tsx b/app/digital/components/course/AudienceSection.tsx
new file mode 100644
index 0000000..1616eab
--- /dev/null
+++ b/app/digital/components/course/AudienceSection.tsx
@@ -0,0 +1,27 @@
+import { Section } from "./Section";
+import { AUDIENCE_CONFIG } from "@/config/digital/course";
+
+export function AudienceSection() {
+ return (
+
+
+ {AUDIENCE_CONFIG.title}
+
+
+ {AUDIENCE_CONFIG.subtitle}
+
+
+ {AUDIENCE_CONFIG.items.map((a) => (
+
+
{a.emoji}
+
{a.title}
+
{a.desc}
+
+ ))}
+
+
+ );
+}
diff --git a/app/digital/components/course/BenefitsSection.tsx b/app/digital/components/course/BenefitsSection.tsx
new file mode 100644
index 0000000..a1db39f
--- /dev/null
+++ b/app/digital/components/course/BenefitsSection.tsx
@@ -0,0 +1,31 @@
+import { Section } from "./Section";
+import { BENEFITS_CONFIG } from "@/config/digital/course";
+
+export function BenefitsSection() {
+ return (
+
+
+ {BENEFITS_CONFIG.title}
+
+
+ {BENEFITS_CONFIG.subtitle}
+
+
+ {BENEFITS_CONFIG.items.map((b) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/digital/components/course/CTASection.tsx b/app/digital/components/course/CTASection.tsx
new file mode 100644
index 0000000..4db78e9
--- /dev/null
+++ b/app/digital/components/course/CTASection.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { Link } from "@/app/digital/i18n/navigation";
+import { CTA_CONFIG } from "@/config/digital/course";
+import { useAuthAndVip } from "@/app/digital/lib/useAuthAndVip";
+import { getStoredToken, getStoredUser } from "@/app/digital/lib/pocketbase";
+import AuthModal from "@/app/digital/components/AuthModal";
+import {
+ getPayEnv,
+ redirectToPay,
+ getDeviceFromEnv,
+} from "@/app/digital/lib/payment";
+import { useState } from "react";
+
+export function CTASection() {
+ const { user, vip, refetch } = useAuthAndVip();
+ const paid = !!user && vip;
+ const [authOpen, setAuthOpen] = useState(false);
+ const [payRedirecting, setPayRedirecting] = useState(false);
+
+ const startPay = (userId: string) => {
+ const env = getPayEnv();
+ const channel = "wxpay";
+ const device = getDeviceFromEnv(env);
+ const returnUrl = `${window.location.origin}/`;
+
+ redirectToPay({
+ user_id: userId,
+ return_url: returnUrl,
+ channel,
+ device,
+ type: "video",
+ useJoinConfig: true,
+ });
+ };
+
+ const handleEnroll = async () => {
+ if (payRedirecting) return;
+ setPayRedirecting(true);
+
+ try {
+ // 先验证登录态
+ let meRes = await fetch("/api/digital/auth/me", { credentials: "include" });
+ let meData = await meRes.json().catch(() => ({}));
+ if (meData?.user?.id) {
+ // 已登录:先校验 VIP,已是 VIP 则无需支付
+ const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
+ const vipData = await vipRes.json().catch(() => ({}));
+ if (vipData?.vip) {
+ await refetch();
+ return;
+ }
+ startPay(meData.user.id);
+ return;
+ }
+
+ const storedUser = getStoredUser();
+ const storedToken = getStoredToken();
+ if (storedUser?.id && storedToken) {
+ await fetch("/api/digital/auth/sync-session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: storedToken, record: storedUser }),
+ credentials: "include",
+ }).catch(() => null);
+ meRes = await fetch("/api/digital/auth/me", { credentials: "include" });
+ meData = await meRes.json().catch(() => ({}));
+ if (meData?.user?.id) {
+ const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
+ const vipData = await vipRes.json().catch(() => ({}));
+ if (vipData?.vip) {
+ await refetch();
+ return;
+ }
+ startPay(meData.user.id);
+ return;
+ }
+ }
+
+ setAuthOpen(true);
+ } finally {
+ setPayRedirecting(false);
+ }
+ };
+
+ return (
+
+
+
{CTA_CONFIG.title}
+
{CTA_CONFIG.subtitle}
+
+ {CTA_CONFIG.badges.map((b, i) => (
+
+ {b}
+
+ ))}
+
+ {paid ? (
+
+ 开始学习 →
+
+ ) : (
+
+ {payRedirecting ? "跳转支付中..." : "立即报名 →"}
+
+ )}
+
+ setAuthOpen(false)}
+ onSuccess={() => {
+ setAuthOpen(false);
+ void refetch();
+ }}
+ />
+
+ );
+}
diff --git a/app/digital/components/course/CourseHero.tsx b/app/digital/components/course/CourseHero.tsx
new file mode 100644
index 0000000..98806f8
--- /dev/null
+++ b/app/digital/components/course/CourseHero.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Link } from "@/app/digital/i18n/navigation";
+import { HERO_CONFIG, CURRICULUM_CONFIG } from "@/config/digital/course";
+import { useAuthAndVip } from "@/app/digital/lib/useAuthAndVip";
+import { getStoredToken, getStoredUser } from "@/app/digital/lib/pocketbase";
+import { getLastWatched, getProgress } from "@/app/digital/lib/learning";
+import AuthModal from "@/app/digital/components/AuthModal";
+import {
+ getPayEnv,
+ redirectToPay,
+ getDeviceFromEnv,
+} from "@/app/digital/lib/payment";
+
+const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
+
+export function CourseHero() {
+ const { user, vip, refetch } = useAuthAndVip();
+ const [last, setLast] = useState>(null);
+ const [progress, setProgress] = useState({ completed: 0, percent: 0 });
+ const [authOpen, setAuthOpen] = useState(false);
+ const [payRedirecting, setPayRedirecting] = useState(false);
+
+ useEffect(() => {
+ setLast(getLastWatched());
+ setProgress(getProgress(TOTAL_LESSONS));
+ const onProgress = () => {
+ setLast(getLastWatched());
+ setProgress(getProgress(TOTAL_LESSONS));
+ };
+ window.addEventListener("learning:progress", onProgress);
+ return () => window.removeEventListener("learning:progress", onProgress);
+ }, []);
+
+ const paid = !!user && vip;
+
+ const startPay = (userId: string) => {
+ const env = getPayEnv();
+ const channel = "wxpay";
+ const device = getDeviceFromEnv(env);
+ const returnUrl = `${window.location.origin}/`;
+
+ redirectToPay({
+ user_id: userId,
+ return_url: returnUrl,
+ channel,
+ device,
+ type: "video",
+ useJoinConfig: true,
+ });
+ };
+
+ const handleEnroll = async () => {
+ if (payRedirecting) return;
+ setPayRedirecting(true);
+
+ try {
+ // 先验证登录态
+ let meRes = await fetch("/api/digital/auth/me", { credentials: "include" });
+ let meData = await meRes.json().catch(() => ({}));
+ if (meData?.user?.id) {
+ // 已登录:先校验 VIP,已是 VIP 则无需支付
+ const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
+ const vipData = await vipRes.json().catch(() => ({}));
+ if (vipData?.vip) {
+ await refetch();
+ return;
+ }
+ startPay(meData.user.id);
+ return;
+ }
+
+ const storedUser = getStoredUser();
+ const storedToken = getStoredToken();
+ if (storedUser?.id && storedToken) {
+ await fetch("/api/digital/auth/sync-session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: storedToken, record: storedUser }),
+ credentials: "include",
+ }).catch(() => null);
+ meRes = await fetch("/api/digital/auth/me", { credentials: "include" });
+ meData = await meRes.json().catch(() => ({}));
+ if (meData?.user?.id) {
+ const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
+ const vipData = await vipRes.json().catch(() => ({}));
+ if (vipData?.vip) {
+ await refetch();
+ return;
+ }
+ startPay(meData.user.id);
+ return;
+ }
+ }
+
+ setAuthOpen(true);
+ } finally {
+ setPayRedirecting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {HERO_CONFIG.title}
+
+
+ {HERO_CONFIG.subtitle}
+
+
+ {HERO_CONFIG.stats.map((s) => (
+
+
{s.value}
+
{s.label}
+
+ ))}
+
+
+ {paid && last ? (
+
+ 继续学习 →
+
+ ) : paid ? (
+
+ 进入课程 →
+
+ ) : (
+
+ {payRedirecting ? "跳转支付中..." : "立即报名 →"}
+
+ )}
+
+ {paid && progress.completed > 0 && (
+
+
+ 学习进度
+ {progress.completed}/{TOTAL_LESSONS} 节
+
+
+
+ )}
+
+ setAuthOpen(false)}
+ onSuccess={() => {
+ setAuthOpen(false);
+ void refetch();
+ }}
+ />
+
+ );
+}
diff --git a/app/digital/components/course/CurriculumSection.tsx b/app/digital/components/course/CurriculumSection.tsx
new file mode 100644
index 0000000..9a0bf3e
--- /dev/null
+++ b/app/digital/components/course/CurriculumSection.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Link } from "@/app/digital/i18n/navigation";
+import { Section } from "./Section";
+import { CURRICULUM_CONFIG } from "@/config/digital/course";
+import { canWatchLesson, isFreeTrial } from "@/app/digital/lib/course-payment";
+import { useAuthAndVip } from "@/app/digital/lib/useAuthAndVip";
+import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/digital/lib/learning";
+
+export function CurriculumSection() {
+ const { vip } = useAuthAndVip();
+ const [expanded, setExpanded] = useState>(() =>
+ Object.fromEntries(CURRICULUM_CONFIG.modules.map((_, i) => [i, i === 0]))
+ );
+ const [search, setSearch] = useState("");
+ const [bookmarks, setBookmarks] = useState>(() =>
+ typeof window === "undefined" ? new Set() : getBookmarks()
+ );
+ const [completed, setCompleted] = useState>(() =>
+ typeof window === "undefined" ? new Set() : getCompletedLessons()
+ );
+
+ useEffect(() => {
+ const onB = () => setBookmarks(getBookmarks());
+ const onP = () => setCompleted(getCompletedLessons());
+ window.addEventListener("learning:bookmarks", onB);
+ window.addEventListener("learning:progress", onP);
+ return () => {
+ window.removeEventListener("learning:bookmarks", onB);
+ window.removeEventListener("learning:progress", onP);
+ };
+ }, []);
+
+ const toggle = (i: number) => {
+ setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
+ };
+
+ const allLessons = CURRICULUM_CONFIG.modules.flatMap((m, mi) =>
+ m.lessons.map((l, li) => ({ moduleIndex: mi, lessonIndex: li, ...l, moduleTitle: m.title }))
+ );
+ const filtered = search.trim()
+ ? allLessons.filter(
+ (l) =>
+ l.name.toLowerCase().includes(search.toLowerCase()) ||
+ l.moduleTitle.toLowerCase().includes(search.toLowerCase())
+ )
+ : allLessons;
+
+ const filteredByModule = CURRICULUM_CONFIG.modules
+ .map((m, mi) => ({
+ ...m,
+ lessons: filtered.filter((l) => l.moduleIndex === mi),
+ }))
+ .filter((m) => m.lessons.length > 0);
+
+ return (
+
+
+ {CURRICULUM_CONFIG.title}
+
+
+ {CURRICULUM_CONFIG.subtitle}
+
+
+ setSearch(e.target.value)}
+ className="w-full rounded-full border border-[var(--border)] bg-[var(--background)] px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
+ />
+
+
+ {filteredByModule.map((c) => {
+ const moduleIndex = CURRICULUM_CONFIG.modules.findIndex((m) => m.title === c.title);
+ const isOpen = expanded[moduleIndex] ?? true;
+ return (
+
+
toggle(moduleIndex)}
+ className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5 sm:py-4"
+ >
+
+
+ ▶
+
+ {c.emoji}
+ {c.title}
+
+
+ {c.lessons.length} 节
+
+
+ {isOpen && (
+
+ {c.lessons.map((l) => {
+ const unlocked = canWatchLesson(l.moduleIndex, l.lessonIndex, vip);
+ const key = `${l.moduleIndex}-${l.lessonIndex}`;
+ const bookmarked = isBookmarked(l.moduleIndex, l.lessonIndex);
+ const isCompleted = completed.has(key);
+ return (
+
+
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleBookmark(l.moduleIndex, l.lessonIndex);
+ }}
+ className="shrink-0 text-xs"
+ aria-label={bookmarked ? "取消收藏" : "收藏"}
+ >
+ {bookmarked ? "⭐" : "☆"}
+
+
+ {l.name}
+
+ {isFreeTrial(l.moduleIndex, l.lessonIndex) && (
+
+ 试看
+
+ )}
+ {isCompleted && (
+
+ ✓
+
+ )}
+ {!unlocked && (
+
+ 🔒
+
+ )}
+
+
+ {l.duration}
+ →
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/app/digital/components/course/FAQSection.tsx b/app/digital/components/course/FAQSection.tsx
new file mode 100644
index 0000000..032f6cf
--- /dev/null
+++ b/app/digital/components/course/FAQSection.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { useState } from "react";
+import { Section } from "./Section";
+import { FAQ_CONFIG } from "@/config/digital/course";
+
+export function FAQSection() {
+ const [openIndex, setOpenIndex] = useState(null);
+
+ return (
+
+
+ {FAQ_CONFIG.title}
+
+
+ {FAQ_CONFIG.items.map((item, i) => (
+
+
setOpenIndex(openIndex === i ? null : i)}
+ className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5"
+ >
+ {item.q}
+
+ ▼
+
+
+ {openIndex === i && (
+
+ {item.a}
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/app/digital/components/course/InstructorsSection.tsx b/app/digital/components/course/InstructorsSection.tsx
new file mode 100644
index 0000000..87df589
--- /dev/null
+++ b/app/digital/components/course/InstructorsSection.tsx
@@ -0,0 +1,37 @@
+import { Section } from "./Section";
+import { INSTRUCTORS_CONFIG } from "@/config/digital/course";
+
+export function InstructorsSection() {
+ return (
+
+
+ {INSTRUCTORS_CONFIG.title}
+
+
+ {INSTRUCTORS_CONFIG.subtitle}
+
+
+ {INSTRUCTORS_CONFIG.items.map((i) => (
+
+
+
+ {i.name.slice(0, 1)}
+
+
+
{i.name}
+
{i.role}
+
+ {i.desc}
+
+
+ {i.tag}
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/digital/components/course/Section.tsx b/app/digital/components/course/Section.tsx
new file mode 100644
index 0000000..46a4a33
--- /dev/null
+++ b/app/digital/components/course/Section.tsx
@@ -0,0 +1,17 @@
+import { type ReactNode } from "react";
+
+type SectionProps = {
+ children: ReactNode;
+ className?: string;
+};
+
+/** 通用区块容器 - 统一内边距与最大宽度,响应式(来自 nomadlms) */
+export function Section({ children, className = "" }: SectionProps) {
+ return (
+
+ );
+}
diff --git a/app/digital/components/course/VideoPlayer.tsx b/app/digital/components/course/VideoPlayer.tsx
new file mode 100644
index 0000000..5bafd3f
--- /dev/null
+++ b/app/digital/components/course/VideoPlayer.tsx
@@ -0,0 +1,362 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+const STORAGE_PREFIX = "lms-video-";
+const SAVE_INTERVAL = 5000;
+const MIN_PROGRESS_TO_SHOW = 10;
+
+function getStoredProgress(videoId: string): number {
+ if (typeof window === "undefined") return 0;
+ try {
+ const raw = localStorage.getItem(`${STORAGE_PREFIX}${videoId}`);
+ if (!raw) return 0;
+ const data = JSON.parse(raw);
+ return typeof data?.time === "number" ? data.time : 0;
+ } catch {
+ return 0;
+ }
+}
+
+function saveProgress(videoId: string, time: number): void {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.setItem(`${STORAGE_PREFIX}${videoId}`, JSON.stringify({ time }));
+ } catch {
+ /* ignore */
+ }
+}
+
+type VideoPlayerProps = {
+ src: string;
+ poster?: string;
+ title?: string;
+ className?: string;
+ videoId?: string;
+ onComplete?: () => void;
+};
+
+export function VideoPlayer({ src, poster, title, className = "", videoId, onComplete }: VideoPlayerProps) {
+ const videoRef = useRef(null);
+ const [playing, setPlaying] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [volume, setVolume] = useState(1);
+ const [muted, setMuted] = useState(false);
+ const [speed, setSpeed] = useState(1);
+ const [showControls, setShowControls] = useState(true);
+ const [showResumePrompt, setShowResumePrompt] = useState(false);
+ const [savedTime, setSavedTime] = useState(0);
+ const hideTimerRef = useRef | null>(null);
+ const lastSaveRef = useRef(0);
+
+ const togglePlay = useCallback(() => {
+ const v = videoRef.current;
+ if (!v) return;
+ if (v.paused) {
+ v.play();
+ setPlaying(true);
+ } else {
+ v.pause();
+ setPlaying(false);
+ }
+ }, []);
+
+ const handleTimeUpdate = useCallback(() => {
+ const v = videoRef.current;
+ if (!v) return;
+ const time = v.currentTime;
+ const dur = v.duration;
+ setCurrentTime(time);
+ setProgress(Number.isFinite(dur) && dur > 0 ? (time / dur) * 100 : 0);
+
+ if (videoId && v.currentTime > 0) {
+ const now = Date.now();
+ if (now - lastSaveRef.current > SAVE_INTERVAL) {
+ lastSaveRef.current = now;
+ saveProgress(videoId, v.currentTime);
+ }
+ }
+ }, [videoId]);
+
+ const handleLoadedMetadata = useCallback(() => {
+ const v = videoRef.current;
+ if (!v) return;
+ const dur = v.duration;
+ if (!Number.isFinite(dur) || dur <= 0) return;
+ setDuration(dur);
+
+ if (videoId) {
+ const stored = getStoredProgress(videoId);
+ if (Number.isFinite(stored) && stored >= MIN_PROGRESS_TO_SHOW && stored < dur - 5) {
+ setSavedTime(stored);
+ setShowResumePrompt(true);
+ }
+ }
+ }, [videoId]);
+
+ const handleEnded = useCallback(() => {
+ setPlaying(false);
+ setProgress(0);
+ setCurrentTime(0);
+ if (videoId) saveProgress(videoId, 0);
+ onComplete?.();
+ }, [videoId, onComplete]);
+
+ const handlePause = useCallback(() => {
+ const v = videoRef.current;
+ if (videoId && v && v.currentTime > 0) {
+ saveProgress(videoId, v.currentTime);
+ }
+ }, [videoId]);
+
+ const handleResume = useCallback(() => {
+ const v = videoRef.current;
+ if (!v || savedTime <= 0) return;
+ const dur = v.duration;
+ if (!Number.isFinite(dur) || dur <= 0) return;
+ const time = Math.min(savedTime, dur - 0.1);
+ v.currentTime = time;
+ setCurrentTime(time);
+ setProgress((time / dur) * 100);
+ v.play();
+ setPlaying(true);
+ setShowResumePrompt(false);
+ }, [savedTime]);
+
+ const handleRestart = useCallback(() => {
+ const v = videoRef.current;
+ if (!v) return;
+ if (videoId) saveProgress(videoId, 0);
+ v.currentTime = 0;
+ setCurrentTime(0);
+ setProgress(0);
+ setShowResumePrompt(false);
+ }, [videoId]);
+
+ const handleSeek = useCallback((e: React.MouseEvent) => {
+ const v = videoRef.current;
+ if (!v) return;
+ const dur = v.duration;
+ if (!Number.isFinite(dur) || dur <= 0) return;
+ const rect = e.currentTarget.getBoundingClientRect();
+ if (rect.width <= 0) return;
+ const p = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ const time = p * dur;
+ v.currentTime = time;
+ setCurrentTime(time);
+ setProgress(p * 100);
+ }, []);
+
+ const toggleMute = useCallback(() => {
+ const v = videoRef.current;
+ if (!v) return;
+ v.muted = !v.muted;
+ setMuted(v.muted);
+ }, []);
+
+ const handleVolumeChange = useCallback((e: React.ChangeEvent) => {
+ const v = videoRef.current;
+ if (!v) return;
+ const val = parseFloat(e.target.value);
+ v.volume = val;
+ setVolume(val);
+ setMuted(val === 0);
+ }, []);
+
+ const cycleSpeed = useCallback(() => {
+ const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
+ const i = speeds.indexOf(speed);
+ const next = speeds[(i + 1) % speeds.length];
+ const v = videoRef.current;
+ if (v) v.playbackRate = next;
+ setSpeed(next);
+ }, [speed]);
+
+ const toggleFullscreen = useCallback(() => {
+ const container = videoRef.current?.parentElement;
+ if (!container) return;
+ if (!document.fullscreenElement) {
+ container.requestFullscreen();
+ } else {
+ document.exitFullscreen();
+ }
+ }, []);
+
+ const showControlsTemporarily = useCallback(() => {
+ setShowControls(true);
+ if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
+ hideTimerRef.current = setTimeout(() => setShowControls(false), 3000);
+ }, []);
+
+ useEffect(() => () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }, []);
+
+ const formatTime = (s: number) => {
+ if (!Number.isFinite(s) || s < 0) return "0:00";
+ const m = Math.floor(s / 60);
+ const sec = Math.floor(s % 60);
+ return `${m}:${sec.toString().padStart(2, "0")}`;
+ };
+
+ const formatResumeTime = (s: number) => {
+ if (!Number.isFinite(s) || s < 0) return "0秒";
+ const m = Math.floor(s / 60);
+ const sec = Math.floor(s % 60);
+ if (m > 0) return `${m}分${sec}秒`;
+ return `${sec}秒`;
+ };
+
+ return (
+ setShowControls(false)}
+ >
+
+
+ {showResumePrompt && (
+
+
+ 上次看到 {formatResumeTime(savedTime)}
+
+
+
+ 从上次继续
+
+
+ 重新观看
+
+
+
+ )}
+
+ {!playing && (
+
+
+
+ )}
+
+
+
+
+
+
+ {playing ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {formatTime(currentTime)} / {formatTime(duration)}
+
+
+
+
+ {muted || volume === 0 ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ {speed}x
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/digital/components/course/index.ts b/app/digital/components/course/index.ts
new file mode 100644
index 0000000..5d1f174
--- /dev/null
+++ b/app/digital/components/course/index.ts
@@ -0,0 +1,9 @@
+export { Section } from "./Section";
+export { VideoPlayer } from "./VideoPlayer";
+export { CurriculumSection } from "./CurriculumSection";
+export { AudienceSection } from "./AudienceSection";
+export { BenefitsSection } from "./BenefitsSection";
+export { InstructorsSection } from "./InstructorsSection";
+export { FAQSection } from "./FAQSection";
+export { CTASection } from "./CTASection";
+export { CourseHero } from "./CourseHero";
diff --git a/app/digital/context/LocaleContext.tsx b/app/digital/context/LocaleContext.tsx
new file mode 100644
index 0000000..a678685
--- /dev/null
+++ b/app/digital/context/LocaleContext.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import {
+ createContext,
+ useContext,
+ useCallback,
+ type ReactNode,
+} from "react";
+
+export type Locale = "zh" | "en";
+
+const LOCALES: Locale[] = ["zh", "en"];
+const DEFAULT_LOCALE: Locale = "zh";
+
+type Messages = Record;
+
+type LocaleContextValue = {
+ locale: Locale;
+ messages: Messages;
+ t: (key: string) => string;
+ setLocale: (locale: Locale) => void;
+};
+
+const LocaleContext = createContext(null);
+
+function getNested(obj: unknown, path: string): string | undefined {
+ const keys = path.split(".");
+ let current: unknown = obj;
+ for (const key of keys) {
+ if (current == null || typeof current !== "object") return undefined;
+ current = (current as Record)[key];
+ }
+ return typeof current === "string" ? current : undefined;
+}
+
+export function LocaleProvider({
+ locale,
+ messages,
+ children,
+}: {
+ locale: Locale;
+ messages: Messages;
+ children: ReactNode;
+}) {
+ const t = useCallback(
+ (key: string) => {
+ const value = getNested(messages, key);
+ return value ?? key;
+ },
+ [messages]
+ );
+
+ const setLocale = useCallback((newLocale: Locale) => {
+ const path = window.location.pathname;
+ const newPath = path.replace(/^\/(zh|en)/, `/${newLocale}`);
+ window.location.href = newPath || `/${newLocale}/digital`;
+ }, []);
+
+ const value: LocaleContextValue = {
+ locale,
+ messages,
+ t,
+ setLocale,
+ };
+
+ return (
+ {children}
+ );
+}
+
+export function useLocale() {
+ const ctx = useContext(LocaleContext);
+ if (!ctx) throw new Error("useLocale must be used within LocaleProvider");
+ return ctx.locale;
+}
+
+export function useTranslation(namespace?: string) {
+ const ctx = useContext(LocaleContext);
+ if (!ctx) throw new Error("useTranslation must be used within LocaleProvider");
+ return {
+ t: (key: string) => {
+ const fullKey = namespace ? `${namespace}.${key}` : key;
+ return ctx.t(fullKey);
+ },
+ };
+}
+
+export function useLocaleRouter() {
+ const locale = useLocale();
+ return {
+ replace: (pathname: string, opts?: { locale?: Locale }) => {
+ const newLocale = opts?.locale ?? locale;
+ const cleanPath = pathname.replace(/^\/(zh|en)/, "") || "/";
+ const digitalPath = cleanPath.startsWith("/digital")
+ ? cleanPath
+ : `/digital${cleanPath === "/" ? "" : cleanPath}`;
+ const newPath = `/${newLocale}${digitalPath}`;
+ window.location.href = newPath;
+ },
+ };
+}
+
+export { LOCALES, DEFAULT_LOCALE };
diff --git a/app/digital/context/ThemeContext.tsx b/app/digital/context/ThemeContext.tsx
new file mode 100644
index 0000000..24159a0
--- /dev/null
+++ b/app/digital/context/ThemeContext.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ useCallback,
+ type ReactNode,
+} from "react";
+
+const STORAGE_KEY = "theme";
+
+export type Theme = "light" | "dark";
+
+type ThemeContextValue = {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+ toggleTheme: () => void;
+};
+
+const ThemeContext = createContext(null);
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [theme, setThemeState] = useState(() =>
+ typeof document !== "undefined" &&
+ document.documentElement.classList.contains("dark")
+ ? "dark"
+ : "light"
+ );
+
+ useEffect(() => {
+ const root = document.documentElement;
+ if (theme === "dark") {
+ root.classList.add("dark");
+ } else {
+ root.classList.remove("dark");
+ }
+ localStorage.setItem(STORAGE_KEY, theme);
+ }, [theme]);
+
+ const setTheme = useCallback((newTheme: Theme) => {
+ setThemeState(newTheme);
+ }, []);
+
+ const toggleTheme = useCallback(() => {
+ setThemeState((prev) => (prev === "light" ? "dark" : "light"));
+ }, []);
+
+ const value: ThemeContextValue = {
+ theme,
+ setTheme,
+ toggleTheme,
+ };
+
+ return (
+ {children}
+ );
+}
+
+export function useTheme() {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
+ return ctx;
+}
diff --git a/app/digital/data/days.ts b/app/digital/data/days.ts
new file mode 100644
index 0000000..906ae64
--- /dev/null
+++ b/app/digital/data/days.ts
@@ -0,0 +1,993 @@
+export interface DayData {
+ day: number;
+ icon: string;
+ title: string;
+ readingTime: string;
+ content: string;
+}
+
+export const daysData: DayData[] = [
+ {
+ day: 1,
+ icon: "👋",
+ title: "认识数字游民",
+ readingTime: "约 12 分钟",
+ content: `
+# 认识数字游民
+
+数字游民 7天指南 - Day 1: 认识数字游民
+
+> *"我不再需要一张固定的办公桌,也不再需要每天挤地铁。我的办公室是整个地球,而我的同事分布在六个时区。这不是假期,这是我的日常。"*
+
+---
+
+## 📖 本章导读
+
+今天你将了解:
+
+* 什么是数字游民,以及它**不是**什么
+* 数字游民和远程工作的**本质区别**
+* 全球数字游民生态的**现状与趋势**
+* 为什么**现在**是最好的开始时机
+
+---
+
+## 先聊聊你的困惑
+
+你可能在社交媒体上看过这样的场景——
+
+一个人在巴厘岛的海边咖啡馆,面前是一台 MacBook 和一杯椰子水。蓝天白云,海风轻拂,背景是无尽的稻田。配文写着:"今天的办公室 🌴"
+
+然后你想:"这也太爽了吧,但这真的可行吗?"
+
+**可行。** 而且比你想象的更接近现实。
+
+但首先,让我们把一些误解清理掉。
+
+---
+
+## 数字游民 ≠ 旅游博主
+
+让我猜猜你对"数字游民"的第一印象——
+
+一群有钱有闲的年轻人,整天在海滩上拍照,偶尔打开电脑假装工作?
+
+**大错特错。**
+
+一个真正的数字游民是什么样的?
+
+| 维度 | 常见误解 | 真实情况 |
+| --- | --- | --- |
+| **工作** | 不用工作 / 轻松赚钱 | 有稳定的远程收入来源,工作量不比上班少 |
+| **地点** | 永远在度假胜地 | 会选择性价比高、基础设施好的城市 |
+| **收入** | 需要很多钱 | 很多目的地生活成本远低于一线城市 |
+| **社交** | 孤独流浪 | 全球数字游民社区非常活跃 |
+| **签证** | 灰色地带 | 50+ 国家已推出官方数字游民签证 |
+| **稳定性** | 不稳定 | 可以长期持续的生活方式 |
+
+> 💡 **关键认知**:数字游民的核心不是"旅行",而是"自由"——选择在哪里生活、如何安排时间、为谁工作的自由。旅行只是这种自由的一个副产品。
+
+---
+
+## 2026年的数字游民生态
+
+数字游民不是一个小众概念——它是一场正在发生的全球性变革。
+
+### 关键数据
+
+* **全球数字游民人数**:超过 3500 万(2024年数据,每年增长 15-20%)
+* **平均收入**:$85,000/年(高于很多发达国家的平均工资)
+* **最热门目的地**:葡萄牙、泰国、墨西哥、哥伦比亚、印尼
+* **最常见职业**:软件开发、设计、营销、写作、咨询
+* **提供数字游民签证的国家**:55+(且在持续增加)
+
+### 为什么突然火了?
+
+三个因素在同时发生:
+
+**1. 远程工作成为主流**
+
+疫情后,远程工作从"例外"变成了"标配"。全球超过 60% 的知识工作者现在可以选择远程工作。公司不再问"你为什么不来办公室",而是问"你需要什么才能高效工作"。
+
+**2. 基础设施全球化**
+
+高速互联网覆盖了越来越多的地方。共享办公空间(Coworking Space)在全球遍地开花。数字游民社区在各大城市扎根。
+
+**3. 签证政策松绑**
+
+从爱沙尼亚(全球首个数字游民签证)开始,越来越多国家推出了专门面向远程工作者的签证。这意味着你可以完全合法地在另一个国家生活和工作。
+
+> 🌍 **趋势观察**:2024年,西班牙、日本、韩国都推出或升级了数字游民签证政策。这不是昙花一现——各国正在竞争吸引高收入远程工作者。
+
+---
+
+## 数字游民的三种模式
+
+不是所有数字游民都一样。根据移动频率和工作方式,大致可以分为三类:
+
+### 🐝 蜜蜂型 — 频繁移动
+
+* 每 1-3 个月换一个城市
+* 追求新鲜感和多元体验
+* 适合:单身、喜欢冒险、工作时间灵活
+* 挑战:需要频繁适应新环境
+
+### 🐢 乌龟型 — 慢旅行
+
+* 在一个地方待 3-12 个月
+* 深度融入当地生活
+* 适合:需要稳定工作环境、有伴侣/宠物
+* 优势:能建立深度人际关系
+
+### 🦅 候鸟型 — 季节迁移
+
+* 有几个固定的"基地"城市
+* 随季节或心情在基地间切换
+* 适合:有一定经济基础、追求舒适
+* 优势:每个基地都有熟悉的社交圈
+
+> 💡 **选择建议**:如果你是第一次尝试数字游民生活,建议从"乌龟型"开始——选一个目的地,待 1-3 个月。频繁移动会消耗大量精力,不利于保持工作效率。
+
+---
+
+## 你适合成为数字游民吗?
+
+不是每个人都适合这种生活方式。在你兴奋地订机票之前,先诚实地回答以下问题:
+
+### ✅ 你可能适合,如果:
+
+* 你有一技之长可以远程交付(编程、设计、写作、营销等)
+* 你对新环境适应力强
+* 你能自我管理,不需要别人监督才能工作
+* 你对物质生活的需求不高(不需要大房子和固定资产)
+* 你享受独处,同时也善于建立新的社交关系
+
+### ❌ 你可能不适合,如果:
+
+* 你需要固定的社交圈和稳定感
+* 你的工作必须线下完成
+* 你无法忍受网络不稳定
+* 你有很多不能移动的责任(房贷、年幼的孩子、需要照顾的家人)
+* 你把数字游民等同于"永久度假"
+
+**关键不是"你想不想",而是"你能不能"。** 想清楚这个问题,剩下的都是技术细节。
+
+---
+
+## 接下来的 7 天,你将获得什么?
+
+让我帮你预览一下这趟旅程:
+
+| 天数 | 你会做什么 | 结果 |
+| --- | --- | --- |
+| Day 1(今天) | 了解数字游民的真正形态 | ✅ 你在这里 |
+| Day 2 | 评估远程技能与市场需求 | 🎯 找到你的方向 |
+| Day 3 | 构建多元收入体系 | 💰 收入有保障 |
+| Day 4 | 配置远程工作工具链 | 🛠️ 效率最大化 |
+| Day 5 | 搞定签证、税务、保险 | 📋 合规无忧 |
+| Day 6 | 选择你的第一个目的地 | 🗺️ 目标明确 |
+| Day 7 | 制定行动计划并启程 | 🚀 准备出发 |
+
+**7 天后,你将拥有一份完整的数字游民启动方案——从技能到工具,从签证到目的地,一切就绪。**
+
+这不需要辞职,不需要卖房,不需要一笔巨大的存款。你只需要一台电脑、一个可远程的技能、和开始行动的勇气。
+
+---
+
+## 🔑 本章要点回顾
+
+* **数字游民 ≠ 旅游博主**:核心是"远程工作+地点自由",不是"不工作"
+* **全球 3500 万+**:这是一场正在发生的全球性变革
+* **三种模式**:蜜蜂型(频繁移动)、乌龟型(慢旅行)、候鸟型(季节迁移)
+* **不适合所有人**:需要可远程的技能、自我管理能力、对不确定性的容忍度
+
+---
+
+## 今日任务 ✅
+
+Day 1 没有需要动手的步骤——今天你只需要做两件事:
+
+**1. 评估你的远程工作能力**
+
+拿出一张纸,写下:
+* 你现在的技能中,哪些可以远程交付?
+* 如果离开当前公司,你能独立获得收入吗?
+* 你的工作需要什么——只需要一台电脑和网络?还是需要特殊设备或面对面沟通?
+
+**2. 想象你的理想一天**
+
+如果没有任何限制,你理想中的一天是什么样的?几点起床?在哪里工作?工作多久?下午做什么?
+
+把这些写下来。这就是你的"北极星"——接下来 6 天的一切规划,都是为了让这个理想变成现实。
+
+---
+
+## 预告:Day 2 — 发现你的远程技能
+
+> 明天我们要认真审视你的技能库。哪些技能最容易远程变现?市场需求如何?怎样在 30 天内让你的技能准备好远程工作?
+>
+> 准备好你的简历和作品集,我们明天见。
+
+---
+
+> 🌍 **数字游民小贴士**:如果你正在阅读这篇文章的时候还坐在办公室的格子间里——别急。改变不是一天发生的,但它从今天开始。每一个成功的数字游民,都是从"我想试试"这个念头开始的。
+`,
+ },
+ {
+ day: 2,
+ icon: "💡",
+ title: "发现远程技能",
+ readingTime: "约 15 分钟",
+ content: `
+# 发现远程技能
+
+数字游民 7天指南 - Day 2: 发现你的远程技能
+
+> *"不是所有技能都适合远程工作,但几乎所有人都有至少一项可以远程变现的能力。关键是发现它、打磨它、定价它。"*
+
+---
+
+## 📖 本章导读
+
+今天你将了解:
+
+* 最受欢迎的**10大远程工作技能**及其市场需求
+* 如何**评估你现有技能**的远程变现潜力
+* **30天技能升级计划**——快速让你的技能达到远程工作标准
+* 建立**个人品牌**的核心方法
+
+---
+
+## 远程工作技能全景图
+
+并非所有工作都能远程完成。以下是 2026 年最受欢迎的远程工作领域:
+
+### 🔥 高需求 · 高薪资
+
+| 技能领域 | 平均年收入 | 入门难度 | 市场需求 |
+| --- | --- | --- | --- |
+| 软件开发 | $95,000 | ⭐⭐⭐⭐ | 🔥🔥🔥🔥🔥 |
+| 产品设计 (UI/UX) | $85,000 | ⭐⭐⭐ | 🔥🔥🔥🔥 |
+| 数据科学 / AI | $110,000 | ⭐⭐⭐⭐⭐ | 🔥🔥🔥🔥🔥 |
+| DevOps / 云架构 | $105,000 | ⭐⭐⭐⭐ | 🔥🔥🔥🔥 |
+| 网络安全 | $100,000 | ⭐⭐⭐⭐ | 🔥🔥🔥🔥 |
+
+### 📈 中等门槛 · 灵活自由
+
+| 技能领域 | 平均年收入 | 入门难度 | 市场需求 |
+| --- | --- | --- | --- |
+| 数字营销 / SEO | $65,000 | ⭐⭐ | 🔥🔥🔥🔥 |
+| 内容创作 / 写作 | $55,000 | ⭐⭐ | 🔥🔥🔥 |
+| 项目管理 | $75,000 | ⭐⭐⭐ | 🔥🔥🔥 |
+| 翻译 / 本地化 | $50,000 | ⭐⭐ | 🔥🔥🔥 |
+| 在线教育 / 咨询 | $70,000 | ⭐⭐ | 🔥🔥🔥🔥 |
+
+> 💡 **重要提醒**:以上数据基于全球市场。如果你主要服务国内客户,收入水平可能不同,但远程工作的可行性是一样的。
+
+---
+
+## 评估你的技能
+
+回答以下 5 个问题,给自己打分(1-5分):
+
+1. **可远程性**:你的工作是否只需要电脑和网络就能完成?
+2. **市场需求**:有多少公司/个人需要你的技能?
+3. **可量化交付**:你的工作成果是否容易衡量?(代码、设计稿、文章等)
+4. **时区灵活性**:你的工作是否允许异步协作?
+5. **可替代性**:你的技能是否容易被替代?(越难替代越好)
+
+**总分解读**:
+* **20-25分**:你已经具备远程工作的条件,可以直接开始
+* **15-19分**:稍加调整就能远程工作
+* **10-14分**:需要学习新技能或转型
+* **5-9分**:建议先在现有领域积累,同时学习可远程的副技能
+
+---
+
+## 30天技能升级路线
+
+无论你处于什么阶段,以下路线都能帮你提升远程竞争力:
+
+### 第 1 周:评估与定位
+* 完成技能自评(上面的 5 个问题)
+* 研究 3-5 个感兴趣的远程工作领域
+* 在 LinkedIn、Remote OK、电鸭社区浏览远程岗位需求
+* 确定你的目标方向
+
+### 第 2 周:快速学习
+* 选择 1-2 门在线课程(Udemy、Coursera、极客时间)
+* 每天投入 2 小时学习
+* 加入相关的社区和论坛
+
+### 第 3 周:项目实战
+* 完成 2-3 个练习项目
+* 建立你的作品集 / Portfolio
+* 开始写技术博客或发社交媒体
+
+### 第 4 周:市场验证
+* 在自由职业平台注册(Upwork、Fiverr、猪八戒)
+* 投递 10 个远程工作申请
+* 接第一个小项目(哪怕低价)
+
+> 🌍 **数字游民小贴士**:不要等到"完全准备好"才开始。完美主义是数字游民最大的敌人。先开始,边做边学,这就是最好的策略。
+
+---
+
+## 🔑 本章要点回顾
+
+* **高薪远程技能**:开发、设计、数据、安全是最热门的领域
+* **自我评估**:诚实评估你的技能远程化潜力
+* **30天行动**:评估→学习→实战→验证,一个月内完成转型准备
+* **立即开始**:不要等到完美,先迈出第一步
+
+---
+
+## 今日任务 ✅
+
+1. 完成技能自评打分
+2. 在远程工作平台浏览 20 个职位,了解市场需求
+3. 确定你的 1-2 个目标技能方向
+`,
+ },
+ {
+ day: 3,
+ icon: "💰",
+ title: "构建收入体系",
+ readingTime: "约 14 分钟",
+ content: `
+# 构建收入体系
+
+数字游民 7天指南 - Day 3: 构建可持续的收入体系
+
+> *"数字游民的自由不是来自不工作,而是来自多元化的收入来源。当你不再依赖单一雇主,你就真正自由了。"*
+
+---
+
+## 📖 本章导读
+
+今天你将了解:
+
+* 数字游民的**四大收入模式**及其优劣
+* 如何从**零开始构建**你的第一个远程收入来源
+* **被动收入**的真相——它真的被动吗?
+* 定价策略与**收入最大化**技巧
+
+---
+
+## 四大收入模式
+
+### 1. 🏢 远程全职
+
+为一家公司全职远程工作,拿固定工资。
+
+* **优势**:收入稳定、有社保/福利、风险低
+* **劣势**:时间不完全自由、受限于公司政策
+* **适合**:刚开始尝试数字游民的人
+* **平台推荐**:Remote OK、We Work Remotely、电鸭社区
+
+### 2. 💼 自由职业
+
+按项目或小时收费,为多个客户提供服务。
+
+* **优势**:时间高度灵活、可选择客户、收入上限高
+* **劣势**:收入不稳定、需自己找客户、无福利
+* **适合**:有专业技能且善于沟通的人
+* **平台推荐**:Upwork、Toptal、Fiverr、猪八戒
+
+### 3. 📦 产品化收入
+
+创建数字产品(课程、模板、SaaS、电子书等)进行销售。
+
+* **优势**:做一次卖多次、可规模化、真正的被动收入
+* **劣势**:前期投入大、需要营销、成功率不高
+* **适合**:有创业精神、愿意长期投入的人
+* **代表案例**:Gumroad 创作者、独立开发者、在线教育
+
+### 4. 🎨 内容创作
+
+通过写作、视频、播客等内容获取广告、赞助、付费订阅收入。
+
+* **优势**:表达自我、建立影响力、收入形式多样
+* **劣势**:见效慢、竞争激烈、需持续产出
+* **适合**:善于表达、有独特视角的人
+* **平台推荐**:YouTube、B站、公众号、Substack、小红书
+
+---
+
+## 收入组合策略
+
+**最健康的数字游民收入结构是「三角形」**:
+
+\`\`\`
+ 稳定收入(基座)
+ / \\
+ 自由职业 产品收入
+ (灵活) (增长)
+\`\`\`
+
+* **基座**:一个稳定的收入来源(远程全职或长期客户),覆盖基本生活开支
+* **左翼**:自由职业项目,提供额外收入和灵活性
+* **右翼**:数字产品或内容创作,提供长期增长潜力
+
+> 💡 **新手建议**:先搞定"基座",确保生活无忧,然后逐步发展另外两个收入来源。不要一开始就辞职去做自由职业——除非你已经有了 6 个月的生活储备金。
+
+---
+
+## 定价的艺术
+
+定价是自由职业者最头疼的问题。以下是几个原则:
+
+1. **按价值定价,不按时间定价**:客户买的是结果,不是你的时间
+2. **起步价可以低,但要有涨价计划**:前 3 个项目可以低于市场价,积累作品和评价后立即提价
+3. **永远报你觉得"有点贵"的价格**:如果客户从不还价,说明你定价太低了
+4. **打包服务**:比如不卖"一个 Logo",卖"品牌视觉包"(Logo + 名片 + 社交媒体头图)
+
+---
+
+## 🔑 本章要点回顾
+
+* **四大模式**:远程全职、自由职业、产品化、内容创作
+* **三角形结构**:稳定收入为基座,灵活收入和增长收入为两翼
+* **先稳后闯**:确保基本收入后再冒险
+* **按价值定价**:不要贱卖你的时间
+
+---
+
+## 今日任务 ✅
+
+1. 确定你当前最适合的收入模式(四选一作为起点)
+2. 在对应平台创建账号并完善个人资料
+3. 计算你的"数字游民最低月收入"——覆盖目标城市的基本开支需要多少钱?
+`,
+ },
+ {
+ day: 4,
+ icon: "🛠️",
+ title: "工具武装",
+ readingTime: "约 13 分钟",
+ content: `
+# 工具武装
+
+数字游民 7天指南 - Day 4: 打造你的移动办公装备库
+
+> *"一个优秀的数字游民,装备精简但效率惊人。你的整个办公室,应该能装进一个背包里。"*
+
+---
+
+## 📖 本章导读
+
+今天你将了解:
+
+* 数字游民的**硬件装备清单**——什么该带,什么不该带
+* **软件工具栈**——协作、通讯、生产力全覆盖
+* 如何在**任何地方**高效工作
+* **网络安全**必备措施
+
+---
+
+## 硬件装备清单
+
+### 🎒 核心三件套
+
+| 装备 | 推荐 | 说明 |
+| --- | --- | --- |
+| 笔记本电脑 | MacBook Air M3 / ThinkPad X1 | 轻薄、续航长、性能够用 |
+| 降噪耳机 | AirPods Pro / Sony WH-1000XM5 | 嘈杂环境必备 |
+| 手机 | 任意智能手机 | 热点备份、双因素认证 |
+
+### 📦 可选装备
+
+* **便携显示器**:15.6寸 USB-C 便携屏,双屏效率翻倍
+* **折叠键盘**:如果你经常用手机/平板工作
+* **移动电源**:20000mAh,支持 PD 快充笔记本
+* **万能转换插头**:全球通用
+* **便携路由器**:GL.iNet 旅行路由器,VPN 直连
+
+> 💡 **极简原则**:能不带就不带。你需要的东西远比你想象的少。记住:每多一件行李,就多一份牵挂。
+
+---
+
+## 软件工具栈
+
+### 💬 沟通协作
+* **Slack** — 团队即时通讯,频道管理清晰
+* **Discord** — 社区交流,很多数字游民社区都在这里
+* **Zoom / Google Meet** — 视频会议
+* **Loom** — 录制异步视频消息,跨时区神器
+
+### 📋 项目管理
+* **Notion** — 笔记、知识库、项目管理一体化
+* **Linear** — 开发团队的项目追踪
+* **Todoist** — 个人任务管理
+
+### 💻 开发工具
+* **VS Code / Cursor** — 代码编辑器
+* **GitHub** — 代码托管和协作
+* **Vercel / Netlify** — 一键部署
+
+### 🔒 安全工具
+* **NordVPN / Mullvad** — 公共 WiFi 必开
+* **1Password** — 密码管理
+* **Authy** — 双因素认证
+
+### 💳 财务工具
+* **Wise** — 多币种账户,跨境转账手续费最低
+* **Revolut** — 旅行消费卡,汇率好
+* **Stripe / PayPal** — 收款
+
+---
+
+## 在任何地方高效工作的秘诀
+
+### 🏠 住宿选择优先级
+1. **WiFi 速度** > 50 Mbps(必须!预订前问清楚)
+2. **安静的工作空间**(独立桌子和椅子)
+3. **位置便利**(离 Coworking Space 近)
+4. **价格合理**
+
+### ☕ 咖啡馆工作守则
+* 先买东西再开电脑(尊重店家)
+* 自带充电线和转换插头
+* 戴耳机
+* 不要占位超过 3 小时
+* 找好备选地点
+
+### 🏢 Coworking Space 推荐
+* **WeWork** — 全球连锁,稳定可靠
+* **Hubud (巴厘岛)** — 数字游民圣地
+* **Punspace (清迈)** — 性价比之王
+* **Second Home (里斯本)** — 设计感十足
+
+---
+
+## 🔑 本章要点回顾
+
+* **轻装上阵**:核心三件套 + 精选可选装备
+* **工具为效率服务**:不要为了工具而工具
+* **安全第一**:VPN + 密码管理 + 双因素认证
+* **适应环境**:学会在任何地方保持高效
+
+---
+
+## 今日任务 ✅
+
+1. 检查你的硬件装备,列出需要补充的清单
+2. 安装并配置核心软件工具(至少 5 个)
+3. 测试你当前的网络速度,并准备一个备用方案
+`,
+ },
+ {
+ day: 5,
+ icon: "📋",
+ title: "签证与法律",
+ readingTime: "约 11 分钟",
+ content: `
+# 签证与法律
+
+数字游民 7天指南 - Day 5: 搞定签证、税务、保险
+
+> *"自由的前提是合规。搞定法律问题,你才能真正安心地享受数字游民生活。"*
+
+---
+
+## 📖 本章导读
+
+今天你将了解:
+
+* **数字游民签证**全球政策概览
+* **税务规划**基础知识——如何合法优化税务
+* 必备的**保险方案**
+* 海外**银行与支付**方案
+
+---
+
+## 数字游民签证一览
+
+越来越多的国家推出了专门面向远程工作者的签证。以下是 2026 年最受欢迎的选项:
+
+### 🌟 热门签证
+
+| 国家 | 签证名称 | 停留期 | 收入要求 | 费用 |
+| --- | --- | --- | --- | --- |
+| 🇵🇹 葡萄牙 | D8 签证 | 1年(可续) | €3,500/月 | €75 |
+| 🇪🇸 西班牙 | 数字游民签证 | 1年(可续至5年) | €2,520/月 | €80 |
+| 🇹🇭 泰国 | DTV 签证 | 5年(每次180天) | $500,000资产 或 收入证明 | $230 |
+| 🇮🇩 印尼 | B211A | 6个月 | $2,000/月 | $300 |
+| 🇲🇽 墨西哥 | 临时居留 | 1年(可续至4年) | $2,500/月 | $400 |
+| 🇭🇷 克罗地亚 | 数字游民签证 | 1年 | €2,540/月 | 免费 |
+| 🇯🇵 日本 | 数字游民签证 | 6个月 | ¥10,000,000/年 | 免费 |
+| 🇰🇷 韩国 | Workation 签证 | 1-2年 | 变化中 | ₩40,000 |
+
+> 💡 **注意**:签证政策经常变化,申请前务必查看最新的官方信息。以上数据截至 2026 年初。
+
+---
+
+## 税务规划基础
+
+数字游民的税务问题比较复杂,但核心是搞清楚一个问题:**你的税务居民身份在哪里?**
+
+### 关键概念
+
+* **税务居民**:你在哪个国家有纳税义务
+* **183天规则**:很多国家规定,一年内居住超过 183 天就成为税务居民
+* **双重征税协定**:两国之间的协议,避免你被两边收税
+* **源泉税**:某些收入在产生地就被征税
+
+### 基本策略
+
+1. **确定你的税务居民身份**
+2. **了解你的纳税义务**
+3. **利用双重征税协定避免重复纳税**
+4. **考虑专业税务顾问**(强烈推荐!)
+
+> ⚠️ **重要提醒**:税务问题非常个人化,以上信息仅供参考。强烈建议咨询专业的国际税务顾问。错误的税务规划可能导致严重的法律后果。
+
+---
+
+## 保险方案
+
+数字游民需要的保险和普通旅行保险不同。你需要:
+
+### 推荐方案
+
+* **SafetyWing Nomad Insurance** — 专为数字游民设计,$45/月起
+* **World Nomads** — 覆盖范围广,含极限运动
+* **Genki World Explorer** — 欧洲公司,含心理健康覆盖
+
+### 保险应覆盖
+
+* ✅ 医疗(含住院)
+* ✅ 紧急撤离
+* ✅ 电子设备(笔记本电脑等)
+* ✅ 个人责任
+* ✅ 旅行中断
+* ❌ 普通旅行险通常不覆盖"工作"相关场景
+
+---
+
+## 🔑 本章要点回顾
+
+* **签证合规**:选择合适的数字游民签证,合法居留和工作
+* **税务规划**:明确税务居民身份,必要时咨询专业顾问
+* **保险保障**:选择专为数字游民设计的保险方案
+* **提前准备**:签证申请需要时间,尽早开始
+
+---
+
+## 今日任务 ✅
+
+1. 根据你的目标目的地,研究对应的签证政策
+2. 评估你当前的税务居民身份
+3. 获取 1-2 家数字游民保险的报价
+`,
+ },
+ {
+ day: 6,
+ icon: "🗺️",
+ title: "目的地指南",
+ readingTime: "约 14 分钟",
+ content: `
+# 目的地指南
+
+数字游民 7天指南 - Day 6: 选择你的第一个目的地
+
+> *"世界那么大,选第一站反而是最难的。别想太多——你的第一个目的地不需要完美,它只需要'足够好'。"*
+
+---
+
+## 📖 本章导读
+
+今天你将了解:
+
+* 选择目的地的**核心评估维度**
+* **10大热门数字游民城市**深度对比
+* **亚洲、欧洲、美洲**各区域推荐
+* 如何做出你的**第一站选择**
+
+---
+
+## 选择目的地的 6 个维度
+
+### 评估框架
+
+| 维度 | 权重 | 说明 |
+| --- | --- | --- |
+| 🌐 网速 | ⭐⭐⭐⭐⭐ | 最核心!低于 30Mbps 不考虑 |
+| 💰 生活成本 | ⭐⭐⭐⭐ | 决定你的财务可持续性 |
+| 🌡️ 气候 | ⭐⭐⭐ | 影响你的工作状态和心情 |
+| 🔒 安全 | ⭐⭐⭐⭐ | 人身和财产安全 |
+| 👥 社区 | ⭐⭐⭐⭐ | 数字游民社区的活跃度 |
+| 📋 签证 | ⭐⭐⭐ | 入境和停留的便利程度 |
+
+---
+
+## 🌏 亚洲推荐
+
+### 🇹🇭 清迈,泰国
+
+数字游民的"入门款"目的地,没有之一。
+
+* **月均生活成本**:$800 - $1,200
+* **网速**:50-100 Mbps
+* **优势**:物价极低、美食天堂、社区超活跃、Coworking 多
+* **劣势**:空气质量(烧季)、语言障碍
+* **适合**:预算有限的初次数字游民
+
+### 🇮🇩 巴厘岛(Canggu),印尼
+
+Instagram 上出镜率最高的数字游民圣地。
+
+* **月均生活成本**:$1,200 - $1,800
+* **网速**:30-80 Mbps
+* **优势**:社区氛围最佳、冲浪+瑜伽、创业者聚集
+* **劣势**:网速不稳定、交通混乱、签证限制
+* **适合**:喜欢社交和生活方式的人
+
+### 🇰🇷 首尔,韩国
+
+亚洲最被低估的数字游民城市。
+
+* **月均生活成本**:$1,500 - $2,200
+* **网速**:200+ Mbps(全球最快之一)
+* **优势**:网速逆天、公共交通完美、美食、安全
+* **劣势**:生活成本较高、语言障碍
+* **适合**:重视网速和基础设施的开发者
+
+---
+
+## 🇪🇺 欧洲推荐
+
+### 🇵🇹 里斯本,葡萄牙
+
+欧洲数字游民的首选之城。
+
+* **月均生活成本**:$1,800 - $2,500
+* **网速**:100+ Mbps
+* **优势**:气候宜人、英语普及、签证友好、创业生态好
+* **劣势**:房价上涨快、旅游旺季拥挤
+* **适合**:想在欧洲长期定居的人
+
+### 🇪🇸 巴塞罗那,西班牙
+
+文化、美食、海滩、建筑——应有尽有。
+
+* **月均生活成本**:$2,000 - $2,800
+* **网速**:100+ Mbps
+* **优势**:生活品质极高、文化丰富、社区活跃
+* **劣势**:生活成本较高、小偷多
+* **适合**:追求生活品质的人
+
+---
+
+## 🌎 美洲推荐
+
+### 🇲🇽 墨西哥城,墨西哥
+
+北美数字游民的后花园。
+
+* **月均生活成本**:$1,200 - $1,800
+* **网速**:50-100 Mbps
+* **优势**:美食(!!)、文化底蕴、性价比高、时区好(对美国客户)
+* **劣势**:空气质量、部分区域安全
+* **适合**:服务北美客户的自由职业者
+
+### 🇨🇴 麦德林,哥伦比亚
+
+从"最危险城市"到"数字游民天堂"的逆袭。
+
+* **月均生活成本**:$1,000 - $1,500
+* **网速**:50-100 Mbps
+* **优势**:四季如春、物价低、人民友好、社区爆发式增长
+* **劣势**:语言(西班牙语为主)、刻板印象
+* **适合**:喜欢户外运动和社交的人
+
+---
+
+## 如何做出第一站选择?
+
+**不要纠结。** 根据以下三个条件快速决定:
+
+1. **预算**:月预算 < $1,500 → 东南亚;$1,500-2,500 → 拉美/南欧;> $2,500 → 随便选
+2. **时区**:你的客户/团队在哪个时区?选时差小于 6 小时的目的地
+3. **直觉**:以上条件筛完后还剩 2-3 个?选你最"想去"的那个
+
+> 🌍 **数字游民小贴士**:你的第一站不是终点。大多数数字游民在第一年会换 2-3 个城市。别把第一次选择想得太重——去了不喜欢,换就是了。
+
+---
+
+## 🔑 本章要点回顾
+
+* **六维评估**:网速、成本、气候、安全、社区、签证
+* **亚洲入门**:清迈(最便宜)、巴厘岛(最社交)、首尔(最快网速)
+* **欧洲进阶**:里斯本(最全面)、巴塞罗那(最有文化)
+* **美洲选择**:墨西哥城(最佳时区)、麦德林(最佳气候)
+* **快速决策**:预算→时区→直觉
+
+---
+
+## 今日任务 ✅
+
+1. 使用六维评估框架,对 3 个感兴趣的城市打分
+2. 在 Nomad List 上查看这些城市的详细数据
+3. 做出你的第一站选择!
+`,
+ },
+ {
+ day: 7,
+ icon: "🚀",
+ title: "启程出发",
+ readingTime: "约 10 分钟",
+ content: `
+# 启程出发
+
+数字游民 7天指南 - Day 7: 从规划到行动
+
+> *"知道和做到之间,隔着一张机票的距离。今天,我们把这个距离归零。"*
+
+---
+
+## 📖 本章导读
+
+今天你将了解:
+
+* 出发前的**完整检查清单**
+* **过渡期规划**——如何优雅地从当前生活切换
+* **第一个月生存指南**
+* **长期可持续**的数字游民生活策略
+
+---
+
+## 出发前检查清单
+
+### 📋 必须完成
+
+- [ ] 收入来源已确认(远程工作 / 自由职业 / 产品收入)
+- [ ] 至少 3-6 个月的储蓄金(安全网)
+- [ ] 护照有效期 > 6 个月
+- [ ] 签证/入境要求已研究清楚
+- [ ] 保险已购买
+- [ ] 银行已通知(避免海外刷卡被冻结)
+- [ ] 重要文件已数字化备份(护照、保险单、学历证明等)
+- [ ] 住宿已预订(至少前 2 周)
+
+### 📦 打包原则
+
+**核心规则:一个背包 + 一个随身包**
+
+* 衣物:7天的量(到了再买/洗)
+* 电子设备:笔记本 + 手机 + 充电器 + 转换插头
+* 文件:护照 + 1张实体信用卡 + 少量现金
+* 其他:降噪耳机 + 眼罩 + 旅行锁
+
+> 💡 **打包哲学**:打完包后,把一半的衣服拿出来。你永远会带太多东西。
+
+---
+
+## 过渡期规划
+
+### 如果你还有全职工作
+
+1. **提前 1-3 个月** 和公司沟通远程工作的可能性
+2. 如果公司不支持,**开始准备 Plan B**(自由职业或新的远程工作)
+3. **不要裸辞**——确保有稳定收入后再行动
+4. 处理好社保、公积金等事务
+
+### 如果你有租房
+
+1. 提前通知房东,按合同处理退租
+2. 重要物品寄回家或存放朋友处
+3. 不重要的东西——卖掉或捐掉
+
+### 心理准备
+
+* **接受不确定性**:第一个月会有各种意想不到的情况
+* **放下比较心**:你的节奏和别人不同,这没关系
+* **保持联系**:定期和家人朋友视频通话
+* **允许后悔**:如果不喜欢,回来就是了,这不是失败
+
+---
+
+## 第一个月生存指南
+
+### 第 1 周:适应
+
+* 调整时差和生活节奏
+* 找到你的"工作基地"(Coworking Space / 咖啡馆 / 住所)
+* 办理本地 SIM 卡
+* 熟悉周边环境(超市、药店、医院位置)
+* **不要急着工作**——给自己 2-3 天适应
+
+### 第 2 周:建立节奏
+
+* 确立你的工作时间表
+* 开始正常工作
+* 参加一次本地数字游民 Meetup
+* 探索 2-3 个不同的工作地点
+
+### 第 3-4 周:深入
+
+* 工作已进入正轨
+* 开始探索城市更多面(周末短途旅行)
+* 评估这个城市是否适合你
+* 如果满意,考虑续租或找长租
+
+---
+
+## 长期可持续策略
+
+数字游民不是一场冲刺,而是一场马拉松。以下策略帮你走得更远:
+
+### 1. 财务缓冲
+
+始终保持 3-6 个月的生活费储蓄。这是你面对意外的安全网。
+
+### 2. 健康管理
+
+* 定期运动(跑步、瑜伽、健身房)
+* 注意饮食(不要天天吃外卖)
+* 关注心理健康(独处太久要警惕)
+* 定期体检
+
+### 3. 社交投入
+
+* 主动参加本地 Meetup 和社区活动
+* 考虑 Coliving(共居空间)
+* 保持和老朋友的联系
+
+### 4. 技能持续升级
+
+* 每月学习一项新技能或工具
+* 关注行业动态
+* 建立你的个人品牌
+
+### 5. 定期复盘
+
+每个月问自己:
+* 我快乐吗?
+* 我的收入在增长吗?
+* 我在学习新东西吗?
+* 我想继续这种生活方式吗?
+
+---
+
+## 🔑 本章要点回顾
+
+* **检查清单**:收入、储蓄、签证、保险、住宿——缺一不可
+* **轻装上阵**:一个背包走天下
+* **循序渐进**:第一个月分为适应期、建立期、深入期
+* **长期思维**:财务缓冲、健康管理、社交投入、持续学习
+
+---
+
+## 🎉 恭喜你完成了 7 天学习路径!
+
+如果你从 Day 1 一路看到这里,你已经具备了成为数字游民所需的全部知识:
+
+* ✅ 理解了数字游民的本质
+* ✅ 找到了你的远程技能方向
+* ✅ 知道了如何构建收入体系
+* ✅ 配置了你的工具库
+* ✅ 搞清了签证和税务问题
+* ✅ 选好了你的第一个目的地
+* ✅ 制定了行动计划
+
+**剩下的,就是行动。**
+
+不需要等到一切完美。不需要等到存够了钱。不需要等到"时机成熟"。
+
+**最好的时机,就是现在。**
+
+买一张机票,带上你的背包和电脑,去你选好的城市,开始你的第一天。
+
+---
+
+> 🌍 **最后一条建议**:当你在新城市的第一个早晨醒来,打开电脑,连上 WiFi,开始工作的那一刻——你会意识到,数字游民不是一个标签,不是一种身份,而是一种你选择的活法。
+>
+> 欢迎来到这个世界。🌏
+`,
+ },
+];
+
+export function getDayData(id: number): DayData | undefined {
+ return daysData.find((d) => d.day === id);
+}
diff --git a/app/digital/data/ebook.md b/app/digital/data/ebook.md
new file mode 100644
index 0000000..ef795a1
--- /dev/null
+++ b/app/digital/data/ebook.md
@@ -0,0 +1,4901 @@
+# 前言
+
+## 🚀 数字游民:地理套利与自动化杠杆
+> 低成本工作室
+
+自建`云手机`集群,实现tiktok无人直播,youtube运营自动化,以及saas开发(web3)
+
+
+
+
+> 公众号: 异度世界
+
+
+
+
+
+# 数字游民:自动化杠杆副业
+
+## 云手机:无人直播,赚美金💰
+
+> 低成本打造`工作室`
+
+其实早在几个月前,我就写了3本电子书
+
+- 云手机 (tiktok无人直播)
+
+- youtube运营笔记
+
+- 数字游民指南
+
+但是那个时候,业务上,还存在运营痛点和技术难点,需要不断攻克,和优化
+
+就没有发布在网上
+
+另外`工作室`的几乎所有业务,几乎都是在家宽的内网穿透下来支持全球访问,后续直播占用了大量的上行带宽,才把核心的web网站做了迁移,到公有云vps服务器,也费了了很多心力
+
+用到的硬件/软件/服务,都整理在导航: [nomadro.com](https://nomadro.com)
+
+这段是内容是用笔记本在`marktext`,使用markdown语法撰写,图床使用了`minio`,
+
+
+
+并不打算进行结构化的框架输出,如果在读的你,有意见交流,可以直接在👉`文章底部留言`
+
+web电子书也是自己开发的,开源的也很丰富,但比起研究配置,二次开发,`自建更快`
+
+> 安卓手机桌面,仅供参考
+
+
+
+---
+
+## 为什么是直播?
+
+> 因为流量会爆,即时反馈,变现门槛低
+
+目前最适合普通人的`轻资产`业务是
+
+1. tiktok无人直播
+
+2. youtube自媒体
+
+3. web (saas+web3)
+
+至于跨境电商/金融股票/实体门店,一些重人力/物力的业务,不会出现在之后的内容里
+
+在三类业务里,最值得做的是`直播`
+
+- 不需要剪辑,颜值/才艺,厨艺,甚至睡觉直播
+
+- 0资本投入,不需要硬件设备,人人都有手机
+
+- 爆量:同时几千上万人观看,互动
+
+- 变现门槛:礼物打赏,无需资格审核
+
+下图是`RPA自动化脚本`+xposed插件(vcam)
+
+
+
+> 直播内容选什么?
+
+如下图所以,
+
+- 小窗是tiktok接收 视频采集卡的live流数据
+
+- chrome网页监听 OBS推流事件
+
+其实1个安卓云手机,可以接收`同样的视频流`,把原始数据流,推送到srs直播服务器(开源自建),然后拉流分开推送即可
+
+如果你不知道做什么,选`美食`赛道是可以兜底的
+
+这是每个国家`通用`的文化,而国内`更有优势`
+
+
+
+> SRS直播服务器
+
+
+
+除了内容直播,还可以结合互动api (弹幕/礼物),进行游戏
+
+比如 "生态造景,打赏=>云喂鱼",这是调试时的照片
+
+
+
+> `即时反馈`的真的很重要
+
+
+
+---
+
+# 低成本工作室
+
+> 工作室的运营核心
+
+
+
+在调研轻资产被动收入的时候,运营设备和软件尤其重要,总结有3类
+
+1. 云手机
+
+2. 云桌面
+
+3. 云服务器
+
+云桌面基本用户办公协同流程,而云服务器主要是架构的底层支持,比如数据
+
+真正直接辅助业务的还是`云手机`
+
+而且它兼容的领域着实有点宽广: 游戏/社媒/电商/金融/直播/独立开发等等
+
+> 硬件成本 `100-300` RNB就可以入门
+
+无论什么系统,android/windows/linux,都可以在`arm开发板`,有良好的支持
+
+比如:
+
+- aosp原生安卓,docker容器的安卓-redroid
+
+- PVE系统可以创建N多windows云桌面
+
+- linux服务器,KVM虚拟化就没不用说了
+
+
+
+---
+
+## 那么,社媒和web呢?
+
+## 什么是认知盈余?
+
+> 研究副业,或者创业
+
+
+
+先捋一捋前奏:
+
+户外各种场合,都能看到大量刷短视频/打游戏的人,消磨时间
+
+积极努力的人也不在少数,但是迷茫,没有业务,严格来说,没有`被动收入`
+
+于是就把`输入`作为核心,比如:
+
+- 健身锻炼,户外徒步
+
+- 通勤FM播客
+
+- kindle电子书阅读
+
+- 大量的云盘视频教程~~
+
+它们不是没有价值,但都不能产生现金收益
+
+ps: 再次申明,`资本/金融`不在本文赘述
+
+兜底的应该是输出,产生收益,激励输入,正循环,而不是永远卡在第一步
+
+- 无人直播
+
+- 视频拍摄+剪辑
+
+- 编程 (借助AI)
+
+普通人的三大的杠杆
+
+目前对我影响比较大的是nomadlist的创始人`levelsio`
+
+创建了全球最知名的数字游民网站,也一直在践行
+
+最近就不断地中国旅行,并在X持续发布动态
+
+本书借鉴了他的书籍:`MAKE`,直接自建单页面网页,发布
+
+
+
+可以在[会员群](https://vip.hackrobot.cn)获取pdf下载地址
+
+不需要等编辑校对,出版社税号,何种审查审核~~
+
+---
+
+## web独立开发
+
+> 于是就出现了电子书:`云手机`,如下图:
+
+
+
+> web: saas/导航/wordpress播客/ebook
+
+以下是模仿nomadlist的数据可视化
+
+数据和社群是轻资产的saas,不需要算力,更不要显卡(GPU)做工具
+
+
+
+> 最简单的,就是`导航网站`
+
+直接找个开源项目,替换icon图标和网址就可以了
+
+怎么赚钱?
+
+网址可以带上`ref`,新用户有优惠,服务商获客,推广者拿佣金,实现三赢
+
+当然,里面的我都用过,`付出大量的试错成本`,挑选出来
+
+多年前,在我还没有affiliate佣金思维的时候,把`搬瓦工`平台的ref推广链接放在视频简介区
+
+为我带来了数千美元的收益,无心插柳柳成荫🤑
+
+> 很多人对信息差都是敏感的
+
+比如早些年注册wise全球金融账户,还需要中国护照,最近只需要大陆身份证即可
+
+经过导航也陆续看到读者注册的消息
+
+`paypal/wise/以及U卡(safepal)`绝对是出海金融三件套,决定了出金/入金,最起码的收款问题
+
+至于港卡/交易所等等后续会谈到
+
+
+
+---
+
+## 社媒运营: RPA自动化
+
+> 注意力短缺的时代,视频是传播的基底
+
+如果你能读到这里,那一定是产生了比较大的兴趣
+
+因为比起色彩丰富的视频,文字需要耐心阅读,汲取,从某种层面类似健身,是反人性的
+
+> 60min视频理论
+
+我买过很多拍摄设备:运动相机/拇指相机/手持云台
+
+但现在出门,一定会带的是`AI眼镜`
+
+买了2k(rmb)的`雷鸟V3`,也买了0.3k的`华强北AI GLASS`
+
+这帮理工男,感觉就是沉浸在自己的技艺里无法自拔
+
+
+
+它能随时随地记录第一视角的生活碎片,除了vlog记录
+
+60min集成的各种片段,可以帮助我很快通过🤖`刷量`=>获取1个通过ypp合作伙伴计划的频道
+
+
+而目前刷量的成本已经从最初的2k,降至0.5-1k左右
+
+在赚钱这件事上:`买ypp频道`和`刷频道过ypp`,才是最快的,7-15天即可完成
+
+- 买频道=>2天后(ip变动风控)可开始上传视频=>查看收益
+
+让你可以摆脱没有收益的数据,和漫长的原创积累:
+
+- 1000粉丝+4000小时观看时长`==最少3个月试错和兴致消磨
+
+
+
+> 没有rpm(千次播放收益)的播放数据,毫无意义!
+
+
+
+> AI,各种辅助脚本
+
+视频的处理无论是`搬运`还是`原创`都需要大量的AI处理
+
+- srt双语字幕
+
+- 语音翻译+克隆音色+对口型
+
+- 抽帧/去重/消水印
+
+
+
+---
+
+
+
+
+这些年,对于数字游民的`旅居和地理套利`,进行了不少研究
+
+
+
+当然,好多人是打着教别人成为数字游民(难民)的名义敛财(割韭菜)
+
+---
+
+> 电子书最底部,看到 `阅读更多`是付费按钮
+
+主要把核心: `低沉本搭建工作室,云手机`的技术细节写在里面
+
+有不少读者更新试读,这才有了以上关于自身的业务概括记录
+
+后续会持续更新
+
+业务交流(free)和技术指导(pay),个人不认为存在冲突
+
+当然,也会在技术手册里更新业务具体流程,不是概述
+
+
+
+# 只谈赚钱,不讲技术
+
+> 业务远比技术重要
+
+上一篇,从`搭建工作室`和自建`云手机`的角度,在技术的视角分析业务
+
+- tiktok直播
+
+- youtube自媒体 -RPA自动化
+
+- web (saas+web3)
+
+很多小伙伴反馈,看不太明白,所以接下来会简单叙述,另外
+
+关于youtube(油管)运营,可以参考 `小红书`帖子置顶的评论区
+
+很多提问,都有解答 (被判违规,刚申请解除,但封面和标题只能隐喻)
+
+
+
+---
+
+## 把大象放进冰箱需要几步?
+
+> 三步:1.打开冰箱=>2.放进大象=>3.关门
+
+
+
+做tiktok直播,怎么立即开始?一夜暴富?
+
+- 注册账号
+
+- 开通直播权限(收礼物) / 中视频计划
+
+- 钱包=>提现
+
+比如youtube呢
+
+- 注册账号
+
+- 申请ypp合作伙伴计划
+
+- adsense=>提现
+
+以及web (saas)
+
+- 上线网站
+
+- 支付/订阅(按钮)
+
+- 收款网关=> 提现
+
+其实归纳起来,核心只有3个: 美国手机号/获利资格/收款账户
+
+1. 注册账号:美国手机号,可以esim立即开通,实体卡也有,最低3$/m
+
+2. 获利资格: 直接`买号`,或者 `刷粉刷量`
+
+3. 收款账户: 凭借大陆身份证,就可以开通N多全球银行账户 (可参考导航站: `nomadro.com`)
+
+## 其他全是`细节`
+
+当然细节分为很多:
+
+- 运营细节 (云手机,RPA自动化脚本)
+
+- 技术细节 (科学上网/静态住宅ip)
+
+- 灰色细节 (加密货币)
+
+- 多少有点黑色细节(tk实名资料等)
+
+难度深浅不一,所以很多东西既不能公开写出来
+
+后台回复关键词: `加群 `
+
+---
+
+## 赚钱:为快不破🤑
+
+> 世界最喜欢欺负老实人
+
+想要极速变现的前提,自媒体就必须立即拥有获利资格的账号
+
+否则努力都是白费的,为什么?如下:
+
+- 辛苦原创视频,为平台打工 (没获利资格的播放量无收益)
+
+- 伪数据: 没有rpm (千次播放收益)的后台分析,毫无意义
+
+- 即时反馈: 哪怕是1$,立即提现,也能形成给打工牛马的积极正反馈
+
+> 直接买号/买频道
+
+
+
+> 刷粉刷量
+
+
+
+---
+
+## 一人公司方法论
+
+> 道术和心法
+
+@easychen 在github开源电子书《一人企业方法论》
+
+
+
+阅读地址
+
+https://github.com/easychen/one-person-businesses-methodology-v2.0
+
+虽然`数字游民`和`一人公司`,在国内99%都是割韭菜的代名词
+
+但这位开发了`server酱`和`IT网课`,以及在`微信读书`上架book过的程序员,实力有
+
+他自己虽然在做AI出海赛道,却没有系统分享
+
+所以个人对该书的评价是: 寻找被动收入的思维启发
+
+在最终的执行层面,还是nomadlist创始人的`MAKE`更胜
+
+---
+
+## 异度星球
+
+> 自建社群
+
+这几年创建了几次微信群
+
+但是缺乏管理,以及担心敏感词/广告于是解散了
+
+目前自建了收费 web社区
+
+平时会分享 `不便公开`的信息和`正在运营`的过程
+
+
+
+> 点击 chat,可以发起咨询
+
+部署了开源的客服系统
+
+针对某些敏感问题,即时没有telegram的读者,也不必担心
+
+发起的咨询,我会尽力解答
+
+
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (3)
+
+## X给你发工资了吗?
+
+> 形象符号化表达
+
+如果你不小心对该号进行了考古,就知道之前开发过一个图文编辑器
+
+因为帖子/评论区/聊天窗,工程师们都问对可执行的代码做屏蔽,只有纯文本
+
+于是就有了`emoji,颜文字,特殊字符`的纯文字排版系统
+
+当然今天要聊的不是图文,而是平台,比如国内的小红书,`海外的X`
+
+
+
+马斯克不仅造车(特斯拉),研发火箭/星链/脑机接口,还用买来的X给你发工资
+
+> 怎么发工资?
+
+所有媒体的兜底要求其实都差不多
+
+- 1000个粉丝(理论): 500个蓝标用户
+
+- 帖子阅读量500w/3m
+
+
+
+还有一个国内平台根本不敢抄的: 成为付费会员/8$/m
+
+这都是鸡生蛋,蛋生鸡的问题
+
+你不交钱,就没有资格获取收益,说明你对平台的认可/忠诚度不高
+
+这也是一个X平台的运营逻辑
+
+- 把会员的钱,按百分比放进池子作为收益分发
+
+- 其他广告商投放分发
+
+- premium的博主无论是帖子曝光还是评论,权重更高
+
+
+
+> 如果你达标了
+
+那么恭喜你,除了直播/视频剪辑
+
+你可以动动小手`打字`,按时领取工资
+
+
+
+其实现在X的收益并不低
+
+如果你做简中,关于出海的金融领域/独立开发/加密货币
+
+算法推荐权重高
+
+当然!个人还是坚持租英文赛道,流量至少10倍以上
+
+> 如何收款提现?
+
+赶紧用大陆身份证去开通wise
+
+参考`游民导航`: `nomadro.com`
+
+
+
+---
+
+## RPA自动化
+
+如果对编程不熟悉,可以使用`N8N`,自动化流程和AI
+
+但是但凡你对代码熟悉一点,都建议使用`tweepy`
+
+
+
+---
+
+## xposed外挂插件
+
+> chrome油猴脚本插件`chromext`
+
+我一直都想吐槽开发者,好懒
+
+明明都更新维护了,就是不肯build一下,打个release
+
+以至于你去下载,都支持不了安卓chromede的v140+新版本
+
+不过你可以后台回复: `chromext`
+
+下载我编译好的apk
+
+它的核心功能就是让安卓浏览器,可以执行外挂js代码,俗称油猴脚本
+
+比如`沉浸式翻译`
+
+它能让所有web内容双语化,方便阅读
+
+
+
+> 只是见识更大的世界吗?
+
+不,最好是作为一个内容创作者,产生收益
+
+动动你的小手指
+
+用`gboard`谷歌输入法,中文实时翻译成英语
+
+抨击歪果仁水深火热
+
+
+
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (4)
+
+# Youtube才是普通人出海的最佳选择
+
+> 中文俗称`油管`
+
+
+
+对于出海内容,比较即时/友好的平台,建议去`小红书`
+
+看本公众号`同名账号`置顶帖
+
+虽然也存在大量审核/封禁,但稍稍留了口子,可以一窥
+
+
+
+> 内容和盈利机制对比
+
+youtube是自媒体平台,核心形式有`长视频,短视频shorts,以及直播live`3种
+
+盈利方式是平台的`合作伙伴计划ypp`,主要是播放广告
+
+而tiktok是直播平台,推荐机制是奶头乐,是爆款逻辑,是病毒传播式的流量
+
+盈利方式是礼物打赏,以及`电商带货`,偏商业生态
+
+
+
+> 运营环境对比
+
+youtube的运营环境比较宽松,只要能科学的姿势上网,就可以发布视频
+
+但是tiktok会检测大陆的sim卡,以及严格要求`静态住宅ip`,包括时区/语言等等=>直接显示无网络
+
+上网姿势解决方案:最便宜的美国vps服务器只要10$/年,部署一键脚本即可
+
+ps: 静态住宅ip/美国esim手机号,可以参考出海导航网站: `nomadro.com`
+
+
+
+> 收款提现渠道
+
+youtube的PIN码信件,可以直接邮寄`大陆手机号+居住地址`
+
+google adsense`收款账户`可以绑定国内`招商银行卡`,直接在app内把美元换汇成人民币提现
+
+税务问题,在填写WEB8表格后,打款会自然扣除,合规下不会超过10%,最高也才30%
+
+相反,tiktok的提现需要申请美国个人税号ITIN(护照+代办2k/几个月时间),以及美国paypal账号(风控严格)
+
+tiktok的实名资料与收款账户无需同名,但认证资料细节,就不可明说了,可加群
+
+
+
+所以普通人,最好的赛道是自媒体,而不是直播平台tiktok
+
+当然,对tiktok工作室有兴趣的朋友,可以参考下一章 `数字游民系列5`低成本工作室搭建
+
+---
+
+## RPA自动化运营 (24小时赚美金)
+
+> `搬运`还是`原创`
+
+作为一个普通人, 建议原创,但是也同时搬运,测试平台推荐算法
+
+所以抛砖引玉,先聊聊搬运
+
+其实大家最关心的还是`赛道`
+
+个人觉得兜底是`中华美食`,其次是`宠物猫咪`,再就是`金融领域`(rpm收益高)
+
+至于其他,大家可以自行研究,这里同样很多不能明说
+
+
+
+> 无人直播
+
+下图是一个24小时无人值守,自动推送直播的脚本
+
+- 有ypp的频道,播放就有美金收益
+
+- 没有ypp的频道,可以凭借`播放时长`(4000小时)达标申请获利门槛
+
+ ps:1000粉刷量仅需几美元
+
+
+
+核心的原理非常简单:
+
+- 在一个百元的arm开发板上,安装`linux操作系统`
+
+- `ffmpeg`+srs进行直播的拉流,转发,推流
+
+- docker虚拟化`安卓系统`,通过chrome浏览器打开studio后台
+
+- 浏览器的`油猴外挂脚本`,自动处理停播和开播
+
+懂技术的朋友,相信看到思路后,就可以立即开始执行,实现24小时开播
+
+
+
+不懂技术也没关系
+
+在公众号后台回复: `加群` `, 可以参与交流,也有提供装好的硬件
+
+> 短视频shorts
+
+如果你有1个root的手机,或者arm开发板(安装安卓)
+
+那么你就可以在各个社交媒体,启用`xposed外挂`模版
+
+实现各种XX功能,包括`无水印`下载
+
+
+
+记住,短视频1天最多上传9个,多了会直接0播限流
+
+下载到安卓系统后
+
+`借助安卓termux终端apk,或者安卓16原生的linux子系统`
+
+python脚本可以`实时监听下载,并进行2k转播,自动上传`
+
+> 长视频剪辑
+
+长视频并不适合`搬运`,尽量原创,这里的原创本人划分成2个基本类
+
+- 60min合成碎片
+
+- 8min AI自动化生成或剪辑
+
+
+
+在前面文章,我提到过`认知盈余`这个概念,就是很多事,它不仅可以并行实践,也是可以用来赚钱的
+
+往往可以形成闭环,比如参考这个系统: `探店`
+
+- 抖音lv4登记探店达人,车马费保底100元左右
+
+免费在美食店/酒旅/景点,进行体验,拍摄宣传视频
+
+
+
+它的多元,以及闭环特征在哪里?
+
+- 1.抖音平台:
+
+ 涨粉+商单(lv4=>100左右元车马费)+团购佣金
+
+- 2.AI拍摄眼镜(数码测评):
+
+ 60min视频=>上传youtube=>刷量网站 (获取快速观看时长)
+
+- 3.N8N自动化工作流, GPU显卡:
+
+ `8`min精华=>srt双语字幕,语音克隆+翻译(对口型)等等
+
+一定有朋友会问:
+
+- 刷量能通过ypp资格审核吗?
+
+刷量肯定会被平台算法进行异常检测, 但经验是:可以 (得有技巧,尤其是素材本身的内容)
+
+长视频的(rpm千次播放收益)最高
+
+> 总结
+
+有ypp的资格的频道,可以实现内容的运营自动化,24小时赚取收益
+
+没有ypp的频道,也凭借内容素材,通过无人直播,shorts自动上传,长视频60min(AI眼镜)
+
+达到ypp获利资格门槛 (1000粉+4000小时观看时长)
+
+最终实现`工作室,多频道,无人自动化运营矩阵`
+
+注意:本文提到的都是`开源硬件(ARM开发板)+开源软件`,并不是二手手机或者主板机箱
+
+> 显卡的成本稍高,没有怎么办?
+
+在海鲜市场搜索cap-cut`破解版`, svip功能是免费的,实在不行,`矿卡`也是可以,一两百即可
+
+下图是网图:
+
+
+
+---
+
+## Youtube如何改变了我的人生?
+
+> How youtube changed my life?
+
+如果你实在不知道选什么赛道,无论搬运,原创,还是AIGC生成
+
+那么给个建议:`讲youtube运营`
+
+类似于
+
+- 1000粉丝的youtube频道,赚了多收钱?
+
+- youtube如何改变了我的人生?
+
+- youtube的PIN码:实体信函如何接收(敲黑板)
+
+- 不露脸的6个youtube赛道
+
+- youtube如何收款,提现?
+
+- 巴拉巴拉.......巴啦啦能量小魔仙~~
+
+是不是有哪味了:教别人成为数字游民的背后,全靠`教本身`获取收入🤫
+
+但背后是有底层推荐算法支撑的:`在平台就讲该平台如何赚钱`,一个劲给你猛推给用户
+
+
+
+好在我本人是个技术信仰者: 脚本小子
+
+所以无论是什么领域的探索,都尽可能用技术加持,比如AI 就是继英语/编程之后的第3大杠杆
+
+利用`开源硬件+开源软件`,实现`低成本工作室`搭建,尽可能`自动化`
+
+最是憎恶简中教育文化的:XXX的背景/xxx的起源/xxx的意义,曲尼玛德
+
+> 所以youtube到底改变了我的人生吗?
+
+当你从一个打工牛马,变成这样:
+
+ 每天醒来就看到几百几千人同时在线观看直播,多少万的观看数据
+
+分析昨天单个频道的长视频/短视频/直播,平稳保底又赚了多少美金,总计多少收益
+
+你问我:
+
+做youtube有什么意义? => 我不知道怎么说
+
+我只知道在这个领域,付出很多试错成本,买ypp频道被骗,刷量差点没过(二次复审),拍摄硬件和时间.....
+
+对经历故事感兴趣,可以后台回复: `电子书`
+
+但你可以问问非遗文化传播大使`李子柒`,当她被资本做局,停更后,每年依然几百万美金的收益,你问她:
+
+请问,当初你选择做youtube有什么意义?
+
+
+
+---
+
+## 买ypp获利的频道,立刻,马上
+
+> 赚钱,唯快不破
+
+
+
+如果你看到这里,一定消耗了大量的耐心,前面说的 `技术运营,刷粉刷量`
+
+都需要建立认知,体系,并且实践(金钱/精力/时间),过程会有无数个小细节 (深坑)
+
+才能实现无人自动化运营,赚美金的效果
+
+最快的方式,不过是:
+
+- `买`1个ypp获利账号`
+
+最快2天就可以上传视频,获取收益,查看带有rpm的真实商业数据 (非纯播放的伪数据)
+
+为什么最少2天?
+
+因为更换频道管理员(gmail邮箱后),再加上科学姿势上网的ip变化,极易引起youtube官方风控
+
+导致频道撤回ypp获利资格,就损失惨重了
+
+俺最初的几个频道,就是从不同的网站平台购买的(被骗过1次,高价买过1次,平价1次)
+
+ps: 真正完成交易,需要七天, 只有超过7天,副管理员才能被提升成真正管理员(然后移除原主)
+
+> 说了一堆废话,请问:在哪里可以买到呢?
+
+由于`矩阵`运营,目前我个人是不会出售Youtube的ypp账号的,后期也会考虑(仅限群友)
+
+而且之前交易过的网站居然倒闭了....
+
+所以买卖这块,各位可能会需要进行试错了,有被骗,被风控回收的可能
+
+平台的算法是动态的,无论是`刷粉刷量`,还是`账号交易`,都是实时变化:价格/政策
+
+
+
+---
+
+## 数字游民系列
+
+> 出海套利
+
+出海和内卷,其实不冲突,实现24小时自动化运营,作为`副业`
+
+
+
+
+
+有些关键点,出于众所周知的原因,不能公开
+
+关于系统固件,技术脚本,关乎商业利益,也不能公开
+
+关于运营方案,硬件设备,可以进群交流,后台回复关键词: `加群`
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (5)
+
+# Tiktok无人直播 💰
+
+> 直播`一夜暴富`不是神话
+
+很多工作室做`娱播`,美女就坐着聊聊天,唱歌跳舞表演才艺
+
+一晚就可以轻松几百美金💲
+
+你看的出左边是`真人`还是`录播`或者`数字人`吗?
+
+更或者:`AI一键换脸`
+
+
+
+tiktok的变现门槛非常低,直播权限和`礼物打赏`,1千粉就可以了
+
+1000粉很简单,花几美元,在`刷量网站`自助下单,半小时就能达标,`直播权限秒开`
+
+推荐的刷量网站参考`出海导航`: `nomadro.com`
+
+
+
+即时反馈很重要:
+
+- 实时看直播数据
+
+- 弹幕互动
+
+- 下播就到账
+
+没有限制提现时间 ,`即时取款`
+
+在前面的几篇`数字游民系列1-4`,写了`Youtube/X/以及提到web(saas+web3)`
+
+只有直播是`超级流量入口`,也是`最佳商业生态链路`
+
+简单来说:它离钱最近🤑
+
+
+
+---
+
+## 不止看世界,也赚美金 (环境配置)
+
+> tiktok的门槛比较苛刻
+
+首先声明:如果你不是为了`挣美金`,直接下载`Tiktok分区版apk`,或者`tiktok插件`
+
+就可以在国内的网络环境正常观看,可以自助选择国家地区
+
+至于有没有后门木马,各位自己斟酌即可
+
+本篇谈的核心是`tiktok直播赚美金`,所以是针对`官方的app,进行一些环境配置`,如下图:
+
+
+
+- root环境: magiskdelta [红色面具]+lsposed [外挂容器]
+
+- 爱玩机工具箱: 修改机型
+
+- 小猫图标: 科学姿势上网的软件,需要配置`静态住宅ip`
+
+- fake location: gps定位
+
+- NrFr+shizuku: 魔改SIM运营商属性:大陆号=>美股手机卡
+
+- 系统语言/时区:国家/城市/语言 (新机系统初始化的选项,后续手动改的无效)
+
+- 谷歌框架,以及认证
+
+做完这些,无论是真实的安卓手机,还是AOSP系统,还是docker虚拟android
+
+基本都可以正常登录/浏览,
+
+如果你有更好的方案交流,可以在后台私信关键词: `加群`
+
+ps: `静态住宅ip` 和 `gps定位`,非常重要!!!!
+
+记得买了ip后,检测一下,同样ip服务商在`出海导航`:`nomadro.com`,有推荐
+
+
+
+---
+
+## 无人直播:RPA自动化
+
+在研究无人直播过程中,采取了3个策略,层层递进,后续我会解释为什么
+
+- 1.`真机`=>`测试`
+
+- 2.`虚拟机`=>docker安卓:`多开群控`=>`养号`
+
+- 3.arm开`发板`-aosp原生系统=>直播`推流`
+
+买一个二手安卓手机,并不贵,可以随便折腾:
+
+- 各种刷海外ROM
+
+- 研究root权限以及反检测
+
+- xposed外挂模块:改机/gps定位/代理
+
+- RPA自动化:autojs/mitm抓包等等
+
+> 真机测试
+
+
+
+这里在真机用的xposed外挂模块: `vcam` 测试
+
+
+
+本人推荐你可以采取`最低成本的策略`:在海鲜市场买几分钱的`安卓虚拟机apk(破解版)`
+
+正常安卓手机,安装后,就可以拥有多个内置外挂系统,对于真机不会有破坏
+
+没错,小黄鱼从某种意义上,是一种特殊暗网
+
+
+
+在`搞机`的世界里转悠一圈,多测试各种插件/脚本
+
+- 一键换新
+
+- gps定位
+
+- 谷歌全套
+
+- vcma虚拟相机
+
+
+
+
+
+> redroid虚拟机安卓
+
+基于docker虚拟化的容器安卓.兼容性非常好
+
+无论是X86还是arm架构的芯片,都可以安装在linux服务器,实现`上百的的安卓系统`
+
+
+
+只要cpu/内存性能强悍,1个`群控工作室`分分钟就出来了
+
+就哪怕是普通的电脑主机也很容易
+
+当然为了兼容性,建议还是购买`ARM开发板`,几乎不会出现问题
+
+如果是小白,建议你可以入手`树莓派`,不是吃的,是raspberry pi开发板
+
+无论是`linageos`安卓,还是`容器安卓`redroid,兼容性都不错
+
+进阶版还可以`pve虚拟化`各种操作系统
+
+树莓派并不平价,我本人会采取性价比更高的开发板自建
+
+
+
+> ARM开发板
+
+其实`vcam`虚拟相机模块,很容易被TIKTOK检测到,从而导致弹红窗警告:直播限流
+
+账号也很容易被降权惩罚
+
+所以从`物理上突破`,是最稳妥的,开发板原生的AOSP安卓(单独1个系统)
+
+
+如果想实时直播,直接插上`usb摄像头`就可以了,实现2k/4k/8k+夜视各种高清自由
+
+但是呢,既然主题是无人直播,那么使用usb这种策略肯定是初级的
+
+- 使用廉价的`辅助板`,将`视频素材/直播流`,通过`HDMI`先推送到`安卓开发板`的相机流
+
+比如这个基于filebower进行二次开发的直播系统:
+
+
+
+实现上传各种素材到`网盘`, 可以点击按钮,实现`单次/循环`直播
+
+如果是循环,对素材进行`抽帧/去重/加水印/背景音频变化`等措施
+
+让2-12小时的视频素材被循环播放,降低检测风控
+
+> OBS软件推荐
+
+很多朋友对ffmpeg命令行比较陌生
+
+那么可以直接将OBS软件的视频处理,推流到安卓相机流,在用户眼里是实时的
+
+
+
+为什么要执行与安卓系统+tiktok官方app,难道不可以用tiktok studio,或者授权推流码吗
+
+用app的`权重`是studio客户端的`十倍百倍`,而且推荐算法和风控检逻辑,根本不是一个次元
+
+稍微不慎,你会看见这个😭:
+
+
+
+> tiktok其他玩法: `互动api`
+
+你在国内抖音看见,最终都会被复制到tiktok
+只不过收益是美金
+
+比如根据弹幕/礼物,进行物理世界的自动化`互动游戏`
+
+也就是几行python代码监听事件,然后通过MQTT协议触发`esp32`控制各种舵机/电磁阀/气缸之类
+
+属于单片机应用场景
+
+相比较本文最初提到的AI数字人/换脸(GPU显卡),esp32+传感器+执行器械,成本极其低廉
+
+
+
+
+
+实现抖音直播/Youtube live/Tiktok推流,
+
+几个百元级开发板(开源硬件),就是起始的副业投资
+
+实现24小时无人值守自动化(开源软件,免费/强大)
+
+后续会有一篇`数字游民7:低成本工作室`,再次之前可能会有一篇`数字游民6:独立开发(saas+web3)`
+
+当然这是随手整理的主题,完整的技术原理和搭建过程,后台私信:`电子书`
+
+如果你有建议,可以在`留言区评论`,或者后台私信: `加群`
+
+ ---
+
+## 门槛越高,机会越大 ,但不是躺赚神话
+
+> 不要害怕挑战,狼少肉多
+
+
+
+上一篇,我写的是:`Youtube是普通人最佳选择`
+
+Tiktok相对困难太多:
+
+- 锁SIM卡(大陆) =>买`美国手机号(esim)`
+
+- 检测国家ip=>科学姿势上网+`静态住宅ip`
+
+- 提现: 实名认证(灰色)+`美区paypal`收款账户(换汇)
+
+如果你不甘平淡,特别是shit一样的畸形内卷大环境,值得一试
+
+本人一步一步自己摸索,折腾,无数个日夜,没有参加`训练营`/`大航海`/`陪跑`,无人指导
+
+比如我最近在:
+
+- `编译安卓源码`,让uvc摄像头,被识别成系统前后相机
+
+- 编译`Linux内核`,让它支持docker安卓的虚拟化
+
+从而进一步降低开发板成本,实现性价比部署
+
+关于设备,后台私信:`加群`
+
+
+
+是否选择真正的`商业云手机方案或者设备`,针对各自的业务见仁见智,不必拘泥于`自建`
+
+俺这里强调的是`普通人在自媒体`里,无人直播赚美金,并不涉及`电商`或其他`电子农场`业务
+
+开源硬件+开源软件,是低成本杠杆
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (6)
+
+# 独立开发者
+
+> web (saas+web3)
+
+网站,听起来有点古老,但依然是不可估量的被动收入的现金牛
+
+如果你想`0成本`/低成本做副业,web开发是不二之选
+
+
+
+同直播一样,它是一个`超级载体`,且完全`24小时自动化`
+
+如何赚美金呢,盈利关键词如下:
+
+- 广告:adsense (内容)
+
+- 佣金: affiliate (电商好物)
+
+- 订阅: offer (工具:月付/年付)
+
+- 内购: Purchase (Hybrid App)
+
+- 赞助: Sponsored (商业投放)
+
+本篇是数字游民系列(6):独立开发web(saas+web3),更偏向于`从技术层面,阐述业务方向,列出收入框架`
+
+> 独立开发者
+
+成为一个`独立开发者`,并不需要你是一个程序猿,有AIGC的加持,实现`Vibe Coding`,对于不懂代码的其他职业人,也能轻松跨界,创造产品,为什么我会用web来概括`开发`这个领域呢,而不是前端/后端/运维,或者用`端`的描述:`手机app/PC客户端/脚本/网页`...等等,因为它
+
+- 兼容:任何操作系统win/mac/linux/adnroid,默认内置浏览器
+
+- 简单:点击url网址即可打开
+
+- 强大:可wasm调用端侧边缘计算
+
+有兴趣的朋友,可以后台私信: `加群`
+
+提供了nomadlist创始人levelsio的书`MAKE`,讲述他如何利用`上古开发语言`jquery/sqlite/php打造了20k$/m的数字游民网站
+
+
+
+---
+
+## 1分钟上线网站
+
+> 技术者的穷鬼套餐
+
+当你用`nextjs`在电脑开发了网页,你可以免费部署在`Cloudflare Pages`或者`Vercel`等平台上
+
+建议各位创业的第一步是:`导航`
+
+如果我想了解1个领域,那么一定要清楚它的`脉络结构`,比如`出海`,有哪些平台值得做,有哪些软件是运营工具,有几本书籍可以参考,有哪些课程可以学习,有什么社群可以加入交流,需要配置什么硬件等等
+
+
+
+它是纯粹的静态网页,不需要数据库,也不需要运行后台脚本任务,`0成本部署`
+
+有兴趣可以去搜索: `nomadro.com`
+
+基于github的`开源代码`,你只需要替换图标和URL即可,so easy
+
+> 导航如何赚美金?
+
+相信你对国内的`电商刷单/返佣`并不陌生,还有`网盘拉新`,但是在出海赛道,简直不值一提,low到爆
+
+不仅门槛奇高,要`实名/备案/扣税`,各种限制,必须`要求当场转化,收益非常低`
+
+但海外的网站,注册就有`ref推广专属链接`,cookies的追踪可以`长达1个月有效`
+
+背后的运营逻辑是`三赢`:
+
+- 用户注册有`优惠/折扣`
+
+- 平台获取`新客户`
+
+- 邀请者赚取`奖励`
+
+出海赚美金有3样东西,在座的各位一样都逃不过,如同`水电气`一般的存在
+
+- 美国手机号(esim)
+
+- vps服务器 / 静态住宅ip
+
+- 银行卡 (收款提现)
+
+所以你不仅可以参考导航,挑选服务 (付出大量实践的试错成本:时间/精力/金钱筛选)
+
+也可以直接复制模版,换成你自己的专属url,分分钟上线,`赚取佣金`
+
+
+
+---
+
+## wordpress : 网站的六边形战士
+
+> 国内的次等XX,海外香饽饽
+
+
+
+前面的提到的导航是`纯静态`,没有任何交互/功能,但wordpress是`动态`,意味着你可以写文章,发图片视频等等
+
+> 如何一键部署?
+
+买1个vps服务器,然后copy一行代码
+
+- 1.一键脚本 (.sh)
+
+- 2.docker一行命令
+
+- 3.web控制台 (aapanel/1panel)
+
+你就可以分分钟上线一个wordpress网站,就像是把大象塞进冰箱:开门=>塞大象=>关门
+
+部署完成后,你就可以进入`后台仪表盘`:换主题/装插件/写文章
+
+
+
+> 为什么是六边形战士?
+
+除了博客,它还可以是公司的`门户网站`,甚至美国白宫网站都是基于wordpress搭建
+
+它还可以是电商独立站(woocomerce)/慕课(mooc)/交流论坛(forum),你能想到的形式,它都可以借助`主题/插件`瞬间变换
+
+不会代码?它还可以`nocode`(Elementor) 拖拽式构建网页
+
+
+
+> 如何盈利
+
+加入`google adsense`广告,`用户浏览`,就可以产生收益
+
+
+
+不要疑惑写什么,输出你的热爱,比如我写了`树莓派(raspberry pi)的50个项目`/`数字游民系列(digital nomad)`
+
+ps:一定要做英文,别做简中,你会die得很惨
+
+至于`站群``黑帽SEO`,那不是你一开始就考虑的东西
+
+---
+
+## Saas+web3
+
+> 让用户愿意付费才是真的有价值
+
+如果你能读到这里,那么接下来才是web的最佳方案:`1000个粉丝理论`
+
+作用用网站,效果是:假设有一千个用户`按月`订阅付费,哪怕是1$,MRR也会达到1000💲/m
+
+
+
+而web的`边际成本`是非常低的,用户越多,成本均摊约低,比如:`数据可视化`,不需要显卡(GPU)进行AIGC,哪怕是最便宜的raspberry pi开发板,都可以供全世界访问 (国内需要内网穿透)
+
+> 最简单的saas: `数据可视化`
+
+以nomadlist为例子,它的网站价值非常简单,就是为数字游民,提供各个国家/城市基础数据: 天气/房租/消费水平/是否危险等等
+
+
+
+同样,不需要你手敲代码,现成的`saas模版`,立马就可以上线,例如:`create t3-app`
+
+
+
+> 什么是web3
+
+web3的概念,不是简单可以描述清楚的,但是应用在web上,就极其简单:`登录/支付/数据`
+
+普通saas的三件套: google登录/firebase数据/stripe(收款网关)
+
+那么web3的对应是:Solana登录/IPFS中心化存储 /`加密收款`(BTC/USDT等),都是基于区块链
+
+当然最重要的,一定是`支付网关`,它是未来世界的入口
+
+如果你去访问各个海外平台,基本都接入了加密支付,比如我选择了`nowpayment`,无需KYC认证,即可收款
+
+
+
+---
+
+## 链路和闭环
+
+> web可以承载一切
+
+鉴于很多朋友,还没有跨过科学姿势上网的基本门槛,海外网站无法访问
+
+在末尾,阐述国内web的微信H5链路闭环.以本公众号为例仅供参考
+
+- spa单页 (linktree/下载页/重定向)
+
+- nav导航站
+
+- ebook电子书
+
+- vip社区论坛
+
+如下图所示:
+
+`nomadro.com`,是超级入口
+
+只要记住1个网址,保存,后续的导航/电子书/社区都可以点击打开
+
+如何盈利?==>`佣金/广告/付费墙`
+
+国内`个人可以收款`的`支付网关`: 参考这篇xxxxx
+
+
+
+进入社区,就是进入了`私域`,这个时候就可以为群友提供一定价值(非公开)
+
+- 网课视频(mooc)
+
+- 电商 (独角数卡)
+
+- 咨询 (live chat)
+
+关于`数字游民:出海`/`低成本工作室:云手机`,很多都是国情敏感+偏灰色
+
+会在群内分享,感兴趣可后台私信: `加群`
+
+可以提供一定的`技术咨询`和`硬件设备`
+
+
+
+你可以通过上面2张图示,看到web其实可以是各种形态,承载各种功能
+
+在下一篇:数字游民7:低成本工作室:云手机,还有更多web的形态,比如`后台管理系统`,`自动化控制面板``,``远程团队kit`,`homelab基建`,等等,属于`一人公司`的内部系统
+
+---
+
+## 低成本副业/创业
+
+> 你只需要1台运服务器(vps)
+
+以上web的各种形态,目前都是托管在10$/year的海外云服务器
+
+以及99r/year的国内云服务器
+
+每个月的成本,按地域国际划分,`不到10r/m`,甚至不到一顿饭钱
+
+如果你愿意,`穷鬼套餐,0成本方案`实在是太多了,不知道就认真再看看文首
+
+
+
+但是web网站可以24小时在线
+
+持续不断的为你带来各种收入`广告/佣金/订阅/赞助`等等
+
+所以,web是不是值得你重新审视一番?
+
+
+
+
+
+
+# 云手机搭建
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (7)
+
+## 低成本工作室- 硬件篇
+
+> `开源硬件`才是超级杠杆
+
+
+
+试想一下,如果你不能从事现在的工作了,你最好的归宿是什么?
+
+我们都会从最初的青涩,到迷茫,到现在的迷惑
+
+所以有一阵子,就一直不停地问AI (chatgpt/grok/gemini ),得到的结论就是`工作室`
+
+> 极致的低成本`创业/副业`
+
+三四线城市或者小镇乡村,最佳的资源杠杆,在`人力资源`排名之前的是:
+
+- 便宜的租房 /家用宽带 /居民电价
+
+只需要再添一个`物理服务器`,就可以打开`互联网搬砖`的通道,成本仅此,
+
+而设备最佳的业务形态是:`云手机`=>开启`无人直播`
+
+交流可后台私信: `加群`
+
+当然,出海赚美金的业务,不止一种,可参考:
+
+1. Tiktok无人直播
+
+2. Youtube自媒体
+
+3. web (saas+web3)
+
+
+
+---
+
+## 云手机
+
+> 自建方案
+
+`商业运营平台`,以及二手安卓`工作机`(硬改直供电),或者`主板机`,均不在后续的阐述里,本篇的核心主题是个人如何利用`低成本开源硬件自建云手机`
+
+为什么要这么折腾?`开箱就用`才是赚钱效率的真谛不是吗?
+
+别急,看完你也许会有答案
+
+
+
+> Redroid
+
+*Redroid*(*远程*安卓*系统*)是一款基于GPU加速的云端Android(AIC)解决方案。可以在Linux主机(例如`Docker`Linux、`podman`Linux`k8s`等)上启动多个实例。Redroid同时支持Linux*和*`arm64`Android`amd64`架构。Redroid 适用于云游戏、虚拟化手机、自动化测试等应用场景
+
+简单来说,只要你有一台linux系统主机,随便就能`虚拟化N个安卓系统`
+
+但它有个不好解决的痛点:`缺乏相机支持`
+
+除非有公司团队去专门编译源码,否则在公开的网络平台,几乎找不到支持默认相机的docker镜像,毕竟它也不是传统的aosp安卓架构
+
+所以在直播领域,个人给它的应用场景是:
+
+- Youtube live : 网页studio无人直播
+
+具体的措施就是
+
+- `ffmpeg拉流并转发推流`,
+
+- chrome浏览器打开studio网页后台
+
+- `油猴脚本Greasemonkey`实现`自动开播断播`
+
+如图:依次是=>左(tiktok)/中(Youtube)/右(Youtube)
+
+它们24小时持续带来美金收益,即使在睡觉
+
+
+
+> ARM开发板的aosp安卓系统
+
+现在的开发板,除了linux,大多也支持aosp安卓系统,`有源码,可以自行编译`
+
+如果缺少组件/功能,就可以随时`开发/定制`
+
+比如:不支持uvc摄像头=>编译HAL,不支持google全家桶=>安装,把apk应用/脚本=>系统内置
+
+一个开发板的价格:`仅二百多`,就可以搭建一台云手机
+
+如下图所示:
+
+- `RPA转播脚本=>自建TK云手机=>真机查看效果`
+
+
+
+---
+
+## 软路由
+
+> 出海网络的基础设备
+
+云手机只是业务形态:直播
+
+但是出海业务千千万万,最离不开的网络基础设施:`软路由`
+
+没有它,可谓寸步难行
+
+
+
+家用的路由器,只不过是`残缺`的系统,是`被阉割`的
+
+要想勇闯互联网,你必须了解`真正的网络`是什么!!
+
+比如:`openwrt`
+
+知道什么是`科学代理,P2P组网,内网穿透,tun中继,DNS解析,mqtt通信`等等
+
+如同瑞士军刀,为业务披荆斩棘
+
+
+
+简单来说,你必须拥有`科学姿势上网`的能力,且必须是`静态住宅ip`
+
+科学姿势,只能让你扩大眼界,静态住宅ip才是赚美金的`门票`
+
+可参考出海导航: `nomadro.com`
+
+拥有一个软路由,你就找到了通往世界探索的隐秘入口
+
+---
+
+## ESP32
+
+> 智能实验室homelab控制
+
+在前面的`数字游民系列`,提到过esp32+传感器+执行器可以用于直播互动游戏
+
+比如: `生态造景=>云养鱼`
+
+简单来说,观众可以看到 鱼缸/草苔/宠物等
+
+下图为调试场景:
+
+
+
+通过弹幕评论 (比吃/666) /礼物打赏 (鲜花/钻石) ,发出指令,驱动效果:
+
+- 喂食器自动出粮
+
+- 灯带色彩/音乐变化
+
+- 氧气泵开启/关闭
+
+实现的原理是,就是利用python代码监听直播间事件
+
+mqtt网关驱动esp32控制传感器+执行器,进行互动
+
+`盈利逻辑`
+
+- 观众参与`互动的氛围感`=>礼物打赏
+
+- 直播带货`:鱼缸(好看/鱼粮(营养),橱窗直接下单
+
+- 用户关注=>涨粉丝: 接商业广告
+
+整个过程,既不是搬运,也是AI数字人,是`真实环境`
+
+所以,你看,无人直播,也可以是直接插入`usb摄像头`,无惧电池爆炸,24小时实时直播
+
+自动收打赏/带货/涨粉
+
+> Homelab工作室自动化
+
+在实际的运营业务中,还需要很多的配件支持
+
+比如 路由器,网线,视频采集卡,风扇,插座等普通配件
+
+也有`显卡欺骗器,5g通信模块,可刷写的esim实体卡`这些特殊硬件
+
+
+
+`云手机+软路由+esp32+配件`,一套成本`不过千`,就可以开始副业之旅
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (8)
+
+# vps云服务器
+
+> 最具有性价比的超级杠杆
+
+`vps云服务器(Virtual Private Server)`,绝对是你最值得了解的数字产品,可以把它的功能发挥到极致
+
+
+
+在数字游民系列1-7,我分别从`出海框架/业务模式/工作室搭建`等阐述了普通人赚美金的`方程式`
+
+并且始终强调几个关键词:`低成本杠杆/认知盈余/把大象关进冰箱/水电气理论`
+
+用于vps服务器,你可以这么理解:
+
+- 10$/年 (低成本杠杆)
+
+- 超高佣金返利 (认知盈余)
+
+- 一键脚本 (把大象关进冰箱)
+
+- 出海必备 (水电气理论)
+
+本篇就是你能立即开始,完美的`副业`入门教程
+
+---
+
+## 科学上网
+
+> 见识更大的世界
+
+互联网的意义就在于全球链接,可惜某些地方是个畸形的大内网,所以想要赚美金,第一步就是用科学的姿势上网,当然`出门`简单,要想赚钱,它的终极价值是`中转通道`,后续我会解释
+
+> 怎么出门?
+
+就两步
+
+- 买一个vps服务器 (参考出海导航的推荐: `noamdro.com`)
+
+- 一键脚本 (后台回复: `加群`)
+
+其实脚本在github都是公开的,选star数量最高的就行,因为敏感,所以这里不能明文提及
+
+整个过程,就只有2行英语执行语句,一句ssh登录,一句脚本部署
+
+在你的windows电脑的cmd/或者macbook的terminal,打开这种系统自带的`终端`,就能连接全世界
+
+
+
+ps: 如果你做无人直播,一定要买最便宜的服务器,因为24小时运行,`流量和带宽`需求巨大
+
+当额定的流量消耗完(比如2T/m),云服务器就会被停止,直到下个月1号自动开启
+
+> 怎么赚美金?
+
+注册并购买云服务器,获取`ref推广地址`
+
+公开分享你的使用经验:测评价格/性能等等
+
+实现`三赢`:
+
+- 用户注册有`优惠/折扣`
+
+- 平台获取`新客户`
+
+- 邀请者赚取`奖励`
+
+比如早期推荐的`搬瓦工`(国内线路友好),无心插柳柳成荫赚了些佣金
+
+
+
+---
+
+## 静态住宅ip
+
+> 落地节点
+
+想要赚美金,没有它,一切都是徒劳
+
+假设你做美国的业务, 你用着香港/泰国/新加坡/日本的ip,平台就会对你区别对待,比如`gpt降智,电商海淘砍单,金融app注册拦截,社媒发布视频/图文/直播=>0播放,网站禁止访问`等等
+
+从`风控`的角度来说,是为了阻止`高风险恶意脚本/欺诈型用户`
+
+
+
+作用于`无人直播`,拥有它,就不会`零播/限流`,而是`秒进人`
+
+看着哗啦啦的头像join/join/join...,弹幕互动/礼物打赏,这种`即时反馈`,能不让人心动吗?
+
+> 如何检测ip纯净度?
+
+常用的网站是:`whoer`/`ping0.cc`
+
+更多检测工具请参考 出海导航: `nomadro.com`
+
+
+
+网站提供的信息只是参考,不一定准确,但有个方法一定OK
+
+- 自动过cloudflare盾
+
+
+
+但你访问一个海外网站(比如,chatgpt),出现这个中转页,当它自动打绿勾√,并重定向到你的目标网址
+
+那么,恭喜你,静态住宅ip没有问题
+
+你已经通过了全世界最大的网络基础平台(cloudflare)的官方认证
+
+> 使用它,也可赚佣金
+
+
+
+---
+
+## 内网穿透
+
+> 让家里的服务,被全世界访问
+
+如果我说,前面提到了 出海导航: nomadro.com
+其实部署在`家里的89元的开发板`上:香橙派zero3(1GB内存)
+
+已为我带来了数千美金的佣金收益,你信吗?
+
+
+
+> 你用过NAS吗?
+
+大家对于网盘一定不陌生,要想扩大容量,提升下载速度,就必须vip/svip/sbigSB充值会员 (吃相难看)
+
+而且你存放的小姐姐,还会被审查后变成金刚葫芦娃
+
+至于同步的`私密相册和视频`,很容易泄露到公开的互联网上 (不是黑客厉害,是这些草苔班子垃圾)
+
+所以自己组装一个多硬盘的`NAS存储`就非常重要,但一般它默认只在家里同步:上传/下载
+
+如果在户外,想要随时随地访问怎么办?这就需要进行内网穿透了
+
+同样,如果把网站部署在家里,任何脚本/api,都可以被穿透到互联网访问
+
+俺使用的似乎开源的`frp-panel`,用web-ui管理面板,轻松配置
+
+
+
+> 为什么要让家里的服务被访问?
+
+你在三四线小城市的低成本工作室,其实是`你的全球总部`
+
+
+
+接下来再从反面来解释内网穿透的必要性:
+
+1. 最直接的原因就是`成本`
+
+100元的开发板,你可以用十年,转手挂在海鲜市场,而同样的硬件配置,云服务器卖你100元/月,就问你心疼不心疼?
+
+当某个服务,要求最低配置xx内存/xx核芯片,而价格最低300元/月,而你还只是新手,无法稳定的盈利,你觉得值得买吗?
+
+如果你想创业,运营一个AI saas网站, 知道`云显卡服务器`多贵吗,几块钱/1小时~~,而我的朋友,bro,你平时在家里打游戏提供支持的老伙计(GPU),就能在初期上线测试时,为你提供免费的算力
+
+2. 次要原因是`宕机`
+
+一旦云服务器,所在城市的数据中心,要进行维护,出了故障,那么主机就会暂停/关机,纵使发工单/联系人工,也没有办法让业务立即上线,只能干等,除非还有其他云主机做了备份,做负载均衡
+
+非运维工程师,一般不会注重备份,但是买的vps云服务器`只是用来穿透`,A云服务器坏了,换B云服务器,家里的服务立马回复上线
+
+3. 最可怕的是`销毁`
+
+被数据存在云上,没有做备份的情况下,基本很难恢复,而云服务商,仅仅送你几张代金券,至于你用户多少万,盈利多少钱,"实在不好意思"
+
+`你永远,只需要买一个最便宜的vps服务器`就够了
+
+> 怎么赚美金?
+
+- 几分钟上线1个的导航站(静态)=>佣金
+
+- wordpres博客(动态)=>谷歌adsense广告
+
+- AI Saas : 数据可视化 /工具站 =>用户订阅
+
+
+
+
+---
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (9)
+
+# RPA自动化
+
+> 被动收入:躺着睡着把钱挣了
+
+如果你开始赚到了美金,但还是整日苦兮兮的呆在工作室里
+
+手动操作业务,麻木搬砖
+
+那么,不妨问问自己,除去少了通勤,少了牛鬼蛇神的办公室政治,现在,你时间自由了吗?
+
+
+
+本篇既不是思维框架,也是不是业务运营,而是讲`技术:RPA自动化`
+
+人生遵循`二八法则`,只把最核心且简单的掌握,就够了,不需要深入学习或者写代码
+
+
+
+先把工具用起来(赚美金),然后再研究,根据`低成本/杠杆`的策略
+
+在技术层面可以按照以下`方法论`:
+
+- 开源项目(github):直接用,以python为主
+
+- 二次开发 (借助AI 修改)
+
+- 外挂脚本 (xposed/油猴/hook等)
+
+数字游民系列,核心的业务是`无人直播`,相关的`开源项目/脚本/软件`都整理在 出海导航: `nomadro.com`
+
+如果你觉得看到代码头疼,也可以试试`N8N`,在网页拖拽节点制作工作流,底层逻辑是一样的
+
+---
+
+## Python:人生苦短
+
+> 让繁琐的工作自动化
+
+无人直播,到底播什么?是搬运,数字人,还是实时拍摄?
+
+
+其实都有,前面的文章详细写过
+
+这里以`搬运`:实时转播,作为例子:
+
+- 在`github搜索关键词`: xx live,或者某某直播
+
+- 按`star数量`进行排序
+
+- 查看`更新时间`:确认最近半年,是否有提交代码
+
+
+
+你就能看到排名第一的是`[*DouyinLive*Recorder](https://github.com/ihmily/DouyinLiveRecorder)
+
+然后把代码下载到电脑,按照文档,就可以运行起来了 (记住:把大象关进冰箱)
+
+> 二次开发
+
+通过项目简介,可以知道它是无人值守录制,那我们其实想要的是`实时转播`
+
+`第一性原理`:直接改核心逻辑,把`录制=>转播`
+
+借助AI (gpt),秒改,如果项目代码过于庞大,可以下载vscode(编辑器)的`插件`
+
+
+
+当然,核心需求实现后,并不一定能实现完全自动化
+
+原始项目的检测逻辑是`多频道同时下载`,而转播的需求实际应该是:`A主播下线后才轮询B/C/D主播`
+
+不然会造成N个直播流,推送到同一个Youtube live的bug
+
+我把脚本编译打包了,集成在设备上,插电即可运行
+
+有需要的可咨询,后天私信: `加群`
+
+部署在linux服务器(开发板)上,让它`24小时运行`
+
+下图是windows查看日志 +远控
+
+
+
+---
+
+## 油猴脚本
+
+> 浏览器外挂
+
+Tampermonkey是浏览器的外挂插件
+
+能让比如chrome之类的浏览器执行自定义的js代码
+
+控制网页UI界面/数据
+
+
+
+它可以实现的功能实在是太多了,最基础的`去广告`,`无水印下载`,`数据爬虫`
+
+在低成本工作室中,Youtube live,可以网页studio,接受直播流
+
+所以我们的核心需求是: `停播自动关闭弹窗`,
+
+而一个设备可以虚拟出N个安卓(redroid),安装chrome浏览器,直播多个频道
+
+即时你的Youtube频道,没有ypp获利资格
+
+你也可以直播`积累播放时长` (完成4000个小时的指标)
+
+
+
+同样,不懂代码没关系,让AI 给你生成,直接一键安装
+
+获取脚本,可后台私信: `加群`
+
+
+---
+
+## adb
+
+> app应用自动化
+
+Youtube支持网页studio,但是Tiktok更严格,它只允许app/客户端(XX伴侣)开播
+
+那么意味着,需求点击app里面`start live /stop live`原生按钮
+
+以及可能出现额各种意外情况:`提示弹窗`/风控警告等等
+
+
+
+adb是安卓的调试工具,有命令行,也有各种开源封装的GUI图形软件
+
+当安卓系统,通过usb线连接,或者在同一个wifi (局域网)时
+
+就可以在终端直接控制,进行各种操作
+
+- 应用管理: 安装( adb install )、卸载( adb uninstall ) 应用程序。
+
+- 文件传输: 在电脑和设备之间推送( adb push ) 和拉取( adb pull ) 文件。
+
+- 设备控制: 重启设备、模拟按键、截屏、录屏。
+
+- Shell 访问: 进入设备端的Unix shell,执行更底层的命令。
+
+- 日志获取: 查看设备日志( adb logcat ),用于排查问题。
+
+- 端口转发:: 将设备上的端口映射到电脑上。
+
+这里仍然使用`第一性原理: adb可以点击`坐标``
+
+不要学习复杂的`安卓自动化框架 appium,Uiautomator2,lamda`
+
+你要做的就是,找到核心按钮坐标,用python+adb,写个点点点
+
+然后就没有然后了,安心睡觉,bro,
+
+每天为你的自动化添砖加瓦,`不要追求极致的完美`,你是一人公司,不是团队
+
+后台回复: `云手机`
+
+
+
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (10)
+
+# 赚美金: 收款与提现
+
+> 钱到手里才是真的!
+
+本文会涉及`金融`和`web3`
+
+前面提到的数字游民,出海赚美金的思维框架/业务汇总/硬件设备/技术:RPA自动化等等
+
+都是为了这终极一刻,不是吗?
+
+> 收款=>提现 (`出入金`)
+
+
+
+核心平台: `美区paypal/wise/U卡` (含加密交易所)
+
+参考出海导航: `nomadro.com`,去注册吧~
+
+
+
+---
+
+## 美区paypal
+
+
+
+> 为什么一定要有?
+
+因为无论是佣金,还是Tiktok,大多数平台,都`指定了paypal收款` (Youtube用招商银行卡即可)
+
+落地必须是它,不然就卡了脖子,取不出来
+
+> 为什么是美区?
+
+因为国内的paypal要公司注册,才能有更多的核心权限,每一笔转账都要35$的手续费
+
+而且为了合规,会被`缴税/审查` (解释权在谁手里,懂得都懂)
+
+美区的paypal`注册简单`,只需要`美国手机号`, 推荐用esim远程秒开卡
+
+ps: 没有申请`非美国居民税号(ITIN)`,就不要上传身份证或者护照,容易封号,不用实名!!
+
+封了资金就拿不回来了,这是血的教训😢
+
+转账到wise/或其他美国银行卡没啥手续费,而wise又可以把美金直接结汇到`支付宝/微信`,
+
+最终可以提现到银行卡,变现成人民币 (5w美元/人/年的结汇额度)
+
+所以`美区paypal=>wise=>某宝/某信=>银行卡`, 就是一个链路
+
+当然链路不止这个,还有别的:港卡(需要通行证)/U卡 (仅身份证)/payonerr(身份证)等
+
+> 做Tiktok无人直播,没有paypal可不行呢~~
+
+
+
+---
+
+## wise: 全球多币种账户
+
+> 全球币种账户,支持持有和管理 40+ 种货币
+
+
+
+早期wise还需要`护照`,目前只需要国内的身份证就可以开通
+
+然后就可以开通很多国家的银行账户, 美国/欧洲/香港/中国等等
+
+而且相互兑换的手续费很低
+
+其中香港账户
+
+- 可以收取X的收益
+
+- 可以申请stripe支付网关,接入saas网站
+
+> 如何提现人民币?
+
+直接转正到`支付宝`账号后,在小程序`闪速收款`,查看
+
+微信也有,也是小程序,叫`微汇款`
+
+
+
+---
+
+## U卡: 未来世界的通道
+
+> 进入未来世界的通道
+
+
+
+门槛很低,只需要国内身份证即可`线上秒批`
+
+U卡之所以这么称呼,就是它可以`把加密货币,转化成法币`
+
+而它可以`直接绑定到微信/支付宝/Apple pay`等,消费 (不用兑换人民币)
+
+海外平台总会拦截国内的双币信用卡(VISA),直接拒绝或者砍单
+
+想当年,俺订阅了2次nomadlist,钱被扣了七八百,然后国内银行不认(不销卡,每个月一直扣),海外网站注册又不成功,直到后来第3次(赚美金后)才成功,so:简中的东西,别人不认
+
+而现在,AI 时代, 各种`gpt订阅/流媒体/海淘电商`,拥有一张U卡,你就直接支付,虽然国内存在很多`代付/合租`平台,但手续费很高,也会泄露隐私
+
+> 有没有实体卡?
+
+不要完美主义好么,新手先拥有在说,总是用一些乱七八糟偏门方式,最终被禁止了又喊疼
+
+wise很多人就喜欢这么干,搞什么改地址,当平台是沙比
+
+建议了解`加密交易平台`:欧易(OKX),币安(binance),它门是`中转站`
+
+参考出海导航: `nomadro.com`
+
+记住:不是炒币,更不要杠杆
+
+本期只讲收款提现,不是`金融投资`或者`web3领域`,这两后期也许会写,maybe or not
+
+
+
+> 领取硬件钱包
+
+U卡有很多,`safepal`,或者Bitget Wallet等,但是由于都和Fiat24合作,开了一个,另外的就无法开通了
+
+- 瑞士银行独立账户(IBAN)可转账
+
+- 0费开卡,出入金直转港卡
+
+- 国内绑定直接消费:微信、支付宝、美团、Apple Pay、Google Pay
+
+- `免费X1硬件钱包`(参考出海导航: nomadro.com,邀请码`104809`)
+
+- 美股入金:支持盈透证券、嘉信券商
+
+俺收到的硬件钱包:
+
+
+
+Fiat24 是 HashKey、SafePal 等机构投资的一家瑞士 Web3 银行,获瑞士金融市场监管局(FINMA)发放牌照,结合区块链智能合约和传统瑞士银行账户。它可以实现转账、支付、存储等功能,是区块链世界和传统金融的桥梁,还能为各种 DApp 提供法币支持
+
+SafePal 与 Fiat24 的关系 SafePal 对 Fiat24 瑞士银行进行了战略投资,而 SafePal 本身还有币安的支持,背景相当强大。这种合作关系确保了服务的合规性和安全性
+
+适合以下人群:
+
+- 加密货币投资者:需要安全合规的出金通道
+
+- 跨境工作者:需要多币种账户和国际支付能力
+
+- 美股投资者:需要向券商入金
+
+- 海淘用户:需要订阅国外服务或购物
+
+- 高净值人群:需要瑞士银行账户进行资产配置
+
+- Web3 从业者:需要连接加密世界和传统金融
+
+快去注册吧,别等口子关闭了,后悔不及
+
+---
+
+## 最后
+
+> 低成本工作室 or 数字游民
+
+数字游民游民系列1-10,已经是业务闭环
+
+只要花点心思,就可以在极短时间内顺利走过我几年的坎坷路
+
+
+
+后续11-20若是更新 ,不确定会不会增加`金融/web3/电商`等
+
+但一定会增加`远程团队`,`Homelab基建`等底层支持
+
+让你`随时随地,远程移动办公`
+当然,你也可以选择更快落地,后台私信: `加群`
+
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (11)
+
+# 远程办公: 随时随地
+
+`低成本工作室`只是为了经营互联网业务,自动化赚美金
+
+而真正要实现的,是`数字游民的生活方式`
+
+如果我们能把家里的设备(Homelab)运行的系统/服务,都能实时`远控`
+
+那无论是身处何方,什么环境,都能把时间/空间的效率提升到最高
+
+
+
+参考前面的提到的`认知盈余`,别人在打游戏,刷短视频,而你,可以随时`剪辑视频/编程/操控无人直播`
+
+有空可以了解下什么是`p2p(点对点)/tun(中继)`
+
+---
+
+## P2P组网
+
+> 互联网打洞
+
+使用开源的`EasyTier`,当2个设备之间尝试打洞成功后,它们就等同于在一个局域网,内网(生成特定的ip)
+
+EasyTier 是一款简单、安全、去中心化的内网穿透和异地组网工具,适合远程办公、异地访问、游戏加速等多种场景。无需公网 IP,无需复杂配置,轻松实现不同地点设备间的安全互联。
+
+软件即可通过命令行使用,也可以通过`图形界面操作`
+
+
+
+> 适用场景
+
+- **远程办公**:让公司、家中和外地的电脑像在同一局域网一样互通。
+- **异地访问**:随时随地安全访问家中 NAS、服务器或其他设备。
+- **游戏加速**:组建虚拟局域网,畅玩联机游戏。
+- **物联网组网**:让分布在不同地点的设备安全互联
+
+---
+
+## rustdesk
+
+> 远程控制操作系统
+
+一般常用的系统是windows,使用开源面得`rustdesk`,可以直接远程控制操作系统
+
+包括mac电脑和linux主机
+
+它的兼容性很好,无论是x86/arm都支持
+
+多平台支持:Windows、macOS、Linux、Android。
+
+
+
+---
+
+## scrcpy
+
+> 安卓远控
+
+开源的`scrcpy`,可以通过adb协议,将安卓界面可视化,鼠标触控
+
+基于它,又延伸比如`qtscrcpy /escrcpy/scrcpy-web`等项目
+
+> qtscrcpy可以`群控`: 让多个安卓系统,同时执行某一个流程
+
+
+
+> escrcpy更好管理
+
+每次打开就会自动连接设备
+
+并且新增了很多功能:`反向供网/终端调试/计划任务`等
+
+
+
+---
+
+## 最后
+
+所以其实只需要带上笔记本,挎着背包,领着行李箱
+
+让你的低成本工作室(Homelab),24小时运行,随时随地远控
+
+
+
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (12)
+
+# 地理套利
+
+> 利用国内的`资源杠杆`
+
+在前面的系列文章,`搬运转播`或者`数字人`(AIGC),更倾向于用技术来解决`业务内容来源`的问题
+
+但实际上,如果做Youtube运营,从很多角度而言,做`原创`视频的IP价值和收益天花板最高
+
+而赚美金,就是要利用好国内的资源:`人力/平台/物价`等等,去放大杠杆,实现`地理套利`
+
+
+
+简单通俗的讲: 你在国内做一件事,应该对它的要求是:
+
+- 有病毒式`爆发潜力`
+
+- 具备`长期价值`
+
+- 本身有`商业属性`:可以赚人民币
+
+- 成为`内容素材`:出海杠杆=>赚美金
+
+接下来,我会从3个实践:`探店/沙龙/体验营`,来阐述
+
+要赚美金,何不多赚一笔人民币?
+
+---
+
+## 探店
+
+> 也叫团购达人
+
+其业务形式是,商家在互联网平台发布`商单`,招募吃喝玩乐的达人`拍摄视频`
+
+酬劳是`免费置换`,或者`车马费`,以及`团购订单佣金`
+
+这里不会对于某些争议进行批判:`霸王餐/赛博要饭`,接下来只谈存在商业价值
+
+> 以`某音`举例
+
+
+
+只要达到`1000粉丝`,系统就会开通`探店宝`
+
+LV1-LV3等级,会有无数免费置换的商单
+
+LV4以上就有100r保底的车马费
+
+
+
+可以`免费/收钱去吃喝玩乐`
+
+
+
+而进一步,如果你有1w粉丝以上,基本很容易开通`某音伙伴计划`(类似Youtube的YPP)
+
+ps: `1w粉丝,其实非常简单`,不要太老实了,bro
+
+视频`有播放量,就有收益` (终于知道为什么那么多`营销号`滋滋不倦了吗?)
+
+试想一下:
+
+- 同城体验各种`美食餐饮/猫狗咔/卡丁车/工作坊/景点/水疗SPA/新奇体验`
+
+- 同省的广州/潮汕等城市圈,也是一样有商单
+
+- 出省,去天南地北,游历祖国河山,体验不同的风土人情
+
+商家花钱请你拍视频,平台监督流程交付,国内赚`保底车马费+订单佣金+伙伴计划`,再把素材作为内容出海,赚美金,是不是就实现了自媒体的`基本套利`
+
+而你,本就是一个`漂泊在旅途,远程办公`的数字游民
+
+最后,一定要明白,国内收益不值一提,要`收费享受,而不是花钱办事`
+
+很多,`你觉得平平无奇的画面/文化,在世界的彼岸,是他们的惊叹的存在`
+
+多去发发视频,多去看看数据分析,不要一开始,就纠结赛道,没卵用,量变引起质变
+
+---
+
+## 沙龙
+
+> 小团体的coffechat
+
+下图是举办沙龙的实拍照片:
+
+
+
+如果你成为一个数字游民
+
+`图文`只是一个输出的锚点,`视频剪辑`也是,或者国内有些小众的`FM播客`形式
+
+不妨`每周末`在当地`组织一场沙龙`
+
+面对面交流各自的`业务形式`/痛点难点,完成`信息交换`开拓眼界
+
+可以免费,也可以是收费,作为发起人和主持人,建议后者
+
+因为`场地/茶歇/PPT/控场`都是很费精力/时间/成本
+
+> 发起UGC (用户生成内容)
+
+信息交换/收费举办,都是比较浅的层次
+
+试想一下,你写一本书,哪怕1w字,有点难对吗?
+
+但是10人*1k字,`一个晚上就能上架一本小册`,并且每个人的业务分享背后,都是极大的试错成本,不可复制
+
+国内,可以出版到`微信读书`,海外就极其简单,几分钟就能同步到全球亚马逊的`kindle商店`
+
+多年前就上架了第一本电子书(kdp自出版)
+
+
+从媒体角度,视频也可以发起`共创`:个人访谈,剧本演绎等
+
+> 异业合作
+
+这是商业价值,最本质的一层:价值交换
+
+`你有渠道,他会营销,我有技术,那是不是合作一番?`
+
+下一章: 数字游民系列13:`分布式团队协作`,会介绍完整的工作流(自建/开源)
+
+比如: 企业管理系统(HRM/ERP/CRM/ATS) /IM通讯/看板/wiki文档等等
+
+---
+
+## 体验营
+
+> 文旅全身都是宝
+
+虽然没有经常出国,但是很庆幸曾去浪过一次泰国
+
+逛过佛寺,看过大象,摸过人妖㊙️,越过丛林,海上滑翔,船边浮潜
+
+体验清迈的digital nomad办公空间,巴拉巴拉~~
+
+
+
+现在几乎各个城市都在发展文旅,比起重工业 (有污染/人力成本)
+
+旅游业从各个链路里都可以淘金
+
+在这里强调的不是`自由行`,也不是`旅行团`,而是自组织的`体验营`
+
+你会发现很多业务的底层,都是旅行,比如游学营/恋爱营/训练营
+
+把业务结合在`游历旅拍/下午茶/篝火露营之上,很容易破冰,形成特殊的体验/记忆`
+
+以数字游民为例:nomadlist只是提供了数据可视化
+
+而某些平台在很多国家/城市,都安排了`联合办公空间/民宿/沙龙讲座`,可以按照既定的路线和指导,完成旅行,其实国内也有,不点名了,伪二房东,不提也罢,呸
+
+---
+
+## 最后
+
+假设,你是一个数字游民,`低成本打造工作室`,然后你拎着背包,游历各个城市
+
+不仅可以`远程办公`,`分布式团队`
+
+还可以`边旅行边赚钱`(国内套利/出海美金):
+
+- 美食/酒店/景点等,都是`商单` (店家 or 文旅机构)
+
+- 当地咖啡厅,发起一场分享`沙龙`
+
+- 组织一个`游学体验营`
+
+
+
+本篇仅仅是一个简单的套利框架, `真正的商业模式远不是个人视角,而是商家/企业`
+
+个人模式,只是让个体可以不再陷入认知盈余,浑浑噩噩
+
+把时间和空间的效益,实现的基本的地理套利,实时都在赚钱 (商单盈利,不是打工攒钱=>花钱生存)
+
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (13)
+
+# 分布式团队
+
+> 协同办公
+
+数字游民也好,一人公司也罢,并不意味着单打独斗,`众包/兼职/雇佣`等,都是一种人力资源策略
+
+
+
+包括前面提到的`UGC`(用户产生内容),以及`异业合作`等
+
+有人的流程,就需要沟通,就需要协作,如果人数达到团队的标准,就必要进行管理
+
+本篇,从分布式团队,如何协同,自建开源基础设施,仅供参考
+
+- gauzy (业务管理平台ERP - CRM - HRM - ATS - PM)
+
+- element web (IM沟通平台/语音视频)
+
+- planka: 任务看板
+
+- docmost:wiki文档=>实时笔记
+
+- mirotalk: p2p视频通话(基于webrtc)
+
+ps: 为什么不用x信/X钉/X书企业版?
+
+因为`开源免费/自定义/隐私加密`,用国产的东西做数字游民(出海业务),就是带着镣铐跳舞的蠢货
+
+---
+
+## 业务管理平台
+
+> 一人公司方法论
+
+
+
+如果还是个打工者,一定有空了解这些缩写名词`ERP - CRM - HRM - ATS - PM`
+
+然后去使用行业第一的saas系统 (一般是web网页)
+
+就会对公司的整个管理,有清晰的了解:
+
+- 企业资源计划 (ERP)
+
+- 客户关系管理 (CRM)
+
+- 人力资源管理 (HRM)
+
+- 应聘者跟踪系统 (ATS)
+
+- 工作与项目管理 (PM)
+
+具体而言,就是针对业务,在执行项目,具体到任务编排,绩效统计,发票管理,物料采购(供应商),对外营销投放,客户关系维护等等
+
+严格来说,庞大复杂的`实体行业`,链路繁多冗余,才会需要整套的`行业解决方案`
+
+所以俺在这里用`gauzy`提及的目的,是`先繁后简`,只有站在`地图式/3D立体`的角度,进行结构化的思考,才看看清全貌
+
+---
+
+## Element: 聊天
+
+> 基于matrix协议
+
+可以`多个操作系统使用`,安卓/ios,windows/mac,以及纯web网页
+
+
+Element 使每个团队都能运行和控制自己的数字化自主、可互操作且安全的通信解决方案
+
+基于可互操作的 Matrix 协议开放标准构建产品
+
+- `去中心化,端到端加密`,没有人可以随意监控聊天记录
+
+- `联邦机制`: 多个自建网络,可以安全协同工作
+
+- `数据主权`: 在保证隐私的同时,个人有权对数据进行导出/下载等操作
+
+所以它是`微信/telegram`的极佳替代品,开源免费,自部署
+
+可以为团队成员分配账号`私聊`,建立`工作群`,设置`群机器人Bot`,触发`webhook`事件
+
+可以语音视频
+
+
+
+有兴趣加入团队的朋友,可后台私信: `加群`
+
+---
+
+## planka: 任务看板
+
+> trello的开源替代品
+
+
+
+用过`Trello`或者国产的`Teambition`的都知道任务看板
+
+它可以让我们为团队分配任务,指定完成日期,重要程度
+
+通过拖拽的形式,改变任务状态
+
+通过tab标签,能更好的分类,以及task拆分子任务
+
+如果你没有用过任务看板,那你一定没有/或者没有合理的完成一个项目
+
+同时项目也提供可webhook,可以把它接入到 element中的群bot机器人呢
+
+当一个任务发起/完成时,群内就能收到通知,直接打开查看
+
+---
+
+## Docmost : WIKI文档
+
+
+
+无论是 element聊天/planka看板等, 都没有`对信息进行整理和沉淀`
+
+Docmost可以让团队成员
+
+- 实时协同编辑文档
+
+- 支持图表/搜索
+
+- 权限角色管理
+
+有了标准化的SOP文档,就可以让新成员快速入门
+
+也可以让其他成员快速学习,成长
+
+---
+
+## mirotalk: 视频语音
+
+> 纯网页,基于webrtc协议
+
+
+
+无需下载、安装插件或登录。即可`浏览器直接进行`
+
+- 视频语音通话
+
+- 发送消息
+
+- 屏幕共享
+
+P2P沟通,不消耗服务器流量,加密,隐私保护
+
+为什么,在element支持视频语音的情况下,俺还要推荐mirotalk呢?
+
+前面的产品都是团队协同,但是在吸纳新成员时
+
+需要进行`远程视频面试`, 这时候`一个url`,就可以新建`即时性房间`,`屏幕共享和视频对话`
+
+---
+
+## 最后
+
+你可能会疑惑,为什么我的选择:
+
+- 不是github star最高的项目
+
+- 不是单独的项目,而是多个开源产品共同支持
+
+- 怎么简单使用
+
+
+
+登录问题,可以使用`SSO`进行统一认证鉴权
+
+集成问题: `webhook`是应用之间自动化的桥梁
+
+安全问题: 分布式协同,使用 NAT打洞,`P2P组网`
+
+本篇所提及的项目,请参考 出海导航: `nomadro.com`
+
+同样,你只需要买个两百多的开发板,就可以部署所有了
+
+
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (14)
+
+# 数字游民的一天
+
+> A Day in the Life of a Digital Nomad
+
+前言:这些年,后天私信经常收到很多大学生针对`数字游民`做`市场调研报告`/`毕业论文`
+
+要求进行采访 (承诺隐私保密)
+
+另外很多朋友有业务,缺乏技术支持,而另一些朋友,懂代码,但对营销/市场需求/盈利方式一无所知
+
+所以本篇尽量以`普通人一天的视角`,不扯什么思维框架/技术脚本等等
+
+
+
+> `早上`查看数据
+
+- Tiktok直播礼物打赏
+
+- Youtube Ypp收益/ 观看时长(小时)
+
+- web: saas订单
+
+- 金融投资: meme币收益/美股涨幅
+
+- 其他: X收益
+
+为什么是早上看收益,因为几乎所有的业务,都是在工作室(Homelab)自动化
+
+晚上睡觉的时候,对应欧美的白天 (英文受众)
+
+而且实时盯盘 (在线人数,互动弹幕),数据有起伏,反而容易让人有情绪波动
+
+> `中午`调试硬件/脚本
+
+
+
+- 无人直播 (云手机)
+
+- 工作流脚本:视频剪辑(ffmpeg+AI)
+
+- web界面交互优化/增加功能
+
+自动化不可能一劳永逸的,但是有人喜欢在标题用`永久`这个词的,基本是个下贱的标题党
+
+平台的推荐算法,UI样式,风控逻辑在变化,编程语言在升级,操作系统都在更新
+
+如果遇到`卡点`,就需要针对硬件/软件方案,进行修复维护
+
+> 下午: 户外商单/沙龙
+
+- 探店商单: AI眼镜拍摄,新奇体验
+
+- 周末: 同城沙龙/参团旅行
+
+不可能一直闷在房间里,这不是想要的生活
+
+`认识有趣的人,真实享受当下的美好`,明天和意外,不知道谁先来
+
+`在路上,闲暇时间`可以实时远程办公
+
+
+
+> 晚上
+
+- 远程团队:看板任务
+
+- 社群:咨询消息/教程更新
+
+- 写一篇公开长文 (数字游民系列)
+
+- 剪辑视频 (Youtube长视频)
+
+
+
+---
+
+## RPA 自动化
+
+> Tiktok直播
+
+Tiktok至少准备2个号直播,一个`颜值`,一个`互动`玩法
+
+它的盈利逻辑是`提供情绪价值,礼物打赏`
+
+
+
+> Youtube live
+
+Youtube准备2个频道,一个有ypp获利资格,另一个普通频道
+
+- ypp频道,直播保底`广告收益10$/天`
+
+- 普通频道:`积累观看时长` (达标4000小时,可申请ypp获利)
+
+Youtube live适合稳定的矩阵,而Tiktok的爆发力更强
+
+
+
+> X自动发帖 (图文)
+
+马斯克打钱,现在互联网喊话要超过Youtube收益
+
+但是它有些悖论
+
+- `要赚钱,先付钱`成为Premium
+
+所以更建议自动化: RSS feed =>新闻=>图文贴(定时)
+
+X 的api免费额度,每月最多500,每天发贴<15次
+
+
+
+> `加密`投资和`美股`涨幅
+
+这个由于比较敏感,加上投资的门槛挺高 (不是杠杆玩法)
+
+所以前面的数字游民系列没有写
+
+但是可以关注下meme币,`百倍不是神话`,亲历,比如 (pepe,仅供参考)
+
+自动化方式,可以参考`量化交易`脚本
+
+
+
+> web saas
+
+saas的功能有很多,比如:`数据可视化,工具箱,AIGC`
+
+俺借鉴了nomadlist,偏向于数字游民的`数据可视化和社群`
+
+后台私信: `加群`
+
+- 海外收款可以使用`stripe和加密网关`
+
+- 国内也有个人支付:微信和支付宝 (参考XXX)
+
+也可访问出海导航: `nomadro.com`
+
+
+
+---
+
+## 半自动化
+
+> Youtube长视频的`原创`
+
+
+
+剪辑过程,可以自动化工作流,但是内容很难,`想要天花板高,就不能搬运或者AIGC`
+
+所以我保留了 `60min=>8min=>1min`的策略
+
+也就是看中感兴趣的探店商单,就会约同伴去体验
+
+- 通勤,会被`AI眼镜`拍摄=>60min=>刷量平台(SMM panel)=>刷观看时长
+
+- 店内体验: `vlog相机`拍摄视频,8min=>美食/景点
+
+- 随手拍: 手机竖屏=>1min=>shorts短视频
+
+沙龙也好,周末旅行也罢,记录当下的美好
+
+
+
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (15)
+
+# 产品化
+
+低成本工作室,我一直在强调开源硬件(开发板)
+
+因为它足够便宜,二百多就可以买到4gb/2hz的配置,可以安装各种系统:安卓/linux服务器/软路由等等
+
+而且arm芯片架构,对安卓的虚拟化支持友好,便于做成集群,进行群控/远控
+
+但是,`产品化`试什么意思?
+
+下图是含有`商业数码`的示例图,后续都会以开发板展示搭建原理
+
+
+
+---
+
+## Tiktok无人直播 (HDMI)
+
+先看图,tiktok的无人直播套装
+
+
+
+其实这个套装,用到了3个开发板,安装了不同的操作系统
+
+- A: 软路由:提供科学的网络
+
+- B: linux服务器,python+ffmpeg实时转播脚本 (HDMI输出)
+
+- C 原生aosp安装, Tiktok直播 (采集卡输入视频流)
+
+很复杂对不对, 光是网线,usb,电源,都需要一堆,插错了,就很麻烦
+
+但是如果有铝合金外壳,最外层只需要`通电/1根网线`,是不是就方便很多了?
+
+
+
+这会涉及到`3D打印外壳`,和`PCB设计`
+
+当然最简单的是,`串联模块+公模外壳`即可
+
+可代部署,后台私信: `加群`
+
+---
+
+## Youtube live (Redroid)
+
+> 推荐普通人(小白),先入手Youutbe
+
+先看图
+
+
+
+tiktok直播需要3个开发板完成的事情,youtube只需要1个开发板
+
+因为Youtube live支持 `网页studio后台面板`
+
+Tiktok app需要从硬件获取视频流,但是Youtube只需要接受软件直播流就可以 (推流脚本)
+
+比如图里的 `Redroid (docker虚拟安卓)`,安装chrome浏览器,油猴外挂实现网页直播自动化
+
+但是它也存在一个小问题
+
+- 系统内核/GPU驱动不一定支持
+
+所以接着往下看
+
+---
+
+## 通用解决方案 (Docker-chrome)
+
+在实际的测试中, 你会发现无数的深坑问题
+
+比如,
+
+- aoap安卓系统,没有谷歌套件,也不支持usb摄像头,连Root权限都不开放
+
+- linux系统:armbin没有对该款做适配 (内核/GPU等驱动不支持虚拟化安卓redroid)
+
+- 市场价格贵(内存/芯片涨价离谱),有价无货 (电商平台缺乏供应)
+
+这些问题,都极大的限制了业务的扩展,无论是`价格/性能/还是厂商定制`,都费心力
+
+那应该怎么办?
+
+- 核心杠杆业务: Youtube无人直播
+
+- 通用linux兼容方案: ubuntu系统
+
+如下图所示
+
+
+
+核心解决三个问题
+
+- 网络: shellclash
+
+- 转播: python+ffmpeg
+
+- 浏览器: docker-chrome
+
+这些都是一个linux`原生支持`的,而且对arm芯片的开发板也`兼容`
+
+详细教程,后台私信: `加群`
+
+---
+
+## 最后
+
+> 为什么树莓派是硬通货?
+
+
+
+如果我把开发板,换成固定的`树莓派(Raspberry pi)`,你会发现很多问题基本不存在
+
+因为无论是AOSP安卓/Redroid虚拟安卓/还是armbian适配
+
+操作系统和固件/脚本,都会第一时间更新兼容
+
+你所在的行业/业务,`几乎都能找到解决方案`,很多还是PI独有的,比如矿场(加密挖矿)的管理系统
+
+但是它偏贵,同样性能,价格可能翻倍
+
+所以,小白新手用来入门学习,是极其推荐的,一旦你开始经营业务,就可以选择和俺一样,做垃圾佬,找最低成本的解决方案,然后集群化
+
+同样,如果不选树莓派,你就买旗舰的开发板(RK3588芯片),也有很多公开的解决方案/业界方案
+
+但是价格,就比较贵,见仁见智啦
+
+> 业务为先
+
+有业务支撑,只是缺乏技术,建议买旗舰配置,省心
+
+没有业务,只是出海,做无人直播尝鲜,建议低成本折腾
+
+从宏观的生活角度来看,无论是创业/副业
+
+- 有一个通用的低成本兜底方案,才是不会焦虑的数字游民
+
+你说呢?
+
+下一篇预告: 移动办公:一部手机走天下
+
+已经写了几万字,可在`公众号--菜单-数字游民`阅读电子书
+
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (16)
+
+# 一部手机走天下
+
+前面的数字游民系列,阐述了RPA自动化和产品化
+
+但`旅行更多时候都是在路上`,无论是笔记本还是Pad,都没有手机方便
+
+
+
+本篇强调3个你无法拒绝的app
+
+- 自研app (react native)
+
+- Traccar (实时定位gps)
+
+- backCam (后台录像机)
+
+从时间空间上的效率进行提升
+
+---
+
+## App远控
+
+无论是tiktok直播,还是Youtube live,即使硬件不同,自动化脚本不通过
+
+都可以通过web控制频道`开播/停播`
+
+并且实时查看各个账号的在线人数,弹幕等数据
+
+APP其实是把web套壳后,方便手机端直接安装 (使用`react native`开发)
+
+不懂代码,也没关系,AI秒生成
+
+
+
+额,目前是自己在使用,未开放
+
+有兴趣的朋友,可以后台私信: `加群`
+
+---
+
+## Traccar定位
+
+之前提到过vps服务器, 你只需要花几分钟部署好Traccar server服务
+
+然后在Traccar app,填写账号密码
+
+它就会在gps定位变化时,手机实时上报经纬度,你便可以
+
+- 共享给朋友Url查看`实时定位`
+
+- 每天都有查看`轨迹回放`/存档
+
+- 设置`电子围栏`警报
+
+
+
+它能后台无时无刻保证你的安全,而且省电,只有gps变化才会激活
+
+---
+
+## BackCam 取证摄像机
+
+
+
+Backcam和赚美金其实没啥关系,它是用于录像/录音取证的
+
+如果你在旅途中遇到了不公平的事
+
+当你提前点击伪装的app图标时,它可以在`后台静默录像+录音`
+
+关键是:它不会有任何提示,也不影响你操作任何app,比如微信聊天/语音/视频
+
+意味着,对方根本不知道在被取证
+
+可能有人问,为什么不用民间执法记录仪:大疆运营相机?
+
+运动相机/拇指相机/无人机/以及AI拍摄眼镜,我都有,但想要一个人露出本色破绽
+
+必须是手机才没有防备
+
+darkweb还有很多直接把摄像头硬改成侧面小孔的,水平放在桌面拍摄,防不胜防
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (17)
+
+# 电商独立站
+
+本篇不是`跨境电商`的`独立站`概念,所以不会讨论`shopify`或者`woocommerce`自建电商网站
+
+这里讲的广义上的电商卖货:`实体物品`+`虚拟发卡`
+
+
+
+在经营业务时,永远有2个痛点,以工作室为例
+
+- 1.运营设备
+
+- 2.矩阵账号
+
+设备包含了很多东西
+
+- 网络: 光猫,硬路由,软路由,交换机,网线,POE供电,无线网桥等
+
+- 操作系统: 云桌面,云手机,云服务器,NAS存储
+
+- 智能家居: 插座,风扇,温湿度传感器,中控面板,摄像头
+
+- 配件: 视频采集卡,各种线材:hdmi/双头usb,读卡器,ssd固态硬盘/TF 卡
+
+矩阵账号相关
+
+- 基础注册资料: esim美国手机号,google账号,appleid美区
+
+- 运营账号: youtube(YPP账号),Tiktok(中视频/直播/店铺)号
+
+- ip/线路: 普通平台,自建线路,静态住宅ip
+
+但是业务依赖核心只有3个:
+
+- 1.云手机
+
+- 2.软路由
+
+- 3.ypp油管账号/Tiktok(中视频/直播)账号
+
+拥有它们,意味着,你可以一夜之间开始赚美金
+
+不信?上传几个shorts短视频,2天后收益数据就出来,就算是0.x美金
+
+也比吭哧吭哧做国内平台强几百倍
+
+---
+
+## 独角数卡
+
+
+
+独角数卡,是一个开源的自建电商方案
+
+接入支付网关后,就可以轻松让用户下单购买
+
+如果是虚拟物品,可以实现`自动发货`,也就是`网盘链接`或者`卡密`,俗称发卡
+
+除了`硬件/虚拟`也可以包括`服务`,设置`人工处理`,就可以了,手动更新进度
+
+在出海导航: `nomadro.com`里, `刷粉刷量平台`,就是经营社交媒体`账号买卖,增粉互动`业务
+
+> 管理后台
+
+
+
+之前一直强调: `把大象塞进冰箱`
+
+实现一个独角数卡电商网站,也是非常简答,
+
+- 1.注册域名(网址),买vps服务器
+
+- 2.一键脚本:docker部署独角数卡
+
+- 3.接入支付: 提交实名资料(把`key/密钥`填后台填写,就ok了)
+
+全程不需要写代码
+
+---
+
+## 支付网关
+
+
+
+ 个人在国内不需要注册公司!维护成本太高!责任太大!
+
+强制缴纳社保(>1),强制实缴注册资本,强制记账报税,强制办公地址(红本合同)
+
+开个公司,成本最低1w/年,不设上限,注册麻烦
+
+而申请`小微商户`,微信/支付宝审核
+
+大多公司只收取开户费,后期成本只是每笔交易的x%的手续费,资金直接从腾讯/阿里结算,没有中间`过桥`
+
+> usdt
+
+如果你不想赚人民币,那就搭建一个开源的usdt收款网关
+
+收取加密货币就好了
+
+科学上网之后,大多数网站和telegram群组,都是这样收款的
+
+ps: 抖音/小红书/淘宝开店时,一旦开通微信支付收款,它们会为你悄悄开通微信小商户
+
+你可以在小程序: 微信支付商家助手,查询自己有几个?
+
+---
+
+## 卖什么最值得?
+
+`不要卖硬件`,重资产,涉及仓储,销售和物流,退货各种繁琐流程
+
+`不要卖服务`,被动收入的意思是实现自动化,至少要把时间效率杠杆化
+
+`不要卖二类资产`,哪怕是虚拟资产,也不要随便当二道贩子,被平台/政策所左右
+
+
+
+一定要是`虚拟数字资产`,可以实现`自动发货(无人值守)`
+
+比如`软件脚本`,比如`ypp账号`,比如`视频教程`
+
+它们符合`可创建`,`可增值`,`可批量`的属性,有产品属性
+
+当然,具体的业务,可以结合自己的工作/兴趣来思考,`人生不要给自己设限`
+
+硬件这玩意只是群友的赠送福利,按收益的价值换算,于我是不值得的
+
+有兴趣的朋友,后台私信:`加群`
+
+至于`ypp油管账号`,目前的业务矩阵,暂时还没有多余,但后期会偶尔上线
+
+尽可能将你的业务实现自动化,无论它属于`直播/社媒/电商/开发/金融`还是其他
+
+
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (18)
+
+# Youtube无人直播2
+
+下图是定制系统os的图片
+
+
+
+之前提到过,`Youtube无人直播的成本是最低`的,只需要`两百多`rnb的开发板(4GB内存)
+
+使用3个核心程序:`shellcrash/srs/neko`,就可以实现无人直播
+
+- shellcrash: 科学上网的火箭 (注意:没有燃料,下文会提示加油策略)
+
+- srs: 直播服务器 (让视频素材/直播流,推送到 油管直播)
+
+- neko: chrome浏览器 -虚拟化 (可开启油猴脚本外挂)
+
+当然,除了这些其实还有很多基础设置,比如python/node代码环境,docker容器,程序配置
+
+不懂代码没关系,`跟着教程部署就行,或者自助下单拿设备`,后台私信: `加群`
+
+
+
+---
+
+## 硬件使用教程
+
+开发板收到后, `通电通网`后,直接浏览器访问 `ip:8000`,就能看到web控制台
+
+内容可以是`usb实时拍摄/录播素材/直播间转播`
+
+也可以接受`OBS软件`的推流,可以有更强大的`AIGC`功能辅助 (显卡)
+
+ps: 需要AI的,建议投入到Tiktok直播,下篇会与赘述
+
+
+
+直接控制开播,关播
+
+也不必担心停播后,Youtube studio网页的后台的响应,因为油猴脚本设置了循环监听
+
+一旦没有直播流,就会自动处理弹窗
+
+
+
+---
+
+## 优雅而科学的上网冲浪
+
+
+
+> shellcrash
+
+一个轻量的脚本软件
+
+可以让整个操作系统拥有科学上网的环境,但是它本身没有燃料
+
+燃料需要你自己`购买或者提练`,从直播业务的角度来说,更建议后者
+
+这块属于`红线`,不会有保姆级的教程
+
+但在群里,有`提供关键词和推荐的平台`,可以让你不走弯路,后台私信: `加群`
+
+购买,只是让你`快速测试`,提炼燃料才能确保不会因为动力不够导致坠机
+
+> 软路由
+
+当你从新手,迈入工作室,批量化时,就可以准备`软路由`了,它是独立硬件
+
+同样,几百块的开发板,安装openwrt开源系统
+
+安装定制的猫咪插件,就能从底层伪装了
+
+> 链式代理 (静态住宅ip)
+
+如果你想做业务, 科学上网,只是旧时代的门槛
+
+`没有静态住宅ip,还不如洗洗睡吧,别做什么赚美金的春秋大梦`
+
+ip直接购买,填写到shellcrash 或者软路由配置就好了
+
+这块你自己无法提炼,除非肉身到漂亮国
+
+推荐的平台,可以参考出海导航: `nomadro.com`
+
+---
+
+## 地理套利
+
+
+
+出海的意义是什么?是赚美金
+
+那,还有吗?是地理套利 (Geographic arbitrage)!
+
+试想一下,马上就是中国的农历新年,这段时间,几乎所有的实体门店都要停摆
+
+交通工具,快递物流,商场超市都暂停业务,普通人想要经营国内业务,几乎不可能
+
+而你,bro, 正因为你在经营海外Youtube频道
+
+你随身佩戴AI眼镜,拍摄新年各种趣事,赶集买年货,婚宴酒席,烟花礼炮,走亲访友等等
+
+它不仅是生活vlog,更是几千年的传统文化,在你眼里平平无奇的场景,是世界另一个角落新奇惊讶的看点
+
+一个8min的长视频爆了,就可以赚到几个月甚至几年的的牛马工资
+
+一个好的视频,不会类似国内的垃圾平台转瞬即逝 ,它会在接下来的岁月,持续给你推荐带来收益
+
+还有那廉价的开发板,静静的躺在家里最不起眼的角落里,24小时无人直播
+
+每天早上醒来就能看到数据和收益
+
+---
+
+## 最后
+
+
+
+把为数不多的时间里,把金钱/精力/时间,投资在出海副业上
+
+参考数字游民系列文章,`几天就可以走完我几年的弯路`,过年多陪陪父母
+
+`不要给你身边的bitch发红包/转账`, look my eyes: 520/1314 可以搭建几个无人直播矩阵?
+
+搞钱才是一个西格玛男人的正事,某些人只会影响你拔刀的速度
+
+如果你是`小白,建议先做Youtube无人直播,低成本副业`,然后才是Tiktok无人直播
+
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (19)
+
+# Tiktok无人直播2
+
+
+
+如果你是小白,且想要稳定,做Youtube无人直播
+
+如果你有经验,`想要极速盈利,做Tiktok无人直播`
+
+因为,`Tiktok才是专业的直播平台`,有`完美的商业生态`
+
+---
+
+## 硬件升级
+
+
+
+Youtube无人直播,只需要1个硬件
+
+但是Tiktok无人直播,为什么一定需要一个`辅助板`呢
+
+数字游民系列,前篇提到很多从核心原理:`A(视频素材)=>HDMI=>B (安卓)`
+
+简单来说,就是`让HDMI视频源,被安卓识别为系统摄像头`,任意改变内容,实现自动化
+
+> 配件
+
+
+
+原先自用的品牌的HDMI线(1m长)+视频采集卡,但是最近很多人下单后
+
+发现`布线`是一个非常头痛的问题
+
+因为用户需要
+
+- 3个网口+3个typeC电源+显卡欺骗器+hdmi+采集卡
+
+- 双头usb
+
+所以为了让后面一批用户收到硬件时,只需
+
+- `插1根网线/1个typeC供电`
+
+就在测试`模块化`的配件,不是有外壳商标的数码,降低用户心智,也降低成本
+
+ps: 不要对比真正的`商业云手机平台`或者`主板机`/`XX藤`硬件
+
+`开源硬件,并不是商业产品,没有团队,是群友的代部署福利`
+
+有兴趣的,后台私信: `加群`
+
+个人更希望用户按照教程,自己部署使用
+
+---
+
+## 业务组合
+
+
+
+从技术来说,同时实现2个业务,是可行的
+
+- 辅助板,shellclash+srs+neko=>单独实现Youtube无人直播
+
+- 辅助板=>HDMI接口=>安卓板,Tiktok无人直播
+
+如果把shellclash给 DHCP给安卓供网,软路由(第3个硬件),也可以省掉 (待测试)
+
+但是这对`系统配置,和程序进程的自动化控制`,要求就会高很多
+
+这里只对懂技术的朋友提供思路,也是目前俺正在做的事
+
+---
+
+## 最后
+
+
+
+如果你看完了数字游民1-19,就知道核心业务一直只有3个
+
+- Tiktok直播/Youtube自媒体/Web (saas+web3)
+
+而`直播`,又是业务核心的核心
+
+当你人生迷茫的时候,不要犹豫,研究直播生态,软件/硬件/上下游
+
+它一定是当下`变现最快`的
+
+可参考出海导航: `nomadro.com` ,会持续更新
+
+
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (20)
+
+# 如何成为一个数字游民?
+
+
+
+数字游民1-20,每十篇文章,总和就是1w (1k*10篇)字的小册/电子书(ebook)
+
+所以一旦序号,能被十整除的时候,是时候写个小结了📗
+
+其实1-10,核心就是阐述业务`无人直播`,从技术的视角,这会让很多不懂代码的新手小白有点蒙圈
+
+后面2-20,把描述的重点偏移,向内:`低成本工作室`搭建,朝外:`旅居游玩,远程办公`
+
+为了简单易读,特意写了一篇 [数字游民的一天](https://mp.weixin.qq.com/s/iocFeJXw-e6YPZB40wj_dw),从早/中/晚的时间轴,线性叙事
+
+还有很多技术类的主题没空写,比如`边缘计算`,比如`AIGC`
+
+这是当下时代的杠杆,是普通人最值得投资和学习的技能
+
+`授人以鱼不如授人以渔`,本期从生活往昔的记忆,分享我是如何成为数字游民的
+
+---
+
+## 读万卷书
+
+> 买纸质书,花了好多万,你信吗?
+
+
+
+家里的纸质书,用搬家的大纸箱,装满了10箱,你猜花了多少万?
+
+这要追溯到十多年前了
+
+周末的时候,在出租屋里不想出门,但是学习了中科大,罗邵峰(中国)老师的<<信息管理与文献分析>>
+
+这门课最初在网易云课堂,后来上架到Mooc
+
+其中的`wiki笔记` `思维导图`,与我个人,影响深远,也包括当时的`RSS阅读与信息检索术`
+
+当然,这个老师,没有教唆我多买书,而是在知识管理进行启蒙后,按遇到了另一个良师: `胜间和代` (日本)
+
+当时在豆瓣搜索书单,关于学习策略的,就有她撰写的几本书,比如:`效率提升10倍的google化知性生产技巧`
+
+
+
+用Ipad找到网上的pdf版本,一口气看了四本,要知道,当俺阅读的时候,它已经出版了十年
+
+她提到在`思维框架力`的基础上,要`投资读书`,以及`购买最好的数码设备`
+
+周末,就去深圳的`中心书城`,选书,买书,实体能免费试读,避免不良资产
+
+再之后,又读了`吉姆·罗杰斯(Jim Rogers-美国)` 的 `旅行,人生最有价值的投资`,把现实的旅游与国际金融结合起来, 两层投资
+
+直到后来,就发现一个特点,中文书几本都是空洞的废话,译文书相对硬核,专业术语就可见一斑
+
+最后,虽然英语不好,从kindle就开始渐入美国亚马逊图书平台,用工具翻译阅读
+
+中文`数字游民`,对应的就是`digita nomad`
+
+看到无数人分享如何经营自己的业务:电商/独立开发/其他`利基`,开始房车/旅居~
+
+
+
+---
+
+## 行万里路
+
+> 边旅行,边赚钱,听起来多爽呢
+
+在刚毕业的时候,那个时候人生没有规划,就沿着火车高铁+飞机,游览了小半个中国
+
+就是纯粹的`特种兵式`的走马观花
+
+后来工作之余就`有目的`的`去北京,参加网安沙龙`(下图),顺便爬长城看故宫,去香港,买免税商品,办港卡,去泰国,见识真正的数字游民城市
+
+
+
+山山水水,吸引力并不是那么大了
+
+旅行的成本并不高,如果时间充足,绿皮火车/民宿甚至网吧包间通宵,都能把消费降到最低
+
+最重要的是,`在路上`
+
+当一个人在陌生的城市的时候,就会遇到各种意外情况,要学会充分的准备和应急预案
+
+`只有未知,才会了解新奇的事物,才会认识有趣的人`
+
+每个月,我都在旅居的城市,租一个`loft公寓`,举办`沙龙`,仅限群友
+
+有兴趣的小伙伴,在后台私信: `加群`
+
+多年前一场`海岸城饭局`,印象还是很深刻的,故事如下:
+
+博主`小不点`,策划每周认识不同的人,而我以程序猿的职业身份赴约
+
+同行的还有其他小伙伴:
+
+- 周游数十列国,最后留学深造的少女:子晴
+
+- 毕业穷游,在委内瑞拉换汇比特币,成为百万富翁:晓晟
+
+- 一篇深度长文:kindle指南,当晚知乎百万阅读的知识大使:直树桑
+
+- 从事室内设计:美媚
+
+也只有`弱关系,才会有不一样的人生`
+
+好多年前的`深跑团`马拉松 (下图)
+
+
+
+> 旅行本身是商业生态
+
+互联网的爆炸,导致信息的泛滥,很多时候亲身体验带来的价值巨大
+
+`轻商业`是文旅,不多赘述
+
+`重商业`是各个城市的产业链,参考` 阿里巴巴-产业地图`
+
+
+
+单就拿`深圳`单个城市来说,你去旅游,除了山水田园,还有什么呢?
+
+- 福田:华强北,世界最大的电子集散地
+
+- 横岗:眼镜城,AI眼镜又引来一波产业复苏
+
+- 罗湖:水贝,全国最大的黄金珠宝交易市场
+
+- 南山:科技园,IT佬遍地
+
+- 坂田: 跨境电商
+
+当你以商业的角度,看待城市的时候,就会产生非常多的业务
+
+早期,华强北的背包客,香港代购,水贝买手/定制,赚的盆满钵满
+
+而你,不知道,听说过,没去过
+
+> 城市/国际
+
+如果当初没有疫情,我会计划去`菲律宾游学`几个月,拿下`雅思`,`进修/旅行/赚钱`既要都要
+
+当你看到以上的安排的时候,你应该要思考: 为什么是菲律宾?
+
+它不是`菲佣`出口大国吗,不是跟印度比肩的`世界话务客服中心`吗
+
+为什么在英语殖民历史的文化,有英语第二语言
+
+却不能赚美金,很多人都挣扎在贫困线之下,朝不保夕呢?
+
+这就涉及到了`地缘政治`,复杂的国际关系博弈了
+
+所以,旅行,一直都能`地理套利`
+
+---
+
+## 最后
+
+
+
+你看,俺这个人伤春悲秋写了一堆自己的岁月破事
+
+还装老成的说:`读万卷书/行万里路`,这种废话
+
+大道至简,分析一下标题:
+
+- `数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金`
+
+已经把所有`关键词`,浓缩在文章标题里了
+
+甚至不用展开去解释
+
+它就是俺的前半生,关于`一人公司`的总结
+
+如果只能有2个字,就是:`直播`,你会发现它既能`伴生所有业务`作为副产品,又能`融合所以商业矩阵`
+
+方向比努力更重要
+
+俺现在的业务,几乎都是自动化,除了深度长文和youtube长视频,是半自动化,这个本源无解
+
+可参考出海导航: `nomadro.com`
+
+最新的无人直播教程,今晚更新一波,上传i`mg镜像`,`插电插网,即可使用`
+
+
+
+
+
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (21)
+
+# 数字游民社群
+
+
+
+`nomadlist`是海外最大的数字游民社区
+
+- 🌍 各国生活/成本/安全/网络 **数据可视化**
+
+- 👥 线上社群 + 线下活动
+
+- 💘 约会 / 社交 / 同城连接
+
+- 🧮 报告 / 计算器 / 工具页
+
+这是会员的后台界面
+
+
+
+整个网站用的是二十年前古老的技术,比如前端jquery,后端php,数据库sqlite
+
+实际它目前`每个月收入 $25K/月,换算成人民币约等于17万/月`
+
+最便宜的美国vps服务器,只要10$/年,非并发的情况下,轻松承受1k用户的使用
+
+基于`一千个粉丝理论`
+
+网站saas,是一门非常好的业务, 按月付费,哪怕10元/月,就足以月入过万
+
+创始人`levelsio`把自己的创业故事写成了<>电子书,有兴趣的朋友,可以下载阅读
+
+后台私信: `加群`
+
+---
+
+## 国内社区
+
+
+
+海外与国内的数字游民文化的一些区别`
+
+nomadlist网站的设计思想非常轻,都是基于`数据`和`社交`,方便海外的游民,旅居到不同的国家时,解决信息差和人际交往的问题
+
+但是国内的数字游民不一样,由于英语不是母语,而且护照支持的免签国家都是什么尿性,各位可以自己搜索下,实现自由职业赚美金,会各种卡脖子(科学网络,支付收款等),导致大部分人,首先要解决的问题是:`赚钱的信息差`,实现从0到1
+
+先摆脱内卷畸形的996文化,开始副业/自由职业,才是国内数字游民的第一步
+
+而中文平台商业生态,独立接外包,要么极其廉价,要么牺牲隐私为代价(网暴,开盒)
+
+当下,最佳的生存策略是`出海赚美金`
+
+国内有没有专业运营的社区呢? 有的,兄弟有的,但是在个人眼里,大部分等同于`二房东/伪中介`套壳,实在觉得可耻,文青味太重了,盈利来源于`教你成为数字游民`本身,shit
+
+如果你有兴趣做saas网站,我个人建议`极简技术栈:nextjs+superbase`,开源免费且强大
+
+无论是海外,还是国内,我的很多mvp,都是`一夜上线网站`,一周优化
+
+这是后台数据库
+
+
+
+还记得本文最初说的:一千个粉丝理论吗?
+
+这是国内网站运营的`后台支付网关截图`
+
+
+
+---
+
+## 异度星球
+
+
+
+公众号名为:`异度世界`, 关联的社群是:`异度星球`
+
+为什么,我要做付费社群,先抛出一个最直白的问题:
+
+`你也是割韭菜的吧,嗯哼!?`
+
+当你看到这里的时候,数字游民系列,发表在微信公众号和出海导航:`nomadro.com`,累计字数已达到2w,俺在公开的内容里,不仅详细的列出了`开源硬件型号,软件技术栈,以及各种运营方法论`,具体到`技术原理/成本,最佳策略`,但凡有`技术基础`的人,都能自己动手,在一周内践行多年踩坑获得的经验,只不过,具体的部署教程/配置参数没有公开
+
+- 一是`敏感`,比如科学上网姿势,比如Tiktok认证资料
+
+- 二是`门槛`,拒绝白嫖,收费才能验证价值
+
+- 三是`副产品`,主业出海,并不影响国内`次要盈利`
+
+- 四是`异业合作`,任凭如何吹嘘,,总有人level站在山巅俯视,`相互合作才是王者`
+
+公开的文章里,提到很多词,`地理套利,杠杆理论,多元策略,认知盈余`等等
+
+还有一个`金字塔模式`
+
+
+
+很多时候,盈利赚钱,并不是门槛费本身,而是`筛选真正有执行力/认知高的用户`
+
+他们会跟着教程一步一步实践
+
+有非常多的虚拟资产,是`水电气`一样的存在,如果不购买,赚美金绝不可能,比如:`域名/vps服务器/美国手机号`
+
+但有个东西在水电气之上是`究极核心`:`静态住宅ip`
+
+它不仅仅是唯一卡脖子的`超级节点`,也是佣金的金字塔结构
+
+大白话就是,通过你(A)的专属链接注册的用户(B),有注册奖励,每个月续费,都有返利,而用户B推广带来的用户C/D/E/F/G.....也会按阶梯比例,为你(A)最顶端贡献交易返利,
+
+记住:每人/每月/每笔=>阶梯下线=>返利
+
+这是初级的offer(订阅)/affiliate(佣金)远远达不到的:比如`网盘拉新`,只能赚取一次注册奖励
+
+当然金字塔不止这一个,还有很多,后续在群里分享
+
+> 聪明的人只看方向,只要关键词
+
+真正有能力的人,不会苦恼于`保姆级`教程
+
+而是在海量垃圾营销文案/AIGC的信息粪坑里
+
+找到`定位/灯塔`
+
+凡是执着于`大航海学习`/`1v1的陪跑`的.几乎全是大冤种
+
+俺只能是一个`低成本工作室无人直播`的业务`启蒙者`,帮你达到`基础门槛和避免陷进`,并不是一个悉心教导的老师,因为自己的矩阵都忙不过来,承诺教你赚到钱的,都是骗子
+
+---
+
+## 私域运营
+
+> 个人ip类型的数字游民私域社群
+
+
+
+微信群和telegram群,都曾经创建过,但是数字游民的出海业务,本身就与国内环境相悖
+
+很容易出现`敏感词,被喝茶`
+
+老粉都知道,有阵子一直在跑`微信机器人`,陆续加爆了几个微信号,消息轰炸难以承受
+
+所以最终采取了`自建memos`社区的web方案,内容和权限都自主把控
+
+㊙️私密社群
+
+
+
+🌟你将收获:
+
+🚀 业务方向:Tiktok无人直播、Youtube自媒体,web开发
+
+📖 解锁=>电子书+视频教程
+
+1️⃣[《数字游民》:地理套利 + 自动化杠杆](https://hackrobot.cn)
+
+2️⃣ [《云手机》:低成本工作室搭建指南](https://android.hackrobot.cn)
+
+
+
+📱设备自助下单(开箱即用)
+
+
+
+📚 即时问答 (技术咨询)
+
+
+
+📍线下沙龙-报名
+
+每月不同城市/深圳(每周)
+
+
+
+> 长按二维码,加群
+
+
+
+
+
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (22)
+
+# 边缘计算
+
+算力时代,各种贵金属原料和商业数码都在疯狂涨价,因为AI是超级杠杆,是技术奇点
+
+本篇核心阐述`npu`在无人直播的应用 (不是`GPU显卡`)
+
+两百多人民币的开发板,就有`3TOPS`的算力
+
+可以解决无人直播中2个核心`风控`问题
+
+- 推荐极致/黄色暴力
+
+- 音频版权
+
+常在河边走,哪有不湿鞋,见下图
+
+
+
+无人直播,真的是就内容"无人"吗,显然不是,说的是无人值守
+
+由于是几十个直播间,轮询开播状态,然后实时转播
+
+就不可避免会遇到各种场景,有的主播会突然进行pk状态,有的会偶尔用音响放音乐
+
+这就会导致Youtube根据声纹,识别到音乐版权,进行警告
+
+多次触发后,就会进行判罚
+
+- 第1次,禁止该频道7天不能发布,频道降权
+
+- 第2次,禁止该频道14天不能发布,频道降权
+
+- 第3次,名下所有频道封禁,撤销Ypp获利资格
+
+所以,如果达到第3次封禁,那损失不可估量,于是就必须采取以下解决措施
+
+---
+
+## 机器视觉Yolov5
+
+廉价的开发板,除了CPU,还有3tops的NPU,物尽其用
+
+虽然它不能进行AIGC创造内容,但是可以检测内容
+
+
+
+比如,开播时
+
+- 对主播进行`人脸识别`,商品是被
+
+- 每十分钟对画面进行`黄暴检测`
+
+- 同时在线人数达到100时,对画面进行分析
+
+检测的目的,是为了研究平台的推荐机制和风控处理
+
+其实目前黄暴对于转播意义不大,因为国内平台天然有过滤
+
+但是有些场景会用得到
+
+---
+
+## 音频声纹
+
+背景音乐,一旦检测到版权,进行封禁的后果是非常可怕的
+
+所以在ffmpeg的实时流,就要对音频重新处理
+
+
+
+比如`RNNoise降噪`,人生增强,音速变化,音色调整
+
+ps: RNNoise降噪,需要下载cb.rnn`模型文件`
+
+虽然不能百分百避免音乐版权检测,但是降低概率很重要
+
+```
+"-af",
+"arnndn=m=./arnndn-models/cb.rnnn:mix=0.88," # 人声增强
+"afftdn=nf=-24:tn=1," # FFT 降噪,抹平音乐中频
+"highpass=f=110,lowpass=f=4800," # 切掉低音鼓/高频镲
+"equalizer=f=250:width_type=o:width=2:g=-9," # 衰减低中频,音乐基础弱化
+"equalizer=f=800:width_type=o:width=1.5:g=-6," # 再衰减中频,人声外区域
+"atempo=1.03," # 轻微加速 3%,节奏轻微错位
+"asetrate=48000*1.02,aresample=48000," # 音高上移 ~1/2 半音
+"afreqshift=shift=20," # 整体频率偏移 20Hz,破坏指纹
+"acompressor=threshold=-28dB:ratio=5:attack=8:release=80:makeup=5," # 压缩动态,音乐更“扁”
+"volume=1.15," # 补偿整体音量
+"aresample=async=1:first_pts=0" # 保证音视频同步
+```
+
+---
+
+## 语音转录和文字识别
+
+> deepspeech2 和ocr
+
+
+
+除了对画面的分析,也可以对音频实时识别,以及文字识别
+
+然后进行自然语言NLP分析,和转义
+
+简单来说,就是不可能一直盯着频道,而转成文字分析后,就能得到`内容纪要`
+
+可以对直播的内容进行`存档`
+
+---
+
+## 最后
+
+
+
+现在端侧的`嵌入式设备`和`可穿戴设备`,边缘算力都很强大了
+
+最明显的趋势,就是`AI glass(智能眼镜)`
+
+既可以`实时直播,拍摄视频,也能语音对话/转录`,妥妥的生产力工具
+
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (23)
+
+# 金字塔模型
+
+`被动收入`:`最简单/0成本/天花板高`的就是拥有金字塔模型的`平台佣金`
+
+
+
+把`轻业务`分3类:广告(ads),佣金(affiliate),订阅(offer)
+
+广告,是量变引起质变,而且不是旱涝保收,要持续更新维护内容,比如google 的adsense和admob,就能在各种网页和app自动化投放广告,有浏览/点击就有收入
+
+佣金,无论是互联网,还是实体,都把重资产和繁琐流程,转嫁给平台,只负责前端引流,比如 vps服务器开通和亚马逊商品的专属`ref链接`,只要用户点击,一个月内`消费过,就有佣金(无关具体产品)`
+
+订阅,基于就是开发的`saas软件`或者硬件产品,`按周期(月/年)缴纳服务费`,比如数字游民网站nomadlist,每月收入是17w人民币(实时),以及国内的网盘/影视会员
+
+从收入模型来看,saas是最佳的,忠诚的用户按月付费,一千个粉丝理论,足以拥有富足盈余的生活,但它的要求也高,要懂技术,懂业务,以及营销
+
+而普通人,最适合的就是符合`金字塔模式的佣金`
+
+> 什么是金字塔模型?
+
+
+
+涉及佣金的领域和平台非常多,比如`域名/vps服务器/网盘/电商/金融股票`等等,太多了
+
+但是很多都是`一次性`的
+
+三赢理论:
+
+- 你给A平台拉新 (获取`佣金`)
+
+- A平台增加用户
+
+- A平台为该用户发放`优惠券`
+
+有3个,比较特殊,它不仅是金字塔模型,也是`水电气`刚需,如下:
+
+- 静态住宅ip
+
+- 刷粉刷量平台
+
+- 加密货币交易所
+
+它们是普通人绝佳的选择
+
+不需要投入任何金钱,注册就能拥有ref专属链接地址,把自己实操/踩坑公开,就是最佳的引流策略
+
+金字塔的特征就在于:
+
+- 多层下级
+
+- 交易循环
+
+前面的三赢理论,只符合第一次拉新,就结束了, 但实际`用户后续也会参与拉新和消费`
+
+就比如 A发现平台不错,推荐给B,B注册后又推荐给C,C=>D>E
+
+- B/C/D/E/F的注册/月消费/每笔交易, 都会给A进行一定比例的贡献
+
+所以出海导航 `nomadro.com`,就包含了推荐的平台 (经过实践/正在使用)
+
+
+
+---
+
+## 静态住宅ip
+
+现在已经不是草莽时代,科学上网类似出门骑共享单车,只能让你看看外面风景,但是静态住宅ip就是一辆豪车,你想游玩,不仅可以自由行,陌生国家会更待见你,想经营业务/商务谈判,就必须投资它
+
+说简单点,如果不是静态住宅ip,做社媒就一定不会有流量,会降权,封号,电商购物,会被砍单/拉黑名单,AI平台无法使用/偷偷降智等等,赚美金就是无稽之谈
+
+Tiktok和 youtube运营都需要静态住宅ip,凡是跟你说ip不重要的,不是蠢货就是下贱
+
+
+
+---
+
+## 刷粉刷量平台
+
+是的,`买号最快`,你买一个ypp获利油管账号和TK中视频账号
+
+发作品,有播放量,立马就能看到后台收益统计
+
+但是,买号也是一个高风险行为,因为账号交易涉及ip变更/账户替换,平台是有概率回收获利资格/封禁账号的
+
+最核心的是:普通人很难找到一个靠谱的中介/交易平台,有欺诈和跑路风险
+
+所以刷粉刷量,在同样有风控概下,是一个比较`自主可控/起号快`的方案
+
+`自助下单/按需购买`,控制粉丝的增长/点赞/评论
+
+在这里不批判任何涉及社媒业务的电子农场和服务,只谈策略,存在即有一定道理
+
+
+
+---
+
+## 加密货币交易所
+
+传统银行也好,web3也罢,本质都是金融,赚美金,就一定会涉及`出入金`
+
+也就是消费/提现
+
+而作为一个国内人,必定会被`卡脖子`,拿捏得死死的
+
+拥有一个`U卡`,就是未来世界的通行证, 它既有传统银行的功能,又有web3的创新
+
+- 把法币和加密货币相互转换
+
+- 订阅AI/海淘电商
+
+- 美股投资
+
+- 佣金提现等
+
+加密货币,就是必不可少的一环,激活U卡,就需要 在`交易所`兑换法币
+
+
+
+---
+
+## 最后
+
+
+
+就用`Youtube无人直播`举例,为了实现这个业务,需要
+
+- 注册google账号,开通1个频道
+
+- 在`加密交易所`用法币兑换加密货币,`购买刷粉刷量服务`
+
+- 快速实现: 1000粉丝+4000小时观看时长,申请YPP获利资格
+
+- 购买`静态住宅ip`, 使用ffmpeg推流直播
+
+你必须明白, ip/加密货币/刷量,对于社媒运营,就是水电气一般的存在
+
+要赚美金,就必然从平台购买服务
+
+而赚佣金,就取决于你从谁的ref地址注册平台/消费,以及你选择持续使用什么平台和推荐给别人
+
+金字塔模型是`正反馈系统`,如果推广者耗费心力给你推荐一个垃圾平台,当你知晓有更好的选择,就会决然停止服务,并且对当初的人带有愤恨 (N年前,小白的我,2k/年买了一个香港共享web站点,你敢信,卡成翔?现在10$/年,俺却把它价值杠杆到极致)
+
+> 如何把经验变现?
+
+数字游民游民系列,一直在推荐2个
+
+- 出海导航: `nomadro.com`
+
+- 加群:私密的web社区
+
+导航就包含了广告/佣金/订阅的各种轻资产收入模型,都是`筛选过/正在用`的服务,包含金字塔
+
+公开的文章,已经写了几万字分享:`心路历程/实践策略/方法论`
+
+社群,唯一的好处,就是`加速`, 不必踩坑,比如刷Youtube时长时,上传视频教程,被平台判定不符合推荐要求,于是改成了AI眼镜随时拍摄风景,就复核通过了 (很显然,我可以推荐你刷量的平台,但不一定会告知刷量的细节,比如1.哪个平台效果最佳/2.大部分服务商,要求单个视频>60min时长/3.平台算法鉴定风景有价值,符合ypp审核政策等等)
+
+有兴趣,在公众号后台私信: `加群`
+
+记住: 金字塔模型,才是可持续/高回报的轻资产收入
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (24)
+
+# 副产品
+
+
+
+什么是副产品?前面文章一直提到`认知盈余`的概念,就是精神小伙爱打游戏刷短视频,享受电子鸦片/奶头乐,因为他不知道做什么有价值能挣钱,大学生/职场人士也罢,常常面临着会技术不懂业务,有大把时间,了解业务不会技术的问题,空有一腔奋斗的热血和资本,总有些短板在卡脖子
+
+本文不是提倡通才博学后去创业,实现一人公司,而是在既不躺平也不白费心力的情况下,经营副产品
+
+举个例子,当你在学习部署网站本身的时候,就保持录屏,而不是等你学会了,才精力设计课程,重现场景
+
+> 如何轻松构建副产品?
+
+- AI 眼镜拍摄
+
+- OBS软件录制电脑屏幕
+
+- Fadcam录制手机操作
+
+它们会为你:
+
+- 产生`视频素材`,用于输出分享
+
+- 作为`取证`资料
+
+如何盈利?坚定选择一个领域,做免费分享,赚`金字塔模型`的佣金,或者,`知识付费课程`
+
+这个领域可以是自媒体,是独立开发,是金融理财,等等各种
+
+---
+
+## AI拍摄眼镜
+
+
+
+Youtube用刷量平台,刷4000小时观看时长指标,服务商明显要求:`单个视频时长>60min`,这样电子弄成的设备,才能节约成本,辅助达标Ypp获利资格的门槛
+
+而YPP审核,是需要经过算法和人工查看的,AIGC/解说搬运等内容,很容易造成审核被拒绝,但是真实生活/风景不会被拒
+
+而且由于养成记录拍摄的习惯,很容易构建`Real life`的爆款vlog主题
+
+目前个人使用的是雷鸟,可参考 出海导航: `nomadro.com`关于数码设备的推荐,夸克新推出眼镜的性价比和设计哲学更好,但是一直预售就是恶心的商业策略
+
+这设备,在节假日,比如新年/婚庆,一次就能回本,没有什么比保留记忆更珍贵
+
+---
+
+## OBS
+
+
+
+OBS是一款多系统通用的`开源`软件
+
+你可以用它录屏,直播推流,拥有丰富的插件,实现各种特效/功能
+
+当你在实践一个知识业务本身的时候,就让它在后台稍稍录制
+
+后续只要稍作剪辑,就是一个丰富详细的课程
+
+千万不要等操作完了,最后去特意设计课程,因为`有些步骤是很难复现的,有些细节重要却很容易遗忘`
+
+如果你的主机算力不够,可以考虑买一个`视频采集卡`,带`环出`接口
+
+用另一个mini主机做辅助,全天录制
+
+---
+
+## Fadcam
+
+
+Fadcam是一款手机录屏软件
+
+作为一个root玩家,一直使用安卓系统,基于云手机,经营无人直播工作室的人来说
+
+手机录屏操作演示就非常重要
+
+安卓的功能实现是太多了,超级多的外挂模块课体验
+
+而当你想要分享时,录屏就是最佳策略
+
+声音包含了系统音和麦克风,也能对通话进行取证
+
+---
+
+## 最后
+
+
+
+副产品,是后台默默运行产生的视频资料
+
+经过后期操刀剪辑后,就能有盈利价值
+
+所以,当你迷茫的时候 (陷入认知盈余),优先理解副产品的概念
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (25)
+
+# 买号/刷量/设备
+
+> 极简运营方法论
+
+
+
+其实你会发现很多文章和书籍,都是形而上学,偏`道`,在`术`的方面要么浅尝辄止,要么讳莫如深
+
+框架/理论/策略,都是好东西,但是卡脖子的是`渠道`
+
+掌握渠道,就掌握了`供应链`,也掌握了`信息差`
+
+就比如Youtube来说,不要扯什么封面优化,标题SEO,主题,`怎么拥有一个YPP获利账号`,立刻,马上,现在
+
+几个月起号运营根本等不起,堵不起,讲一堆废话有什么用?
+
+相机拍摄谁不会?AIGC懒人包软件一大堆,需要教?后台真实收益敢不敢给看看?
+
+---
+
+买账号
+
+> 带有获利资格的账号
+
+
+
+没有什么比买账号更快的了
+
+个人的经验是,不要选择`交易市场`,而是spa单页销售页
+
+因为交易市场质量只是撮合平台,账号不是自营,在C2C交易里,进行监管,收取手续费
+
+在国内使用过小黄鱼(海鲜市场)的都知道,平台其实很多时候没法公平解决,容易被诈骗
+
+另外交易市场,胃口非常多,什么业务都想来一脚,所以会眼花缭乱
+
+但是SPA落地页的官方不一样,走的是官方自营精品路线,首页只展示`获利资格账号`的价格
+
+不管改账号的渠道来源是什么,由平台担保售后
+
+不过海外和国内不一样,国内域名都需要查备案,支付也会审查,海外随便几行代码搭建网站,接入支付网关,几分钟的事情.....所以具体的口碑,还需要多查查互联网社区,比如reddit
+
+任何交易,都有被诈骗的风险
+
+---
+
+## 刷粉刷量
+
+如下图, 具体网站参考 出海导航: `nomadro.com`
+
+
+
+Youtube的门槛是一年内 `1000粉丝+4000小时`的观看时长,达标后即可申请获利资格
+
+知道最遗憾的是什么吗?
+
+就是你一个视频爆了,4000小时达标了,但你就差那么几个粉丝
+
+而一千粉丝的价格,不过几美元,几分钟就刷完了,痛心不痛心?
+
+另外买账号的价格相差巨大,你也许可以找到1500r,均价2000r,很多都是2700r左右
+
+但是如果你自己刷量,成本1000以下呢
+
+一旦你找到`方程式`:刷量过ypp审核
+
+意味着,哪怕你没与技术经营工作室,做矩阵,卖账号赚取差价,本身就是一门`互联网虚拟类生意`
+
+等到后来研究平台的推荐算法,找到适合的垂直主题,矩阵带来的收益就是几何级,因为:`可复制`
+
+刷粉刷量,一般都是印度/菲律宾等第三方国家,由于低汇率,搭建的`电子农场`
+
+另外提醒下,有些读者可能会站到道德的制高点进行审判
+
+但你可知,哪怕你付费投流,平台自己的机器人也不少,它们算法的作恶谁来揭露和惩戒?
+
+存在,就有一定的道理,和,价值
+
+此类网站有一个专业名词:`SMM Panel`
+
+---
+
+## 资料门槛
+
+卡脖子的东西可多了
+
+当你拥有获利资格的账号,开始运营赚美金,需要`提现`
+
+就需要`认证资料`,比如tiktok,如下图,懂得都懂
+
+
+
+Youtube的adsense收款账号,可以是大陆身份证,但是tiktok不行
+
+出海信息差:`资料与支付网关无需检验一致性`
+
+这也是为什么很多女主播,做娱乐主播,只能加入公会的原因,
+
+因为她就算解决了设备/环境/风控各种问题,最后一步也被卡住
+
+---
+
+硬件设备
+
+> 创业离不开技术,无论是硬件,还是软件
+
+因为自动化,可以效率和效益最大化,比如`无人直播`
+
+哪怕是shorts短视频,也是自动生成,自动上传,官方api接口,无需手动操作app
+
+下图是tiktok的运营示例图
+
+
+
+不必强调说,主板机/云手机平台,多便宜,多方便
+
+各有取舍,在开源硬件/开源软件基础上,可以随便折腾
+
+资产是标品,不违规,不会被窃取隐私
+
+只要适合业务,收益>投资,就ok了
+
+下图是Youtute设备,只有一个开发板,3个虚拟化软件,解决`网络/内容/自动化`的问题
+
+
+
+> Youtube和Tiktok都是是视频/直播平台
+
+内容完全复用,上传/多线路,不过顺手的事情,不是吗?
+
+
+
+数字游民:低成本工作室:云手机,无人直播Tiktok/Youtube,赚美金 (26)
+
+# 独立开发者: saas
+
+
+
+数字游民系列,关于`出海赚美金`,核心的`3类业务`:Tiktok直播/Youtube自媒体/web开发(saas)
+
+直播,和自媒体,总体都属于`社交媒体`,以流量变现为主,依赖平台的推荐算法和变现机制
+
+但是独立开发不一样,它是构建`产品`,可以`自定义收款逻辑和价格`,saas的月/年的订阅策略,可以使收入稳定以及增长
+
+在数字游民6的文章里,介绍了web的一些固定形式:`导航/ebook电子书/电商独立站/博客`等等
+
+但是saas不同,它是综合体,提供具体的功能/工具,多租户架构,帮助用户获取价值,经营业务
+
+而且,直播和自媒体,给saas引流,就是一个生态闭环
+
+---
+
+## 技术栈
+
+> nextjs+superbase
+
+
+
+`nextjs`是javascript语言的集成开发框架,基于react,开发`前端`web界面UI
+
+但saas的核心是`后端`,开源的superbase,开箱即用,解决用户认证、文件存储、可视化的运维面板等功能
+
+
+
+简单来说,如果要开发一个saas,只需3步,一晚就可以上线
+
+- 1.购买vps服务器和域名 (参考`出海导航 nomadro.com`)
+
+- 2.前后端技术栈:nextjs+superbase
+
+- 3.部署运维:dns解析/反向代理=>上线 (用户可访问)
+
+免费的`google登录`,开源的邮箱登录,都可以让用户数据进行`多角色/权限`定义
+
+---
+
+## 支付网关
+
+> 一定要`区别公司和个人`主体
+
+
+
+独立开发的痛点,一直不是技术,而是`业务`和`支付网关`
+
+简单来说,就是做什么主题和`如何收款`
+
+整个世界的商业逻辑,都是让开发者`注册公司`,这样就可以处理`合同/发票/税务`等流程合规问题
+
+可问题是: 经营公司的成本实在是太高了 : 场地租赁/员工社保/会计财税/对公账户,每个月都有固定的支出
+
+而很多业务,都处于验证阶段,贸然注册公司,必然会把财务拉胯
+
+好在,无论是国内还是海外,都有第三方公司,做支付集成,给个人开发者定位`小微商户`
+
+它们经过了比如`stripe`和`微信支付`等授权,做渠道商,赚取手续费,从而降低了个人的门槛
+
+> 加密货币
+
+抛开公司,使用小微商户,已经非常nice,
+
+如果你还是不想提交KYC认证资料等,不想等待审核
+
+那么直接接入加密货币收款网关,比如 BTC/USDT/doge等等
+
+
+
+很多平台和开源项目,都支持sdk集成,分分钟接入,海外web3的生态已经非常好了
+
+所以不必拘泥于传统金融收款
+
+国内/海外/加密等支付网关,推荐平台,请参考`出海导航: nomadro.com`
+
+参与交流,可后台私信: `加群`
+
+---
+
+## 业务
+
+
+
+很多开发者,空有技术,离市场需求太远
+
+常常就是`导航/笔记/记账`这种自以为是的垃圾三件套,自娱自乐式
+
+saas的核心的逻辑是`为用户提供价值`
+
+`levelsio`在MAKE那本书里,谈到创业历程:最初是把各个国家城市`数据做可视化和报告`
+
+但是到后来发现: 社交领域的需求极大,比如:`线上社群,同城活动,恋爱约会,远程招聘`等
+
+实现这些功能根本无需算力,一个`年付几十美金`的vps服务器绰绰有余
+
+> 数字游民
+
+技术不是问题,而且支付可以使用`个人身份`申请`小微商户`
+
+所以思考核心业务就非常重要
+
+在调研了很久后,俺发现围绕`数字游民`做2个事,就可以比较快速投放市场
+
+- 出海的`digial nomd`数据可视化
+
+- 国内的数字游民做`同城沙龙` (如下图)
+
+
+
+> 报名表单
+
+
+
+目前是投放在小红书平台,女性参与线下活动的意愿是男性的几倍
+
+> web第一
+
+在经营saas前,我也尝试过PC客户端(elecron)/IOS app(react native)/ chrome插件等各种端的产品形式
+
+以及微信小程序/快应用,等国产特色
+
+但最终发现,web优先,才是最佳策略,其他端的产品都在验证价值后最补充
+
+在AI的加持下,编程已经不是门槛, saas就是你的产品最佳选择
+
+数字游民:低成本AI工作室:云手机,无人直播Tiktok/Youtube/Saas,赚美金 (27)
+
+# AI
+
+
+
+AI是普通人的超级杠杆,无论是`gpt文字对话,文生图,视频制作`,还是其他垂直功能,比如`一键换脸/数字人/语音克隆`等等,而基于上下文 `Skill/MCP/RAG/Agent`的智能助理,比如`openclaw`就是在实现AI自动化处理业务,远不是当初的RPA流程自动化
+
+举个例子,X发图文帖子,基础的RPA自动化,需要自己写python脚本,触发一系列if/else戴拿逻辑,但是openclaw可以用`口令的方式让它自助操作/学习/进化`
+
+本篇是个人对于AI工作室的使用场景总结
+
+- gpt对话
+
+- 视频剪辑
+
+- chatbot
+
+gpt对话目前一直订阅的服务是`grok`,只要提示词正确,它不仅答案更加准确,而且可以正面回答`灰色/黑产`相关的角度,不会类似`chatgpt`用道德和法规装圣母婊节省算力,因为无论是业务还是生活,都不是非黑即白,需要有各个方面的能力来`抵御风险和提升杠杆`
+
+无人直播,背景音乐版权问题用ffmpeg的滤镜解决了检测conentId概率问题,而Youtube的长视频剪辑,因为针对英语受众,所以核心是`翻译转录`
+
+- SRT双语字幕 (cc字幕)
+
+- 语音翻译+克隆+人声分离+对口型
+
+至于chatbot,其实自从GPT出现以来,网页的对话是反人性的,最佳的场景都是嵌入到`聊天应用`,做个人或者群机器人(bot),比如`telegram/微信`,当然,前者API很开放(需要外网),后者需要xposed等外挂插件做辅助,当下的openclaw也是这个逻辑
+
+
+
+---
+
+## 显卡GPU
+
+几年前为了AI场景,购买了3060TI 12gb和 4060TI 16gb的显卡,以及游戏笔记本(8GB)
+
+在当前各种芯片/存储/内存/原材料疯狂涨价的时期,购买显卡,显然是一个非常重的投资,如果你只是为了了解/测试,其实也可以考虑`矿卡`,几百元的性能虽不能匹敌最新的产品,但是降低模型参数,是可以胜任很多基础需求的
+
+> 拓展坞
+
+大机箱其实不太方便,现在显卡拓展坞,已经能把损耗做到低于10%了
+
+个人更倾向于 `mini主机+拓展坞+显卡`的组合
+
+
+
+这样硬件之间是方便解耦和替换的,无论是`空间部署还`是`硬件参数`
+
+---
+
+## 个人助理
+
+
+
+虽然AIGC的场景非常多,但是有一类的模式比较固定,那就是 `chatbot+android`
+
+前面提到过, 相比较网页,安卓手机的里聊天应用,是核心,它有很多关键特性
+
+- 聊天应用是必装app,比如微信
+
+- 对话模式符合人类操作逻辑(而不是搜索域名,打开网页对话)
+
+- 安卓手机,移动优化,业务生态丰富,成本低
+
+- 安卓系统更容易定制/控制集成 (Skill/MCP/RAG/Agent)
+
+- 无时无刻随身携带
+
+服务器虽然24小时运营,但真正交互的,一定是随身携带的设备
+
+还有`发短信/打电话/gps定位`这种PC根本无法企及的操作
+
+openclaw甚至可以运行在5$的esp32单片机上,而AI能力,只需要订阅大模型,通过api消耗token
+
+不要被虚假的AI生态迷惑,在实现AI助理前,先确定好自己真正适合的业务是什么?
+
+---
+
+## 最后
+
+即便是云端的大模型如何强大,都推荐在自己的homelab`私有化部署`,不仅仅能保证业务隐私,也可以让算力成为超级杠杆,不必担心token变成巨额账单
+
+
+
+数字游民:低成本AI工作室:云手机,无人直播Tiktok/Youtube/SaaS,赚美金 (28)
+
+# 数字员工
+
+
+
+在openclaw(龙虾)出现之前,只能用代码/N8N进行`RPA自动化`,中间的节点,用AI润色
+
+但是现在,只需要在chatbot (聊天机器人),对它用`大白话`说出要求,就可以实现全部功能了
+
+比如
+
+- 分析摄像头流,当有人/车进入画面,进行通知+响铃,并进行门店客流统计,打印报告
+
+- 浏览器打开`nomadro.com` (出海导航),分析该网站收录了哪些信息,有什么价值
+
+- 安卓手机打开X/小红书,自动浏览,并分析画面的内容进行数据分析,对特定主题自动点赞/评论
+
+一行代码都不需要写,直接得到结果
+
+> 24小时打工
+
+用GPT进行对话是实时的,它不会在多少小时后给你汇报,但是龙虾可以
+
+它可以在睡觉的时候,继续工作
+
+并且每个龙虾,可以设定专业的`角色分工`,比如:客服/程序员/设计师/会计等
+
+
+
+它只是`把网络/电力/算力,变成了AI货币:token`
+
+可以做员工经营业务,也可以直接对外出售变现
+
+与雇佣真实的人类比较,它很便宜,且长久
+
+---
+
+## 硬件
+
+
+
+开源项目openclaw和大模型对mac芯片/系统做了适配和优化
+
+所以很多人选择`mac mini`作为家庭服务器
+
+它静音/省电,能私有化部署,不必担心天价的token账单和隐私风险
+
+> 但本文以`显卡`GPU为例
+
+前些年配置了RTX4060TI 16gb和RTX3060TI 12gb,都是NVIDIA,CUDA对AI大模型支持友好
+
+游戏笔记本也含有8gb的显存
+
+为了单独配置小龙虾,在海鲜市场,又淘了 RTX3060 12gb,以及` P40 24gb(矿卡)`
+
+> P40 24gb 矿卡
+
+
+
+目前市场价0.8k左右,24gb的显存,不用担心服务宕机,效果与RTX 3060 其实差不多,目前在ubuntu server运行`Qwen3.5-35B-A3B`模型,35B占比22gb显存,多模态(图片识别)占用2gb,刚刚好=24gb
+
+实际速度,`30token/s`
+
+另外:9B与35B对比,前者是玩具,不足以胜任数字员工的标准
+
+P40配置如下
+
+
+
+---
+
+## 部署
+
+
+
+大模型选择qwen3.5
+
+只需要在小黄脸(huggingface)下载`GGUF模型文件`,:
+
+
+
+用`llama.cpp`运行如下命令
+
+```
+llama-server -m Qwen3.5-35B-A3B-Q4_K_M.gguf --mmproj mmproj-BF16.gguf -ngl 40 --threads 12 --host 0.0.0.0 --port 8007 -c 32768 --verbose --no-mmap --image-min-tokens 1024 -b 256 -ub 128 -np 1 --main-gpu 0 --reasoning_format none
+```
+
+网盘下载地址,可以后台私信: `qwen`,获取网盘地址
+
+然后就有了是部署在本地的`qwen3.5-35B-A3B`模型的webui界面
+
+
+
+小龙虾openclaw部署,也只需要一行命令
+
+```
+curl -fsSL https://openclaw.ai/install.sh | bash
+```
+
+> openclaw的web管理面板
+
+
+
+> 对话,配置telegram
+
+要求获取服务器的配置和信息
+
+
+
+openclaw交流,可以后台私信:`加群`
+
+---
+
+## 最后
+
+
+
+龙虾openclaw用AI控制你的手机/电脑/电器设备
+
+具体到浏览器网页/app,以及更底层的控制协议http/adb等等
+
+在不断地迭代升级中,确实能成为一名优秀的数字员工
+
+它现在并不是玩具,是未来
+数字游民:低成本AI工作室:云手机,无人直播Tiktok/Youtube/SaaS,赚美金 (29)
+
+## vibe coding
+
+在openclaw(龙虾)爆火之前,垂直于编程领域的商业平台`cursor` 和`Claude` 以及`codex`,就已经能非常出色的完成各种任务,比如实现各种网页/app/小程序,全程大白话,不用手写一句代码
+
+最初用于补充代码段,完成组件,还需要手动编程,但现在已经能`全业务流程,跨项目文件,多线程开发`
+
+
+
+举个例子,一段话,它就能在`2小时`内生成了3个前端项目,1个共用后端:
+
+"你是高级架构师,是全栈工程师,也是优秀的设计师,请用nextjs构建`数字游民`为主题的资源导航/数据saas/私域vip的3个网站,并生成基于fastapi的后端项目,使用pocketbase数据库和minio S3存储"
+
+生成过程,加上样式/排版/功能调教,也不过2小时
+
+如果外包高级程序员,没有1个月,几个w的预算,是无法实现的,但实际的消费只有20$*3=60美元
+
+其实也可以使用vscode编辑器加本地大模型(显卡)API,但效果比云端商业模型会逊色一点,适合后期调教,比如qweb35 -35B-A3B目前运行openclaw基本任务很不错
+
+网站界面/功能如下:
+
+> 数字游民saas
+
+
+
+
+
+> 资源导航
+
+
+
+> 私域会员
+
+
+
+目前网站已经具备了各种功能:
+
+- markdown文档/电子书/视频播放/网盘下载
+
+- 登录注册/权限控制/数据可视化/表单
+
+当然,由于定位比较全面,不是SPA那种landing page
+
+所以很多具体的模块都要不断实现和优化
+
+---
+
+## 超级助理
+
+本期只讨论商业平台`cursor `和`Claude `以及`codex`的效果
+
+openclaw(龙虾)可以查看上一期文章
+
+除了之前说的vibe coding,氛围编程,普通人要的是偏向于效率或者生产力的任务
+
+写`思维导图`有很多年了,一个30MB的文件,迭代了无数次的思考和总结
+
+
+
+让codex对导图文件进行分析总结
+
+
+
+再比如`独角数卡`电商站在服务器异常,去冰箱倒一杯橙汁的功夫,codex就完成了数据备份,服务修复,以及启动脚本优化
+
+在此之前,我尝试用chatgpt plus对话,复制粘贴,花了半小时都没有搞定docker redis损坏的问题
+
+模型都是同样的GPT5.4,但是工作流和效果完全不一样
+
+
+
+---
+
+## 普通人如何利用AI赚钱?
+
+AI在每个领域可以做的事情实现是太多了
+
+本文只是简单阐述了vibe coding和文件分析/服务器运维的简单应用
+
+比如还有视频剪辑/生成,金融量化交易,电商运营等等
+
+这里有个关键概念是:`平台优先`,不是to c(个人用户),也不是to B (企业)
+
+如果一个标榜数字游民的人只是靠教别人做数字游民卖课赚钱,那就是击鼓传花的割韭菜
+
+就像在深圳中心书城看到一本躺在角落"如何编写出版千万的畅销书"的冷笑话~
+
+海外平台优先是`Youtube/Tiktok/X`,以及独立开发saas( 接入谷歌oauth登录和stripe支付)
+
+去了解ypp(Youtube)/ Rewards(tiktok) /Premium(X)的广告分成计划,靠流量,美金变现
+
+再去了解`订阅拉新(offer)/佣金带货(affiliate)/电商卖货(shop)`更多渠道
+
+
+
+为了更通俗的阐述基本获利方式,接下来以`国内平台`为例子
+
+> 广告:流量主
+
+追求长期,可以分享在AI领域的新闻热点,以及实践心得
+
+- 新模型 / 新 agent / 新浏览器自动化 / 新视频工作流
+
+- 对创作者更省时间的功能
+
+- 能把原本 2 小时流程压到 10 分钟的工具
+
+目前所有媒体平台,都对 hash tag标签 #AI 算法推荐
+
+短期爆发
+
+- 写一篇专题,深度好文,用专业的理论和实践心得,使用各种 emoji/gif/表格/视频 表达
+
+- 推广:付费投流
+
+基于真实的分享,仅仅平台的广告费就不少,一旦粉丝达到1w以上,商业投放就会纷至沓来
+
+ps:数据可怜,就打码了,但苍蝇再小也是肉
+
+
+
+> 拉新:网盘奖励
+
+网盘的`拉新/转存`,都是有现金激励的
+
+由于国内网络的限制,以及安装的繁琐,可以为普通用户提供2个东西
+
+- 大模型下载
+
+- 一键懒人安装包 (windows)
+
+没有让用户付费,但每天的被动收入源源不断 (国内是 百度网盘/夸克网盘)
+
+当然,如同支付一样,官方把渠道授权给了第三方公司,进行管理和分成
+
+你可以去出海导航 `nomadro.com`,查看第三方平台推荐
+
+
+
+> 佣金:云服务器
+
+
+
+openclaw的安装,`腾讯云`就提供了云服务器,开箱即用
+
+在文章里告诉用户,贴上URL/小程序
+
+企鹅推出了龙虾专属服务器,下单即可使用,大厂出品
+
+> 远程技术支持
+
+
+
+就算写了文章:安装教程,提供一键懒人包和大模型下载,但是依然有很多用户不会
+
+所以海鲜市场就有非常多人提供:`远程安装`
+
+只需要利用rustdesk到等远程电脑控制软件,就可以快速解决软件安装问题(你愿意上门也可以)
+
+ps: 公众号流量主/云服务器佣金/网盘拉新/远程安装
+
+> 总结
+
+如果你对AI保持热情,长期分享实践,仅仅国内的微信公众号,就可以有被动收入的基本框架
+
+当然,更推荐去拓展海外市场,流量和汇率,是国内的10x以上
+
+在研究挣钱这件事上,先选好利基(比如AI agent),再布置好免费获利框架(ads广告/offer订阅/affiliate佣金),后续再研究更多收费盈利模式(直播/带货/电商/咨询)等等
+
+先从自己需求出发,再利他
+
+数字游民:低成本AI工作室:云手机,无人直播Tiktok/Youtube/SaaS,赚美金 (30)
+
+# AI算力工作室
+
+关于赚钱,我一直推崇做"无人直播"
+
+AI的`数字人/一键换脸/姿态迁移`,可以从内容上解决24小时UGC的问题
+
+小龙虾openclaw也可以从`流程自动化`和`数据分析`上进行管理
+
+抛开业务本身不谈,这期只简单聊下`本地部署`的自由主义:
+
+1. Token无限 (电力转成算力)
+
+2. 隐私保护 (商业数据不被云厂商收集)
+
+3. 灰色和黑产 (内容开放,无神茶)
+
+
+
+比起 `arm开发板(云手机)`,以及`AI眼镜/全景相机`,`显卡`是当下工作室最好的投资
+
+如果从成本上考虑,可以选择矿卡P40 (24gb)/千元,选择可以含有多个pcie插槽的主板,显存足够
+
+可以部署多个数字助理,分配到不同的业务
+
+---
+
+## 大模型:Token自由
+
+个人不太建议用windows去部署懒人包
+
+虽然它会让`小白`,快速自部署AI脚本,运行服务,但是由于操作系统本身的特性,会导致性能有所牺牲
+
+推荐用linux系统,比如ubuntu,来测试AI程序
+
+由于server服务器,天生就是ssh到终端执行cli操作,遇到任何问题,交给小龙虾或者商业平台(codex)之类,都能自动化快速解决,不必有运维知识
+
+> 基座:llama.cpp
+
+qwen3.5开源后,建议下载大模型文件,用`llama.cpp`部署
+
+这样你就拥有了类似chatgpt一样的网页,以及`标准的api`
+
+
+
+> Agent数字助理
+
+为什么要使用API?
+
+除了gpt网页对话,现在的`数字助理`,是借助大模型的思考,来实现`agent`操作
+
+白话就是有`权限和能力`在你的操作系统:windows/mac os/linux/android,执行各种任务
+
+比如openclaw就可以调用本地的qwen3.5 api,以及telegram实现对话指令 (国内有飞书和微信clawBot插件)
+
+
+
+> vide coding
+
+其实就是`AI+独立开发`, 只不过再也不用用手敲打一行代码了
+
+可以在vscode和cursor集成 本地大模型
+
+分分钟实现网站功能(登录/支付/工具),然后立马上线 (海外域名/vps,无需备案,秒访问)
+
+
+
+> 无神茶
+
+以上的'本地网页gpt'/"Agent数字助理"/'独立开发',都是老生常谈
+
+但是本地最大的意义就是保护隐私和"无神茶"
+
+
+
+阿里公布的模型文件自带"神茶",以至于AI会拒绝回答很多问题
+
+或者在圣母婊的基础上给出不痛不痒的废话
+
+但是作为土生土长的神国人,想要生存,赚钱,是不得已需要知道很多灰色/跨境的方法论的
+
+于是有人基于开源模型,进行训练处理,就得到了解锁的`Uncensored-HauhauCS-Aggressive`版本
+
+这也是本篇文章的灵魂:`Qwen3.5-35B-A3B-Uncensored-HauhauCS-Aggressive`
+
+
+
+关于AI的生产力课程:agnet助理,有在筹备
+
+有兴趣可私信: `加群`
+
+但出于业务繁忙和后台消息众多,不一定能及时更新上线
+
+---
+
+## 低成本预算
+
+虽然一直在讨论`AI的生产力`和`业务的关联`,但回归到硬件,与工作室本身
+
+如何低成本部署多套设备呢?答案是:`闲鱼`
+
+
+
+- 1.搜索`电脑/主机 `
+
+选择`搬家`原因的`同城自提`
+
+主机比较大,不方便携带,由于要搬家,也比较急出手,这样就可以购买千元以内的主机
+
+包含完整的CPU芯片/内存/硬盘
+
+- 2.矿卡/计算卡
+
+比如大显存的P40 (24GB), 也是千元以下,ps:不推荐魔改卡,容易坏
+
+当然,如果要省心,也可以选择mac mini,但是一旦会话题吵起来,售价就会水涨船高
+
+就像是数字游民`旅居地`,最近的惠州,一旦被公开宣扬,势必会造成一波游客新增
+
+> 成本总计
+
+按2k/套, 5个数字助理的成本`不过万元`, 且是家电家宽
+
+在经营业务的投资成本上,不值一提
+
+无论是内网穿透,还是打通chatbot,都可以随时随地远程使用
+
+---
+
+## 最后
+
+> 没有任何贬义,用着国内的的AI产品,和裸奔有什么区别呢?
+
+
+
+除了telegram,其实个人真正的团队和业务,都是打通在开源的`element`,基于matrix通讯协议
+
+chatbot一直都是最佳的应用入口,网页不是
+
+有兴趣的可以回顾往期的 `远程团队`
+
+> 碎碎念
+
+最近在勘察旅居地,所以即使vibe coding很多专题网站,但内容也没来得及更新
+
+周末针对`小红书受众`在loft民宿做了2场沙龙 (入口在`nomadro.com`)
+
+也是后期的一个方向:`旅居/共创`,而不是纯粹的`技术/业务`单向输出分享
+
+ps: 相对于咖啡馆/茶舍/联合办公空间/户外天幕 `200/h的场地报价`,loft民宿性价比很高,配上投影仪,更易于交流分享
+
+数字游民系列至此写了1-30篇,其中序号缺失的是被平台删除了,最近大动作不断,有些如履薄冰
+
+可后台私信:`电子书`,下载完整pdf文档
+
+
diff --git a/app/digital/data/tools.json b/app/digital/data/tools.json
new file mode 100644
index 0000000..46ffc35
--- /dev/null
+++ b/app/digital/data/tools.json
@@ -0,0 +1,219 @@
+{
+ "categories": [
+ {
+ "id": "nomad-community",
+ "icon": "👥",
+ "title": "游民社区",
+ "color": "from-sky-500 to-cyan-500",
+ "bg": "bg-sky-50",
+ "text": "text-sky-700",
+ "tools": [
+ { "name": "nomadro", "desc": "出海导航", "href": "https://nomadro.com" },
+ { "name": "nomadlist", "desc": "海外最大的数字游民社区", "href": "https://nomads.com?ref=https://nomadro.com" },
+ { "name": "nomadcna", "desc": "国内数字游民社区", "href": "https://meetup.hackrobot.cn" }
+ ]
+ },
+ {
+ "id": "toolbox",
+ "icon": "🔧",
+ "title": "工具箱",
+ "color": "from-violet-500 to-purple-500",
+ "bg": "bg-violet-50",
+ "text": "text-violet-700",
+ "tools": [
+ { "name": "ChatGPT", "desc": "OpenAI开发的人工智能聊天机器人程序", "href": "https://chat.openai.com/" },
+ { "name": "Grok", "desc": "xAI 免费 AI 助手", "href": "https://grok.com/" },
+ { "name": "沉浸式翻译", "desc": "双语翻译插件", "href": "https://immersivetranslate.com/?via=nomad-vps" },
+ { "name": "奈飞小铺", "desc": "代充值,外网账号合租平台", "href": "https://ihezu.art/CZhn6c" }
+ ]
+ },
+ {
+ "id": "us-phone",
+ "icon": "📱",
+ "title": "美国手机号",
+ "color": "from-emerald-500 to-teal-500",
+ "bg": "bg-emerald-50",
+ "text": "text-emerald-700",
+ "tools": [
+ { "name": "1psim", "desc": "低成本:2.5$/m,支持esim和实体卡(邮寄中国)", "href": "https://1psim.com/7GARRL" },
+ { "name": "yesim", "desc": "美国手机号,3$/m", "href": "https://yesim.app/?partner_id=1948" },
+ { "name": "tello", "desc": "最低5$/m,支持esim", "href": "https://tello.com/" },
+ { "name": "9esim", "desc": "让任何手机支持esim,可写数据的SIM实体卡", "href": "https://www.9esim.com/?coupon=XIAOSHUANGERIC2" }
+ ]
+ },
+ {
+ "id": "finance",
+ "icon": "💳",
+ "title": "金融收款",
+ "color": "from-amber-500 to-orange-500",
+ "bg": "bg-amber-50",
+ "text": "text-amber-700",
+ "tools": [
+ { "name": "wise", "desc": "线上开通境外银行账户(身份证即可)", "href": "https://wise.com/invite/amc/xiaoshuangh" },
+ { "name": "OKX交易所", "desc": "加密货币交易所", "href": "https://okx.com/join/84999861" },
+ { "name": "safepal (U卡)", "desc": "出海神卡,邀请码:104809", "href": "https://www.safepal.com/zh-cn/" },
+ { "name": "paypal", "desc": "出海收款必备,tiktok提现", "href": "https://py.pl/1tyAwY" },
+ { "name": "buy me coffee", "desc": "赞助收款平台", "href": "https://www.buymeacoffee.com/invite/xiaoshuangi" }
+ ]
+ },
+ {
+ "id": "vps",
+ "icon": "🖥️",
+ "title": "VPS服务器",
+ "color": "from-blue-500 to-indigo-500",
+ "bg": "bg-blue-50",
+ "text": "text-blue-700",
+ "tools": [
+ { "name": "vultr", "desc": "可按分钟计费的服务器 (可支付宝)", "href": "https://www.vultr.com/?ref=8985003" },
+ { "name": "bandwagonhost", "desc": "针对中国线路优化的高质量vps", "href": "https://bandwagonhost.com/aff.php?aff=25463" },
+ { "name": "RackNerd", "desc": "最便宜的美国vps服务器", "href": "https://my.racknerd.com/aff.php?aff=15630" },
+ { "name": "腾讯云", "desc": "腾讯服务器,国内速度快", "href": "https://curl.qcloud.com/BZZ2MBTN" },
+ { "name": "阿里云", "desc": "阿里巴巴旗下,vps服务器", "href": "https://www.aliyun.com/minisite/goods?userCode=dii0ofzg" }
+ ]
+ },
+ {
+ "id": "residential-ip",
+ "icon": "🌐",
+ "title": "静态住宅IP",
+ "color": "from-pink-500 to-rose-500",
+ "bg": "bg-pink-50",
+ "text": "text-pink-700",
+ "tools": [
+ { "name": "iproyal", "desc": "静态住宅ip,出海必备", "href": "https://iproyal.cn/?r=nomadro" },
+ { "name": "proxy-cheap", "desc": "便宜的住宅ip", "href": "https://app.proxy-cheap.com/r/bmcMoY" },
+ { "name": "smartproxy", "desc": "静态住宅ip服务商", "href": "https://www.smartproxy.org/register/?invitation_code=7RXPLM" }
+ ]
+ },
+ {
+ "id": "residential-vps",
+ "icon": "🏠",
+ "title": "住宅VPS服务器",
+ "color": "from-rose-500 to-pink-500",
+ "bg": "bg-rose-50",
+ "text": "text-rose-700",
+ "tools": [
+ { "name": "lisa", "desc": "住宅ip服务器,tiktok必备", "href": "https://lisahost.com/aff.php?aff=7566" },
+ { "name": "voyracloud", "desc": "静态住宅ip服务器", "href": "https://www.voyracloud.com/?ref_code=2LJPRTE5" }
+ ]
+ },
+ {
+ "id": "hardware",
+ "icon": "🖲️",
+ "title": "硬件",
+ "color": "from-slate-500 to-gray-600",
+ "bg": "bg-slate-50",
+ "text": "text-slate-700",
+ "tools": [
+ { "name": "树莓派", "desc": "无与伦比的ARM开发板", "href": "https://uland.taobao.com/coupon/edetail?e=5hkWkiThOmClhHvvyUNXZfh8CuWt5YH5OVuOuRD5gLJMmdsrkidbOWBzzpT26idJPSbzO0ArF68ertbZl9wqRUbeJevxT02sHgNIxyFsmaMOsi2OxhyQb%2BhYUTTNN7GRRSHvQe2jOLZ9pbNCYX0I%2BPP%2BWUTgK%2F%2B0I%2BtaUgbudUxA%2B536asYsLWVfKa%2BhVnND4NyB1fwjO3bJLf667Yy8Q5jB6TX2HR3Qrq8LCw4Dujjqyaqc1rGLUwdQHHM8iY%2BUDMxLRSiIntldeBz90au3REAmOgZ0RtdnfeSBK3KXa0%2FA9snq37kKL6zL4bZ3EAS%2F2oYsLHTsIOxroXBFP6oz%2BA%3D%3D&traceId=213e032e17650447230113327e0c4f&union_lens=lensId%3APUB%401765044691%400b521400_0da4_19af4dca231_da77%400290EZjuZ2xbgOlH3NTYAyI%40eyJmbG9vcklkIjoyMDg3MTcxMDEwLCJzcG1CIjoiiX3BvcnRhbF92Ml90b29sX3NlbGVjdGlvbmxpc3RfZGV0YWlsX2luZGV4X2h0bSIsInNyY0Zsb29ySWQiiOiiI4MDY2NCJ9" },
+ { "name": "迷你主机", "desc": "具有性价比的X86小主机", "href": "https://s.click.taobao.com/t?e=m%3D2%26s%3DCb1Quo%2F0Wb5w4vFB6t2Z2ueEDrYVVa64YUrQeSeIhnK53hKxp7mNFuhCZFSw9yq2woqx0EHqItb0JlhLk0Jl4eVWmNyHytKdTKJ80yOOA0Mtd0e5Ku7CIDk5ffgV5fRI1qrpxiwMoCNxc1AtbZGVS936JshS%2F%2FIdwgQgG5hIzyLNEPXytV9ALtCLThlbPuuZLb93Df8fOziGg7JVKZos%2BjLJJgRsfWlWtajntMnDLEHhuApO%2F8klJ62RdK%2B6%2FP7Z6k6s3rDgsC6jO9AJYjY8CXJ%2BwEVkOqHFPSJJXArlDuzso5nyPLY4NPiLqets6lIJ" },
+ { "name": "GPU显卡", "desc": "AI时代的算力基础设施", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BATUJK1olXwEDUFZVCkkRC18IGloWWwALV19aAUonRzBQRQQlBENHFRxWFlVPRjtUBABAQlRcCEBdCUoUBWkBGFoSVAcdDRsBVXsQB29pbiZRKGMHKlclCEgVWghcGV91UQoyVW5dCUoWAmYPHl4RbTYCU24fZp-esLulkIK0yd6n5V1eOEonA2kPH1oQWQUFV19VCnsQA2Y4El0dWQ4LU1hBC00SCmcJK2slXjYFVFdJDjlWUXsOaWslXTYBZF5cCEoQAGoLH1kRQQYHV1lbAFcXBWgMGl4RXgUKUllYOEkWAmsBK2vL05J3IB9Uajl8UStqSz4dFQ9jitDJGTlnA2sPH0olImF5ECFaSU0VVAdNQDxuW09KFiw8SyljbW1jcih3JWNxNlwaS0hqWBhoX2sQbQEEVW4" }
+ ]
+ },
+ {
+ "id": "software",
+ "icon": "⚙️",
+ "title": "软件",
+ "color": "from-indigo-500 to-blue-500",
+ "bg": "bg-indigo-50",
+ "text": "text-indigo-700",
+ "tools": [
+ { "name": "3X-UI", "desc": "科学上网的正确姿势,仪表盘", "href": "https://github.com/MHSanaei/3x-ui" },
+ { "name": "虚空终端mihomo", "desc": "代理软件以及配置wiki", "href": "https://wiki.metacubex.one/config/" },
+ { "name": "frp", "desc": "高性能内网穿透", "href": "https://gofrp.org/zh-cn/docs/" },
+ { "name": "wireguard", "desc": "P2P打洞组网", "href": "https://github.com/WireGuard/wireguard-go" },
+ { "name": "lineageos (pi5)", "desc": "树莓派的安卓:linageos", "href": "https://konstakang.com/devices/rpi5/" }
+ ]
+ },
+ {
+ "id": "digital-gear",
+ "icon": "📷",
+ "title": "数码装备",
+ "color": "from-cyan-500 to-teal-500",
+ "bg": "bg-cyan-50",
+ "text": "text-cyan-700",
+ "tools": [
+ { "name": "AI眼镜", "desc": "第一视角拍摄", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BATkJK1olXw8AXVxfCU8UA18IGloVWwcKXFlZDUgnRzBQRQQlBENHFRxWFlVPRjtUBABAQlRcCEBdCUoXBW4AE1wRWAUdDRsBVXtwXw1pZzNKAmZgFFwlYUh8UzVBZyZlUQoyVW5dCUoWAmYPHl4RbTYCU24fZpO9hbeBtYKjxt-jwInikZ-fjV8JK1sTWgIDUVtVAEkVA2w4HFscbQ8EXFpVAUwRH2wOHlIdXDYyZF1tD0seF2l6WgkBW3QyZF5tC3sXAm8AGFsVXAEBXEJdDUMVB24UG10SWQcHUVZbAE8fCl8KGloRVDYyitPtY0JrQAYIYj0dXGZKMVc5T5Was35jYlMVXQUTZAQOch9SZTMNbC9gIGdQJi5VCzkRSCpfWDUXVAJlDQoCbw1gcG9qRwYdHnYyUW5aDEgn" },
+ { "name": "运动相机", "desc": "骑行记录仪", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BASgJK1olXDYCVV9eCE0UB28ME1slGVlaCgFtUQ5SQi0DBUVNGFJeSwUIFxlJX3EIGloWXQABUF5ZAEsIWipURmt3CFhXIQkpbChuRy8JGDpRJUJQDwkbBEcnAl8IGloUXA8FUVtZOHsXBF9edVsUXAcDVV1fDEonAl8IHVwRXAMGVVpaDEsUM2gIEmscWw4GXFdaDlcUBWoBE1olbTYBZFldAV8RcS5aD11nbTYCZF1tCEoXAmsNHl8UXQQeVF1ZDE0UH28OHF8UWAIDUVddCkgnAW4JH1IlbdiMwDxcaAJfBTlNTRpCBHpHLhyDht8GcR8BHFIUTDZ6NSMpYRJweWtBfAUXC2JyXBkpfTNRWi1mGV4UAlgENFpeDA13cRRcTAJLbQMyU1peOA" },
+ { "name": "无人机", "desc": "天空视角", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BASgJK1olXDYCVV9eAUIeB2YMH14lGVlaCgFtUQ5SQi0DBUVNGFJeSwUIFxlJX3EIGloWVA8LUFdZDE4IWipURmtCHHVEABsFVChPYRJAbVhlGRhbFRgLBEcnAl8IGloUXA8FUVtZOHsXBF9edVsUXAcDVV1fDEonAl8IHVwRXAMGVVdbC04VM2gIEmscWw4GXFdaDlcUBWoBE1olbTYBZFldAV8RcS5aD11nbTYCZF1tCEoXAmgLHlgRXgIeVFpfDU0UH28OHF8UWAIDUlteAUwnAW4JH1IlbdiMwCQvcipMBxJhfV9HGgF3FVaDht8GfxgBGVkRTDZUCjsVbzBTYRJbeRkWGG9WAx02D0JcUy5mGRNRFEJiUwgldStzfx1wTj1GbQMyU1peOA" }
+ ]
+ },
+ {
+ "id": "tiktok",
+ "icon": "🎵",
+ "title": "Tiktok",
+ "color": "from-fuchsia-500 to-pink-500",
+ "bg": "bg-fuchsia-50",
+ "text": "text-fuchsia-700",
+ "tools": [
+ { "name": "Tiktok", "desc": "全球直播平台", "href": "https://www.tiktok.com/" },
+ { "name": "云手机", "desc": "用开源硬件打造云手机", "href": "https://www.hackrobot.cn/" },
+ { "name": "vcam虚拟摄像头", "desc": "xposed模块,替换摄像头视频流", "href": "https://pan.baidu.com/s/1NW4GMGA3cxfUQNWZPD9vOg?pwd=live" }
+ ]
+ },
+ {
+ "id": "youtube",
+ "icon": "▶️",
+ "title": "Youtube",
+ "color": "from-red-500 to-rose-500",
+ "bg": "bg-red-50",
+ "text": "text-red-700",
+ "tools": [
+ { "name": "Youtube", "desc": "全球最大的视频网站", "href": "https://www.youtube.com/" },
+ { "name": "smmfollows", "desc": "刷粉刷量平台", "href": "https://smmfollows.com/ref/dfugq" },
+ { "name": "YouTube Studio Auto Dismiss", "desc": "油猴脚本,无人直播辅助", "href": "https://greasyfork.org/zh-CN/scripts/557378-youtube-studio-auto-dismiss" }
+ ]
+ },
+ {
+ "id": "web",
+ "icon": "🌍",
+ "title": "Web网站",
+ "color": "from-green-500 to-emerald-500",
+ "bg": "bg-green-50",
+ "text": "text-green-700",
+ "tools": [
+ { "name": "namesilo", "desc": "注册域名网址", "href": "https://www.namesilo.com/?rid=3bd4047mc" },
+ { "name": "aapanel", "desc": "宝塔面板", "href": "https://www.aapanel.com?referral_code=wcn04vez" },
+ { "name": "cloudflare", "desc": "互联网基础节点", "href": "https://www.cloudflare.com/" },
+ { "name": "Google Analytics", "desc": "网站流量统计服务", "href": "https://analytics.google.com/" },
+ { "name": "Google AdSense", "desc": "Google广告服务", "href": "https://www.google.com/adsense/" }
+ ]
+ },
+ {
+ "id": "live-stream",
+ "icon": "📺",
+ "title": "无人直播",
+ "color": "from-orange-500 to-amber-500",
+ "bg": "bg-orange-50",
+ "text": "text-orange-700",
+ "tools": [
+ { "name": "OBS", "desc": "免费开源的电脑客户端:录屏/推流直播", "href": "https://obsproject.com/download" },
+ { "name": "ffmpeg", "desc": "命令行视频处理/推流工具", "href": "https://www.ffmpeg.org/download.html" },
+ { "name": "SRS", "desc": "开源直播服务器,拉流推流中转", "href": "https://ossrs.io/" }
+ ]
+ },
+ {
+ "id": "payment",
+ "icon": "💲",
+ "title": "支付网关",
+ "color": "from-lime-500 to-green-500",
+ "bg": "bg-lime-50",
+ "text": "text-lime-700",
+ "tools": [
+ { "name": "z-pay", "desc": "支持独角数卡-易支付", "href": "https://z-pay.cn/?uid=22119" },
+ { "name": "xorpay", "desc": "支持收银台支付", "href": "https://xorpay.com?r=nomadro" }
+ ]
+ },
+ {
+ "id": "forum",
+ "icon": "📝",
+ "title": "论坛社区",
+ "color": "from-sky-500 to-blue-500",
+ "bg": "bg-sky-50",
+ "text": "text-sky-700",
+ "tools": [
+ { "name": "异度世界", "desc": "独立博客,hugo搭建", "href": "https://blog.hackrobot.cn" },
+ { "name": "大师兄", "desc": "AI实践", "href": "https://dsx2016.com" }
+ ]
+ }
+ ]
+}
diff --git a/app/digital/i18n/navigation.ts b/app/digital/i18n/navigation.ts
new file mode 100644
index 0000000..52bb748
--- /dev/null
+++ b/app/digital/i18n/navigation.ts
@@ -0,0 +1,9 @@
+"use client";
+
+export { LocaleLink as Link } from "@/app/digital/lib/locale-link";
+export { usePathname } from "@/app/digital/lib/locale-link";
+export {
+ useLocale,
+ useLocaleRouter as useRouter,
+ useTranslation,
+} from "@/app/digital/context/LocaleContext";
diff --git a/app/digital/lib/auth-cookie.ts b/app/digital/lib/auth-cookie.ts
new file mode 100644
index 0000000..77df8ed
--- /dev/null
+++ b/app/digital/lib/auth-cookie.ts
@@ -0,0 +1,34 @@
+/**
+ * 认证 Cookie 配置
+ * 生产环境设置 AUTH_COOKIE_DOMAIN 实现跨子域 SSO(如 .hackrobot.cn)
+ * 本地开发不设置 domain,避免 IP 访问时 cookie 失效
+ */
+
+const COOKIE_NAME = "pb_session";
+const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
+
+export { COOKIE_NAME, COOKIE_MAX_AGE };
+
+/** 从请求 Host 头提取 hostname(不含端口) */
+export function getHostFromRequest(request: { headers: { get: (k: string) => string | null } }): string {
+ const host = request.headers.get("host") || request.headers.get("x-forwarded-host") || "";
+ return host.split(",")[0].trim().replace(/:\d+$/, "");
+}
+
+export function getCookieOptions(clear = false, requestHost?: string) {
+ const isProd = process.env.NODE_ENV === "production";
+ const domain = process.env.AUTH_COOKIE_DOMAIN?.trim();
+ const opts: Record = {
+ path: "/",
+ maxAge: clear ? 0 : COOKIE_MAX_AGE,
+ secure: isProd,
+ sameSite: "lax" as const,
+ httpOnly: true,
+ };
+ if (domain && isProd) {
+ opts.domain = domain;
+ } else if (!isProd && requestHost && /^(\d{1,3}\.){3}\d{1,3}$/.test(requestHost)) {
+ opts.domain = requestHost;
+ }
+ return opts;
+}
diff --git a/app/digital/lib/course-payment.ts b/app/digital/lib/course-payment.ts
new file mode 100644
index 0000000..080d04c
--- /dev/null
+++ b/app/digital/lib/course-payment.ts
@@ -0,0 +1,19 @@
+/**
+ * 课程解锁 - 校验登录态 + VIP(/api/digital/vip/check)
+ * 与 join 报名支付分离,课程解锁用 VIP 状态
+ */
+
+/** 前 2 章试看(暂时关闭,全部需 VIP 解锁) */
+export function isFreeTrial(_moduleIndex: number, _lessonIndex: number): boolean {
+ return false;
+}
+
+/** 章节是否可观看(需传入 vip 状态,来自 useAuthAndVip 或 /api/digital/vip/check) */
+export function canWatchLesson(
+ moduleIndex: number,
+ lessonIndex: number,
+ vip: boolean
+): boolean {
+ if (isFreeTrial(moduleIndex, lessonIndex)) return true;
+ return vip;
+}
diff --git a/app/digital/lib/ebook.ts b/app/digital/lib/ebook.ts
new file mode 100644
index 0000000..8a89d96
--- /dev/null
+++ b/app/digital/lib/ebook.ts
@@ -0,0 +1,43 @@
+import { marked } from "marked";
+
+export interface EbookChunk {
+ html: string;
+ estimatedHeight: number;
+}
+
+export interface EbookData {
+ chunks: EbookChunk[];
+ headers: string[];
+}
+
+export function parseEbookMarkdown(source: string): EbookData {
+ const html = marked.parse(source, { breaks: true, gfm: true }) as string;
+ const lazyHtml = html.replace(/ ])/i).filter((c) => c.trim());
+ if (rawChunks.length <= 1) {
+ rawChunks = lazyHtml.split(/(?=])/i).filter((c) => c.trim());
+ }
+
+ const headers: string[] = [];
+ const headerRegex = /]*>(.*?)<\/h1>/gi;
+ let m;
+ while ((m = headerRegex.exec(lazyHtml)) !== null) {
+ headers.push(m[1].replace(/<[^>]+>/g, ""));
+ }
+ if (headers.length === 0) {
+ const h2Regex = /]*>(.*?)<\/h2>/gi;
+ while ((m = h2Regex.exec(lazyHtml)) !== null) {
+ headers.push(m[1].replace(/<[^>]+>/g, ""));
+ }
+ }
+
+ const chunks: EbookChunk[] = rawChunks.map((chunkHtml) => {
+ const imgCount = (chunkHtml.match(/ ]+>/g, "").length;
+ const estimatedHeight = Math.max(300, Math.round(textLen * 0.6 + imgCount * 350));
+ return { html: chunkHtml, estimatedHeight };
+ });
+
+ return { chunks, headers };
+}
diff --git a/app/digital/lib/env.ts b/app/digital/lib/env.ts
new file mode 100644
index 0000000..230096a
--- /dev/null
+++ b/app/digital/lib/env.ts
@@ -0,0 +1,17 @@
+/**
+ * 环境配置:区分本地调试与生产部署
+ * 域名更换:设置 ROOT_DOMAIN(如 nomadro.com)可推导默认值
+ */
+
+import { DEFAULT_PAYMENT_API_URL, DEFAULT_SITE_URL } from "@/config/digital/domain.config";
+
+/** 支付 API 地址(payjsapi) */
+export const PAYMENT_API_URL =
+ process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL;
+
+/** 站点根地址(用于 return_url 等回跳,生产需配置) */
+export const SITE_URL =
+ process.env.NEXT_PUBLIC_SITE_URL || DEFAULT_SITE_URL;
+
+/** 是否为本地开发 */
+export const IS_DEV = process.env.NODE_ENV === "development";
diff --git a/app/digital/lib/learning.ts b/app/digital/lib/learning.ts
new file mode 100644
index 0000000..434fc00
--- /dev/null
+++ b/app/digital/lib/learning.ts
@@ -0,0 +1,141 @@
+/**
+ * 学习进度、继续学习、收藏、笔记 - localStorage(来自 nomadlms)
+ */
+
+const PREFIX = "lms-";
+const KEY_LAST = `${PREFIX}last`;
+const KEY_COMPLETED = `${PREFIX}completed`;
+const KEY_BOOKMARKS = `${PREFIX}bookmarks`;
+const KEY_NOTES = `${PREFIX}notes`;
+const KEY_RATING = `${PREFIX}rating`;
+
+export type LastWatched = { moduleIndex: number; lessonIndex: number; name: string };
+
+export function getLastWatched(): LastWatched | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = localStorage.getItem(KEY_LAST);
+ if (!raw) return null;
+ const d = JSON.parse(raw);
+ if (typeof d?.moduleIndex !== "number" || typeof d?.lessonIndex !== "number") return null;
+ return { moduleIndex: d.moduleIndex, lessonIndex: d.lessonIndex, name: d.name || "" };
+ } catch {
+ return null;
+ }
+}
+
+export function setLastWatched(m: number, l: number, name: string): void {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.setItem(KEY_LAST, JSON.stringify({ moduleIndex: m, lessonIndex: l, name }));
+ } catch {
+ /* ignore */
+ }
+}
+
+export function getCompletedLessons(): Set {
+ if (typeof window === "undefined") return new Set();
+ try {
+ const raw = localStorage.getItem(KEY_COMPLETED);
+ if (!raw) return new Set();
+ const arr = JSON.parse(raw);
+ return new Set(Array.isArray(arr) ? arr : []);
+ } catch {
+ return new Set();
+ }
+}
+
+export function markCompleted(moduleIndex: number, lessonIndex: number): void {
+ if (typeof window === "undefined") return;
+ try {
+ const set = getCompletedLessons();
+ set.add(`${moduleIndex}-${lessonIndex}`);
+ localStorage.setItem(KEY_COMPLETED, JSON.stringify([...set]));
+ window.dispatchEvent(new CustomEvent("learning:progress"));
+ } catch {
+ /* ignore */
+ }
+}
+
+export function getProgress(totalLessons: number): { completed: number; percent: number } {
+ const set = getCompletedLessons();
+ const completed = set.size;
+ return { completed, percent: totalLessons > 0 ? Math.round((completed / totalLessons) * 100) : 0 };
+}
+
+export function getBookmarks(): Set {
+ if (typeof window === "undefined") return new Set();
+ try {
+ const raw = localStorage.getItem(KEY_BOOKMARKS);
+ if (!raw) return new Set();
+ const arr = JSON.parse(raw);
+ return new Set(Array.isArray(arr) ? arr : []);
+ } catch {
+ return new Set();
+ }
+}
+
+export function toggleBookmark(moduleIndex: number, lessonIndex: number): boolean {
+ if (typeof window === "undefined") return false;
+ try {
+ const set = getBookmarks();
+ const key = `${moduleIndex}-${lessonIndex}`;
+ if (set.has(key)) set.delete(key);
+ else set.add(key);
+ localStorage.setItem(KEY_BOOKMARKS, JSON.stringify([...set]));
+ window.dispatchEvent(new CustomEvent("learning:bookmarks"));
+ return set.has(key);
+ } catch {
+ return false;
+ }
+}
+
+export function isBookmarked(moduleIndex: number, lessonIndex: number): boolean {
+ return getBookmarks().has(`${moduleIndex}-${lessonIndex}`);
+}
+
+export function getNote(moduleIndex: number, lessonIndex: number): string {
+ if (typeof window === "undefined") return "";
+ try {
+ const raw = localStorage.getItem(KEY_NOTES);
+ if (!raw) return "";
+ const map = JSON.parse(raw);
+ return map[`${moduleIndex}-${lessonIndex}`] ?? "";
+ } catch {
+ return "";
+ }
+}
+
+export function setNote(moduleIndex: number, lessonIndex: number, text: string): void {
+ if (typeof window === "undefined") return;
+ try {
+ const raw = localStorage.getItem(KEY_NOTES);
+ const map = raw ? JSON.parse(raw) : {};
+ map[`${moduleIndex}-${lessonIndex}`] = text;
+ localStorage.setItem(KEY_NOTES, JSON.stringify(map));
+ } catch {
+ /* ignore */
+ }
+}
+
+export function getRating(): { stars: number; comment: string } | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = localStorage.getItem(KEY_RATING);
+ if (!raw) return null;
+ const d = JSON.parse(raw);
+ return { stars: d?.stars ?? 0, comment: d?.comment ?? "" };
+ } catch {
+ return null;
+ }
+}
+
+export function setRating(stars: number, comment: string): void {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.setItem(KEY_RATING, JSON.stringify({ stars, comment }));
+ window.dispatchEvent(new CustomEvent("learning:rating"));
+ } catch {
+ /* ignore */
+ }
+}
diff --git a/app/digital/lib/locale-link.tsx b/app/digital/lib/locale-link.tsx
new file mode 100644
index 0000000..a20bc6f
--- /dev/null
+++ b/app/digital/lib/locale-link.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import NextLink from "next/link";
+import { usePathname as useNextPathname } from "next/navigation";
+import { useLocale } from "../context/LocaleContext";
+
+type LocaleLinkProps = React.ComponentProps;
+
+export function LocaleLink({ href, ...props }: LocaleLinkProps) {
+ const locale = useLocale();
+ const hrefStr = typeof href === "string" ? href : href.pathname ?? "/";
+ const localeHref =
+ hrefStr.startsWith("/api/") || hrefStr.startsWith("/_next")
+ ? hrefStr
+ : hrefStr.startsWith("/")
+ ? `/${locale}/digital${hrefStr === "/" ? "" : hrefStr}`
+ : hrefStr;
+ return ;
+}
+
+export function usePathname() {
+ const path = useNextPathname();
+ const match = path?.match(/^\/(zh|en)\/digital(\/.*)?$/);
+ return match ? (match[2] ?? "/") : path ?? "/";
+}
diff --git a/app/digital/lib/minio/client.ts b/app/digital/lib/minio/client.ts
new file mode 100644
index 0000000..c2961f1
--- /dev/null
+++ b/app/digital/lib/minio/client.ts
@@ -0,0 +1,67 @@
+/**
+ * MinIO 客户端封装 - 上传、下载等操作
+ * 通过 getMinioConfig() 获取配置,支持主题覆盖
+ */
+
+import * as Minio from "minio";
+import { getMinioConfig } from "./config";
+import { getThemeConfig } from "@/config/digital/site.config";
+
+/** 获取 MinIO 客户端实例(使用主题配置) */
+export function getMinioClient(): Minio.Client {
+ const theme = getThemeConfig();
+ const cfg = getMinioConfig(theme.services?.minio);
+
+ return new Minio.Client({
+ endPoint: cfg.endPoint,
+ port: cfg.port,
+ useSSL: cfg.useSSL,
+ accessKey: cfg.accessKey,
+ secretKey: cfg.secretKey,
+ pathStyle: true,
+ region: cfg.region,
+ });
+}
+
+export interface UploadResult {
+ url: string;
+ objectKey: string;
+}
+
+/**
+ * 上传文件到 MinIO
+ * @param buffer 文件内容
+ * @param objectKey 对象键(含路径,如 joins/xxx.jpg)
+ * @param contentType MIME 类型
+ */
+export async function uploadBuffer(
+ buffer: Buffer,
+ objectKey: string,
+ contentType: string = "application/octet-stream"
+): Promise {
+ const theme = getThemeConfig();
+ const cfg = getMinioConfig(theme.services?.minio);
+
+ if (!cfg.enabled) {
+ throw new Error("MinIO 存储未启用");
+ }
+
+ const client = getMinioClient();
+ await client.putObject(cfg.bucket, objectKey, buffer, buffer.length, {
+ "Content-Type": contentType,
+ });
+
+ const url = `${cfg.publicUrl}/${objectKey}`;
+ return { url, objectKey };
+}
+
+/**
+ * 生成上传用的对象键
+ * @param ext 文件扩展名
+ */
+export function generateObjectKey(ext: string): string {
+ const theme = getThemeConfig();
+ const cfg = getMinioConfig(theme.services?.minio);
+ const prefix = cfg.uploadPrefix.replace(/\/$/, "");
+ return `${prefix}/${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
+}
diff --git a/app/digital/lib/minio/config.ts b/app/digital/lib/minio/config.ts
new file mode 100644
index 0000000..91178c0
--- /dev/null
+++ b/app/digital/lib/minio/config.ts
@@ -0,0 +1,2 @@
+export { getMinioConfig } from "@/config/digital/services.config";
+export type { MinioConfig } from "@/config/digital/services.config";
diff --git a/app/digital/lib/minio/index.ts b/app/digital/lib/minio/index.ts
new file mode 100644
index 0000000..510b2d9
--- /dev/null
+++ b/app/digital/lib/minio/index.ts
@@ -0,0 +1,13 @@
+/**
+ * MinIO 存储组件化封装
+ * 通过 config/services.config 和主题配置,支持多环境、多主题
+ */
+
+export { getMinioConfig } from "./config";
+export type { MinioConfig } from "./config";
+export {
+ getMinioClient,
+ uploadBuffer,
+ generateObjectKey,
+} from "./client";
+export type { UploadResult } from "./client";
diff --git a/app/digital/lib/payEnv.ts b/app/digital/lib/payEnv.ts
new file mode 100644
index 0000000..0ce636b
--- /dev/null
+++ b/app/digital/lib/payEnv.ts
@@ -0,0 +1,49 @@
+/**
+ * 支付环境检测:PC / H5 / 微信浏览器
+ * 用于 ZPAY 等聚合支付,需区分环境以选择正确的支付通道
+ * 参考:https://juejin.cn/post/7122720360683798542
+ */
+
+export type PayEnv = "pc" | "h5" | "wechat";
+
+export type PayChannel = "alipay" | "wxpay";
+
+/** 是否在微信内置浏览器 */
+export function isWeChat(): boolean {
+ if (typeof navigator === "undefined") return false;
+ return /MicroMessenger/i.test(navigator.userAgent);
+}
+
+/** 是否在支付宝内置浏览器 */
+export function isAlipay(): boolean {
+ if (typeof navigator === "undefined") return false;
+ return /AlipayClient|Alipay/i.test(navigator.userAgent);
+}
+
+/** 是否移动设备(含平板) */
+export function isMobile(): boolean {
+ if (typeof navigator === "undefined") return false;
+ return /AppleWebKit.*Mobile|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
+ navigator.userAgent
+ );
+}
+
+/** 当前支付环境 */
+export function getPayEnv(): PayEnv {
+ if (isWeChat()) return "wechat";
+ if (isMobile()) return "h5";
+ return "pc";
+}
+
+/**
+ * 根据环境推荐支付通道
+ * 所有支付默认微信支付,支付宝暂不提供
+ */
+export function getRecommendedChannel(_env: PayEnv): PayChannel {
+ return "wxpay";
+}
+
+/** 微信内是否只能选微信支付 */
+export function mustUseWxPay(env: PayEnv): boolean {
+ return env === "wechat";
+}
diff --git a/app/digital/lib/payment/client.ts b/app/digital/lib/payment/client.ts
new file mode 100644
index 0000000..7850f1d
--- /dev/null
+++ b/app/digital/lib/payment/client.ts
@@ -0,0 +1,57 @@
+/**
+ * 支付客户端 - 封装支付 API 调用
+ * 用于前端发起支付、跳转支付页
+ */
+
+import { getPaymentConfig, getJoinPaymentConfig } from "@/config/digital/services.config";
+import type { PayChannel, PayEnv } from "./types";
+
+/** 发起支付并跳转(表单 POST 到 /api/digital/pay) */
+export function redirectToPay(params: {
+ user_id: string;
+ return_url: string;
+ total_fee?: number;
+ type?: string;
+ channel: PayChannel;
+ device: "pc" | "h5" | "wechat";
+ /** 是否使用「加入」场景配置(主题覆盖) */
+ useJoinConfig?: boolean;
+}): void {
+ const baseConfig = getPaymentConfig();
+ const joinConfig = params.useJoinConfig ? getJoinPaymentConfig() : null;
+ const total_fee = params.total_fee ?? joinConfig?.amount ?? baseConfig.defaultAmount;
+ const type = params.type ?? joinConfig?.type ?? baseConfig.defaultType;
+
+ const payForm = document.createElement("form");
+ payForm.method = "POST";
+ payForm.action = "/api/digital/pay";
+ payForm.target = "_self";
+ payForm.style.display = "none";
+
+ const fields: [string, string][] = [
+ ["user_id", params.user_id],
+ ["return_url", params.return_url],
+ ["total_fee", String(total_fee)],
+ ["type", type],
+ ["channel", params.channel],
+ ["device", params.device],
+ ["html", "1"],
+ ];
+
+ fields.forEach(([k, v]) => {
+ const input = document.createElement("input");
+ input.type = "hidden";
+ input.name = k;
+ input.value = v;
+ payForm.appendChild(input);
+ });
+
+ document.body.appendChild(payForm);
+ payForm.submit();
+}
+
+/** 根据 PayEnv 确定 device:微信内用 wechat 走收银台表单,payurl2 仅支持微信外 */
+export function getDeviceFromEnv(env: PayEnv): "pc" | "h5" | "wechat" {
+ if (env === "wechat") return "wechat";
+ return env === "pc" ? "pc" : "h5";
+}
diff --git a/app/digital/lib/payment/config.ts b/app/digital/lib/payment/config.ts
new file mode 100644
index 0000000..dae568e
--- /dev/null
+++ b/app/digital/lib/payment/config.ts
@@ -0,0 +1,2 @@
+export { getPaymentConfig, getServicesConfig } from "@/config/digital/services.config";
+export type { PaymentConfig } from "@/config/digital/services.config";
diff --git a/app/digital/lib/payment/index.ts b/app/digital/lib/payment/index.ts
new file mode 100644
index 0000000..fcf0ef2
--- /dev/null
+++ b/app/digital/lib/payment/index.ts
@@ -0,0 +1,4 @@
+export { redirectToPay, getDeviceFromEnv } from "./client";
+export { getPayEnv, getRecommendedChannel, mustUseWxPay } from "../payEnv";
+export type { PayChannel, PayEnv, CreatePayParams, PayRedirectParams } from "./types";
+export { getPaymentConfig } from "./config";
diff --git a/app/digital/lib/payment/types.ts b/app/digital/lib/payment/types.ts
new file mode 100644
index 0000000..36d0c97
--- /dev/null
+++ b/app/digital/lib/payment/types.ts
@@ -0,0 +1,21 @@
+export type PayChannel = "alipay" | "wxpay";
+export type PayEnv = "pc" | "h5" | "wechat";
+
+export interface CreatePayParams {
+ user_id: string;
+ return_url: string;
+ total_fee?: number;
+ type?: string;
+ channel?: PayChannel;
+ device?: "pc" | "h5" | "wechat";
+}
+
+export interface PayRedirectParams {
+ user_id: string;
+ total_fee: number;
+ type: string;
+ channel: PayChannel;
+ return_url: string;
+ device: "pc" | "h5" | "wechat";
+ html?: "1";
+}
diff --git a/app/digital/lib/payment/usePayStatusPoll.ts b/app/digital/lib/payment/usePayStatusPoll.ts
new file mode 100644
index 0000000..63f6645
--- /dev/null
+++ b/app/digital/lib/payment/usePayStatusPoll.ts
@@ -0,0 +1,179 @@
+import { useEffect, useRef, useState } from "react";
+
+const COOKIE_NAME = "join_pay_order";
+const STORAGE_NAME = "join_pay_order";
+const POLL_INTERVAL_MS = 1200;
+const MAX_POLLS = 60;
+const MAX_AGE_MS = 15 * 60 * 1000;
+
+function getCookie(name: string): string | null {
+ if (typeof document === "undefined") return null;
+ const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
+ return match ? decodeURIComponent(match[1]) : null;
+}
+
+function clearCookie(name: string): void {
+ if (typeof document === "undefined") return;
+ document.cookie = `${name}=; path=/; max-age=0`;
+}
+
+function getStorage(name: string): string | null {
+ if (typeof window === "undefined") return null;
+ return window.localStorage.getItem(name);
+}
+
+function removeStorage(name: string): void {
+ if (typeof window === "undefined") return;
+ window.localStorage.removeItem(name);
+}
+
+function clearPendingOrder(): void {
+ clearCookie(COOKIE_NAME);
+ removeStorage(STORAGE_NAME);
+}
+
+function persistPendingOrder(raw: string): void {
+ if (typeof document !== "undefined") {
+ document.cookie = `${COOKIE_NAME}=${encodeURIComponent(raw)}; path=/; max-age=900`;
+ }
+ if (typeof window !== "undefined") {
+ try {
+ window.localStorage.setItem(STORAGE_NAME, raw);
+ } catch {}
+ }
+}
+
+function parsePendingOrder(raw: string | null): [string | null, boolean] {
+ if (!raw?.trim()) return [null, false];
+ const parts = raw.split("|");
+ const orderId = parts[0]?.trim() || raw.trim();
+ const ts = parts[1] ? parseInt(parts[1], 10) : NaN;
+ if (!orderId) return [null, false];
+ if (Number.isNaN(ts)) return [orderId, false];
+ if (Date.now() - ts > MAX_AGE_MS) return [orderId, false];
+ return [orderId, true];
+}
+
+export function usePayStatusPoll(onPaid: (orderId: string) => void): boolean {
+ const [polling, setPolling] = useState(false);
+ const onPaidRef = useRef(onPaid);
+ onPaidRef.current = onPaid;
+
+ useEffect(() => {
+ const urlOrderId =
+ typeof window !== "undefined"
+ ? new URLSearchParams(window.location.search).get("order_id") || ""
+ : "";
+ const cookieRaw = getCookie(COOKIE_NAME);
+ const [cookieOrderId, cookieValid] = parsePendingOrder(cookieRaw);
+ const storageRaw = getStorage(STORAGE_NAME);
+ const [storageOrderId, storageValid] = parsePendingOrder(storageRaw);
+ let orderId = cookieValid ? cookieOrderId : storageValid ? storageOrderId : null;
+ let activeRaw = cookieValid ? cookieRaw : storageValid ? storageRaw : null;
+ if (!orderId && urlOrderId.trim()) {
+ orderId = urlOrderId.trim();
+ activeRaw = `${orderId}|${Date.now()}`;
+ persistPendingOrder(activeRaw);
+ }
+
+ if (!orderId) {
+ if (cookieRaw || storageRaw) {
+ clearPendingOrder();
+ }
+ return;
+ }
+
+ if (activeRaw) {
+ persistPendingOrder(activeRaw);
+ }
+
+ setPolling(true);
+ let attempts = 0;
+ let stopped = false;
+ let timer: number | undefined;
+ let inFlight = false;
+
+ const stop = () => {
+ stopped = true;
+ if (timer) window.clearTimeout(timer);
+ };
+
+ const scheduleNext = () => {
+ if (stopped) return;
+ timer = window.setTimeout(tick, POLL_INTERVAL_MS);
+ };
+
+ const tick = async () => {
+ if (stopped) return;
+ if (inFlight) {
+ scheduleNext();
+ return;
+ }
+
+ attempts += 1;
+ if (attempts > MAX_POLLS) {
+ stop();
+ clearPendingOrder();
+ setPolling(false);
+ return;
+ }
+
+ inFlight = true;
+ try {
+ const res = await fetch(
+ `/api/digital/pay/status?order_id=${encodeURIComponent(orderId)}`,
+ { cache: "no-store" }
+ );
+ const data = await res.json().catch(() => ({}));
+ if (data?.ok && data?.paid) {
+ stop();
+ clearPendingOrder();
+ setPolling(false);
+ // 对齐 nomadvip:调用 confirm 写入 PocketBase site_vip(不依赖 payjsapi 异步 notify)
+ try {
+ const confirmRes = await fetch("/api/digital/pay/confirm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ order_id: orderId }),
+ cache: "no-store",
+ });
+ if (confirmRes.ok && typeof window !== "undefined") {
+ window.dispatchEvent(new CustomEvent("vip:updated"));
+ }
+ } catch {
+ /* 忽略,不影响跳转 */
+ }
+ onPaidRef.current(orderId);
+ return;
+ }
+ } catch {
+ // keep polling
+ } finally {
+ inFlight = false;
+ }
+
+ scheduleNext();
+ };
+
+ const handleResume = () => {
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
+ return;
+ }
+ void tick();
+ };
+
+ document.addEventListener("visibilitychange", handleResume);
+ window.addEventListener("pageshow", handleResume);
+ window.addEventListener("focus", handleResume);
+ void tick();
+
+ return () => {
+ document.removeEventListener("visibilitychange", handleResume);
+ window.removeEventListener("pageshow", handleResume);
+ window.removeEventListener("focus", handleResume);
+ stop();
+ };
+ }, []);
+
+ return polling;
+}
diff --git a/app/digital/lib/payment/xorpayStatus.ts b/app/digital/lib/payment/xorpayStatus.ts
new file mode 100644
index 0000000..618fdc9
--- /dev/null
+++ b/app/digital/lib/payment/xorpayStatus.ts
@@ -0,0 +1,49 @@
+import { createHash } from "crypto";
+
+const XORPAY_AID = process.env.XORPAY_AID || "8220";
+const XORPAY_SECRET =
+ process.env.XORPAY_SECRET || "afcacd99570945f88de62624aaa3578e";
+const XORPAY_QUERY_URL =
+ process.env.XORPAY_QUERY_URL || "https://xorpay.com/api/query2";
+
+export async function queryXorPayOrderStatus(
+ orderId: string,
+ timeoutMs = 5000
+): Promise<{ paid: boolean; status?: string; error?: string; url: string } | null> {
+ const normalizedOrderId = orderId.trim();
+ if (!normalizedOrderId || !XORPAY_AID || !XORPAY_SECRET) {
+ return null;
+ }
+
+ const sign = createHash("md5")
+ .update(`${normalizedOrderId}${XORPAY_SECRET}`, "utf8")
+ .digest("hex")
+ .toLowerCase();
+
+ const url = new URL(
+ `${XORPAY_QUERY_URL.replace(/\/$/, "")}/${encodeURIComponent(XORPAY_AID)}`
+ );
+ url.searchParams.set("order_id", normalizedOrderId);
+ url.searchParams.set("sign", sign);
+
+ try {
+ const res = await fetch(url.toString(), {
+ cache: "no-store",
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ const data = await res.json().catch(() => ({}));
+ const status = String(data?.status || "").trim().toLowerCase();
+ return {
+ paid: res.ok && (status === "payed" || status === "success"),
+ status,
+ url: url.toString(),
+ error: res.ok ? undefined : `status=${res.status}`,
+ };
+ } catch (error) {
+ return {
+ paid: false,
+ url: url.toString(),
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+}
diff --git a/app/digital/lib/payment/zpayStatus.ts b/app/digital/lib/payment/zpayStatus.ts
new file mode 100644
index 0000000..4bf9a0c
--- /dev/null
+++ b/app/digital/lib/payment/zpayStatus.ts
@@ -0,0 +1,45 @@
+const ZPAY_PID = process.env.ZPAY_PID || "2025121809351743";
+const ZPAY_KEY = process.env.ZPAY_KEY || "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89";
+const ZPAY_QUERY_URL =
+ process.env.ZPAY_QUERY_URL || "https://zpayz.cn/api.php";
+
+export async function queryZPayOrderStatus(
+ orderId: string,
+ timeoutMs = 5000
+): Promise<{ paid: boolean; status?: string; error?: string; url: string } | null> {
+ const normalizedOrderId = orderId.trim();
+ if (!normalizedOrderId || !ZPAY_PID || !ZPAY_KEY) {
+ return null;
+ }
+
+ const url = new URL(ZPAY_QUERY_URL);
+ url.searchParams.set("act", "order");
+ url.searchParams.set("pid", ZPAY_PID);
+ url.searchParams.set("key", ZPAY_KEY);
+ url.searchParams.set("out_trade_no", normalizedOrderId);
+
+ try {
+ const res = await fetch(url.toString(), {
+ cache: "no-store",
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ const data = await res.json().catch(() => ({}));
+ const code = String(data?.code || "").trim();
+ const status = String(data?.status || "").trim();
+ return {
+ paid: res.ok && code === "1" && status === "1",
+ status,
+ url: url.toString(),
+ error:
+ res.ok && code === "1"
+ ? undefined
+ : String(data?.msg || `status=${res.status}`),
+ };
+ } catch (error) {
+ return {
+ paid: false,
+ url: url.toString(),
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+}
diff --git a/app/digital/lib/pocketbase/auth.ts b/app/digital/lib/pocketbase/auth.ts
new file mode 100644
index 0000000..54baa22
--- /dev/null
+++ b/app/digital/lib/pocketbase/auth.ts
@@ -0,0 +1,112 @@
+/**
+ * PocketBase 认证服务 - 客户端使用
+ * 登录、注册、登出、获取当前用户
+ */
+
+import { getPocketBaseConfig } from "@/config/digital/services.config";
+import { PB_STORAGE_KEYS } from "./constants";
+
+export interface AuthUser {
+ id: string;
+ email: string;
+ [key: string]: unknown;
+}
+
+export interface AuthResult {
+ token: string;
+ record: AuthUser;
+}
+
+/** 获取 PocketBase 基础 URL(客户端用 NEXT_PUBLIC_ 前缀) */
+function getPBUrl(): string {
+ return getPocketBaseConfig().url;
+}
+
+/** 登录 */
+export async function pbLogin(identity: string, password: string): Promise {
+ const url = getPBUrl();
+ const res = await fetch(`${url}/api/collections/users/auth-with-password`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ identity, password }),
+ });
+ if (!res.ok) {
+ const err = await res.json();
+ throw new Error(err?.message || "登录失败");
+ }
+ const data = await res.json();
+ if (!data?.token) throw new Error("登录响应异常");
+ return { token: data.token, record: data.record };
+}
+
+/** 注册 */
+export async function pbRegister(
+ email: string,
+ password: string,
+ passwordConfirm: string
+): Promise {
+ const url = getPBUrl();
+ const config = getPocketBaseConfig();
+ const res = await fetch(`${url}/api/collections/${config.usersCollection}/records`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, password, passwordConfirm }),
+ });
+ if (!res.ok) {
+ const err = await res.json();
+ throw new Error(err?.message || "注册失败");
+ }
+ // 注册成功后自动登录
+ return pbLogin(email, password);
+}
+
+/** 保存认证信息到 localStorage */
+export function pbSaveAuth(token: string, record: AuthUser): void {
+ if (typeof window === "undefined") return;
+ localStorage.setItem(PB_STORAGE_KEYS.token, token);
+ localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
+}
+
+/** 登出 - 清除本地存储、支付订单 cookie 和根域 Cookie */
+export async function pbLogout(): Promise {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(PB_STORAGE_KEYS.token);
+ localStorage.removeItem(PB_STORAGE_KEYS.user);
+ localStorage.removeItem("join_pay_order");
+ try {
+ document.cookie = "join_pay_order=; path=/; max-age=0";
+ } catch {
+ /* ignore */
+ }
+ try {
+ await fetch("/api/digital/auth/logout", { method: "POST" });
+ } catch {
+ /* ignore */
+ }
+}
+
+/** 获取当前存储的用户(id + email) */
+export function getStoredUser(): AuthUser | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = localStorage.getItem(PB_STORAGE_KEYS.user);
+ if (!raw) return null;
+ const user = JSON.parse(raw);
+ if (!user?.id || !user?.email) return null;
+ return { id: user.id, email: user.email };
+ } catch {
+ return null;
+ }
+}
+
+/** 获取当前存储的用户邮箱 */
+export function getStoredUserEmail(): string | null {
+ const user = getStoredUser();
+ return user?.email ?? null;
+}
+
+/** 获取存储的 token(用于 API 请求) */
+export function getStoredToken(): string | null {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem(PB_STORAGE_KEYS.token);
+}
diff --git a/app/digital/lib/pocketbase/config.ts b/app/digital/lib/pocketbase/config.ts
new file mode 100644
index 0000000..72f66ed
--- /dev/null
+++ b/app/digital/lib/pocketbase/config.ts
@@ -0,0 +1,2 @@
+export { getPocketBaseConfig, getServicesConfig } from "@/config/digital/services.config";
+export type { PocketBaseConfig } from "@/config/digital/services.config";
diff --git a/app/digital/lib/pocketbase/constants.ts b/app/digital/lib/pocketbase/constants.ts
new file mode 100644
index 0000000..fad70d0
--- /dev/null
+++ b/app/digital/lib/pocketbase/constants.ts
@@ -0,0 +1,5 @@
+/** localStorage 存储 key */
+export const PB_STORAGE_KEYS = {
+ token: "pb_token",
+ user: "pb_user",
+} as const;
diff --git a/app/digital/lib/pocketbase/index.ts b/app/digital/lib/pocketbase/index.ts
new file mode 100644
index 0000000..aea22de
--- /dev/null
+++ b/app/digital/lib/pocketbase/index.ts
@@ -0,0 +1,12 @@
+export {
+ pbLogin,
+ pbRegister,
+ pbSaveAuth,
+ pbLogout,
+ getStoredUser,
+ getStoredUserEmail,
+ getStoredToken,
+} from "./auth";
+export type { AuthUser, AuthResult } from "./auth";
+export { PB_STORAGE_KEYS } from "./constants";
+export { getPocketBaseConfig } from "./config";
diff --git a/app/digital/lib/theme-data.ts b/app/digital/lib/theme-data.ts
new file mode 100644
index 0000000..ab71045
--- /dev/null
+++ b/app/digital/lib/theme-data.ts
@@ -0,0 +1,130 @@
+/**
+ * 主题数据加载器 - 根据 CURRENT_THEME 加载对应主题的内容
+ * 新增主题:1. 在 config/site.config.ts 添加 THEME_CONFIGS
+ * 2. 在 content/themes/[theme-id]/ 下添加 messages 和 data
+ * 3. 在本文件 switch 中添加 case
+ */
+
+import { CURRENT_THEME, THEME_IDS, type ThemeId } from "@/config/digital";
+
+export type Locale = "zh" | "en";
+
+export type ToolsCategory = {
+ id: string;
+ icon: string;
+ title: string;
+ color: string;
+ bg: string;
+ text: string;
+ tools: Array<{ name: string; desc: string; href: string }>;
+};
+
+/** 加载当前主题的 messages */
+export async function getThemeMessages(
+ locale: Locale
+): Promise> {
+ const theme = CURRENT_THEME;
+ switch (theme) {
+ case "digital-nomad": {
+ const mod =
+ locale === "zh"
+ ? await import("@/content/digital/themes/digital-nomad/messages/zh.json")
+ : await import("@/content/digital/themes/digital-nomad/messages/en.json");
+ return mod.default;
+ }
+ case "solo-company":
+ case "tiktok-ops":
+ case "indie-dev":
+ // 新主题暂无内容时回退到 digital-nomad
+ return getThemeMessagesFallback(locale);
+ default:
+ return getThemeMessagesFallback(locale);
+ }
+}
+
+async function getThemeMessagesFallback(
+ locale: Locale
+): Promise> {
+ const mod =
+ locale === "zh"
+ ? await import("@/content/digital/themes/digital-nomad/messages/zh.json")
+ : await import("@/content/digital/themes/digital-nomad/messages/en.json");
+ return mod.default;
+}
+
+/** 加载当前主题的 tools 数据 */
+export async function getThemeToolsData(): Promise<{
+ categories: ToolsCategory[];
+}> {
+ const theme = CURRENT_THEME;
+ switch (theme) {
+ case "digital-nomad": {
+ const mod = await import(
+ "@/content/digital/themes/digital-nomad/data/tools.json"
+ );
+ return mod.default;
+ }
+ case "solo-company":
+ case "tiktok-ops":
+ case "indie-dev":
+ return getThemeToolsDataFallback();
+ default:
+ return getThemeToolsDataFallback();
+ }
+}
+
+async function getThemeToolsDataFallback(): Promise<{
+ categories: ToolsCategory[];
+}> {
+ const mod = await import(
+ "@/content/digital/themes/digital-nomad/data/tools.json"
+ );
+ return mod.default;
+}
+
+export type ResourceData = {
+ books: Array<{ cover: string; title: string; href: string }>;
+ products: Array<{ image: string; name: string; href: string }>;
+ pan: {
+ categories: Array<{
+ id: string;
+ title: string;
+ icon: string;
+ items: Array<{ name: string; desc: string; href: string }>;
+ }>;
+ };
+};
+
+/** 加载资源导航数据:图书、商业数码、网盘 */
+export async function getResourceData(): Promise {
+ const theme = CURRENT_THEME;
+ if (theme === "digital-nomad") {
+ const [booksMod, productsMod, panMod] = await Promise.all([
+ import("@/content/digital/themes/digital-nomad/data/books.json"),
+ import("@/content/digital/themes/digital-nomad/data/products.json"),
+ import("@/content/digital/themes/digital-nomad/data/pan.json"),
+ ]);
+ return {
+ books: booksMod.default.books,
+ products: productsMod.default.products,
+ pan: panMod.default,
+ };
+ }
+ return getResourceDataFallback();
+}
+
+async function getResourceDataFallback(): Promise {
+ const [booksMod, productsMod, panMod] = await Promise.all([
+ import("@/content/digital/themes/digital-nomad/data/books.json"),
+ import("@/content/digital/themes/digital-nomad/data/products.json"),
+ import("@/content/digital/themes/digital-nomad/data/pan.json"),
+ ]);
+ return {
+ books: booksMod.default.books,
+ products: productsMod.default.products,
+ pan: panMod.default,
+ };
+}
+
+export { CURRENT_THEME, THEME_IDS };
+export type { ThemeId };
diff --git a/app/digital/lib/useAuthAndVip.ts b/app/digital/lib/useAuthAndVip.ts
new file mode 100644
index 0000000..c477e47
--- /dev/null
+++ b/app/digital/lib/useAuthAndVip.ts
@@ -0,0 +1,68 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { getStoredUser, getStoredToken } from "@/app/digital/lib/pocketbase";
+
+export interface AuthAndVipState {
+ user: { id: string; email: string } | null;
+ vip: boolean;
+ loading: boolean;
+ refetch: () => Promise<{ user: { id: string; email: string } | null; vip: boolean }>;
+}
+
+export function useAuthAndVip(): AuthAndVipState {
+ const [user, setUser] = useState<{ id: string; email: string } | null>(null);
+ const [vip, setVip] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ const refetch = useCallback(async (): Promise<{ user: { id: string; email: string } | null; vip: boolean }> => {
+ setLoading(true);
+ try {
+ const meRes = await fetch("/api/digital/auth/me", { credentials: "include" });
+ const meData = await meRes.json();
+ let newUser = meData?.user ?? null;
+
+ if (!newUser) {
+ const storedUser = getStoredUser();
+ const storedToken = getStoredToken();
+ if (storedUser && storedToken) {
+ newUser = storedUser;
+ try {
+ await fetch("/api/digital/auth/sync-session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: storedToken, record: storedUser }),
+ credentials: "include",
+ });
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+
+ let newVip = false;
+ if (newUser?.email) {
+ const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(newUser.email)}`, { credentials: "include" });
+ const vipData = await vipRes.json();
+ newVip = vipData?.vip === true;
+ }
+
+ setUser(newUser);
+ setVip(newVip);
+ return { user: newUser, vip: newVip };
+ } catch {
+ const storedUser = getStoredUser();
+ setUser(storedUser);
+ setVip(false);
+ return { user: storedUser, vip: false };
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ refetch();
+ }, [refetch]);
+
+ return { user, vip, loading, refetch };
+}
diff --git a/app/digital/lib/useCrossSiteUrls.ts b/app/digital/lib/useCrossSiteUrls.ts
new file mode 100644
index 0000000..1271d7b
--- /dev/null
+++ b/app/digital/lib/useCrossSiteUrls.ts
@@ -0,0 +1,5 @@
+import { MEETUP_URL, VIP_URL } from "@/config/digital/domain.config";
+
+export function useCrossSiteUrls(): { meetupUrl: string; vipUrl: string } {
+ return { meetupUrl: MEETUP_URL, vipUrl: VIP_URL };
+}
diff --git a/app/globals.css b/app/globals.css
index a34b25a..b32ec9d 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -2,6 +2,7 @@
@source "./";
@source not "./favicon.ico";
@source "../config";
+@source "../content/digital";
@custom-variant dark (&:where(.dark, .dark *));
@@ -22,6 +23,14 @@
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
+ --color-primary: #0ea5e9;
+ --color-primary-dark: #0284c7;
+ --color-accent: #f59e0b;
+ --color-accent-dark: #d97706;
+ --animate-scroll: digital-scroll 30s linear infinite;
+ --animate-scroll-reverse: digital-scroll 30s linear infinite reverse;
+ --animate-fade-in: digital-fade-in 0.6s ease-out;
+ --animate-float: digital-float 6s ease-in-out infinite;
}
body {
@@ -372,3 +381,474 @@ html {
.animate-fade-in {
animation: fadeIn 0.6s ease-out;
}
+
+/* ==================== Digital Project Styles ==================== */
+.digital-scope {
+ --background: #ffffff;
+ --foreground: #0f172a;
+ --card: #ffffff;
+ --card-foreground: #0f172a;
+ --muted: #f1f5f9;
+ --muted-foreground: #64748b;
+ --border: #e2e8f0;
+ --accent: #f59e0b;
+ --accent-foreground: #1c1917;
+ --accent-muted: rgba(245, 158, 11, 0.2);
+ --gradient-accent: rgba(251, 191, 36, 0.15);
+ min-height: 100vh;
+ background: var(--background);
+ color: var(--foreground);
+ overflow-x: hidden;
+}
+
+.dark .digital-scope {
+ --background: #0f172a;
+ --foreground: #f8fafc;
+ --card: rgba(255, 255, 255, 0.05);
+ --card-foreground: #f8fafc;
+ --muted: rgba(255, 255, 255, 0.05);
+ --muted-foreground: #94a3b8;
+ --border: rgba(255, 255, 255, 0.08);
+ --accent: #f59e0b;
+ --accent-foreground: #1c1917;
+ --accent-muted: rgba(245, 158, 11, 0.2);
+ --gradient-accent: rgba(251, 191, 36, 0.15);
+}
+
+@keyframes digital-scroll {
+ from {
+ transform: translateX(0);
+ }
+ to {
+ transform: translateX(-50%);
+ }
+}
+
+@keyframes digital-fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes digital-float {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-10px);
+ }
+}
+
+@keyframes digital-gradient-shift {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.85;
+ }
+}
+
+@keyframes digital-shimmer {
+ 0% {
+ background-position: -200% 0;
+ }
+ 100% {
+ background-position: 200% 0;
+ }
+}
+
+@keyframes digital-float-badge {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-2px);
+ }
+}
+
+.digital-scope .animate-gradient {
+ animation: digital-gradient-shift 8s ease-in-out infinite;
+}
+
+.digital-scope .animate-fade-in {
+ animation: digital-fade-in 0.6s ease-out;
+}
+
+.digital-scope .animate-float-badge {
+ animation: digital-float-badge 3s ease-in-out infinite;
+}
+
+.digital-scope .hover-shimmer {
+ position: relative;
+ overflow: hidden;
+}
+
+.digital-scope .hover-shimmer::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.05),
+ transparent
+ );
+ background-size: 200% 100%;
+ opacity: 0;
+ transition: opacity 0.3s;
+ pointer-events: none;
+}
+
+.digital-scope .hover-shimmer:hover::after {
+ opacity: 1;
+ animation: digital-shimmer 1.5s ease-in-out;
+}
+
+.digital-scope .marquee-container {
+ mask-image: linear-gradient(
+ to right,
+ transparent 0%,
+ black 10%,
+ black 90%,
+ transparent 100%
+ );
+ -webkit-mask-image: linear-gradient(
+ to right,
+ transparent 0%,
+ black 10%,
+ black 90%,
+ transparent 100%
+ );
+}
+
+.digital-scope .gradient-text {
+ background: linear-gradient(135deg, #0ea5e9, #06b6d4, #f59e0b);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.digital-scope .card-hover {
+ transition: all 0.3s ease;
+}
+
+.digital-scope .card-hover:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.1);
+}
+
+.dark .digital-scope .card-hover:hover {
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
+}
+
+.digital-scope .resource-card {
+ transition: all 0.2s ease;
+}
+
+.digital-scope .resource-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
+}
+
+.digital-scope .prose-custom {
+ font-size: 1rem;
+ line-height: 1.85;
+ color: #334155;
+}
+
+.dark .digital-scope .prose-custom {
+ color: #94a3b8;
+}
+
+.digital-scope .prose-custom h1 {
+ font-size: 2.25rem;
+ font-weight: 800;
+ letter-spacing: 0;
+ color: #0f172a;
+ margin-top: 2.5rem;
+ margin-bottom: 1rem;
+ line-height: 1.2;
+}
+
+.dark .digital-scope .prose-custom h1 {
+ color: #f8fafc;
+}
+
+.digital-scope .prose-custom h2 {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #0f172a;
+ margin-top: 2.5rem;
+ margin-bottom: 0.75rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.dark .digital-scope .prose-custom h2 {
+ color: #f8fafc;
+ border-bottom-color: #334155;
+}
+
+.digital-scope .prose-custom h3 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #1e293b;
+ margin-top: 2rem;
+ margin-bottom: 0.5rem;
+}
+
+.digital-scope .prose-custom h4 {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: #1e293b;
+ margin-top: 1.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.digital-scope .prose-custom p {
+ margin-bottom: 1.25rem;
+}
+
+.digital-scope .prose-custom a {
+ color: #0ea5e9;
+ font-weight: 500;
+ text-decoration: underline;
+ text-decoration-color: #bae6fd;
+ text-underline-offset: 2px;
+ transition: text-decoration-color 0.2s;
+}
+
+.digital-scope .prose-custom a:hover {
+ text-decoration-color: #0ea5e9;
+}
+
+.digital-scope .prose-custom strong {
+ font-weight: 700;
+ color: #0f172a;
+}
+
+.digital-scope .prose-custom em {
+ color: #475569;
+}
+
+.digital-scope .prose-custom ul {
+ list-style-type: disc;
+ padding-left: 1.5rem;
+ margin-bottom: 1.25rem;
+}
+
+.digital-scope .prose-custom ol {
+ list-style-type: decimal;
+ padding-left: 1.5rem;
+ margin-bottom: 1.25rem;
+}
+
+.digital-scope .prose-custom li {
+ margin-bottom: 0.4rem;
+}
+
+.digital-scope .prose-custom li > ul,
+.digital-scope .prose-custom li > ol {
+ margin-top: 0.4rem;
+ margin-bottom: 0;
+}
+
+.digital-scope .prose-custom blockquote {
+ border-left: 4px solid #0ea5e9;
+ background: #f0f9ff;
+ padding: 1rem 1.25rem;
+ margin: 1.5rem 0;
+ border-radius: 0 0.75rem 0.75rem 0;
+ color: #334155;
+}
+
+.dark .digital-scope .prose-custom blockquote {
+ background: #0c4a6e33;
+ border-left-color: #38bdf8;
+ color: #94a3b8;
+}
+
+.digital-scope .prose-custom blockquote p:last-child {
+ margin-bottom: 0;
+}
+
+.digital-scope .prose-custom hr {
+ border: none;
+ border-top: 1px solid #e2e8f0;
+ margin: 2rem 0;
+}
+
+.digital-scope .prose-custom code {
+ background: #f1f5f9;
+ color: #0f172a;
+ padding: 0.15rem 0.4rem;
+ border-radius: 0.375rem;
+ font-size: 0.875em;
+ font-family: var(--font-geist-mono), ui-monospace, monospace;
+}
+
+.dark .digital-scope .prose-custom code {
+ background: #334155;
+ color: #e2e8f0;
+}
+
+.digital-scope .prose-custom pre {
+ background: #1e293b;
+ color: #e2e8f0;
+ padding: 1.25rem 1.5rem;
+ border-radius: 0.75rem;
+ overflow-x: auto;
+ margin: 1.5rem 0;
+ font-size: 0.875rem;
+ line-height: 1.7;
+}
+
+.digital-scope .prose-custom pre code {
+ background: transparent;
+ color: inherit;
+ padding: 0;
+ border-radius: 0;
+ font-size: inherit;
+}
+
+.digital-scope .prose-custom table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1.5rem 0;
+ font-size: 0.9rem;
+ overflow: hidden;
+ border-radius: 0.75rem;
+ border: 1px solid #e2e8f0;
+}
+
+.digital-scope .prose-custom thead {
+ background: #f8fafc;
+}
+
+.digital-scope .prose-custom th {
+ padding: 0.75rem 1rem;
+ text-align: left;
+ font-weight: 600;
+ color: #0f172a;
+ border-bottom: 2px solid #e2e8f0;
+ white-space: nowrap;
+}
+
+.digital-scope .prose-custom td {
+ padding: 0.65rem 1rem;
+ border-bottom: 1px solid #f1f5f9;
+ color: #475569;
+}
+
+.digital-scope .prose-custom tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.digital-scope .prose-custom tbody tr:hover {
+ background: #f8fafc;
+}
+
+.digital-scope .prose-custom img {
+ max-width: 100%;
+ border-radius: 0.75rem;
+ margin: 1.5rem 0;
+}
+
+.digital-scope .prose-custom input[type="checkbox"] {
+ margin-right: 0.5rem;
+ accent-color: #0ea5e9;
+}
+
+.digital-scope .prose-custom li:has(input[type="checkbox"]) {
+ list-style-type: none;
+ margin-left: -1.5rem;
+}
+
+@media (max-width: 640px) {
+ .digital-scope .prose-custom h1 {
+ font-size: 1.75rem;
+ }
+ .digital-scope .prose-custom h2 {
+ font-size: 1.3rem;
+ }
+ .digital-scope .prose-custom h3 {
+ font-size: 1.1rem;
+ }
+ .digital-scope .prose-custom table {
+ font-size: 0.8rem;
+ display: block;
+ overflow-x: auto;
+ }
+ .digital-scope .prose-custom th,
+ .digital-scope .prose-custom td {
+ padding: 0.5rem 0.65rem;
+ }
+ .digital-scope .prose-custom blockquote {
+ padding: 0.75rem 1rem;
+ }
+}
+
+.digital-scope .form-input {
+ width: 100%;
+ border: 0;
+ background: transparent;
+ font-size: 15px;
+ line-height: 1.5;
+ color: #1e293b;
+ outline: none;
+ padding: 0;
+}
+
+.digital-scope .form-input::placeholder {
+ color: #b2b2b2;
+}
+
+@media (min-width: 640px) {
+ .digital-scope .form-input {
+ border: 1px solid #e2e8f0;
+ border-radius: 0.75rem;
+ padding: 0.65rem 0.875rem;
+ font-size: 15px;
+ background: #f8fafc;
+ transition: border-color 0.2s, box-shadow 0.2s;
+ }
+ .digital-scope .form-input:focus {
+ border-color: #0ea5e9;
+ box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
+ background: #fff;
+ }
+ .digital-scope textarea.form-input {
+ padding: 0.75rem 0.875rem;
+ }
+ .digital-scope select.form-input {
+ padding-right: 2.25rem;
+ cursor: pointer;
+ }
+}
+
+.dark .digital-scope .form-input {
+ color: #f1f5f9;
+}
+
+.dark .digital-scope .form-input::placeholder {
+ color: #94a3b8;
+}
+
+@media (min-width: 640px) {
+ .dark .digital-scope .form-input {
+ border-color: #475569;
+ background: #1e293b;
+ }
+ .dark .digital-scope .form-input:focus {
+ border-color: #0ea5e9;
+ background: #334155;
+ }
+}
diff --git a/config/digital/course.ts b/config/digital/course.ts
new file mode 100644
index 0000000..44c86d8
--- /dev/null
+++ b/config/digital/course.ts
@@ -0,0 +1,209 @@
+/**
+ * 课程落地页配置 - 数字游民主题(来自 nomadlms)
+ */
+
+export const SITE_CONFIG = {
+ brand: "🌍 数字游民",
+ ctaUrl: "#",
+ footer: "🌍 数字游民 © 2024 | 自由工作,自在生活",
+} as const;
+
+export const HERO_CONFIG = {
+ title: "数字游民实战课",
+ subtitle: "从朝九晚五到边旅行边赚钱,开启你的自由人生",
+ stats: [
+ { value: "21", label: "节实战课程" },
+ { value: "170+", label: "分钟视频" },
+ { value: "3", label: "位资深导师" },
+ ],
+} as const;
+
+export const AUDIENCE_CONFIG = {
+ title: "这门课适合谁?",
+ subtitle: "向往自由,想用技能换时间与地点自由的人",
+ items: [
+ {
+ emoji: "💻",
+ title: "远程打工人",
+ desc: "厌倦通勤,想在家或任何地方高效工作",
+ },
+ {
+ emoji: "✈️",
+ title: "旅行爱好者",
+ desc: "想边旅行边赚钱,不再为假期发愁",
+ },
+ {
+ emoji: "📱",
+ title: "自由职业者",
+ desc: "想建立被动收入,减少接单焦虑",
+ },
+ {
+ emoji: "🌴",
+ title: "生活方式探索者",
+ desc: "想摆脱固定地点,体验不同城市与国家",
+ },
+ ],
+} as const;
+
+export const BENEFITS_CONFIG = {
+ title: "学完你将获得",
+ subtitle: "5大核心能力,助你成为真正的数字游民",
+ items: [
+ {
+ num: "1",
+ title: "远程工作技能体系",
+ desc: "从技能选择到接单渠道,系统掌握远程变现能力",
+ },
+ {
+ num: "2",
+ title: "被动收入搭建方法",
+ desc: "产品化你的技能,让收入不依赖时间",
+ },
+ {
+ num: "3",
+ title: "全球旅居实操指南",
+ desc: "签证、住宿、网络、保险,一站式搞定",
+ },
+ {
+ num: "4",
+ title: "一人公司运营心法",
+ desc: "从个人到团队,可持续的自由商业模式",
+ },
+ {
+ num: "5",
+ title: "数字游民社群与资源",
+ desc: "加入同频社群,获取持续支持与灵感",
+ },
+ ],
+} as const;
+
+export const CURRICULUM_CONFIG = {
+ title: "21节实战课程",
+ subtitle: "从入门到进阶,完整成为数字游民",
+ modules: [
+ {
+ emoji: "🚀",
+ title: "基础篇 · 数字游民入门",
+ lessons: [
+ { name: "01 什么是数字游民?你适合吗?", duration: "11:58" },
+ { name: "02 从朝九晚五到边旅行边赚钱的路径", duration: "14:32" },
+ ],
+ },
+ {
+ emoji: "💼",
+ title: "技能篇 · 远程变现能力",
+ lessons: [
+ { name: "3.1 最适合远程的5大技能方向", duration: "06:25" },
+ { name: "3.2 如何打造你的技能组合与作品集", duration: "07:34" },
+ ],
+ },
+ {
+ emoji: "💰",
+ title: "收入篇 · 被动收入与副业",
+ lessons: [
+ { name: "04 从接单到产品的思维转变", duration: "04:16" },
+ { name: "05 打造你的第一个数字产品", duration: "06:18" },
+ { name: "06 海外平台接单与变现", duration: "02:30" },
+ { name: "07 被动收入组合:课程+会员+咨询", duration: "06:25" },
+ { name: "08 如何定价才能既赚又自由", duration: "03:09" },
+ ],
+ },
+ {
+ emoji: "🧠",
+ title: "心态篇 · 自由与自律",
+ lessons: [
+ { name: "09 数字游民的时间管理心法", duration: "07:49" },
+ { name: "09-1 独处与社交的平衡", duration: "01:24" },
+ { name: "10 如何应对家人的不理解", duration: "04:07" },
+ ],
+ },
+ {
+ emoji: "⚡",
+ title: "落地篇 · 旅居实操",
+ lessons: [
+ { name: "11 签证与长期居留规划", duration: "04:12" },
+ { name: "12 住宿选择:短租、民宿、Co-living", duration: "05:19" },
+ { name: "13 网络、保险与紧急预案", duration: "04:11" },
+ ],
+ },
+ {
+ emoji: "🎯",
+ title: "实战篇 · 真实案例",
+ lessons: [
+ { name: "14 巴厘岛数字游民社区探访", duration: "12:02" },
+ { name: "15 曼谷/清迈生活成本与体验", duration: "09:39" },
+ { name: "16 欧洲数字游民签证攻略", duration: "09:04" },
+ { name: "17 一人公司如何合法合规运营", duration: "05:12" },
+ ],
+ },
+ {
+ emoji: "🏆",
+ title: "进阶篇 · 高阶玩法",
+ lessons: [
+ { name: "18 从一人到小团队的进化", duration: "11:25" },
+ { name: "19 建立你的数字游民品牌", duration: "25:57" },
+ { name: "20 年度旅居计划与预算规划", duration: "12:34" },
+ { name: "21 长期数字游民的可持续之道", duration: "08:53" },
+ ],
+ },
+ ],
+} as const;
+
+export const INSTRUCTORS_CONFIG = {
+ title: "三位资深导师联合授课",
+ subtitle: "异度世界、大师兄、小美带你开启数字游民之旅",
+ items: [
+ {
+ name: "异度世界",
+ role: "数字游民先行者",
+ desc: "5年旅居经验,走过20+国家",
+ tag: "课程主理",
+ },
+ {
+ name: "大师兄",
+ role: "一人公司实践者",
+ desc: "远程自由职业转型教练",
+ tag: "变现专家",
+ },
+ {
+ name: "小美",
+ role: "生活方式博主",
+ desc: "边旅行边创作的斜杠青年",
+ tag: "旅居达人",
+ },
+ ],
+} as const;
+
+export const CTA_CONFIG = {
+ title: "准备好开启你的数字游民之旅了吗?",
+ subtitle: "加入我们,跟着异度世界、大师兄、小美一起,从零开始实现自由人生",
+ badges: ["21节实战视频", "永久回看", "社群答疑"],
+} as const;
+
+/** FAQ 常见问题 */
+export const FAQ_CONFIG = {
+ title: "常见问题",
+ items: [
+ { q: "课程可以永久回看吗?", a: "可以,报名后永久有效,随时回看。" },
+ { q: "适合零基础吗?", a: "适合。课程从入门到进阶;视频试看暂时关闭,报名后可解锁全部章节。" },
+ { q: "有社群答疑吗?", a: "有,报名后加入专属社群,导师和同学一起交流。" },
+ { q: "支持退款吗?", a: "7 天内不满意可申请全额退款。" },
+ ],
+} as const;
+
+/** 社群入口 */
+export const COMMUNITY_CONFIG = {
+ enabled: true,
+ wechat: "扫码加入数字游民社群",
+ qrCodeUrl: "/qrcode-community.png",
+ qq: "",
+ link: "",
+} as const;
+
+/** 章节底部网盘下载模块 */
+export const DOWNLOAD_CONFIG = {
+ enabled: true,
+ url: "https://pan.baidu.com/s/example",
+ title: "课程资料下载",
+ buttonText: "前往网盘下载",
+} as const;
diff --git a/config/digital/domain.config.ts b/config/digital/domain.config.ts
new file mode 100644
index 0000000..2ebebdc
--- /dev/null
+++ b/config/digital/domain.config.ts
@@ -0,0 +1,48 @@
+/**
+ * 域名与根域配置 - 特色标识
+ * 更换域名(如 *.hackrobot.cn -> *.nomadro.com)时,只需设置 ROOT_DOMAIN 或各独立变量
+ *
+ * 环境变量:
+ * - ROOT_DOMAIN: 根域名,如 nomadro.com,用于推导 Cookie、API 等默认值
+ * - AUTH_COOKIE_DOMAIN: Cookie 根域,如 .nomadro.com(优先于 ROOT_DOMAIN)
+ * - PAYMENT_API_URL: 支付 API 地址
+ * - NEXT_PUBLIC_POCKETBASE_URL: PocketBase 地址
+ * - NEXT_PUBLIC_SITE_URL: 站点根地址
+ */
+
+const isDev = process.env.NODE_ENV === "development";
+
+/** 根域名,用于推导默认值。如 nomadro.com、hackrobot.cn */
+export const ROOT_DOMAIN =
+ process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn";
+
+/** 支付 API 默认地址。生产环境固定为 api.${ROOT_DOMAIN},避免 .env 中 127.0.0.1 导致支付失败 */
+export const DEFAULT_PAYMENT_API_URL = isDev
+ ? "http://127.0.0.1:8007"
+ : `https://api.${ROOT_DOMAIN}`;
+
+/** 生产环境若 PAYMENT_API_URL 指向 localhost,则忽略并使用 api 域名 */
+export function resolvePaymentApiUrl(): string {
+ const url = process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL;
+ if (!isDev && /^(https?:\/\/)?(127\.0\.0\.1|localhost)(:\d+)?(\/|$)/i.test(url)) {
+ return `https://api.${ROOT_DOMAIN}`;
+ }
+ return url;
+}
+
+/** PocketBase 默认地址 */
+export const DEFAULT_POCKETBASE_URL = `https://pocketbase.${ROOT_DOMAIN}`;
+
+/** 站点根地址默认值 */
+export const DEFAULT_SITE_URL = isDev
+ ? "http://localhost:3001"
+ : `https://${ROOT_DOMAIN}`;
+
+/** MinIO 端点默认主机 */
+export const DEFAULT_MINIO_HOST = `minioweb.${ROOT_DOMAIN}`;
+
+/** meetup(目的地)站链接 */
+export const MEETUP_URL = `https://meetup.${ROOT_DOMAIN}/`;
+
+/** vip(异度星球)站链接 */
+export const VIP_URL = `https://vip.${ROOT_DOMAIN}/`;
diff --git a/config/digital/index.ts b/config/digital/index.ts
new file mode 100644
index 0000000..60a72dd
--- /dev/null
+++ b/config/digital/index.ts
@@ -0,0 +1,18 @@
+export {
+ CURRENT_THEME,
+ THEME_IDS,
+ THEME_CONFIGS,
+ TOPIC_IDS,
+ getThemeConfig,
+ type ThemeId,
+ type ThemeConfig,
+ type TopicId,
+} from "./site.config";
+
+export {
+ getPocketBaseConfig,
+ getPaymentConfig,
+ getJoinPaymentConfig,
+ getServicesConfig,
+} from "./services.config";
+export type { PocketBaseConfig, PaymentConfig, ServicesConfig } from "./services.config";
diff --git a/config/digital/lessons.ts b/config/digital/lessons.ts
new file mode 100644
index 0000000..2e9f56c
--- /dev/null
+++ b/config/digital/lessons.ts
@@ -0,0 +1,112 @@
+/**
+ * 课程章节详情配置 - 数字游民主题(来自 nomadlms)
+ */
+
+const SAMPLE_VIDEO = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
+
+const SAMPLE_MARKDOWN = `# 课程概述
+
+本小节将带你理解数字游民的核心概念,掌握关键技能。
+
+## 学习目标
+
+- 理解数字游民的本质与生活方式
+- 掌握远程变现的基础路径
+- 完成个人定位与规划
+
+## 核心要点
+
+1. **定义**:数字游民 = 远程工作 + 自由旅行 + 地点独立
+2. **技能**:选择可远程交付的技能方向
+3. **心态**:自律与自由是相辅相成的
+
+## 行动清单
+
+- [ ] 评估自己的技能是否可远程交付
+- [ ] 列出3个你想旅居的城市
+- [ ] 设定6个月内的转型目标
+
+### 代码示例
+
+\`\`\`bash
+# 安装依赖
+pnpm install
+
+# 启动开发
+pnpm dev
+\`\`\`
+
+行内代码示例:\`npm run build\` 可生成生产版本。
+
+## 注意事项
+
+> 数字游民不是逃避,而是主动选择。建议先有稳定收入再考虑长期旅居。
+
+| 项目 | 说明 |
+|------|------|
+| 收入 | 建议月收入稳定在 1 万以上 |
+| 技能 | 至少 1 项可远程交付 |
+| 心态 | 自律与自由平衡 |
+`;
+
+export type LessonDetail = {
+ name: string;
+ duration: string;
+ videoUrl: string;
+ markdown: string;
+};
+
+/** 扁平化课程数据:moduleIndex-lessonIndex 作为 key */
+export const LESSONS_MAP: Record = {
+ "0-0": {
+ name: "01 什么是数字游民?你适合吗?",
+ duration: "11:58",
+ videoUrl: SAMPLE_VIDEO,
+ markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "数字游民的定义与自我评估"),
+ },
+ "0-1": {
+ name: "02 从朝九晚五到边旅行边赚钱的路径",
+ duration: "14:32",
+ videoUrl: SAMPLE_VIDEO,
+ markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "从上班族到数字游民的转型路径"),
+ },
+ "1-0": {
+ name: "3.1 最适合远程的5大技能方向",
+ duration: "06:25",
+ videoUrl: SAMPLE_VIDEO,
+ markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "编程、设计、写作、营销、咨询等远程技能"),
+ },
+ "1-1": {
+ name: "3.2 如何打造你的技能组合与作品集",
+ duration: "07:34",
+ videoUrl: SAMPLE_VIDEO,
+ markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "作品集搭建与个人品牌展示"),
+ },
+ "2-0": { name: "04 从接单到产品的思维转变", duration: "04:16", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "2-1": { name: "05 打造你的第一个数字产品", duration: "06:18", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "2-2": { name: "06 海外平台接单与变现", duration: "02:30", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "2-3": { name: "07 被动收入组合:课程+会员+咨询", duration: "06:25", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "2-4": { name: "08 如何定价才能既赚又自由", duration: "03:09", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "3-0": { name: "09 数字游民的时间管理心法", duration: "07:49", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "3-1": { name: "09-1 独处与社交的平衡", duration: "01:24", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "3-2": { name: "10 如何应对家人的不理解", duration: "04:07", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "4-0": { name: "11 签证与长期居留规划", duration: "04:12", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "4-1": { name: "12 住宿选择:短租、民宿、Co-living", duration: "05:19", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "4-2": { name: "13 网络、保险与紧急预案", duration: "04:11", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "5-0": { name: "14 巴厘岛数字游民社区探访", duration: "12:02", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "5-1": { name: "15 曼谷/清迈生活成本与体验", duration: "09:39", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "5-2": { name: "16 欧洲数字游民签证攻略", duration: "09:04", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "5-3": { name: "17 一人公司如何合法合规运营", duration: "05:12", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "6-0": { name: "18 从一人到小团队的进化", duration: "11:25", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "6-1": { name: "19 建立你的数字游民品牌", duration: "25:57", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "6-2": { name: "20 年度旅居计划与预算规划", duration: "12:34", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+ "6-3": { name: "21 长期数字游民的可持续之道", duration: "08:53", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
+};
+
+export function getLessonKey(moduleIndex: number, lessonIndex: number): string {
+ return `${moduleIndex}-${lessonIndex}`;
+}
+
+export function getLesson(moduleIndex: number, lessonIndex: number): LessonDetail | null {
+ return LESSONS_MAP[getLessonKey(moduleIndex, lessonIndex)] ?? null;
+}
diff --git a/config/digital/services.config.ts b/config/digital/services.config.ts
new file mode 100644
index 0000000..d537688
--- /dev/null
+++ b/config/digital/services.config.ts
@@ -0,0 +1,156 @@
+/**
+ * 服务配置 - PocketBase、支付等第三方服务
+ * 可通过环境变量覆盖,不同主题可配置不同集合/金额
+ * 域名更换:设置 ROOT_DOMAIN(如 nomadro.com)可推导默认 API 地址
+ */
+
+import { getThemeConfig } from "./site.config";
+import {
+ DEFAULT_PAYMENT_API_URL,
+ resolvePaymentApiUrl,
+ DEFAULT_POCKETBASE_URL,
+ DEFAULT_MINIO_HOST,
+} from "./domain.config";
+
+export interface PocketBaseConfig {
+ enabled: boolean;
+ /** PocketBase 服务地址 */
+ url: string;
+ /** 用户认证集合 */
+ usersCollection: string;
+ /** 申请/加入表单集合(如 solan、applications) */
+ joinCollection: string;
+}
+
+export interface PaymentConfig {
+ enabled: boolean;
+ /** 支付 API 地址(payjsapi) */
+ apiUrl: string;
+ /** 支付提供商:zpay | xorpay */
+ provider: "zpay" | "xorpay";
+ /** 默认金额(分) */
+ defaultAmount: number;
+ /** 默认订单类型 */
+ defaultType: string;
+ /** ZPAY 提交地址 */
+ zpaySubmitUrl: string;
+}
+
+export interface MinioConfig {
+ enabled: boolean;
+ /** MinIO 服务端点 */
+ endPoint: string;
+ /** 端口 */
+ port: number;
+ /** 是否使用 SSL */
+ useSSL: boolean;
+ /** 存储桶 */
+ bucket: string;
+ /** Access Key */
+ accessKey: string;
+ /** Secret Key */
+ secretKey: string;
+ /** 区域 */
+ region: string;
+ /** 公开访问 URL(用于生成文件链接) */
+ publicUrl: string;
+ /** 上传路径前缀(如 joins、uploads) */
+ uploadPrefix: string;
+}
+
+export interface ServicesConfig {
+ pocketbase: PocketBaseConfig;
+ payment: PaymentConfig;
+ minio: MinioConfig;
+}
+
+const isDev = process.env.NODE_ENV === "development";
+const PAYJSAPI_PORT = process.env.PAYJSAPI_PORT || "8007";
+
+/** 站点标识,用于 VIP 按站点隔离。digital 专题站(ai.xx/android.xx)各自独立,如 digital_ai、digital_android */
+export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "digital";
+
+/** vip 站(vip.hackrobot.cn)的 site_id,其付费 VIP 可解锁所有 digital 专题站 */
+export const VIP_MASTER_SITE_ID = "vip";
+
+export function getApiUrlFromRequest(hostHeader: string | null): string | null {
+ if (!hostHeader) return null;
+ if (!isDev) return resolvePaymentApiUrl();
+ const hostname = hostHeader.split(":")[0];
+ return `http://${hostname}:${PAYJSAPI_PORT}`;
+}
+
+/** 获取 PocketBase 配置(可传入主题覆盖) */
+export function getPocketBaseConfig(themeOverride?: { joinCollection?: string }): PocketBaseConfig {
+ const joinCollection =
+ themeOverride?.joinCollection ||
+ process.env.POCKETBASE_JOIN_COLLECTION ||
+ "solan";
+ return {
+ enabled: true,
+ url: process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.POCKETBASE_URL || DEFAULT_POCKETBASE_URL,
+ usersCollection: "users",
+ joinCollection,
+ };
+}
+
+/** 获取支付配置(可传入主题覆盖,如 join 金额/类型) */
+export function getPaymentConfig(themeOverride?: { amount?: number; type?: string }): PaymentConfig {
+ const base = {
+ enabled: true,
+ apiUrl: resolvePaymentApiUrl(),
+ provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
+ defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 8800,
+ defaultType: process.env.PAYMENT_DEFAULT_TYPE || "meetup",
+ zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
+ };
+ if (themeOverride) {
+ if (themeOverride.amount != null) base.defaultAmount = themeOverride.amount;
+ if (themeOverride.type) base.defaultType = themeOverride.type;
+ }
+ return base;
+}
+
+/** 获取 MinIO 配置(可传入主题覆盖 bucket/prefix) */
+export function getMinioConfig(themeOverride?: { bucket?: string; uploadPrefix?: string }): MinioConfig {
+ const endPoint = process.env.MINIO_ENDPOINT || DEFAULT_MINIO_HOST;
+ const port = parseInt(process.env.MINIO_PORT || "9035", 10);
+ const useSSL = process.env.MINIO_USE_SSL === "true";
+ const bucket = themeOverride?.bucket || process.env.MINIO_BUCKET || "hackrobot";
+ const publicUrl =
+ process.env.MINIO_PUBLIC_URL ||
+ `https://${endPoint}/${bucket}`;
+ const uploadPrefix = themeOverride?.uploadPrefix || process.env.MINIO_UPLOAD_PREFIX || "joins";
+
+ return {
+ enabled: true,
+ endPoint,
+ port,
+ useSSL,
+ bucket,
+ accessKey: process.env.MINIO_ACCESS_KEY || "6i56HHfg4zPfYItCZtnp",
+ secretKey: process.env.MINIO_SECRET_KEY || "vDJCqEit3ejH5UmWKAZnvqhziNfbVsoOlBW12G8Q",
+ region: process.env.MINIO_REGION || "china",
+ publicUrl: publicUrl.replace(/\/$/, ""),
+ uploadPrefix,
+ };
+}
+
+/** 获取完整服务配置 */
+export function getServicesConfig(): ServicesConfig {
+ return {
+ pocketbase: getPocketBaseConfig(),
+ payment: getPaymentConfig(),
+ minio: getMinioConfig(),
+ };
+}
+
+/** 获取「加入/申请」场景的支付配置(含主题覆盖) */
+export function getJoinPaymentConfig(): { amount: number; type: string } {
+ const theme = getThemeConfig();
+ const payment = getPaymentConfig();
+ return {
+ amount: theme.services?.join?.amount ?? payment.defaultAmount,
+ type: theme.services?.join?.type ?? payment.defaultType,
+ };
+}
diff --git a/config/digital/site.config.ts b/config/digital/site.config.ts
new file mode 100644
index 0000000..2d4b335
--- /dev/null
+++ b/config/digital/site.config.ts
@@ -0,0 +1,193 @@
+/**
+ * 站点模板配置 - 切换主题只需修改 CURRENT_THEME
+ * 克隆新主题:复制 themes/digital-nomad 为 themes/your-theme,修改配置和内容
+ */
+
+/** 专题 ID:子域名部署 + 项目复制架构(资源导航中带「专题」标记) */
+export const TOPIC_IDS = ["indieDev", "aiAgent", "cloudPhone", "unmannedLive"] as const;
+
+export type TopicId = (typeof TOPIC_IDS)[number];
+
+export const THEME_IDS = [
+ "digital-nomad",
+ "solo-company",
+ "tiktok-ops",
+ "indie-dev",
+] as const;
+
+export type ThemeId = (typeof THEME_IDS)[number];
+
+export const CURRENT_THEME: ThemeId =
+ (process.env.NEXT_PUBLIC_THEME as ThemeId) || "digital-nomad";
+
+export interface ThemeConfig {
+ id: ThemeId;
+ name: string;
+ nameEn: string;
+ tagline: string;
+ taglineEn: string;
+ /** SEO 元数据 */
+ meta: {
+ title: string;
+ description: string;
+ };
+ /** 功能开关 */
+ features: {
+ roadmap: boolean;
+ tools: boolean;
+ community: boolean;
+ join: boolean;
+ course: boolean;
+ ebook: boolean;
+ };
+ /** 品牌色 - Tailwind 类名 */
+ colors: {
+ primary: string;
+ accent: string;
+ };
+ /** 服务配置覆盖(可选) */
+ services?: {
+ /** 加入/申请支付 */
+ join?: {
+ amount?: number;
+ type?: string;
+ };
+ /** PocketBase 申请集合名 */
+ pocketbaseJoinCollection?: string;
+ /** MinIO 存储(可选) */
+ minio?: {
+ bucket?: string;
+ uploadPrefix?: string;
+ };
+ /** 电商独立站域名(可选,配置后显示电商卡片) */
+ shopUrl?: string;
+ };
+ /** 关于我们:Linktree 链接(可选) */
+ linktreeUrl?: string;
+ /** 社交平台链接(用于关于页展示) */
+ socialLinks?: Array<{ platform: string; label: string; url: string; group?: "domestic" | "overseas" }>;
+ /** 团队成员(可选) */
+ teamMembers?: Array<{ name: string; role: string; avatar?: string; link?: string }>;
+}
+
+export const THEME_CONFIGS: Record = {
+ "digital-nomad": {
+ id: "digital-nomad",
+ name: "数字游民指南",
+ nameEn: "Digital Nomad Guide",
+ tagline: "数字游民资源平台",
+ taglineEn: "Digital nomad resource platform",
+ meta: {
+ title: "数字游民指南 | Digital Nomad Guide",
+ description:
+ "从零开始,7天开启你的数字游民生活。远程工作、地点自由、技能变现 —— 一站式数字游民资源平台。",
+ },
+ features: {
+ roadmap: true,
+ tools: true,
+ community: true,
+ join: true,
+ course: true,
+ ebook: true,
+ },
+ colors: {
+ primary: "sky",
+ accent: "amber",
+ },
+ services: {
+ join: { amount: 8800, type: "meetup" },
+ pocketbaseJoinCollection: "solan",
+ shopUrl: process.env.NEXT_PUBLIC_SHOP_URL || "https://store.hackrobot.cn/", // 独立站域名
+ },
+ linktreeUrl: process.env.NEXT_PUBLIC_LINKTREE_URL || "https://linktr.ee/digitalnomad",
+ socialLinks: [
+ { platform: "douyin", label: "抖音", url: "https://www.douyin.com", group: "domestic" },
+ { platform: "wechat-official", label: "微信公众号", url: "https://mp.weixin.qq.com", group: "domestic" },
+ { platform: "xiaohongshu", label: "小红书", url: "https://www.xiaohongshu.com", group: "domestic" },
+ { platform: "tiktok", label: "TikTok", url: "https://www.tiktok.com", group: "overseas" },
+ { platform: "youtube", label: "YouTube 频道", url: "https://www.youtube.com", group: "overseas" },
+ { platform: "twitter", label: "X", url: "https://x.com", group: "overseas" },
+ ],
+ teamMembers: [
+ { name: "成员 A", role: "发起人", link: "#" },
+ { name: "成员 B", role: "内容运营", link: "#" },
+ { name: "成员 C", role: "技术开发", link: "#" },
+ ],
+ },
+ "solo-company": {
+ id: "solo-company",
+ name: "一人公司指南",
+ nameEn: "Solo Company Guide",
+ tagline: "一人公司创业与运营指南",
+ taglineEn: "Guide to building and running a solo company",
+ meta: {
+ title: "一人公司指南 | Solo Company Guide",
+ description:
+ "从零到一,打造属于你的一人公司。产品、营销、运营 —— 一人公司全流程指南。",
+ },
+ features: {
+ roadmap: true,
+ tools: true,
+ community: true,
+ join: false,
+ course: false,
+ ebook: true,
+ },
+ colors: {
+ primary: "violet",
+ accent: "emerald",
+ },
+ },
+ "tiktok-ops": {
+ id: "tiktok-ops",
+ name: "TikTok 运营指南",
+ nameEn: "TikTok Operations Guide",
+ tagline: "TikTok 出海运营与变现指南",
+ taglineEn: "TikTok operations and monetization guide",
+ meta: {
+ title: "TikTok 运营指南 | TikTok Operations Guide",
+ description:
+ "TikTok 账号运营、内容创作、广告投放、跨境变现 —— 一站式 TikTok 出海指南。",
+ },
+ features: {
+ roadmap: true,
+ tools: true,
+ community: true,
+ join: false,
+ course: true,
+ ebook: true,
+ },
+ colors: {
+ primary: "pink",
+ accent: "cyan",
+ },
+ },
+ "indie-dev": {
+ id: "indie-dev",
+ name: "独立开发指南",
+ nameEn: "Indie Developer Guide",
+ tagline: "独立开发者产品与变现指南",
+ taglineEn: "Indie developer product and monetization guide",
+ meta: {
+ title: "独立开发指南 | Indie Developer Guide",
+ description:
+ "从想法到产品,独立开发全流程。技术选型、产品设计、上线运营 —— 独立开发者必备指南。",
+ },
+ features: {
+ roadmap: true,
+ tools: true,
+ community: true,
+ join: false,
+ course: false,
+ ebook: true,
+ },
+ colors: {
+ primary: "indigo",
+ accent: "orange",
+ },
+ },
+};
+
+export function getThemeConfig(): ThemeConfig {
+ return THEME_CONFIGS[CURRENT_THEME];
+}
diff --git a/config/footer.config.ts b/config/footer.config.ts
index ba99780..c861bc4 100644
--- a/config/footer.config.ts
+++ b/config/footer.config.ts
@@ -17,7 +17,7 @@ export const FOOTER_SECTIONS: FooterSection[] = [
titleKey: "about",
links: [
{ href: "/about", labelKey: "aboutUs" },
- { href: "https://digital.hackrobot.cn/", labelKey: "digitalNomadGuide" },
+ { href: "/digital", labelKey: "digitalNomadGuide" },
{ href: "/careers", labelKey: "careers" },
{ href: "/press", labelKey: "press" },
],
diff --git a/content/digital/themes/digital-nomad/data/books.json b/content/digital/themes/digital-nomad/data/books.json
new file mode 100644
index 0000000..9e7ad15
--- /dev/null
+++ b/content/digital/themes/digital-nomad/data/books.json
@@ -0,0 +1,19 @@
+{
+ "books": [
+ { "cover": "https://covers.openlibrary.org/b/id/6679955-M.jpg", "title": "一人企业方法论", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/1252736-M.jpg", "title": "数字游民", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/10961054-M.jpg", "title": "远程工作革命", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/539652-M.jpg", "title": "4小时工作周", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/10449931-M.jpg", "title": "纳瓦尔宝典", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/8337563-M.jpg", "title": "穷查理宝典", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/7104760-M.jpg", "title": "精益创业", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/9002334-M.jpg", "title": "从0到1", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/388913-M.jpg", "title": "黑客与画家", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/6679955-M.jpg", "title": "重来", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/7988607-M.jpg", "title": "深度工作", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/10467082-M.jpg", "title": "刻意练习", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/8315355-M.jpg", "title": "原则", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/13290711-M.jpg", "title": "思考快与慢", "href": "https://www.jd.com" },
+ { "cover": "https://covers.openlibrary.org/b/id/431011-M.jpg", "title": "影响力", "href": "https://www.jd.com" }
+ ]
+}
diff --git a/content/digital/themes/digital-nomad/data/pan.json b/content/digital/themes/digital-nomad/data/pan.json
new file mode 100644
index 0000000..d3aa7fd
--- /dev/null
+++ b/content/digital/themes/digital-nomad/data/pan.json
@@ -0,0 +1,52 @@
+{
+ "categories": [
+ {
+ "id": "docs",
+ "title": "文档",
+ "icon": "📄",
+ "items": [
+ { "name": "飞书文档", "desc": "在线协作", "href": "https://www.feishu.cn" },
+ { "name": "Notion", "desc": "知识库", "href": "https://www.notion.so" },
+ { "name": "语雀", "desc": "文档协作", "href": "https://www.yuque.com" },
+ { "name": "腾讯文档", "desc": "在线协作", "href": "https://docs.qq.com" },
+ { "name": "石墨文档", "desc": "在线表格", "href": "https://shimo.im" }
+ ]
+ },
+ {
+ "id": "templates",
+ "title": "模版",
+ "icon": "📋",
+ "items": [
+ { "name": "飞书模版", "desc": "文档/表格模版", "href": "https://www.feishu.cn" },
+ { "name": "Notion模版", "desc": "知识库模版", "href": "https://www.notion.so" },
+ { "name": "Canva", "desc": "设计模版", "href": "https://www.canva.com" },
+ { "name": "Figma", "desc": "UI模版", "href": "https://www.figma.com" },
+ { "name": "PPT模版", "desc": "演示文稿", "href": "https://www.jd.com" }
+ ]
+ },
+ {
+ "id": "firmware",
+ "title": "系统固件",
+ "icon": "⚙️",
+ "items": [
+ { "name": "OpenWrt", "desc": "路由器固件", "href": "https://openwrt.org" },
+ { "name": "Armbian", "desc": "ARM开发板", "href": "https://www.armbian.com" },
+ { "name": "树莓派OS", "desc": "Raspberry Pi", "href": "https://www.raspberrypi.com" },
+ { "name": "Android TV", "desc": "电视盒子固件", "href": "https://source.android.com" },
+ { "name": "固件仓库", "desc": "固件合集", "href": "https://www.jd.com" }
+ ]
+ },
+ {
+ "id": "storage",
+ "title": "网盘",
+ "icon": "☁️",
+ "items": [
+ { "name": "阿里云盘", "desc": "大容量不限速", "href": "https://www.aliyundrive.com" },
+ { "name": "百度网盘", "desc": "资源丰富", "href": "https://pan.baidu.com" },
+ { "name": "夸克网盘", "desc": "高速下载", "href": "https://pan.quark.cn" },
+ { "name": "115网盘", "desc": "离线下载", "href": "https://115.com" },
+ { "name": "OneDrive", "desc": "微软云盘", "href": "https://onedrive.live.com" }
+ ]
+ }
+ ]
+}
diff --git a/content/digital/themes/digital-nomad/data/products.json b/content/digital/themes/digital-nomad/data/products.json
new file mode 100644
index 0000000..ae32578
--- /dev/null
+++ b/content/digital/themes/digital-nomad/data/products.json
@@ -0,0 +1,19 @@
+{
+ "products": [
+ { "image": "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=400", "name": "MacBook Pro", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1544244015-0df4b3ffc2b0?w=400", "name": "iPad Pro", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=400", "name": "Kindle", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=400", "name": "罗技MX Keys", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1527864550417-7fd91fc51a46?w=400", "name": "罗技MX Master", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400", "name": "降噪耳机", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?w=400", "name": "便携显示器", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1597872200969-2b65d565bd41?w=400", "name": "移动硬盘", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1583394838336-acd977736f90?w=400", "name": "氮化镓充电器", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1593642632559-0c6d3fc62b89?w=400", "name": "拓展坞", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1574717024653-61fd2cf4d44d?w=400", "name": "云台稳定器", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1593640408182-31c70c8268f5?w=400", "name": "补光灯", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1590602847861-f357a9332bbc?w=400", "name": "麦克风", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1610557892470-55d9e80c0bce?w=400", "name": "树莓派", "href": "https://www.jd.com" },
+ { "image": "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=400", "name": "开发板", "href": "https://www.jd.com" }
+ ]
+}
diff --git a/content/digital/themes/digital-nomad/data/tools.json b/content/digital/themes/digital-nomad/data/tools.json
new file mode 100644
index 0000000..46ffc35
--- /dev/null
+++ b/content/digital/themes/digital-nomad/data/tools.json
@@ -0,0 +1,219 @@
+{
+ "categories": [
+ {
+ "id": "nomad-community",
+ "icon": "👥",
+ "title": "游民社区",
+ "color": "from-sky-500 to-cyan-500",
+ "bg": "bg-sky-50",
+ "text": "text-sky-700",
+ "tools": [
+ { "name": "nomadro", "desc": "出海导航", "href": "https://nomadro.com" },
+ { "name": "nomadlist", "desc": "海外最大的数字游民社区", "href": "https://nomads.com?ref=https://nomadro.com" },
+ { "name": "nomadcna", "desc": "国内数字游民社区", "href": "https://meetup.hackrobot.cn" }
+ ]
+ },
+ {
+ "id": "toolbox",
+ "icon": "🔧",
+ "title": "工具箱",
+ "color": "from-violet-500 to-purple-500",
+ "bg": "bg-violet-50",
+ "text": "text-violet-700",
+ "tools": [
+ { "name": "ChatGPT", "desc": "OpenAI开发的人工智能聊天机器人程序", "href": "https://chat.openai.com/" },
+ { "name": "Grok", "desc": "xAI 免费 AI 助手", "href": "https://grok.com/" },
+ { "name": "沉浸式翻译", "desc": "双语翻译插件", "href": "https://immersivetranslate.com/?via=nomad-vps" },
+ { "name": "奈飞小铺", "desc": "代充值,外网账号合租平台", "href": "https://ihezu.art/CZhn6c" }
+ ]
+ },
+ {
+ "id": "us-phone",
+ "icon": "📱",
+ "title": "美国手机号",
+ "color": "from-emerald-500 to-teal-500",
+ "bg": "bg-emerald-50",
+ "text": "text-emerald-700",
+ "tools": [
+ { "name": "1psim", "desc": "低成本:2.5$/m,支持esim和实体卡(邮寄中国)", "href": "https://1psim.com/7GARRL" },
+ { "name": "yesim", "desc": "美国手机号,3$/m", "href": "https://yesim.app/?partner_id=1948" },
+ { "name": "tello", "desc": "最低5$/m,支持esim", "href": "https://tello.com/" },
+ { "name": "9esim", "desc": "让任何手机支持esim,可写数据的SIM实体卡", "href": "https://www.9esim.com/?coupon=XIAOSHUANGERIC2" }
+ ]
+ },
+ {
+ "id": "finance",
+ "icon": "💳",
+ "title": "金融收款",
+ "color": "from-amber-500 to-orange-500",
+ "bg": "bg-amber-50",
+ "text": "text-amber-700",
+ "tools": [
+ { "name": "wise", "desc": "线上开通境外银行账户(身份证即可)", "href": "https://wise.com/invite/amc/xiaoshuangh" },
+ { "name": "OKX交易所", "desc": "加密货币交易所", "href": "https://okx.com/join/84999861" },
+ { "name": "safepal (U卡)", "desc": "出海神卡,邀请码:104809", "href": "https://www.safepal.com/zh-cn/" },
+ { "name": "paypal", "desc": "出海收款必备,tiktok提现", "href": "https://py.pl/1tyAwY" },
+ { "name": "buy me coffee", "desc": "赞助收款平台", "href": "https://www.buymeacoffee.com/invite/xiaoshuangi" }
+ ]
+ },
+ {
+ "id": "vps",
+ "icon": "🖥️",
+ "title": "VPS服务器",
+ "color": "from-blue-500 to-indigo-500",
+ "bg": "bg-blue-50",
+ "text": "text-blue-700",
+ "tools": [
+ { "name": "vultr", "desc": "可按分钟计费的服务器 (可支付宝)", "href": "https://www.vultr.com/?ref=8985003" },
+ { "name": "bandwagonhost", "desc": "针对中国线路优化的高质量vps", "href": "https://bandwagonhost.com/aff.php?aff=25463" },
+ { "name": "RackNerd", "desc": "最便宜的美国vps服务器", "href": "https://my.racknerd.com/aff.php?aff=15630" },
+ { "name": "腾讯云", "desc": "腾讯服务器,国内速度快", "href": "https://curl.qcloud.com/BZZ2MBTN" },
+ { "name": "阿里云", "desc": "阿里巴巴旗下,vps服务器", "href": "https://www.aliyun.com/minisite/goods?userCode=dii0ofzg" }
+ ]
+ },
+ {
+ "id": "residential-ip",
+ "icon": "🌐",
+ "title": "静态住宅IP",
+ "color": "from-pink-500 to-rose-500",
+ "bg": "bg-pink-50",
+ "text": "text-pink-700",
+ "tools": [
+ { "name": "iproyal", "desc": "静态住宅ip,出海必备", "href": "https://iproyal.cn/?r=nomadro" },
+ { "name": "proxy-cheap", "desc": "便宜的住宅ip", "href": "https://app.proxy-cheap.com/r/bmcMoY" },
+ { "name": "smartproxy", "desc": "静态住宅ip服务商", "href": "https://www.smartproxy.org/register/?invitation_code=7RXPLM" }
+ ]
+ },
+ {
+ "id": "residential-vps",
+ "icon": "🏠",
+ "title": "住宅VPS服务器",
+ "color": "from-rose-500 to-pink-500",
+ "bg": "bg-rose-50",
+ "text": "text-rose-700",
+ "tools": [
+ { "name": "lisa", "desc": "住宅ip服务器,tiktok必备", "href": "https://lisahost.com/aff.php?aff=7566" },
+ { "name": "voyracloud", "desc": "静态住宅ip服务器", "href": "https://www.voyracloud.com/?ref_code=2LJPRTE5" }
+ ]
+ },
+ {
+ "id": "hardware",
+ "icon": "🖲️",
+ "title": "硬件",
+ "color": "from-slate-500 to-gray-600",
+ "bg": "bg-slate-50",
+ "text": "text-slate-700",
+ "tools": [
+ { "name": "树莓派", "desc": "无与伦比的ARM开发板", "href": "https://uland.taobao.com/coupon/edetail?e=5hkWkiThOmClhHvvyUNXZfh8CuWt5YH5OVuOuRD5gLJMmdsrkidbOWBzzpT26idJPSbzO0ArF68ertbZl9wqRUbeJevxT02sHgNIxyFsmaMOsi2OxhyQb%2BhYUTTNN7GRRSHvQe2jOLZ9pbNCYX0I%2BPP%2BWUTgK%2F%2B0I%2BtaUgbudUxA%2B536asYsLWVfKa%2BhVnND4NyB1fwjO3bJLf667Yy8Q5jB6TX2HR3Qrq8LCw4Dujjqyaqc1rGLUwdQHHM8iY%2BUDMxLRSiIntldeBz90au3REAmOgZ0RtdnfeSBK3KXa0%2FA9snq37kKL6zL4bZ3EAS%2F2oYsLHTsIOxroXBFP6oz%2BA%3D%3D&traceId=213e032e17650447230113327e0c4f&union_lens=lensId%3APUB%401765044691%400b521400_0da4_19af4dca231_da77%400290EZjuZ2xbgOlH3NTYAyI%40eyJmbG9vcklkIjoyMDg3MTcxMDEwLCJzcG1CIjoiiX3BvcnRhbF92Ml90b29sX3NlbGVjdGlvbmxpc3RfZGV0YWlsX2luZGV4X2h0bSIsInNyY0Zsb29ySWQiiOiiI4MDY2NCJ9" },
+ { "name": "迷你主机", "desc": "具有性价比的X86小主机", "href": "https://s.click.taobao.com/t?e=m%3D2%26s%3DCb1Quo%2F0Wb5w4vFB6t2Z2ueEDrYVVa64YUrQeSeIhnK53hKxp7mNFuhCZFSw9yq2woqx0EHqItb0JlhLk0Jl4eVWmNyHytKdTKJ80yOOA0Mtd0e5Ku7CIDk5ffgV5fRI1qrpxiwMoCNxc1AtbZGVS936JshS%2F%2FIdwgQgG5hIzyLNEPXytV9ALtCLThlbPuuZLb93Df8fOziGg7JVKZos%2BjLJJgRsfWlWtajntMnDLEHhuApO%2F8klJ62RdK%2B6%2FP7Z6k6s3rDgsC6jO9AJYjY8CXJ%2BwEVkOqHFPSJJXArlDuzso5nyPLY4NPiLqets6lIJ" },
+ { "name": "GPU显卡", "desc": "AI时代的算力基础设施", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BATUJK1olXwEDUFZVCkkRC18IGloWWwALV19aAUonRzBQRQQlBENHFRxWFlVPRjtUBABAQlRcCEBdCUoUBWkBGFoSVAcdDRsBVXsQB29pbiZRKGMHKlclCEgVWghcGV91UQoyVW5dCUoWAmYPHl4RbTYCU24fZp-esLulkIK0yd6n5V1eOEonA2kPH1oQWQUFV19VCnsQA2Y4El0dWQ4LU1hBC00SCmcJK2slXjYFVFdJDjlWUXsOaWslXTYBZF5cCEoQAGoLH1kRQQYHV1lbAFcXBWgMGl4RXgUKUllYOEkWAmsBK2vL05J3IB9Uajl8UStqSz4dFQ9jitDJGTlnA2sPH0olImF5ECFaSU0VVAdNQDxuW09KFiw8SyljbW1jcih3JWNxNlwaS0hqWBhoX2sQbQEEVW4" }
+ ]
+ },
+ {
+ "id": "software",
+ "icon": "⚙️",
+ "title": "软件",
+ "color": "from-indigo-500 to-blue-500",
+ "bg": "bg-indigo-50",
+ "text": "text-indigo-700",
+ "tools": [
+ { "name": "3X-UI", "desc": "科学上网的正确姿势,仪表盘", "href": "https://github.com/MHSanaei/3x-ui" },
+ { "name": "虚空终端mihomo", "desc": "代理软件以及配置wiki", "href": "https://wiki.metacubex.one/config/" },
+ { "name": "frp", "desc": "高性能内网穿透", "href": "https://gofrp.org/zh-cn/docs/" },
+ { "name": "wireguard", "desc": "P2P打洞组网", "href": "https://github.com/WireGuard/wireguard-go" },
+ { "name": "lineageos (pi5)", "desc": "树莓派的安卓:linageos", "href": "https://konstakang.com/devices/rpi5/" }
+ ]
+ },
+ {
+ "id": "digital-gear",
+ "icon": "📷",
+ "title": "数码装备",
+ "color": "from-cyan-500 to-teal-500",
+ "bg": "bg-cyan-50",
+ "text": "text-cyan-700",
+ "tools": [
+ { "name": "AI眼镜", "desc": "第一视角拍摄", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BATkJK1olXw8AXVxfCU8UA18IGloVWwcKXFlZDUgnRzBQRQQlBENHFRxWFlVPRjtUBABAQlRcCEBdCUoXBW4AE1wRWAUdDRsBVXtwXw1pZzNKAmZgFFwlYUh8UzVBZyZlUQoyVW5dCUoWAmYPHl4RbTYCU24fZpO9hbeBtYKjxt-jwInikZ-fjV8JK1sTWgIDUVtVAEkVA2w4HFscbQ8EXFpVAUwRH2wOHlIdXDYyZF1tD0seF2l6WgkBW3QyZF5tC3sXAm8AGFsVXAEBXEJdDUMVB24UG10SWQcHUVZbAE8fCl8KGloRVDYyitPtY0JrQAYIYj0dXGZKMVc5T5Was35jYlMVXQUTZAQOch9SZTMNbC9gIGdQJi5VCzkRSCpfWDUXVAJlDQoCbw1gcG9qRwYdHnYyUW5aDEgn" },
+ { "name": "运动相机", "desc": "骑行记录仪", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BASgJK1olXDYCVV9eCE0UB28ME1slGVlaCgFtUQ5SQi0DBUVNGFJeSwUIFxlJX3EIGloWXQABUF5ZAEsIWipURmt3CFhXIQkpbChuRy8JGDpRJUJQDwkbBEcnAl8IGloUXA8FUVtZOHsXBF9edVsUXAcDVV1fDEonAl8IHVwRXAMGVVpaDEsUM2gIEmscWw4GXFdaDlcUBWoBE1olbTYBZFldAV8RcS5aD11nbTYCZF1tCEoXAmsNHl8UXQQeVF1ZDE0UH28OHF8UWAIDUVddCkgnAW4JH1IlbdiMwDxcaAJfBTlNTRpCBHpHLhyDht8GcR8BHFIUTDZ6NSMpYRJweWtBfAUXC2JyXBkpfTNRWi1mGV4UAlgENFpeDA13cRRcTAJLbQMyU1peOA" },
+ { "name": "无人机", "desc": "天空视角", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BASgJK1olXDYCVV9eAUIeB2YMH14lGVlaCgFtUQ5SQi0DBUVNGFJeSwUIFxlJX3EIGloWVA8LUFdZDE4IWipURmtCHHVEABsFVChPYRJAbVhlGRhbFRgLBEcnAl8IGloUXA8FUVtZOHsXBF9edVsUXAcDVV1fDEonAl8IHVwRXAMGVVdbC04VM2gIEmscWw4GXFdaDlcUBWoBE1olbTYBZFldAV8RcS5aD11nbTYCZF1tCEoXAmgLHlgRXgIeVFpfDU0UH28OHF8UWAIDUlteAUwnAW4JH1IlbdiMwCQvcipMBxJhfV9HGgF3FVaDht8GfxgBGVkRTDZUCjsVbzBTYRJbeRkWGG9WAx02D0JcUy5mGRNRFEJiUwgldStzfx1wTj1GbQMyU1peOA" }
+ ]
+ },
+ {
+ "id": "tiktok",
+ "icon": "🎵",
+ "title": "Tiktok",
+ "color": "from-fuchsia-500 to-pink-500",
+ "bg": "bg-fuchsia-50",
+ "text": "text-fuchsia-700",
+ "tools": [
+ { "name": "Tiktok", "desc": "全球直播平台", "href": "https://www.tiktok.com/" },
+ { "name": "云手机", "desc": "用开源硬件打造云手机", "href": "https://www.hackrobot.cn/" },
+ { "name": "vcam虚拟摄像头", "desc": "xposed模块,替换摄像头视频流", "href": "https://pan.baidu.com/s/1NW4GMGA3cxfUQNWZPD9vOg?pwd=live" }
+ ]
+ },
+ {
+ "id": "youtube",
+ "icon": "▶️",
+ "title": "Youtube",
+ "color": "from-red-500 to-rose-500",
+ "bg": "bg-red-50",
+ "text": "text-red-700",
+ "tools": [
+ { "name": "Youtube", "desc": "全球最大的视频网站", "href": "https://www.youtube.com/" },
+ { "name": "smmfollows", "desc": "刷粉刷量平台", "href": "https://smmfollows.com/ref/dfugq" },
+ { "name": "YouTube Studio Auto Dismiss", "desc": "油猴脚本,无人直播辅助", "href": "https://greasyfork.org/zh-CN/scripts/557378-youtube-studio-auto-dismiss" }
+ ]
+ },
+ {
+ "id": "web",
+ "icon": "🌍",
+ "title": "Web网站",
+ "color": "from-green-500 to-emerald-500",
+ "bg": "bg-green-50",
+ "text": "text-green-700",
+ "tools": [
+ { "name": "namesilo", "desc": "注册域名网址", "href": "https://www.namesilo.com/?rid=3bd4047mc" },
+ { "name": "aapanel", "desc": "宝塔面板", "href": "https://www.aapanel.com?referral_code=wcn04vez" },
+ { "name": "cloudflare", "desc": "互联网基础节点", "href": "https://www.cloudflare.com/" },
+ { "name": "Google Analytics", "desc": "网站流量统计服务", "href": "https://analytics.google.com/" },
+ { "name": "Google AdSense", "desc": "Google广告服务", "href": "https://www.google.com/adsense/" }
+ ]
+ },
+ {
+ "id": "live-stream",
+ "icon": "📺",
+ "title": "无人直播",
+ "color": "from-orange-500 to-amber-500",
+ "bg": "bg-orange-50",
+ "text": "text-orange-700",
+ "tools": [
+ { "name": "OBS", "desc": "免费开源的电脑客户端:录屏/推流直播", "href": "https://obsproject.com/download" },
+ { "name": "ffmpeg", "desc": "命令行视频处理/推流工具", "href": "https://www.ffmpeg.org/download.html" },
+ { "name": "SRS", "desc": "开源直播服务器,拉流推流中转", "href": "https://ossrs.io/" }
+ ]
+ },
+ {
+ "id": "payment",
+ "icon": "💲",
+ "title": "支付网关",
+ "color": "from-lime-500 to-green-500",
+ "bg": "bg-lime-50",
+ "text": "text-lime-700",
+ "tools": [
+ { "name": "z-pay", "desc": "支持独角数卡-易支付", "href": "https://z-pay.cn/?uid=22119" },
+ { "name": "xorpay", "desc": "支持收银台支付", "href": "https://xorpay.com?r=nomadro" }
+ ]
+ },
+ {
+ "id": "forum",
+ "icon": "📝",
+ "title": "论坛社区",
+ "color": "from-sky-500 to-blue-500",
+ "bg": "bg-sky-50",
+ "text": "text-sky-700",
+ "tools": [
+ { "name": "异度世界", "desc": "独立博客,hugo搭建", "href": "https://blog.hackrobot.cn" },
+ { "name": "大师兄", "desc": "AI实践", "href": "https://dsx2016.com" }
+ ]
+ }
+ ]
+}
diff --git a/content/digital/themes/digital-nomad/messages/en.json b/content/digital/themes/digital-nomad/messages/en.json
new file mode 100644
index 0000000..ec94e2c
--- /dev/null
+++ b/content/digital/themes/digital-nomad/messages/en.json
@@ -0,0 +1,212 @@
+{
+ "common": {
+ "siteName": "Digital Nomad Guide",
+ "siteNameShort": "DN Guide",
+ "logout": "Logout",
+ "loginRegister": "Login / Sign up",
+ "explore": "Explore →",
+ "toggleMenu": "Toggle menu"
+ },
+ "nav": {
+ "roadmap": "Learning Path",
+ "tools": "Tools",
+ "community": "Community",
+ "shop": "Shop",
+ "resources": "Resources"
+ },
+ "hero": {
+ "badge": "Curated · 200+ resources",
+ "title": "Digital Nomad",
+ "titleSuffix": "Life Guide",
+ "subtitle": "Start your digital nomad journey in 7 days from scratch",
+ "subtitleEn": "The guide to becoming a digital nomad",
+ "ctaStart": "🚀 Start Learning",
+ "ctaResources": "📚 Browse Resources",
+ "stats": {
+ "resources": "Resources",
+ "path": "Learning Path",
+ "tools": "Remote Tools",
+ "destinations": "Destinations",
+ "free": "Curated"
+ }
+ },
+ "features": {
+ "title": "What is a",
+ "titleHighlight": "Digital Nomad",
+ "titleSuffix": "?",
+ "subtitle": "A digital nomad leverages internet technology to work remotely without geographic constraints.",
+ "subtitle2": "It lets you explore the world while creating value.",
+ "stat": "Over",
+ "statCount": "35 million",
+ "statSuffix": "digital nomads",
+ "items": {
+ "location": {
+ "title": "Location Freedom",
+ "desc": "Work from anywhere—cafés, beaches, coworking spaces. Your office is the whole world. Say goodbye to fixed desks and embrace infinite possibilities."
+ },
+ "skill": {
+ "title": "Monetize Skills",
+ "desc": "Programming, design, writing, marketing, consulting… Exchange your expertise for diversified income and build a diversified revenue stream."
+ },
+ "balance": {
+ "title": "Work-Life Balance",
+ "desc": "Set your own schedule. Work and travel blend seamlessly. Live at your own pace and find the lifestyle that truly fits you."
+ }
+ }
+ },
+ "roadmap": {
+ "title": "7-Day",
+ "titleHighlight": "Learning Path",
+ "subtitle": "One theme per day, from basics to launch—start your digital nomad journey step by step.",
+ "viewDetails": "View details →",
+ "daysSection": "7-Day Guide",
+ "productsSection": "Ebook · Bootcamp",
+ "days": {
+ "1": { "title": "Meet Digital Nomads", "desc": "Understand what digital nomadism really means, assess if it fits you, and debunk common myths." },
+ "2": { "title": "Discover Remote Skills", "desc": "Master the most in-demand remote skills—from coding and design to content creation—and find your direction." },
+ "3": { "title": "Build Income Streams", "desc": "Freelancing, remote jobs, passive income, online courses—build a sustainable, diversified income." },
+ "4": { "title": "Tool Up", "desc": "Productivity tools, communication, project management, cloud services—build your mobile office toolkit." },
+ "5": { "title": "Visa & Legal", "desc": "Digital nomad visas, tax planning, overseas insurance, bank accounts—get legal and compliant." },
+ "6": { "title": "Destination Guide", "desc": "Top digital nomad cities, cost of living, internet speed, community vibes—all at a glance." },
+ "7": { "title": "Launch", "desc": "Packing list, transition plan, first-stop tips—from planning to action." }
+ },
+ "ebook": {
+ "title": "Ebook",
+ "desc": "Digital nomad: geo-arbitrage & automation leverage. Low-cost studio, cloud phones, unmanned live streaming, overseas gold rush.",
+ "cta": "Read online →"
+ },
+ "course": {
+ "tag": "🎓 Bootcamp",
+ "title": "Digital Nomad Bootcamp",
+ "desc": "21 video lessons to plan your digital nomad route from zero and master the full remote workflow.",
+ "cta": "Learn more →"
+ }
+ },
+ "tools": {
+ "badge": "🚀 50+ Curated Tools",
+ "title": "Tool Recommendations",
+ "subtitle": "Essential tools for digital nomads: community, VPS, payments, overseas ventures—every link is clickable.",
+ "stats": {
+ "tools": "Tools",
+ "categories": "Categories",
+ "nomad": "Nomad",
+ "affiliate": "Affiliate"
+ }
+ },
+ "community": {
+ "nomadCommunity": "Digital Nomad Community",
+ "joinTitle": "Join the Digital Nomad Network",
+ "joinDesc": "Apply to join · 1000+ nomads · Weekly online shares · City guides",
+ "applyForm": "Apply via form",
+ "approveJoin": "Join after approval",
+ "applyNow": "Enter private community →",
+ "applyHint": "Private community entry",
+ "resourcesTitle": "Resource Navigation",
+ "resourceSections": {
+ "books": "Books",
+ "products": "Products",
+ "pan": "Cloud & Resources",
+ "totalBooks": "{count} books",
+ "totalProducts": "{count} items"
+ },
+ "topicTag": "Topic",
+ "updating": "Coming soon",
+ "contributeSubtitle": "Digital Nomad Guide builds the best Chinese digital nomad resource hub.",
+ "contributeSubtitle2": "Welcome to add resources, improve content, and share experience.",
+ "links": {
+ "github": { "title": "GitHub", "desc": "Open source—Star and PR welcome", "label": "View repo ↗" },
+ "destinations": { "title": "Destinations", "desc": "Digital nomad city info", "label": "Explore cities ↗" },
+ "vipEntry": { "title": "异度星球", "desc": "Member-exclusive resources and community", "label": "Enter ↗" },
+ "indieDev": { "title": "Indie Dev", "desc": "From idea to product, indie developer guide", "label": "View guide ↗" },
+ "aiAgent": { "title": "AI Agent", "desc": "AIGC & agents, Vibe Coding for creators", "label": "Learn more ↗" },
+ "cloudPhone": { "title": "Cloud Phone", "desc": "Low-cost self-hosted cloud phone for TikTok/YouTube", "label": "Setup guide ↗" },
+ "unmannedLive": { "title": "Unmanned Live", "desc": "TikTok/YouTube 24/7 automated live streaming", "label": "View tutorial ↗" },
+ "appDownload": { "title": "App Download", "desc": "Digital Nomad Guide mobile app", "label": "Download now ↗" }
+ },
+ "betterTitle": "🌟 Help Make Digital Nomad Guide Better",
+ "betterDesc": "Found a great resource? Wrote travel notes? Have tool recommendations?",
+ "betterDesc2": "Open a PR—your contribution helps more people.",
+ "starFork": "⭐ Star & Fork on GitHub"
+ },
+ "footer": {
+ "siteName": "Digital Nomad Guide",
+ "tagline": "Digital nomad resource platform",
+ "tagline2": "Start your free work and travel lifestyle",
+ "sections": {
+ "guide": "Guide",
+ "resources": "Resources",
+ "community": "Community",
+ "about": "About"
+ },
+ "links": {
+ "roadmap": "7-Day Learning Path",
+ "tools": "Tools",
+ "destinations": "Destinations",
+ "remoteJobs": "Remote Jobs",
+ "visa": "Visa Policies",
+ "coliving": "Coliving Spaces",
+ "insurance": "Nomad Insurance",
+ "discord": "Discord",
+ "wechat": "WeChat",
+ "jike": "Jike",
+ "github": "GitHub",
+ "aboutUs": "About Us",
+ "recruit": "Careers",
+ "privacy": "Privacy",
+ "contact": "Contact"
+ },
+ "madeBy": "Made with 🌍 by Digital Nomad Community | DN Guide",
+ "openSource": "Curated content · Regularly updated"
+ },
+ "shop": {
+ "badge": "🛒 Curated Shop",
+ "title": "Digital Nomad Store",
+ "desc": "Curated products and services for digital nomads—from gear to courses, all in one place.",
+ "cta": "Visit Store →",
+ "ctaHint": "Opens in new tab"
+ },
+ "about": {
+ "title": "About Us",
+ "subtitle": "Meet the team behind Digital Nomad Guide",
+ "linktreeTitle": "Social Links",
+ "linktreeDesc": "Follow us for updates and resources",
+ "linktreeCta": "View all links →",
+ "groupDomestic": "Domestic",
+ "groupOverseas": "Overseas",
+ "teamTitle": "Team Members",
+ "teamDesc": "We are digital nomads who love remote work"
+ },
+ "jobs": {
+ "title": "Careers",
+ "subtitle": "Join the Digital Nomad Guide team. Remote work, location freedom.",
+ "position": "Position",
+ "name": "Name",
+ "email": "Email",
+ "phone": "Phone",
+ "intro": "Introduction",
+ "introPlaceholder": "Briefly introduce your experience, skills and motivation",
+ "resume": "Resume",
+ "resumeHint": "PDF, DOC or DOCX, max 10MB",
+ "resumeUpload": "Click to upload resume",
+ "submit": "Submit Application"
+ },
+ "appDownload": {
+ "title": "App Download",
+ "subtitle": "Digital Nomad Guide mobile app—resources and community on the go",
+ "features": {
+ "resources": { "title": "Curated Resources", "desc": "200+ articles, offline reading" },
+ "community": { "title": "Community Updates", "desc": "Latest digital nomad community news" },
+ "tools": { "title": "Tool Recommendations", "desc": "Essential remote work tools" }
+ },
+ "download": "Download Now",
+ "comingSoon": "Coming Soon",
+ "comingSoonDesc": "App is under development, stay tuned"
+ },
+ "auth": {
+ "title": "Login / Sign up",
+ "email": "Email",
+ "password": "Password",
+ "submit": "Login",
+ "close": "Close"
+ }
+}
diff --git a/content/digital/themes/digital-nomad/messages/zh.json b/content/digital/themes/digital-nomad/messages/zh.json
new file mode 100644
index 0000000..62c4c1e
--- /dev/null
+++ b/content/digital/themes/digital-nomad/messages/zh.json
@@ -0,0 +1,212 @@
+{
+ "common": {
+ "siteName": "数字游民指南",
+ "siteNameShort": "DN Guide",
+ "logout": "退出",
+ "loginRegister": "登录 / 注册",
+ "explore": "开始探索 →",
+ "toggleMenu": "切换菜单"
+ },
+ "nav": {
+ "roadmap": "学习路径",
+ "tools": "工具推荐",
+ "community": "社区",
+ "shop": "商店",
+ "resources": "资源"
+ },
+ "hero": {
+ "badge": "精选资源 · 收录 200+ 篇实用内容",
+ "title": "数字游民",
+ "titleSuffix": "生活指南",
+ "subtitle": "从零开始,7天开启你的数字游民生活",
+ "subtitleEn": "The guide to becoming a digital nomad",
+ "ctaStart": "🚀 开始学习",
+ "ctaResources": "📚 浏览资源",
+ "stats": {
+ "resources": "精选资源",
+ "path": "入门路径",
+ "tools": "远程工具",
+ "destinations": "目的地",
+ "free": "精选资源"
+ }
+ },
+ "features": {
+ "title": "什么是",
+ "titleHighlight": "数字游民",
+ "titleSuffix": "?",
+ "subtitle": "数字游民是一种利用互联网技术远程工作、不受地理位置限制的生活方式。",
+ "subtitle2": "它让你在探索世界的同时,持续创造价值。",
+ "stat": "已有超过",
+ "statCount": "3500 万",
+ "statSuffix": "数字游民",
+ "items": {
+ "location": {
+ "title": "地点自由",
+ "desc": "在世界任何角落工作——咖啡馆、海滩、共享空间,你的办公室就是整个地球。告别固定工位,拥抱无限可能。"
+ },
+ "skill": {
+ "title": "技能变现",
+ "desc": "编程、设计、写作、营销、咨询……用你的专业技能换取多元收入,构建多元化的收入体系。"
+ },
+ "balance": {
+ "title": "工作生活平衡",
+ "desc": "自主安排时间,工作与旅行完美融合。按照自己的节奏生活,找到真正属于你的生活方式。"
+ }
+ }
+ },
+ "roadmap": {
+ "title": "7天",
+ "titleHighlight": "学习路径",
+ "subtitle": "从入门到出发,每天一个主题,循序渐进开启你的数字游民之旅。",
+ "viewDetails": "查看详情 →",
+ "daysSection": "7天入门文档",
+ "productsSection": "电子书 · 实战课",
+ "days": {
+ "1": { "title": "认识数字游民", "desc": "了解数字游民的真正含义,评估这种生活方式是否适合你,破除常见误解。" },
+ "2": { "title": "发现远程技能", "desc": "掌握最受欢迎的远程工作技能,从编程、设计到内容创作,找到你的方向。" },
+ "3": { "title": "构建收入体系", "desc": "自由职业、远程全职、被动收入、在线课程,构建可持续的多元收入来源。" },
+ "4": { "title": "工具武装", "desc": "生产力工具、通讯协作、项目管理、云服务,打造你的移动办公装备库。" },
+ "5": { "title": "签证与法律", "desc": "数字游民签证政策、税务规划、海外保险、银行账户,搞定法律合规问题。" },
+ "6": { "title": "目的地指南", "desc": "热门数字游民城市推荐,生活成本对比、网速测评、社区活跃度一目了然。" },
+ "7": { "title": "启程出发", "desc": "打包清单、过渡计划、第一站选择建议,从规划到行动的最后一步。" }
+ },
+ "ebook": {
+ "title": "电子书",
+ "desc": "数字游民:地理套利与自动化杠杆,低成本工作室、云手机、无人直播、出海掘金。",
+ "cta": "在线阅读 →"
+ },
+ "course": {
+ "tag": "🎓 实战训练营",
+ "title": "数字游民实战课",
+ "desc": "21节视频课程,手把手带你从零规划专属数字游民路线,打通远程工作全流程",
+ "cta": "了解详情 →"
+ }
+ },
+ "tools": {
+ "badge": "🚀 50+ 精选工具",
+ "title": "工具推荐",
+ "subtitle": "数字游民出海必备工具精选,含游民社区、VPS、金融收款、出海掘金等,每个链接可点击跳转。",
+ "stats": {
+ "tools": "精选工具",
+ "categories": "分类",
+ "nomad": "出海",
+ "affiliate": "推荐"
+ }
+ },
+ "community": {
+ "nomadCommunity": "数字游民社区",
+ "joinTitle": "加入数字游民网络",
+ "joinDesc": "填写申请表加入社区 · 1000+ 数字游民 · 每周线上分享 · 城市攻略",
+ "applyForm": "填写表单即可申请",
+ "approveJoin": "审核通过入群",
+ "applyNow": "进入私域 →",
+ "applyHint": "私域入口",
+ "resourcesTitle": "资源导航",
+ "resourceSections": {
+ "books": "精选图书",
+ "products": "商业数码",
+ "pan": "网盘与资源",
+ "totalBooks": "共 {count} 本",
+ "totalProducts": "共 {count} 件"
+ },
+ "topicTag": "专题",
+ "updating": "更新中",
+ "contributeSubtitle": "数字游民指南致力于做最好的中文数字游民资源聚合站。",
+ "contributeSubtitle2": "欢迎补充资源、改进内容、分享经验。",
+ "links": {
+ "github": { "title": "GitHub", "desc": "开源项目,欢迎 Star 和 PR", "label": "访问仓库 ↗" },
+ "destinations": { "title": "目的地数据库", "desc": "数字游民城市信息", "label": "探索城市 ↗" },
+ "vipEntry": { "title": "异度星球", "desc": "会员专属资源与社区", "label": "进入 ↗" },
+ "indieDev": { "title": "独立开发", "desc": "从想法到产品,独立开发者全流程指南", "label": "查看指南 ↗" },
+ "aiAgent": { "title": "AI Agent", "desc": "AIGC 与智能体,Vibe Coding 跨界创造", "label": "了解更多 ↗" },
+ "cloudPhone": { "title": "云手机", "desc": "低成本自建云手机,TikTok/YouTube 运营", "label": "搭建指南 ↗" },
+ "unmannedLive": { "title": "无人直播", "desc": "TikTok/YouTube 无人直播,24 小时自动化", "label": "查看教程 ↗" },
+ "appDownload": { "title": "App 下载", "desc": "数字游民指南移动端应用", "label": "立即下载 ↗" }
+ },
+ "betterTitle": "🌟 一起让数字游民指南变得更好",
+ "betterDesc": "发现了好资源?写了旅居心得?有工具推荐?",
+ "betterDesc2": "提一个 PR,你的贡献将帮助更多人。",
+ "starFork": "⭐ Star & Fork on GitHub"
+ },
+ "footer": {
+ "siteName": "数字游民指南",
+ "tagline": "数字游民资源平台",
+ "tagline2": "助你开启自由工作与旅行的生活方式",
+ "sections": {
+ "guide": "指南",
+ "resources": "资源",
+ "community": "社区",
+ "about": "关于"
+ },
+ "links": {
+ "roadmap": "7天学习路径",
+ "tools": "工具推荐",
+ "destinations": "目的地数据库",
+ "remoteJobs": "远程工作平台",
+ "visa": "签证政策汇总",
+ "coliving": "共居空间推荐",
+ "insurance": "数字游民保险",
+ "discord": "Discord",
+ "wechat": "微信群",
+ "jike": "即刻",
+ "github": "GitHub",
+ "aboutUs": "关于我们",
+ "recruit": "招聘",
+ "privacy": "隐私政策",
+ "contact": "联系我们"
+ },
+ "madeBy": "Made with 🌍 by 数字游民社区 | Digital Nomad Guide",
+ "openSource": "精选内容 · 持续更新"
+ },
+ "shop": {
+ "badge": "🛒 精选好物",
+ "title": "数字游民好物商城",
+ "desc": "精选适合数字游民的产品与服务,从装备到课程,一站式满足你的需求。",
+ "cta": "前往商城 →",
+ "ctaHint": "新窗口打开独立站"
+ },
+ "about": {
+ "title": "关于我们",
+ "subtitle": "认识数字游民指南背后的团队",
+ "linktreeTitle": "社交平台",
+ "linktreeDesc": "关注我们,获取最新动态与资源",
+ "linktreeCta": "查看全部链接 →",
+ "groupDomestic": "国内平台",
+ "groupOverseas": "海外平台",
+ "teamTitle": "团队成员",
+ "teamDesc": "我们是一群热爱自由工作的数字游民"
+ },
+ "jobs": {
+ "title": "招聘",
+ "subtitle": "加入数字游民指南团队,远程工作,地点自由",
+ "position": "应聘职位",
+ "name": "姓名",
+ "email": "邮箱",
+ "phone": "手机",
+ "intro": "自我介绍",
+ "introPlaceholder": "请简要介绍您的经历、技能及应聘理由",
+ "resume": "简历",
+ "resumeHint": "支持 PDF、DOC、DOCX,最大 10MB",
+ "resumeUpload": "点击上传简历",
+ "submit": "提交申请"
+ },
+ "appDownload": {
+ "title": "App 下载",
+ "subtitle": "数字游民指南移动端应用,随时随地获取资源与社区动态",
+ "features": {
+ "resources": { "title": "精选资源", "desc": "200+ 篇实用文章,离线阅读" },
+ "community": { "title": "社区动态", "desc": "实时获取数字游民社区最新资讯" },
+ "tools": { "title": "工具推荐", "desc": "远程工作必备工具一览" }
+ },
+ "download": "立即下载",
+ "comingSoon": "即将上线",
+ "comingSoonDesc": "App 正在开发中,敬请期待"
+ },
+ "auth": {
+ "title": "登录 / 注册",
+ "email": "邮箱",
+ "password": "密码",
+ "submit": "登录",
+ "close": "关闭"
+ }
+}
diff --git a/next.config.ts b/next.config.ts
index 316716c..c529c25 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -12,7 +12,7 @@ const nextConfig: NextConfig = {
return {
beforeFiles: [
{
- source: "/api/:path*",
+ source: "/api/:path((?!digital(?:$|/.*)).*)",
destination: `${fastApiTarget}/api/:path*`,
},
],
diff --git a/package.json b/package.json
index e44caa7..610b13e 100644
--- a/package.json
+++ b/package.json
@@ -9,10 +9,21 @@
"lint": "eslint"
},
"dependencies": {
+ "@aws-sdk/client-s3": "^3.1063.0",
+ "@fontsource-variable/geist": "^5.2.9",
+ "@fontsource-variable/geist-mono": "^5.2.8",
"echarts": "^6.0.0",
+ "github-markdown-css": "^5.9.0",
+ "highlight.js": "^11.11.1",
+ "marked": "^18.0.5",
+ "minio": "^8.0.7",
"next": "16.1.6",
+ "pocketbase": "^0.27.0",
"react": "19.2.3",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "react-markdown": "^10.1.0",
+ "rehype-highlight": "^7.0.2",
+ "remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 12fb0b3..87278d0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,18 +8,51 @@ importers:
.:
dependencies:
+ '@aws-sdk/client-s3':
+ specifier: ^3.1063.0
+ version: 3.1063.0
+ '@fontsource-variable/geist':
+ specifier: ^5.2.9
+ version: 5.2.9
+ '@fontsource-variable/geist-mono':
+ specifier: ^5.2.8
+ version: 5.2.8
echarts:
specifier: ^6.0.0
version: 6.0.0
+ github-markdown-css:
+ specifier: ^5.9.0
+ version: 5.9.0
+ highlight.js:
+ specifier: ^11.11.1
+ version: 11.11.1
+ marked:
+ specifier: ^18.0.5
+ version: 18.0.5
+ minio:
+ specifier: ^8.0.7
+ version: 8.0.7
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ pocketbase:
+ specifier: ^0.27.0
+ version: 0.27.0
react:
specifier: 19.2.3
version: 19.2.3
react-dom:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
+ react-markdown:
+ specifier: ^10.1.0
+ version: 10.1.0(@types/react@19.2.14)(react@19.2.3)
+ rehype-highlight:
+ specifier: ^7.0.2
+ version: 7.0.2
+ remark-gfm:
+ specifier: ^4.0.1
+ version: 4.0.1
devDependencies:
'@tailwindcss/postcss':
specifier: ^4
@@ -52,6 +85,109 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
+ '@aws-crypto/crc32@5.2.0':
+ resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
+ engines: {node: '>=16.0.0'}
+
+ '@aws-crypto/crc32c@5.2.0':
+ resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==}
+
+ '@aws-crypto/sha1-browser@5.2.0':
+ resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==}
+
+ '@aws-crypto/sha256-browser@5.2.0':
+ resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==}
+
+ '@aws-crypto/sha256-js@5.2.0':
+ resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==}
+ engines: {node: '>=16.0.0'}
+
+ '@aws-crypto/supports-web-crypto@5.2.0':
+ resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==}
+
+ '@aws-crypto/util@5.2.0':
+ resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
+
+ '@aws-sdk/checksums@3.1000.2':
+ resolution: {integrity: sha512-PIha+kauTbp6IRmOpYktPTrlfrrSqDVixvhO/EUOFOf62DPX81CaJoHJreuA1m9HYpSKyXf99BKjU1dvJPeUfw==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/client-s3@3.1063.0':
+ resolution: {integrity: sha512-ETn+vvmZVK1MmOZwVBXmWANpmD5iTbzojIqyEIoZ86qo+8oWy35S8QyQNE/ZDI+WHgMU1dS+VSYbpRl1QkEySg==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/core@3.974.18':
+ resolution: {integrity: sha512-JDYCPI0j7zGrzXTDFsLB346cxss7J/AxH7+O0MzWlqppJBEyB9Qe6TQXRL6iwLUo/xZkNv9KFmBL2hqElmwW0g==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-env@3.972.44':
+ resolution: {integrity: sha512-3hKJVrZ7bqXzDAXCQp+OaQ1ASN+vWstaNuEH418wQVl//cRZhqhfR9Bjk1qIWmgUGe8/D3gdO73PgidRj378EQ==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-http@3.972.46':
+ resolution: {integrity: sha512-VhwC9pGAZHhiQ2xSViyOPDFqvr9aRxGCAXZtADsUhU3R65nad7y//CwynE6mQnWNR+suRlqE79W36IVayL+m1g==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-ini@3.972.50':
+ resolution: {integrity: sha512-09Xi6ovxiK42+De/qBGF71sT5F2bWgYM+1fFyDwSOpy1xpsQ5R/naIu7MVDpH6Dic36QNc8dAv4KADtMGK2JYg==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-login@3.972.49':
+ resolution: {integrity: sha512-EfJF/1Fh9mI4pZyoheU2RY9xUhTcugIZNkD63+orXMkYj/QXacJNbKVDUK90Yv5hE+aX+rt9J/EZ9Qr3vKOa7g==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-node@3.972.52':
+ resolution: {integrity: sha512-7QX+PbyiWBEOVipJq8Nke/TqXT6lAPLE7fvTaopa39/IVWuLfS+Fzdy71sZJONf/mLGgmtj6aU17+REw3+aRrw==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-process@3.972.44':
+ resolution: {integrity: sha512-V+UUhZpRP7QDRhi+qgBDisM9tUBnYmMje8Bk77A6MZsfeGeGdMsQXmaHP1CDYFcept0o/Rz5g2Y0TMeVlG9dzg==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-sso@3.972.49':
+ resolution: {integrity: sha512-9QqOYGuh5tZ76OzaT68kwI78AH+5lS/uZGGvkfxb3fc8FzRrIz2jOufNTliEBEeSAwmgK2rWLNsK+IB3zbtNPA==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-web-identity@3.972.49':
+ resolution: {integrity: sha512-IYx1lN38MnnPXv+NBLpuATu0cZakbZ321TAfjW+aVkw7HIJF38YnEwdeEO55MSl3pl7hIX1IvvnD6EmnAzmAJw==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/middleware-flexible-checksums@3.974.27':
+ resolution: {integrity: sha512-bZqezPLdllFC4VAeV/f+EIc/hz56ab3TD/+4zNCgOgmG5ZHAE5dMHrX1gtTwdcQXbPr3KR7x3zTC3zuCTE6+ng==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/middleware-sdk-s3@3.972.48':
+ resolution: {integrity: sha512-MRTqx8wD/T3REt6LTT3/yN8rrp6+xIHrbUekkDYJTYWVch70mwtdJBovR4qKJz1jIPlbN+9R/Sn6R04BfsglzA==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/nested-clients@3.997.17':
+ resolution: {integrity: sha512-lDRgraoTfKRawUyc176Ow93mrNrOho/x+EoK4C+lKU+vKkHWhNhzvSMVAx0WEJUJoeQxxDN5ZdKMfiGEyNejig==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/signature-v4-multi-region@3.996.32':
+ resolution: {integrity: sha512-llvApLcsWtmRFhG2wT3WIp1CmDeRaIYutqty1ZZXoMzK7TiJ6MOLOimk9eXUS8PwgG4ew4pa4QAbt0lfhn++1w==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/token-providers@3.1063.0':
+ resolution: {integrity: sha512-nYDaWWdzjKiDP5xj8k4oUgcYd4WPgzfAOgdU5vJsaqH/07Dfvm7ffisHCFJ+NEl7kUC9JEIUxh0kznvenbo3NQ==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/types@3.973.11':
+ resolution: {integrity: sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/util-locate-window@3.965.6':
+ resolution: {integrity: sha512-ZfHjfwSzeXj+Lg9AK5ZNmeDkXev6V+w2tn1t4kgDdRtUaRCthepTQiFwbD06EF9oNGH4LaLg+Mb6U16Ypv5bSw==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/xml-builder@3.972.28':
+ resolution: {integrity: sha512-lI/l3c/vPvsxmspzV63NfS3x9q4CkMmdhJy4QiM+NThAufVkDvi/PZZQ6xETnICL0UD7jI808pY83gllf86RFg==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws/lambda-invoke-store@0.2.4':
+ resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==}
+ engines: {node: '>=18.0.0'}
+
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -166,6 +302,12 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@fontsource-variable/geist-mono@5.2.8':
+ resolution: {integrity: sha512-KI5bj+hkkRiHttYHmccotUZ80ZuZyai+RwI1d7UId0clkx/jXxlo8qYK8j54WzmpBjtMoEMPyllV7faDcj+6RA==}
+
+ '@fontsource-variable/geist@5.2.9':
+ resolution: {integrity: sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==}
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -232,7 +374,6 @@ packages:
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
@@ -393,6 +534,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@nodable/entities@2.1.1':
+ resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -412,6 +556,42 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+ '@smithy/core@3.24.6':
+ resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/credential-provider-imds@4.3.8':
+ resolution: {integrity: sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/fetch-http-handler@5.4.6':
+ resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/is-array-buffer@2.2.0':
+ resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
+ engines: {node: '>=14.0.0'}
+
+ '@smithy/node-http-handler@4.7.7':
+ resolution: {integrity: sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/signature-v4@5.4.6':
+ resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/types@4.14.3':
+ resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-buffer-from@2.2.0':
+ resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==}
+ engines: {node: '>=14.0.0'}
+
+ '@smithy/util-utf8@2.3.0':
+ resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
+ engines: {node: '>=14.0.0'}
+
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -506,15 +686,30 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+ '@types/debug@4.1.13':
+ resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+
+ '@types/estree-jsx@1.0.5':
+ resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/hast@3.0.4':
+ resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+ '@types/mdast@4.0.4':
+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
'@types/node@20.19.37':
resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==}
@@ -526,6 +721,12 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+ '@types/unist@2.0.11':
+ resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
+
+ '@types/unist@3.0.3':
+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
'@typescript-eslint/eslint-plugin@8.56.1':
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -585,6 +786,9 @@ packages:
resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@ungap/structured-clone@1.3.1':
+ resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
+
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
cpu: [arm]
@@ -743,6 +947,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
+ async@3.2.6:
+ resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
+
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@@ -755,6 +962,9 @@ packages:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
+ bail@2.0.2:
+ resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -767,6 +977,12 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ block-stream2@2.1.0:
+ resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
+
+ bowser@2.14.1:
+ resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
+
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@@ -778,11 +994,18 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
+ browser-or-node@2.1.1:
+ resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==}
+
browserslist@4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ buffer-crc32@1.0.0:
+ resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
+ engines: {node: '>=8.0.0'}
+
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -802,10 +1025,25 @@ packages:
caniuse-lite@1.0.30001777:
resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==}
+ ccount@2.0.1:
+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ character-entities-html4@2.1.0:
+ resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
+
+ character-entities-legacy@3.0.0:
+ resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+
+ character-entities@2.0.2:
+ resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
+ character-reference-invalid@2.0.1:
+ resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -816,6 +1054,9 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ comma-separated-tokens@2.0.3:
+ resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -861,6 +1102,13 @@ packages:
supports-color:
optional: true
+ decode-named-character-reference@1.3.0:
+ resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
+
+ decode-uri-component@0.2.2:
+ resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
+ engines: {node: '>=0.10'}
+
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -872,10 +1120,17 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
+ devlop@1.1.0:
+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -937,6 +1192,10 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
+ escape-string-regexp@5.0.0:
+ resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
+ engines: {node: '>=12'}
+
eslint-config-next@16.1.6:
resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==}
peerDependencies:
@@ -1053,10 +1312,19 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
+ estree-util-is-identifier-name@3.0.0:
+ resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ eventemitter3@5.0.4:
+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1070,6 +1338,17 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+ fast-xml-builder@1.2.0:
+ resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
+
+ fast-xml-parser@5.7.3:
+ resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==}
+ hasBin: true
+
+ fast-xml-parser@5.8.0:
+ resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==}
+ hasBin: true
+
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -1090,6 +1369,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
+ filter-obj@1.1.0:
+ resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
+ engines: {node: '>=0.10.0'}
+
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -1138,6 +1421,10 @@ packages:
get-tsconfig@4.13.6:
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
+ github-markdown-css@5.9.0:
+ resolution: {integrity: sha512-tmT5sY+zvg2302XLYEfH2mtkViIM1SWf2nvYoF5N1ZsO0V6B2qZTiw3GOzw4vpjLygK/KG35qRlPFweHqfzz5w==}
+ engines: {node: '>=10'}
+
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -1192,12 +1479,31 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ hast-util-is-element@3.0.0:
+ resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
+
+ hast-util-to-jsx-runtime@2.3.6:
+ resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+
+ hast-util-to-text@4.0.2:
+ resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
+
+ hast-util-whitespace@3.0.0:
+ resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+ highlight.js@11.11.1:
+ resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
+ engines: {node: '>=12.0.0'}
+
+ html-url-attributes@3.0.1:
+ resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
+
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -1214,10 +1520,26 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+ inline-style-parser@0.2.7:
+ resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
+ ipaddr.js@2.4.0:
+ resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==}
+ engines: {node: '>= 10'}
+
+ is-alphabetical@2.0.1:
+ resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
+
+ is-alphanumerical@2.0.1:
+ resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
+
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -1253,6 +1575,9 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
+ is-decimal@2.0.1:
+ resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -1269,6 +1594,9 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
+ is-hexadecimal@2.0.1:
+ resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+
is-map@2.0.3:
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
engines: {node: '>= 0.4'}
@@ -1285,6 +1613,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -1460,28 +1792,182 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+ lodash@4.18.1:
+ resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
+
+ longest-streak@3.1.0:
+ resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
+ lowlight@3.3.0:
+ resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
+
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ markdown-table@3.0.4:
+ resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+
+ marked@18.0.5:
+ resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ mdast-util-find-and-replace@3.0.2:
+ resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
+
+ mdast-util-from-markdown@2.0.3:
+ resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
+
+ mdast-util-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
+
+ mdast-util-gfm-table@2.0.0:
+ resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
+
+ mdast-util-gfm@3.1.0:
+ resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+
+ mdast-util-mdx-expression@2.0.1:
+ resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
+
+ mdast-util-mdx-jsx@3.2.0:
+ resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
+
+ mdast-util-mdxjs-esm@2.0.1:
+ resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+
+ mdast-util-phrasing@4.1.0:
+ resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
+ mdast-util-to-hast@13.2.1:
+ resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+
+ mdast-util-to-markdown@2.1.2:
+ resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
+
+ mdast-util-to-string@4.0.0:
+ resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
+ micromark-core-commonmark@2.0.3:
+ resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
+
+ micromark-extension-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
+
+ micromark-extension-gfm-table@2.1.1:
+ resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
+
+ micromark-extension-gfm@3.0.0:
+ resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+
+ micromark-factory-destination@2.0.1:
+ resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+ micromark-factory-label@2.0.1:
+ resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+ micromark-factory-space@2.0.1:
+ resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
+
+ micromark-factory-title@2.0.1:
+ resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+ micromark-factory-whitespace@2.0.1:
+ resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
+ micromark-util-character@2.1.1:
+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+ micromark-util-chunked@2.0.1:
+ resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+ micromark-util-classify-character@2.0.1:
+ resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+ micromark-util-combine-extensions@2.0.1:
+ resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+ micromark-util-decode-string@2.0.1:
+ resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
+ micromark-util-encode@2.0.1:
+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+ micromark-util-html-tag-name@2.0.1:
+ resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+ micromark-util-normalize-identifier@2.0.1:
+ resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+ micromark-util-resolve-all@2.0.1:
+ resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
+ micromark-util-sanitize-uri@2.0.1:
+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+ micromark-util-subtokenize@2.1.0:
+ resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
+ micromark-util-symbol@2.0.1:
+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+ micromark-util-types@2.0.2:
+ resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
+ micromark@4.0.2:
+ resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
+ mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
@@ -1492,6 +1978,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+ minio@8.0.7:
+ resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==}
+ engines: {node: ^16 || ^18 || >=20}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1588,10 +2078,17 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
+ parse-entities@4.0.2:
+ resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
+ path-expression-matcher@1.5.0:
+ resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
+ engines: {node: '>=14.0.0'}
+
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -1610,6 +2107,9 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
+ pocketbase@0.27.0:
+ resolution: {integrity: sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==}
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -1629,10 +2129,17 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ property-information@7.2.0:
+ resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ query-string@7.1.3:
+ resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
+ engines: {node: '>=6'}
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -1644,10 +2151,20 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+ react-markdown@10.1.0:
+ resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
+ peerDependencies:
+ '@types/react': '>=18'
+ react: '>=18'
+
react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
+ readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
+
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -1656,6 +2173,21 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
+ rehype-highlight@7.0.2:
+ resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
+
+ remark-gfm@4.0.1:
+ resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
+
+ remark-parse@11.0.0:
+ resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+ remark-rehype@11.1.2:
+ resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
+
+ remark-stringify@11.0.0:
+ resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -1684,6 +2216,9 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
safe-push-apply@1.0.0:
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
engines: {node: '>= 0.4'}
@@ -1692,6 +2227,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
+ sax@1.6.0:
+ resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
+ engines: {node: '>=11.0.0'}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -1748,6 +2287,13 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ space-separated-tokens@2.0.2:
+ resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+
+ split-on-first@1.1.0:
+ resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
+ engines: {node: '>=6'}
+
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -1755,6 +2301,16 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
+ stream-chain@2.2.5:
+ resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==}
+
+ stream-json@1.9.1:
+ resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==}
+
+ strict-uri-encode@2.0.0:
+ resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
+ engines: {node: '>=4'}
+
string.prototype.includes@2.0.1:
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
engines: {node: '>= 0.4'}
@@ -1778,6 +2334,12 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
+ string_decoder@1.3.0:
+ resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
+ stringify-entities@4.0.4:
+ resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -1786,6 +2348,15 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
+ strnum@2.3.0:
+ resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==}
+
+ style-to-js@1.1.21:
+ resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
+
+ style-to-object@1.0.14:
+ resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
+
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -1814,6 +2385,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
+ through2@4.0.2:
+ resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
+
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -1822,6 +2396,12 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ trim-lines@3.0.1:
+ resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+
+ trough@2.2.0:
+ resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
@@ -1876,6 +2456,27 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ unified@11.0.5:
+ resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
+ unist-util-find-after@5.0.0:
+ resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
+
+ unist-util-is@6.0.1:
+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+ unist-util-position@5.0.0:
+ resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+
+ unist-util-stringify-position@4.0.0:
+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+ unist-util-visit-parents@6.0.2:
+ resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+ unist-util-visit@5.1.0:
+ resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
unrs-resolver@1.11.1:
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
@@ -1888,6 +2489,15 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ vfile-message@4.0.3:
+ resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+ vfile@6.0.3:
+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -1913,6 +2523,18 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ xml-naming@0.1.0:
+ resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
+ engines: {node: '>=16.0.0'}
+
+ xml2js@0.6.2:
+ resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
+ engines: {node: '>=4.0.0'}
+
+ xmlbuilder@11.0.1:
+ resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
+ engines: {node: '>=4.0'}
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -1932,10 +2554,243 @@ packages:
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
+ zwitch@2.0.4:
+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
snapshots:
'@alloc/quick-lru@5.2.0': {}
+ '@aws-crypto/crc32@5.2.0':
+ dependencies:
+ '@aws-crypto/util': 5.2.0
+ '@aws-sdk/types': 3.973.11
+ tslib: 2.8.1
+
+ '@aws-crypto/crc32c@5.2.0':
+ dependencies:
+ '@aws-crypto/util': 5.2.0
+ '@aws-sdk/types': 3.973.11
+ tslib: 2.8.1
+
+ '@aws-crypto/sha1-browser@5.2.0':
+ dependencies:
+ '@aws-crypto/supports-web-crypto': 5.2.0
+ '@aws-crypto/util': 5.2.0
+ '@aws-sdk/types': 3.973.11
+ '@aws-sdk/util-locate-window': 3.965.6
+ '@smithy/util-utf8': 2.3.0
+ tslib: 2.8.1
+
+ '@aws-crypto/sha256-browser@5.2.0':
+ dependencies:
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-crypto/supports-web-crypto': 5.2.0
+ '@aws-crypto/util': 5.2.0
+ '@aws-sdk/types': 3.973.11
+ '@aws-sdk/util-locate-window': 3.965.6
+ '@smithy/util-utf8': 2.3.0
+ tslib: 2.8.1
+
+ '@aws-crypto/sha256-js@5.2.0':
+ dependencies:
+ '@aws-crypto/util': 5.2.0
+ '@aws-sdk/types': 3.973.11
+ tslib: 2.8.1
+
+ '@aws-crypto/supports-web-crypto@5.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@aws-crypto/util@5.2.0':
+ dependencies:
+ '@aws-sdk/types': 3.973.11
+ '@smithy/util-utf8': 2.3.0
+ tslib: 2.8.1
+
+ '@aws-sdk/checksums@3.1000.2':
+ dependencies:
+ '@aws-crypto/crc32': 5.2.0
+ '@aws-crypto/crc32c': 5.2.0
+ '@aws-crypto/util': 5.2.0
+ '@aws-sdk/core': 3.974.18
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/client-s3@3.1063.0':
+ dependencies:
+ '@aws-crypto/sha1-browser': 5.2.0
+ '@aws-crypto/sha256-browser': 5.2.0
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-sdk/core': 3.974.18
+ '@aws-sdk/credential-provider-node': 3.972.52
+ '@aws-sdk/middleware-flexible-checksums': 3.974.27
+ '@aws-sdk/middleware-sdk-s3': 3.972.48
+ '@aws-sdk/signature-v4-multi-region': 3.996.32
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/fetch-http-handler': 5.4.6
+ '@smithy/node-http-handler': 4.7.7
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/core@3.974.18':
+ dependencies:
+ '@aws-sdk/types': 3.973.11
+ '@aws-sdk/xml-builder': 3.972.28
+ '@aws/lambda-invoke-store': 0.2.4
+ '@smithy/core': 3.24.6
+ '@smithy/signature-v4': 5.4.6
+ '@smithy/types': 4.14.3
+ bowser: 2.14.1
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-env@3.972.44':
+ dependencies:
+ '@aws-sdk/core': 3.974.18
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-http@3.972.46':
+ dependencies:
+ '@aws-sdk/core': 3.974.18
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/fetch-http-handler': 5.4.6
+ '@smithy/node-http-handler': 4.7.7
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-ini@3.972.50':
+ dependencies:
+ '@aws-sdk/core': 3.974.18
+ '@aws-sdk/credential-provider-env': 3.972.44
+ '@aws-sdk/credential-provider-http': 3.972.46
+ '@aws-sdk/credential-provider-login': 3.972.49
+ '@aws-sdk/credential-provider-process': 3.972.44
+ '@aws-sdk/credential-provider-sso': 3.972.49
+ '@aws-sdk/credential-provider-web-identity': 3.972.49
+ '@aws-sdk/nested-clients': 3.997.17
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/credential-provider-imds': 4.3.8
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-login@3.972.49':
+ dependencies:
+ '@aws-sdk/core': 3.974.18
+ '@aws-sdk/nested-clients': 3.997.17
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-node@3.972.52':
+ dependencies:
+ '@aws-sdk/credential-provider-env': 3.972.44
+ '@aws-sdk/credential-provider-http': 3.972.46
+ '@aws-sdk/credential-provider-ini': 3.972.50
+ '@aws-sdk/credential-provider-process': 3.972.44
+ '@aws-sdk/credential-provider-sso': 3.972.49
+ '@aws-sdk/credential-provider-web-identity': 3.972.49
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/credential-provider-imds': 4.3.8
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-process@3.972.44':
+ dependencies:
+ '@aws-sdk/core': 3.974.18
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-sso@3.972.49':
+ dependencies:
+ '@aws-sdk/core': 3.974.18
+ '@aws-sdk/nested-clients': 3.997.17
+ '@aws-sdk/token-providers': 3.1063.0
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-web-identity@3.972.49':
+ dependencies:
+ '@aws-sdk/core': 3.974.18
+ '@aws-sdk/nested-clients': 3.997.17
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-flexible-checksums@3.974.27':
+ dependencies:
+ '@aws-sdk/checksums': 3.1000.2
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-sdk-s3@3.972.48':
+ dependencies:
+ '@aws-sdk/core': 3.974.18
+ '@aws-sdk/signature-v4-multi-region': 3.996.32
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/nested-clients@3.997.17':
+ dependencies:
+ '@aws-crypto/sha256-browser': 5.2.0
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-sdk/core': 3.974.18
+ '@aws-sdk/signature-v4-multi-region': 3.996.32
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/fetch-http-handler': 5.4.6
+ '@smithy/node-http-handler': 4.7.7
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/signature-v4-multi-region@3.996.32':
+ dependencies:
+ '@aws-sdk/types': 3.973.11
+ '@smithy/signature-v4': 5.4.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/token-providers@3.1063.0':
+ dependencies:
+ '@aws-sdk/core': 3.974.18
+ '@aws-sdk/nested-clients': 3.997.17
+ '@aws-sdk/types': 3.973.11
+ '@smithy/core': 3.24.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/types@3.973.11':
+ dependencies:
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@aws-sdk/util-locate-window@3.965.6':
+ dependencies:
+ tslib: 2.8.1
+
+ '@aws-sdk/xml-builder@3.972.28':
+ dependencies:
+ '@smithy/types': 4.14.3
+ fast-xml-parser: 5.7.3
+ tslib: 2.8.1
+
+ '@aws/lambda-invoke-store@0.2.4': {}
+
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -2098,6 +2953,10 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
+ '@fontsource-variable/geist-mono@5.2.8': {}
+
+ '@fontsource-variable/geist@5.2.9': {}
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -2262,6 +3121,8 @@ snapshots:
'@next/swc-win32-x64-msvc@16.1.6':
optional: true
+ '@nodable/entities@2.1.1': {}
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -2278,6 +3139,54 @@ snapshots:
'@rtsao/scc@1.1.0': {}
+ '@smithy/core@3.24.6':
+ dependencies:
+ '@aws-crypto/crc32': 5.2.0
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@smithy/credential-provider-imds@4.3.8':
+ dependencies:
+ '@smithy/core': 3.24.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@smithy/fetch-http-handler@5.4.6':
+ dependencies:
+ '@smithy/core': 3.24.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@smithy/is-array-buffer@2.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/node-http-handler@4.7.7':
+ dependencies:
+ '@smithy/core': 3.24.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@smithy/signature-v4@5.4.6':
+ dependencies:
+ '@smithy/core': 3.24.6
+ '@smithy/types': 4.14.3
+ tslib: 2.8.1
+
+ '@smithy/types@4.14.3':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/util-buffer-from@2.2.0':
+ dependencies:
+ '@smithy/is-array-buffer': 2.2.0
+ tslib: 2.8.1
+
+ '@smithy/util-utf8@2.3.0':
+ dependencies:
+ '@smithy/util-buffer-from': 2.2.0
+ tslib: 2.8.1
+
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -2356,12 +3265,30 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/debug@4.1.13':
+ dependencies:
+ '@types/ms': 2.1.0
+
+ '@types/estree-jsx@1.0.5':
+ dependencies:
+ '@types/estree': 1.0.8
+
'@types/estree@1.0.8': {}
+ '@types/hast@3.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
'@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {}
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/ms@2.1.0': {}
+
'@types/node@20.19.37':
dependencies:
undici-types: 6.21.0
@@ -2374,6 +3301,10 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/unist@2.0.11': {}
+
+ '@types/unist@3.0.3': {}
+
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -2465,6 +3396,8 @@ snapshots:
'@typescript-eslint/types': 8.56.1
eslint-visitor-keys: 5.0.1
+ '@ungap/structured-clone@1.3.1': {}
+
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
optional: true
@@ -2616,6 +3549,8 @@ snapshots:
async-function@1.0.0: {}
+ async@3.2.6: {}
+
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
@@ -2624,12 +3559,20 @@ snapshots:
axobject-query@4.1.0: {}
+ bail@2.0.2: {}
+
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
baseline-browser-mapping@2.10.0: {}
+ block-stream2@2.1.0:
+ dependencies:
+ readable-stream: 3.6.2
+
+ bowser@2.14.1: {}
+
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@@ -2643,6 +3586,8 @@ snapshots:
dependencies:
fill-range: 7.1.1
+ browser-or-node@2.1.1: {}
+
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.10.0
@@ -2651,6 +3596,8 @@ snapshots:
node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
+ buffer-crc32@1.0.0: {}
+
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -2672,11 +3619,21 @@ snapshots:
caniuse-lite@1.0.30001777: {}
+ ccount@2.0.1: {}
+
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ character-entities-html4@2.1.0: {}
+
+ character-entities-legacy@3.0.0: {}
+
+ character-entities@2.0.2: {}
+
+ character-reference-invalid@2.0.1: {}
+
client-only@0.0.1: {}
color-convert@2.0.1:
@@ -2685,6 +3642,8 @@ snapshots:
color-name@1.1.4: {}
+ comma-separated-tokens@2.0.3: {}
+
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
@@ -2725,6 +3684,12 @@ snapshots:
dependencies:
ms: 2.1.3
+ decode-named-character-reference@1.3.0:
+ dependencies:
+ character-entities: 2.0.2
+
+ decode-uri-component@0.2.2: {}
+
deep-is@0.1.4: {}
define-data-property@1.1.4:
@@ -2739,8 +3704,14 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
+ dequal@2.0.3: {}
+
detect-libc@2.1.2: {}
+ devlop@1.1.0:
+ dependencies:
+ dequal: 2.0.3
+
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -2870,6 +3841,8 @@ snapshots:
escape-string-regexp@4.0.0: {}
+ escape-string-regexp@5.0.0: {}
+
eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 16.1.6
@@ -3073,8 +4046,14 @@ snapshots:
estraverse@5.3.0: {}
+ estree-util-is-identifier-name@3.0.0: {}
+
esutils@2.0.3: {}
+ eventemitter3@5.0.4: {}
+
+ extend@3.0.2: {}
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.1:
@@ -3089,6 +4068,26 @@ snapshots:
fast-levenshtein@2.0.6: {}
+ fast-xml-builder@1.2.0:
+ dependencies:
+ path-expression-matcher: 1.5.0
+ xml-naming: 0.1.0
+
+ fast-xml-parser@5.7.3:
+ dependencies:
+ '@nodable/entities': 2.1.1
+ fast-xml-builder: 1.2.0
+ path-expression-matcher: 1.5.0
+ strnum: 2.3.0
+
+ fast-xml-parser@5.8.0:
+ dependencies:
+ '@nodable/entities': 2.1.1
+ fast-xml-builder: 1.2.0
+ path-expression-matcher: 1.5.0
+ strnum: 2.3.0
+ xml-naming: 0.1.0
+
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -3105,6 +4104,8 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
+ filter-obj@1.1.0: {}
+
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -3166,6 +4167,8 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
+ github-markdown-css@5.9.0: {}
+
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -3209,12 +4212,51 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ hast-util-is-element@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hast-util-to-jsx-runtime@2.3.6:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ hast-util-whitespace: 3.0.0
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ property-information: 7.2.0
+ space-separated-tokens: 2.0.2
+ style-to-js: 1.1.21
+ unist-util-position: 5.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ hast-util-to-text@4.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ hast-util-is-element: 3.0.0
+ unist-util-find-after: 5.0.0
+
+ hast-util-whitespace@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
hermes-estree@0.25.1: {}
hermes-parser@0.25.1:
dependencies:
hermes-estree: 0.25.1
+ highlight.js@11.11.1: {}
+
+ html-url-attributes@3.0.1: {}
+
ignore@5.3.2: {}
ignore@7.0.5: {}
@@ -3226,12 +4268,25 @@ snapshots:
imurmurhash@0.1.4: {}
+ inherits@2.0.4: {}
+
+ inline-style-parser@0.2.7: {}
+
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
hasown: 2.0.2
side-channel: 1.1.0
+ ipaddr.js@2.4.0: {}
+
+ is-alphabetical@2.0.1: {}
+
+ is-alphanumerical@2.0.1:
+ dependencies:
+ is-alphabetical: 2.0.1
+ is-decimal: 2.0.1
+
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@@ -3276,6 +4331,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
+ is-decimal@2.0.1: {}
+
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
@@ -3294,6 +4351,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
+ is-hexadecimal@2.0.1: {}
+
is-map@2.0.3: {}
is-negative-zero@2.0.3: {}
@@ -3305,6 +4364,8 @@ snapshots:
is-number@7.0.0: {}
+ is-plain-obj@4.1.0: {}
+
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -3456,10 +4517,20 @@ snapshots:
lodash.merge@4.6.2: {}
+ lodash@4.18.1: {}
+
+ longest-streak@3.1.0: {}
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
+ lowlight@3.3.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ devlop: 1.1.0
+ highlight.js: 11.11.1
+
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -3468,15 +4539,369 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ markdown-table@3.0.4: {}
+
+ marked@18.0.5: {}
+
math-intrinsics@1.1.0: {}
+ mdast-util-find-and-replace@3.0.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ escape-string-regexp: 5.0.0
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ mdast-util-from-markdown@2.0.3:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ mdast-util-to-string: 4.0.0
+ micromark: 4.0.2
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-decode-string: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-stringify-position: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-find-and-replace: 3.0.2
+ micromark-util-character: 2.1.1
+
+ mdast-util-gfm-footnote@2.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ micromark-util-normalize-identifier: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-table@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ markdown-table: 3.0.4
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm@3.1.0:
+ dependencies:
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-gfm-autolink-literal: 2.0.1
+ mdast-util-gfm-footnote: 2.1.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.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-expression@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-jsx@3.2.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ parse-entities: 4.0.2
+ stringify-entities: 4.0.4
+ unist-util-stringify-position: 4.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdxjs-esm@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-phrasing@4.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ unist-util-is: 6.0.1
+
+ mdast-util-to-hast@13.2.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@ungap/structured-clone': 1.3.1
+ devlop: 1.1.0
+ micromark-util-sanitize-uri: 2.0.1
+ trim-lines: 3.0.1
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
+ mdast-util-to-markdown@2.1.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ longest-streak: 3.1.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-string: 4.0.0
+ micromark-util-classify-character: 2.0.1
+ micromark-util-decode-string: 2.0.1
+ unist-util-visit: 5.1.0
+ zwitch: 2.0.4
+
+ mdast-util-to-string@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+
merge2@1.4.1: {}
+ micromark-core-commonmark@2.0.3:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-factory-destination: 2.0.1
+ micromark-factory-label: 2.0.1
+ micromark-factory-space: 2.0.1
+ micromark-factory-title: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-html-tag-name: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-footnote@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-table@2.1.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm@3.0.0:
+ dependencies:
+ micromark-extension-gfm-autolink-literal: 2.1.0
+ micromark-extension-gfm-footnote: 2.1.0
+ micromark-extension-gfm-strikethrough: 2.1.0
+ micromark-extension-gfm-table: 2.1.1
+ micromark-extension-gfm-tagfilter: 2.0.0
+ micromark-extension-gfm-task-list-item: 2.1.0
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-destination@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-label@2.0.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-space@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-title@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-whitespace@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-chunked@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-classify-character@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-combine-extensions@2.0.1:
+ dependencies:
+ micromark-util-chunked: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-decode-string@2.0.1:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ micromark-util-character: 2.1.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-html-tag-name@2.0.1: {}
+
+ micromark-util-normalize-identifier@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-resolve-all@2.0.1:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-subtokenize@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.2: {}
+
+ micromark@4.0.2:
+ dependencies:
+ '@types/debug': 4.1.13
+ debug: 4.4.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-encode: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.1
+ mime-db@1.52.0: {}
+
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
@@ -3487,6 +4912,22 @@ snapshots:
minimist@1.2.8: {}
+ minio@8.0.7:
+ dependencies:
+ async: 3.2.6
+ block-stream2: 2.1.0
+ browser-or-node: 2.1.1
+ buffer-crc32: 1.0.0
+ eventemitter3: 5.0.4
+ fast-xml-parser: 5.8.0
+ ipaddr.js: 2.4.0
+ lodash: 4.18.1
+ mime-types: 2.1.35
+ query-string: 7.1.3
+ stream-json: 1.9.1
+ through2: 4.0.2
+ xml2js: 0.6.2
+
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -3597,8 +5038,20 @@ snapshots:
dependencies:
callsites: 3.1.0
+ parse-entities@4.0.2:
+ dependencies:
+ '@types/unist': 2.0.11
+ character-entities-legacy: 3.0.0
+ character-reference-invalid: 2.0.1
+ decode-named-character-reference: 1.3.0
+ is-alphanumerical: 2.0.1
+ is-decimal: 2.0.1
+ is-hexadecimal: 2.0.1
+
path-exists@4.0.0: {}
+ path-expression-matcher@1.5.0: {}
+
path-key@3.1.1: {}
path-parse@1.0.7: {}
@@ -3609,6 +5062,8 @@ snapshots:
picomatch@4.0.3: {}
+ pocketbase@0.27.0: {}
+
possible-typed-array-names@1.1.0: {}
postcss@8.4.31:
@@ -3631,8 +5086,17 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ property-information@7.2.0: {}
+
punycode@2.3.1: {}
+ query-string@7.1.3:
+ dependencies:
+ decode-uri-component: 0.2.2
+ filter-obj: 1.1.0
+ split-on-first: 1.1.0
+ strict-uri-encode: 2.0.0
+
queue-microtask@1.2.3: {}
react-dom@19.2.3(react@19.2.3):
@@ -3642,8 +5106,32 @@ snapshots:
react-is@16.13.1: {}
+ react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.3):
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/react': 19.2.14
+ devlop: 1.1.0
+ hast-util-to-jsx-runtime: 2.3.6
+ html-url-attributes: 3.0.1
+ mdast-util-to-hast: 13.2.1
+ react: 19.2.3
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ unified: 11.0.5
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
react@19.2.3: {}
+ readable-stream@3.6.2:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -3664,6 +5152,48 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
+ rehype-highlight@7.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-to-text: 4.0.2
+ lowlight: 3.3.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
+ remark-gfm@4.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-gfm: 3.1.0
+ micromark-extension-gfm: 3.0.0
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-parse@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ micromark-util-types: 2.0.2
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-rehype@11.1.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ mdast-util-to-hast: 13.2.1
+ unified: 11.0.5
+ vfile: 6.0.3
+
+ remark-stringify@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-to-markdown: 2.1.2
+ unified: 11.0.5
+
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -3697,6 +5227,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
+ safe-buffer@5.2.1: {}
+
safe-push-apply@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -3708,6 +5240,8 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
+ sax@1.6.0: {}
+
scheduler@0.27.0: {}
semver@6.3.1: {}
@@ -3804,6 +5338,10 @@ snapshots:
source-map-js@1.2.1: {}
+ space-separated-tokens@2.0.2: {}
+
+ split-on-first@1.1.0: {}
+
stable-hash@0.0.5: {}
stop-iteration-iterator@1.1.0:
@@ -3811,6 +5349,14 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
+ stream-chain@2.2.5: {}
+
+ stream-json@1.9.1:
+ dependencies:
+ stream-chain: 2.2.5
+
+ strict-uri-encode@2.0.0: {}
+
string.prototype.includes@2.0.1:
dependencies:
call-bind: 1.0.8
@@ -3861,10 +5407,29 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
+ string_decoder@1.3.0:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ stringify-entities@4.0.4:
+ dependencies:
+ character-entities-html4: 2.1.0
+ character-entities-legacy: 3.0.0
+
strip-bom@3.0.0: {}
strip-json-comments@3.1.1: {}
+ strnum@2.3.0: {}
+
+ style-to-js@1.1.21:
+ dependencies:
+ style-to-object: 1.0.14
+
+ style-to-object@1.0.14:
+ dependencies:
+ inline-style-parser: 0.2.7
+
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3):
dependencies:
client-only: 0.0.1
@@ -3882,6 +5447,10 @@ snapshots:
tapable@2.3.0: {}
+ through2@4.0.2:
+ dependencies:
+ readable-stream: 3.6.2
+
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
@@ -3891,6 +5460,10 @@ snapshots:
dependencies:
is-number: 7.0.0
+ trim-lines@3.0.1: {}
+
+ trough@2.2.0: {}
+
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -3965,6 +5538,44 @@ snapshots:
undici-types@6.21.0: {}
+ unified@11.0.5:
+ dependencies:
+ '@types/unist': 3.0.3
+ bail: 2.0.2
+ devlop: 1.1.0
+ extend: 3.0.2
+ is-plain-obj: 4.1.0
+ trough: 2.2.0
+ vfile: 6.0.3
+
+ unist-util-find-after@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-is@6.0.1:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-stringify-position@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-parents@6.0.2:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-visit@5.1.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
unrs-resolver@1.11.1:
dependencies:
napi-postinstall: 0.3.4
@@ -3999,6 +5610,18 @@ snapshots:
dependencies:
punycode: 2.3.1
+ util-deprecate@1.0.2: {}
+
+ vfile-message@4.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-stringify-position: 4.0.0
+
+ vfile@6.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile-message: 4.0.3
+
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -4046,6 +5669,15 @@ snapshots:
word-wrap@1.2.5: {}
+ xml-naming@0.1.0: {}
+
+ xml2js@0.6.2:
+ dependencies:
+ sax: 1.6.0
+ xmlbuilder: 11.0.1
+
+ xmlbuilder@11.0.1: {}
+
yallist@3.1.1: {}
yocto-queue@0.1.0: {}
@@ -4059,3 +5691,5 @@ snapshots:
zrender@6.0.0:
dependencies:
tslib: 2.3.0
+
+ zwitch@2.0.4: {}