diff --git a/README.md b/README.md index 54543c5..d6fc5ec 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ A 地基(登入 / 權限 / Threads / Job / 通知) ```bash cd apps/web npm install -npm run dev # 本機開發 +npm run dev # 本機開發,監聽 0.0.0.0:5173(區網可連) npm run build # 型別檢查 + 生產建置 ``` diff --git a/apps/web/index.html b/apps/web/index.html index d6f50ca..848327f 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,7 +4,36 @@ - Harbor Desk · 巡樓 + 巡樓 · Lapras + + + + + +
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index bc383bf..c49f602 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -1,15 +1,17 @@ { - "name": "web", + "name": "@harbor/web", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "web", + "name": "@harbor/web", "version": "0.0.0", "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1", + "taipei-sans-tc": "^0.1.1" }, "devDependencies": { "@types/node": "^24.13.2", @@ -738,6 +740,19 @@ } } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1187,6 +1202,44 @@ "react": "^19.2.7" } }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -1227,6 +1280,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1237,6 +1296,12 @@ "node": ">=0.10.0" } }, + "node_modules/taipei-sans-tc": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/taipei-sans-tc/-/taipei-sans-tc-0.1.1.tgz", + "integrity": "sha512-GCvdO+SATITfm128bT+fFmbpVW8+o0zLMVlNskSOk3CBghNzfqj4tvwTbIQ9vmvftd31s5UNypLA3aIw/IG6RQ==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 662ec05..a4c880e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,14 +4,16 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host 0.0.0.0", "build": "tsc -b && vite build", "lint": "oxlint", - "preview": "vite preview" + "preview": "vite preview --host 0.0.0.0" }, "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1", + "taipei-sans-tc": "^0.1.1" }, "devDependencies": { "@types/node": "^24.13.2", diff --git a/apps/web/public/brand-mark.jpg b/apps/web/public/brand-mark.jpg new file mode 100644 index 0000000..04f7941 Binary files /dev/null and b/apps/web/public/brand-mark.jpg differ diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg index 6893eb1..d5a4731 100644 --- a/apps/web/public/favicon.svg +++ b/apps/web/public/favicon.svg @@ -1 +1,25 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/App.css b/apps/web/src/App.css deleted file mode 100644 index d7737c6..0000000 --- a/apps/web/src/App.css +++ /dev/null @@ -1,50 +0,0 @@ -.scaffold { - min-height: 100vh; - margin: 0; - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-start; - gap: 0.75rem; - padding: 2rem; - box-sizing: border-box; - font-family: system-ui, -apple-system, "Segoe UI", sans-serif; - color: #1e293b; - background: #f8fafc; -} - -.scaffold-kicker { - margin: 0; - font-size: 0.875rem; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: #2a9d8f; -} - -.scaffold h1 { - margin: 0; - font-size: 1.75rem; - font-weight: 700; -} - -.scaffold-lead { - margin: 0; - max-width: 28rem; - line-height: 1.6; - color: #475569; -} - -.scaffold-meta { - margin: 0.5rem 0 0; - font-size: 0.875rem; - color: #64748b; -} - -.scaffold code { - font-size: 0.85em; - padding: 0.1em 0.35em; - border-radius: 0.25rem; - background: #e2e8f0; - color: #0f172a; -} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index aa12c99..0e02589 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,19 +1,79 @@ -import "./App.css"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "./auth/AuthContext"; +import { RequireAuth } from "./auth/RequireAuth"; +import { AppShell } from "./components/layout/AppShell"; +import { DataProvider } from "./data/DataContext"; +import { I18nProvider } from "./i18n/I18nContext"; +import { ThemeProvider } from "./theme/ThemeContext"; +import { AdminUsersPage } from "./pages/AdminUsersPage"; +import { BrandsPage } from "./pages/BrandsPage"; +import { CrewPage } from "./pages/CrewPage"; +import { JobDetailPage } from "./pages/JobDetailPage"; +import { InsightsPage } from "./pages/InsightsPage"; +import { JobsPage } from "./pages/JobsPage"; +import { ForgotPasswordPage } from "./pages/ForgotPasswordPage"; +import { LoginPage } from "./pages/LoginPage"; +import { OutboxDetailPage } from "./pages/OutboxDetailPage"; +import { OutboxPage } from "./pages/OutboxPage"; +import { ProfilePage } from "./pages/ProfilePage"; +import { ResetPasswordPage } from "./pages/ResetPasswordPage"; +import { ScoutPage } from "./pages/ScoutPage"; +import { SettingsPage } from "./pages/SettingsPage"; +import { PlanCheckoutPage } from "./pages/PlanCheckoutPage"; +import { PlansPage } from "./pages/PlansPage"; +import { UsagePage } from "./pages/UsagePage"; +import { StudioPage } from "./pages/StudioPage"; +import { VerifyEmailPage } from "./pages/VerifyEmailPage"; +import { WizardPage } from "./pages/studio/WizardPage"; +import { TodayPage } from "./pages/TodayPage"; -/** - * T001 scaffold placeholder. Routing / Harbor Desk shell start at T004–T005. - */ export default function App() { return ( -
-

Harbor Desk

-

apps/web

-

- 巡樓 Console 前端骨架(T001)。下一步:設計 token 與 App Shell。 -

-

- 啟動:npm run dev · 產物:npm run build -

-
+ + + + + + + } /> + } /> + } /> + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + + + + + ); } diff --git a/apps/web/src/auth/AuthContext.tsx b/apps/web/src/auth/AuthContext.tsx new file mode 100644 index 0000000..4190a42 --- /dev/null +++ b/apps/web/src/auth/AuthContext.tsx @@ -0,0 +1,79 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import type { Member } from "../domain/types"; +import type { MemberProfilePatch } from "../data/repos"; +import { useRepos } from "../data/DataContext"; + +type AuthContextValue = { + member: Member | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; + reload: () => Promise; + updateProfile: (patch: MemberProfilePatch) => Promise; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const repos = useRepos(); + const [member, setMember] = useState(null); + const [loading, setLoading] = useState(true); + + const reload = useCallback(async () => { + // 已進過 app 後不要再把 loading=true:否則 RequireAuth 會卸載整棵 /app, + // 各頁本地 state(如用量「全體」分頁)會被重掛成預設。 + try { + const me = await repos.auth.me(); + setMember(me); + } finally { + setLoading(false); + } + }, [repos.auth]); + + useEffect(() => { + void reload(); + }, [reload]); + + const login = useCallback( + async (email: string, password: string) => { + const { member: m } = await repos.auth.login(email, password); + setMember(m); + }, + [repos.auth], + ); + + const logout = useCallback(async () => { + await repos.auth.logout(); + setMember(null); + }, [repos.auth]); + + const updateProfile = useCallback( + async (patch: MemberProfilePatch) => { + const next = await repos.auth.updateProfile(patch); + setMember(next); + return next; + }, + [repos.auth], + ); + + const value = useMemo( + () => ({ member, loading, login, logout, reload, updateProfile }), + [member, loading, login, logout, reload, updateProfile], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/apps/web/src/auth/RequireAuth.tsx b/apps/web/src/auth/RequireAuth.tsx new file mode 100644 index 0000000..97be8ac --- /dev/null +++ b/apps/web/src/auth/RequireAuth.tsx @@ -0,0 +1,32 @@ +import { Navigate, useLocation } from "react-router-dom"; +import { useAuth } from "./AuthContext"; + +export function RequireAuth({ children }: { children: React.ReactNode }) { + const { member, loading } = useAuth(); + const location = useLocation(); + + if (loading) { + return ( +
+

載入中…

+
+ ); + } + + if (!member) { + return ; + } + + // 未驗證信箱:擋下所有 /app,導向驗證頁 + if (!member.email_verified) { + return ( + + ); + } + + return children; +} diff --git a/apps/web/src/components/layout/AccountMenu.tsx b/apps/web/src/components/layout/AccountMenu.tsx new file mode 100644 index 0000000..f991305 --- /dev/null +++ b/apps/web/src/components/layout/AccountMenu.tsx @@ -0,0 +1,129 @@ +import { useEffect, useRef, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "../../auth/AuthContext"; +import { AccountAvatar } from "../ui/AccountAvatar"; +import { useI18n } from "../../i18n/I18nContext"; +import { memberRoleSummary } from "../../lib/memberRole"; +import { useTheme } from "../../theme/ThemeContext"; + +/** + * 頂欄帳號選單:會員資料、設定、主題、登出。 + * 用量與方案改由頂欄 UsagePlanWidget 處理。 + */ +export function AccountMenu() { + const { member, logout } = useAuth(); + const { t } = useI18n(); + const { resolved, toggle } = useTheme(); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + function onDoc(e: MouseEvent) { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + } + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, []); + + const name = member?.display_name || "…"; + const role = memberRoleSummary(member, t); + const avatarAccount = { + display_name: name, + username: member?.email || "user", + avatar_color: "#6dbf7a", + avatar_url: member?.avatar_url, + }; + + return ( +
+ + + {open ? ( +
+
+
+ +
+ {name} + {member?.email || "—"} +
+
+
+ + {role.primaryLabel} + + + {member?.email_verified ? t("role.verified") : t("role.unverified")} + +
+
+ + +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..667762d --- /dev/null +++ b/apps/web/src/components/layout/AppShell.tsx @@ -0,0 +1,21 @@ +import { Outlet } from "react-router-dom"; +import { MobileDock } from "./MobileDock"; +import { SidebarNav } from "./SidebarNav"; +import { Topbar } from "./Topbar"; + +export function AppShell() { + return ( +
+ +
+ +
+
+ +
+
+
+ +
+ ); +} diff --git a/apps/web/src/components/layout/BellMenu.tsx b/apps/web/src/components/layout/BellMenu.tsx new file mode 100644 index 0000000..e218930 --- /dev/null +++ b/apps/web/src/components/layout/BellMenu.tsx @@ -0,0 +1,174 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useData, useRepos } from "../../data/DataContext"; +import type { AppNotification } from "../../domain/types"; +import { useI18n } from "../../i18n/I18nContext"; +import { pathForNotification } from "../../lib/nav"; +import { formatLocalDateTime, formatTimeAgo } from "../../lib/time"; +import { Button } from "../ui"; + +const PREVIEW = 5; + +function kindLabel(kind: AppNotification["kind"], t: (k: string) => string): string { + if (kind === "job") return t("nav.jobs"); + if (kind === "outbox") return t("nav.outbox"); + return t("nav.settings"); +} + +export function BellMenu() { + const repos = useRepos(); + const { refresh, tick } = useData(); + const { t } = useI18n(); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [items, setItems] = useState([]); + const [unread, setUnread] = useState(0); + const rootRef = useRef(null); + + useEffect(() => { + void (async () => { + const list = await repos.notifications.list(); + // 未讀優先,再依時間 + const sorted = list.slice().sort((a, b) => { + const ar = a.read_at ? 1 : 0; + const br = b.read_at ? 1 : 0; + if (ar !== br) return ar - br; + return b.created_at - a.created_at; + }); + setItems(sorted); + setUnread(await repos.notifications.unreadCount()); + })(); + }, [repos.notifications, tick]); + + useEffect(() => { + const onStore = () => refresh(); + window.addEventListener("harbor:store", onStore); + return () => window.removeEventListener("harbor:store", onStore); + }, [refresh]); + + useEffect(() => { + function onDoc(e: MouseEvent) { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + } + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, []); + + async function openItem(n: AppNotification) { + await repos.notifications.markRead(n.id); + refresh(); + setOpen(false); + navigate(pathForNotification(n)); + } + + const preview = items.slice(0, PREVIEW); + const more = Math.max(0, items.length - PREVIEW); + + return ( +
+ + + {open ? ( +
+
+
+ {t("topbar.notifications")} + {unread > 0 ? ( + + {t("topbar.unread", { n: unread })} + + ) : null} +
+ {items.length > 0 ? ( + + ) : null} +
+ + {items.length === 0 ? ( +

{t("topbar.noNotifications")}

+ ) : ( +
    + {preview.map((n) => ( +
  • + +
  • + ))} +
+ )} + +
+ {more > 0 ? ( + + {t("topbar.moreOlder", { n: more })} + + ) : ( + + )} + +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/layout/MobileDock.tsx b/apps/web/src/components/layout/MobileDock.tsx new file mode 100644 index 0000000..0633424 --- /dev/null +++ b/apps/web/src/components/layout/MobileDock.tsx @@ -0,0 +1,141 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { NavLink, useLocation, useNavigate } from "react-router-dom"; +import { useI18n } from "../../i18n/I18nContext"; +import { + isMoreNavActive, + isNavActive, + mobileDockMoreKeys, + mobileDockPrimaryKeys, + navItemsByKeys, +} from "../../lib/nav"; +import { AppIcon } from "../ui/AppIcons"; + +export function MobileDock() { + const { pathname } = useLocation(); + const { t } = useI18n(); + const navigate = useNavigate(); + const [moreOpen, setMoreOpen] = useState(false); + const sheetRef = useRef(null); + const moreBtnRef = useRef(null); + const titleId = useId(); + + const dockItems = navItemsByKeys(mobileDockPrimaryKeys); + const moreItems = navItemsByKeys(mobileDockMoreKeys); + const moreActive = isMoreNavActive(pathname); + + useEffect(() => { + setMoreOpen(false); + }, [pathname]); + + useEffect(() => { + if (!moreOpen) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setMoreOpen(false); + } + function onDoc(e: MouseEvent | TouchEvent) { + const node = e.target as Node; + if (sheetRef.current?.contains(node) || moreBtnRef.current?.contains(node)) return; + setMoreOpen(false); + } + document.addEventListener("keydown", onKey); + document.addEventListener("mousedown", onDoc); + document.addEventListener("touchstart", onDoc); + return () => { + document.removeEventListener("keydown", onKey); + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("touchstart", onDoc); + }; + }, [moreOpen]); + + useEffect(() => { + if (!moreOpen) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [moreOpen]); + + function goMore(path: string) { + setMoreOpen(false); + navigate(path); + } + + return ( + <> + {moreOpen ? ( +
+ + + ); + })} + +
+ + ) : null} + + + + ); +} diff --git a/apps/web/src/components/layout/PageHeader.tsx b/apps/web/src/components/layout/PageHeader.tsx new file mode 100644 index 0000000..022bda5 --- /dev/null +++ b/apps/web/src/components/layout/PageHeader.tsx @@ -0,0 +1,14 @@ +type Props = { + title: string; + description?: string; +}; + +export function PageHeader({ title, description }: Props) { + const desc = description?.trim(); + return ( +
+

{title}

+ {desc ?

{desc}

: null} +
+ ); +} diff --git a/apps/web/src/components/layout/SidebarNav.tsx b/apps/web/src/components/layout/SidebarNav.tsx new file mode 100644 index 0000000..d8c781e --- /dev/null +++ b/apps/web/src/components/layout/SidebarNav.tsx @@ -0,0 +1,32 @@ +import { NavLink, useLocation } from "react-router-dom"; +import { useI18n } from "../../i18n/I18nContext"; +import { isNavActive, primaryNav } from "../../lib/nav"; +import { AppIcon } from "../ui/AppIcons"; + +export function SidebarNav() { + const { pathname } = useLocation(); + const { t } = useI18n(); + + return ( + + ); +} diff --git a/apps/web/src/components/layout/Topbar.tsx b/apps/web/src/components/layout/Topbar.tsx new file mode 100644 index 0000000..8bfc665 --- /dev/null +++ b/apps/web/src/components/layout/Topbar.tsx @@ -0,0 +1,33 @@ +import { Link } from "react-router-dom"; +import { BrandMark } from "../ui/BrandMark"; +import { useI18n } from "../../i18n/I18nContext"; +import { AccountMenu } from "./AccountMenu"; +import { BellMenu } from "./BellMenu"; +import { UsagePlanWidget } from "./UsagePlanWidget"; + +export function Topbar() { + const { t, locale } = useI18n(); + + return ( +
+ + + + {t("app.name")} + {locale === "zh-TW" ? ( + {t("app.nameEn")} + ) : ( + {t("app.nameZh")} + )} + + +
+
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/components/layout/UsagePlanWidget.tsx b/apps/web/src/components/layout/UsagePlanWidget.tsx new file mode 100644 index 0000000..208e193 --- /dev/null +++ b/apps/web/src/components/layout/UsagePlanWidget.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { useData, useRepos } from "../../data/DataContext"; +import { useI18n } from "../../i18n/I18nContext"; +import { getPlanRights } from "../../lib/planRights"; +import type { PlanId, UsageMonthSummary } from "../../lib/usageMeter"; + +/** + * 頂欄:方案名 + 剩餘點數(可讀文字,不塞細進度條)。 + * 點開再看完整進度、權益與變更方案。 + */ +export function UsagePlanWidget() { + const repos = useRepos(); + const { tick } = useData(); + const { t, formatPlanPrice } = useI18n(); + const location = useLocation(); + const [summary, setSummary] = useState(null); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + let alive = true; + void repos.usage.getSummary().then((s) => { + if (alive) setSummary(s); + }); + return () => { + alive = false; + }; + }, [repos.usage, tick, location.pathname, location.key, open]); + + useEffect(() => { + function onDoc(e: MouseEvent) { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + } + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, []); + + if (!summary) { + return ( + + … + + ); + } + + const planId = summary.plan.id as PlanId; + const rights = getPlanRights(planId, t); + const cap = summary.plan.monthly_credits; + const used = summary.total_credits; + const left = Math.max(0, summary.remaining_credits); + const pct = cap > 0 ? Math.min(100, Math.round((used / cap) * 100)) : 0; + const tight = !summary.unlimited && (pct >= 70 || used >= cap); + const over = !summary.unlimited && used > cap; + const isFree = planId === "free"; + const price = formatPlanPrice(summary.plan.price_twd); + + const triggerHint = summary.unlimited + ? t("usage.widget.titleUnlimited", { name: summary.plan.name }) + : t("usage.widget.titleUsed", { name: summary.plan.name, used, cap }); + + return ( +
+ + + {open ? ( +
+
+
+ {t("usage.widget.currentPlan")} + + {summary.plan.name} + {summary.unlimited ? ( + + {" "} + ∞ + + ) : null} + + + {price} + {t("usage.widget.perMonth")} + +
+
+ {t("usage.widget.monthUsage")} + + {used} + /{cap} + + + {summary.unlimited + ? t("usage.widget.unlimited") + : t("usage.widget.remaining", { n: left })} + +
+
+ + {!summary.unlimited ? ( +
+
+
+ ) : null} + +
    + {rights.bullets.map((b) => ( +
  • {b}
  • + ))} +
+ + {tight ?

{t("usage.widget.nudge")}

: null} + +
+ setOpen(false)} + > + {isFree || tight ? t("usage.widget.upgrade") : t("usage.widget.changePlan")} + + setOpen(false)} + > + {t("usage.widget.usageDetail")} + +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/persona/PersonaWorkbench.tsx b/apps/web/src/components/persona/PersonaWorkbench.tsx new file mode 100644 index 0000000..d8cdd7d --- /dev/null +++ b/apps/web/src/components/persona/PersonaWorkbench.tsx @@ -0,0 +1,549 @@ +import { useEffect, useMemo, useState } from "react"; +import { Badge, Button, EmptyState, Input, Select, Textarea } from "../ui"; +import { useData, useRepos } from "../../data/DataContext"; +import type { Persona, StyleDimKey, ThreadsAccount } from "../../domain/types"; +import { mockGenerateOwnPostReply, mockGenerateRoot } from "../../lib/mockAi"; +import { newId } from "../../lib/id"; +import { useI18n } from "../../i18n/I18nContext"; +import { + DIM_ORDER, + buildPersonaPromptBlock, + createEmptyPersona, + isPersonaReady, + markReady, + serializeDraftText, + toneOf, +} from "../../lib/personaPrompt"; + +type AnalyzeMode = "account" | "text"; +type DetailTab = "overview" | "analyze" | "fingerprint" | "preview"; + +export function PersonaWorkbench() { + const repos = useRepos(); + const { refresh, tick } = useData(); + const { t } = useI18n(); + + const [personas, setPersonas] = useState([]); + const [accounts, setAccounts] = useState([]); + const [activeId, setActiveId] = useState(""); + const [selectedId, setSelectedId] = useState(""); + const [detailTab, setDetailTab] = useState("overview"); + const [analyzeMode, setAnalyzeMode] = useState("account"); + const [benchmarkUsername, setBenchmarkUsername] = useState(""); + const [refText, setRefText] = useState(""); + const [sourceLabel, setSourceLabel] = useState(""); + const [busy, setBusy] = useState(""); + const [message, setMessage] = useState(""); + const [previewPost, setPreviewPost] = useState(""); + const [previewReply, setPreviewReply] = useState(""); + const [promptPreview, setPromptPreview] = useState(""); + const [showPrompt, setShowPrompt] = useState(false); + /** mobile: list vs detail */ + const [mobileShowDetail, setMobileShowDetail] = useState(false); + + const selected = useMemo( + () => personas.find((p) => p.id === selectedId) || personas[0], + [personas, selectedId], + ); + + function statusLabel(status: string): string { + if (status === "ready") return t("persona.statusReady"); + if (status === "analyzing") return t("persona.statusAnalyzing"); + return t("persona.statusPending"); + } + + async function load() { + const [list, active, accList] = await Promise.all([ + repos.personas.list(), + repos.personas.getActiveId(), + repos.accounts.list(), + ]); + setPersonas(list); + setAccounts(accList); + setActiveId(active); + setSelectedId((cur) => { + if (cur && list.some((p) => p.id === cur)) return cur; + return active || list[0]?.id || ""; + }); + } + + useEffect(() => { + void load(); + }, [tick, repos.personas, repos.accounts]); // eslint-disable-line react-hooks/exhaustive-deps + + function selectPersona(id: string) { + setSelectedId(id); + setMessage(""); + setPreviewPost(""); + setPreviewReply(""); + setPromptPreview(""); + setShowPrompt(false); + setMobileShowDetail(true); + // 未 ready 預設帶到分析 + const p = personas.find((x) => x.id === id); + if (p && !isPersonaReady(p)) setDetailTab("analyze"); + else setDetailTab("overview"); + } + + async function createPersona() { + setBusy("create"); + try { + const p = createEmptyPersona({ + id: newId("persona"), + name: t("persona.newName"), + brief: "", + status: "empty", + }); + await repos.personas.save(p); + setSelectedId(p.id); + setDetailTab("analyze"); + setMobileShowDetail(true); + setMessage(t("persona.created")); + refresh(); + await load(); + } finally { + setBusy(""); + } + } + + async function saveBasics() { + if (!selected) return; + setBusy("save"); + try { + const draftText = + selected.style.draftText.trim() || serializeDraftText(selected.style.draft); + const next = markReady({ + ...selected, + style: { ...selected.style, draftText }, + voice: selected.style.draft.tone || selected.voice, + }); + if (!draftText) next.status = "empty"; + await repos.personas.save(next); + setMessage(t("persona.saved")); + refresh(); + await load(); + } finally { + setBusy(""); + } + } + + async function runAnalyzeFromText() { + if (!selected) return; + setBusy("analyze"); + setMessage(""); + try { + const next = await repos.personas.analyzeFromText( + selected.id, + refText, + sourceLabel || undefined, + ); + setSelectedId(next.id); + setMessage(t("persona.textDone", { n: next.style.sampleCount })); + setRefText(""); + setDetailTab("fingerprint"); + refresh(); + await load(); + } catch (e) { + setMessage(e instanceof Error ? e.message : t("persona.analyzeFail")); + } finally { + setBusy(""); + } + } + + async function runAnalyzeFromAccount() { + if (!selected) return; + setBusy("analyze"); + setMessage(t("persona.reading")); + try { + const next = await repos.personas.analyzeFromAccount(selected.id, benchmarkUsername); + setSelectedId(next.id); + setMessage( + t("persona.accountDone", { + user: next.style.benchmarkUsername || benchmarkUsername, + n: next.style.sampleCount, + }), + ); + setDetailTab("fingerprint"); + refresh(); + await load(); + } catch (e) { + setMessage(e instanceof Error ? e.message : t("persona.analyzeFail")); + } finally { + setBusy(""); + } + } + + async function setDefault() { + if (!selected) return; + await repos.personas.setActiveId(selected.id); + setActiveId(selected.id); + setMessage(t("persona.setDefaultMsg", { name: selected.name })); + refresh(); + } + + async function removePersona() { + if (!selected) return; + if (!window.confirm(t("persona.confirmDelete", { name: selected.name }))) return; + setBusy("delete"); + try { + await repos.personas.remove(selected.id); + setSelectedId(""); + setMobileShowDetail(false); + setMessage(t("persona.deleted")); + refresh(); + await load(); + } finally { + setBusy(""); + } + } + + function patchSelected(partial: Partial) { + if (!selected) return; + setPersonas((list) => list.map((p) => (p.id === selected.id ? { ...p, ...partial } : p))); + } + + function patchDraftText(text: string) { + if (!selected) return; + patchSelected({ + style: { ...selected.style, draftText: text }, + status: text.trim() ? "ready" : selected.status, + }); + } + + function runPreview() { + if (!selected || !isPersonaReady(selected)) { + setMessage(t("persona.needReady")); + return; + } + const samplePost = t("persona.previewTopic"); + setPreviewPost(mockGenerateRoot(samplePost, selected)); + setPreviewReply( + mockGenerateOwnPostReply({ + postText: samplePost, + replyText: t("persona.previewReplySample"), + persona: selected, + }), + ); + setPromptPreview(buildPersonaPromptBlock(selected, "post")); + } + + const hasAnalysis = + selected && + (selected.status === "ready" || Object.keys(selected.style.dimensions).length > 0); + + const listPane = ( +
+
+ +
+ {personas.length === 0 ? ( + + ) : ( +
    + {personas.map((p) => ( +
  • + +
  • + ))} +
+ )} +
+ ); + + const detailPane = selected ? ( +
+
+ +
+ {selected.name} + + {statusLabel(selected.status)} + +
+
+ + {message ? ( +

+ {message} +

+ ) : null} + +
+ {( + [ + ["overview", "persona.tabOverview"], + ["analyze", "persona.tabAnalyze"], + ["fingerprint", "persona.tabFingerprint"], + ["preview", "persona.tabPreview"], + ] as const + ).map(([key, labelKey]) => ( + + ))} +
+ + {detailTab === "overview" ? ( +
+ patchSelected({ name: e.target.value })} + /> +