From 08c3582d76beb2175398f07fbd62c53e8befc522 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Tue, 18 Aug 2026 15:54:06 +0800 Subject: [PATCH 1/4] =?UTF-8?q?refactor(pwa-next):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E8=B7=A8=E8=A3=9D=E7=BD=AE=E9=9B=B2=E7=AB=AF=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=B9=E7=82=BA=E7=B4=94=E6=9C=AC?= =?UTF-8?q?=E6=A9=9F=E5=84=B2=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 遺留的跨裝置同步 bug 一直沒查出根因,使用者決定放棄整條雲端同步路線。 移除 Clerk 登入、Client 層 apiClient、四個 service 的 remote/merge/pushNow/ restoreForBook 與 tombstone 機制、後端 API route、Prisma schema/資料庫、 TanStack Query,四個 service 只留本機 CRUD。renderer/mobile 後續只做本機儲存版 的 Client/Service/Hook 分層,不再規劃任何登入/跨裝置同步。 Co-Authored-By: Claude Sonnet 5 --- CLIENT_SERVICE_HOOK_REFACTOR.md | 203 ++-- pwa-next/package.json | 10 +- pwa-next/prisma.config.ts | 14 - pwa-next/prisma/schema.prisma | 92 -- pwa-next/src/App.tsx | 28 +- .../api/books/[bookId]/annotations/route.ts | 87 -- .../app/api/books/[bookId]/bookmarks/route.ts | 75 -- .../app/api/books/[bookId]/progress/route.ts | 34 - pwa-next/src/app/api/books/[bookId]/route.ts | 34 - pwa-next/src/app/api/books/route.ts | 15 - pwa-next/src/app/api/health/route.ts | 7 - pwa-next/src/app/api/me/route.ts | 8 - pwa-next/src/app/layout.tsx | 44 +- pwa-next/src/clients/apiClient.ts | 54 - .../src/components/Library/AuthStatus.tsx | 36 - pwa-next/src/components/QueryProvider.tsx | 13 - pwa-next/src/hooks/reader/useAnnotations.ts | 4 +- pwa-next/src/hooks/reader/useBookmarks.ts | 14 +- pwa-next/src/hooks/reader/useProgress.ts | 37 +- pwa-next/src/hooks/reader/useReaderEngine.ts | 21 - pwa-next/src/hooks/useCloudRestore.ts | 68 -- pwa-next/src/hooks/useLibrary.ts | 22 +- pwa-next/src/lib/prisma.ts | 22 - pwa-next/src/lib/requireUserId.ts | 14 - pwa-next/src/page/Library.tsx | 3 - pwa-next/src/page/Notes.tsx | 32 +- pwa-next/src/page/Privacy.tsx | 22 +- pwa-next/src/proxy.ts | 14 - pwa-next/src/services/annotationService.ts | 119 +- pwa-next/src/services/bookService.ts | 63 +- pwa-next/src/services/bookmarkService.ts | 115 +- pwa-next/src/services/progressService.ts | 91 +- pwa-next/src/services/syncGate.ts | 10 - pwa-next/src/services/syncQueue.ts | 10 - pwa-next/yarn.lock | 1071 +---------------- 35 files changed, 156 insertions(+), 2350 deletions(-) delete mode 100644 pwa-next/prisma.config.ts delete mode 100644 pwa-next/prisma/schema.prisma delete mode 100644 pwa-next/src/app/api/books/[bookId]/annotations/route.ts delete mode 100644 pwa-next/src/app/api/books/[bookId]/bookmarks/route.ts delete mode 100644 pwa-next/src/app/api/books/[bookId]/progress/route.ts delete mode 100644 pwa-next/src/app/api/books/[bookId]/route.ts delete mode 100644 pwa-next/src/app/api/books/route.ts delete mode 100644 pwa-next/src/app/api/health/route.ts delete mode 100644 pwa-next/src/app/api/me/route.ts delete mode 100644 pwa-next/src/clients/apiClient.ts delete mode 100644 pwa-next/src/components/Library/AuthStatus.tsx delete mode 100644 pwa-next/src/components/QueryProvider.tsx delete mode 100644 pwa-next/src/hooks/useCloudRestore.ts delete mode 100644 pwa-next/src/lib/prisma.ts delete mode 100644 pwa-next/src/lib/requireUserId.ts delete mode 100644 pwa-next/src/proxy.ts delete mode 100644 pwa-next/src/services/syncGate.ts delete mode 100644 pwa-next/src/services/syncQueue.ts diff --git a/CLIENT_SERVICE_HOOK_REFACTOR.md b/CLIENT_SERVICE_HOOK_REFACTOR.md index 481cfb1..dd8b506 100644 --- a/CLIENT_SERVICE_HOOK_REFACTOR.md +++ b/CLIENT_SERVICE_HOOK_REFACTOR.md @@ -3,7 +3,6 @@ tags: - refactor - progress - client-service-hook - - cloud-sync --- # Client / Service / Hook 分層架構重構進度追蹤 @@ -11,155 +10,119 @@ tags: > 之後任何一次對話(不論是不是同一個 Claude session)都能從這裡接續,不用重新討論一次。 > 格式沿用 [CLOUD_SYNC_PROGRESS.md](CLOUD_SYNC_PROGRESS.md) / [FP_REFACTOR_PROGRESS.md](FP_REFACTOR_PROGRESS.md) 的既有慣例。 -**現況(2026-08-08):pwa-next 的 Phase 0(進度同步)、Phase 1(Book/Bookmarks/Annotations -全量遷移+逐筆 upsert+軟刪除)都已寫碼完成並推上 `dev` 分支。過程中修過好幾輪使用者實測回報的 -bug(見下方討論紀錄),但**跨裝置同步目前仍有一個尚未解出根因的失敗案例,使用者已要求先擱置**, -不要在還沒有新線索的情況下繼續盲改。renderer/mobile 完全還沒開始動工(Phase 2–6)。** +**現況(2026-08-18):跨裝置雲端同步功能已全數移除(使用者決定放棄,原因是 Phase 1 卡住的 +跨裝置同步 bug 一直沒查出根因,見下方「已放棄的雲端同步」)。pwa-next 現在是純本機儲存, +沒有登入機制、沒有後端 API route、沒有資料庫。三個子專案(`pwa-next`/`renderer`/`mobile`) +接下來只做「本機儲存版」的 Client/Service/Hook 分層,不含任何雲端/帳號功能。renderer/mobile +完全還沒開始動工。** --- -## 跟 CLOUD_SYNC_PROGRESS.md 的關係 - -`CLOUD_SYNC_PROGRESS.md` 記錄的是「從零打造雲端同步」這個更早的工程(Next.js 骨架、Clerk、 -Prisma schema、API route、`pwa/` → `pwa-next/` 的 UI 搬遷),**該文件記載的同步機制 -(`utils/cloudSync.ts` 手刻 fire-and-forget、書籤/註記「整包覆蓋」PUT)已經在這次重構裡 -被整個取代**,`cloudSync.ts` 這個檔案已經刪除。 - -這次重構要解決的是同步機制本身的架構問題: -- 原本的「整包覆蓋」寫法沒有可信的逐筆時間戳、也沒有刪除紀錄,跨裝置合併時無法正確處理 - 「這筆是不是已經在別的裝置被刪除」,做了讀取/還原功能後這個缺口會直接讓已刪除的資料復活。 -- 三個資料存取(fetch/存 localStorage/推播雲端)散落在各個 hook 裡,沒有一致的分層, - 難以推廣到 renderer/mobile 兩個還沒有任何雲端同步的平台。 - -**如果之後要查「為什麼書名沒辦法同步改動」「Neon/Clerk 是怎麼申請的」這類背景,去看 -`CLOUD_SYNC_PROGRESS.md`;要查「現在的分層架構長怎樣、書籤/註記怎麼合併、還剩哪些平台沒做」, -看這份文件。** +## 已放棄的雲端同步(2026-08-18 移除,僅供歷史查閱) + +Phase 0(pwa-next 進度同步)與 Phase 1(pwa-next 的 Book/Bookmarks/Annotations 全量遷移+ +逐筆 upsert+軟刪除)原本已完成並推上 `dev` 分支,但 Phase 1 遺留一個沒解出根因的跨裝置同步 +bug(A 端新增註記、B 端看不到;細節見 git 歷史 `371e0e1` 之後、`4471d71` 之前的 +`CLIENT_SERVICE_HOOK_REFACTOR.md` 版本,或 `git log -p` 查看這份文件在拆除雲端同步那次 +commit 之前的內容)。使用者在這次卡住後決定不再追查,直接要求整個拆除雲端同步功能。 + +**2026-08-18 拆除範圍**: +- pwa-next:移除 `@clerk/nextjs` 登入(`ClerkProvider`、`AuthStatus.tsx`、`src/proxy.ts` + middleware、`useUser`/`isSignedIn` 各處呼叫);移除 `clients/apiClient.ts`(Client 層); + 移除四個 service(`bookService`/`progressService`/`bookmarkService`/`annotationService`) + 的 `remote`/`merge`/`pushNow`/`pushDebounced`/`restoreForBook` 與 tombstone(`recordDeleted`/ + `loadDeletedIds`/`clearDeletedIds`)機制,只留 `local` CRUD;移除 `services/syncQueue.ts`/ + `syncGate.ts`/`hooks/useCloudRestore.ts`;移除後端 `src/app/api/books/**`(books/ + bookmarks/annotations/progress route)、`src/app/api/me/route.ts`、`src/app/api/health/route.ts`、 + `src/lib/requireUserId.ts`、`src/lib/prisma.ts`;移除 `prisma/schema.prisma`/ + `prisma.config.ts`/`src/generated/prisma/`;移除 `QueryProvider.tsx`(連同 + `@tanstack/react-query`,拆完雲端還原後專案內沒有其他地方在用 TanStack Query); + `package.json` 移除 `@clerk/nextjs`/`@prisma/*`/`pg`/`prisma`/`@tanstack/react-query` + 五個依賴與 `postinstall: prisma generate`;`.env` 內的 `DATABASE_URL`/ + `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`/`CLERK_SECRET_KEY` 一併清掉(該檔本來就 gitignore, + 不影響版控);`Privacy.tsx` 隱私權政策文案改寫為「純本機儲存、無帳號」。 +- `yarn build`/`yarn lint` 都驗證過,`lint` 維持在改動前的既有基準值 + (29 problems, 10 errors, 19 warnings,跟這次移除完全無關的既有問題),沒有新增任何錯誤。 +- **這批改動尚未經使用者在瀏覽器實際操作驗證(開書、翻頁存進度、加書籤、劃線註記、 + 「我的筆記」頁刪除等),依照專案規則,要等使用者測過確認沒問題才能 commit。** --- -## 整體目標與範圍 +## 整體目標與範圍(2026-08-18 更新) 使用者原始問題:專案裡有沒有「Client 層(網路細節)/Service 層(資料模型)/Hook 層 -(TanStack Query 快取)」這種分層架構?調查後發現完全沒有,三個子專案(`pwa-next`/ -`renderer`/`mobile`)都沒有 TanStack Query,`renderer`/`mobile` 甚至完全沒有登入機制。 - -使用者確認的範圍決策: -1. **三個子專案都要做**(`renderer`、`pwa-next`、`mobile`;不含已被取代的舊版 `pwa/`),且是 - **完整遷移**(改寫既有邏輯,不是新舊並存)。 -2. **renderer 登入**:用**自訂 URL scheme 深連結**(`travelintime://`),不做手動配對碼。 -3. **Electron token 儲存**:`safeStorage` 加密;若系統不支援加密(常見於無 keyring 的 Linux), - 直接擋下登入並顯示錯誤,不降級成明文。 -4. **書籤/註記合併**:不接受「整包覆蓋、無法追蹤刪除」的限制,後端 PUT 改成逐筆 upsert - +軟刪除,讓合併語意(LWW by `updatedAt`)真正站得住腳。 -5. **進度同步**:本機 localStorage 每次翻頁立即寫,遠端推送 debounce 1200–1500ms。 -6. 型別契約不建立要發佈的 npm 套件(三個獨立部署目標,套件版本管理成本高於複製貼上成本)。 - -**完整的架構設計細節(Client/Service/Hook 三層的介面形狀、renderer 深連結認證流程的完整步驟、 -mobile Clerk Expo 整合方式、Bearer token 後端驗證路徑設計)記在 plan mode 產生的計畫書, -路徑:`/Users/yutingchan/.claude/plans/fancy-frolicking-fairy.md`(這是 Claude Code 本機的 -plan 檔案,不在這個 git repo 裡,換一台機器或換人接手看不到——如果那個檔案未來不見了, -下面「三層架構設計」小節有摘要版,足夠接續 renderer/mobile 階段)。** +(快取)」這種分層架構?調查後發現完全沒有。原始規劃是三個子專案都做完整的雲端同步分層, +但 pwa-next 的跨裝置同步卡住一直沒解,使用者決定放棄雲端同步這整條路線。 + +**現行範圍決策(取代舊決策)**: +1. **三個子專案都要做本機版分層**(`renderer`、`pwa-next`、`mobile`;不含已被取代的舊版 + `pwa/`),且是**完整遷移**(改寫既有邏輯,不是新舊並存)。 +2. **不做任何登入/帳號機制**(Clerk 已從 pwa-next 移除,renderer 深連結登入/mobile Clerk + Expo 整合這兩個計畫**取消**,不會實作)。 +3. **不做任何跨裝置同步**(後端 API route、Bearer Token 驗證路徑、Prisma 資料庫這幾個計畫 + **取消**,不會實作)。 +4. 分層的價值改成單純「Client 層之後不再需要(沒有網路)/Service 層封裝本機 + storage(IndexedDB/localStorage/SQLite 依平台而定)/Hook 層包成給元件用的 API」, + 讓 `renderer`/`mobile` 沿用跟 pwa-next 拆完雲端後同樣形狀的本機 Service 層,作為之後 + 維護/單元測試的一致基礎,而不是為了同步。 +5. 型別契約不建立要發佈的 npm 套件(三個獨立部署目標,套件版本管理成本高於複製貼上成本)。 --- -## 三層架構設計摘要 +## 三層架構設計摘要(本機版,2026-08-18 更新) ``` -clients/ Client 層:只管網路(fetch wrapper、timeout、401 處理、Bearer token 注入) -services/ Service 層:純函式,資料模型 + local CRUD + remote CRUD + merge,不 import React -hooks/ Hook 層:把 Service 包成給元件用的 API(TanStack Query 或必要時的 useState/useCallback) +services/ Service 層:純函式,資料模型 + 本機 CRUD(storage 依平台而定),不 import React +hooks/ Hook 層:把 Service 包成給元件用的 API(useState/useCallback,不需要 TanStack Query + ——沒有遠端資料要協調快取,pwa-next 已把 @tanstack/react-query 整個移除) ``` -- **Client 層**(`pwa-next/src/clients/apiClient.ts`):`createApiClient({baseUrl, getAuthHeader?, onUnauthorized?, timeoutMs})`,一律 `throw ApiError`,不吞錯——吞錯是 Service 層的業務語意判斷,不是 Client 層的責任。 -- **Service 層**:每個資源一支檔案(`bookService.ts`/`progressService.ts`/`bookmarkService.ts`/`annotationService.ts`),固定形狀 `{ local: {...}, remote: {...}, merge(local, remote, ...) }`。永遠先寫 local(同步、不用等網路),保留「localStorage 才是 source of truth」的核心語意。 -- **Hook 層**:`progressService` 走 TanStack Query(`useProgressQuery`/`useSaveProgress`,見 `hooks/reader/useProgress.ts`);`bookmarkService`/`annotationService` **實際實作偏離了原計畫書**——原計畫要全部走 TanStack Query cache,但因為書籤/註記的還原時機是「開書當下」「還沒開任何 Reader 的書庫/筆記頁」等情境,跟 query cache 綁定的元件生命週期對不太上,最後改用單純的 `useState` + `useCallback` 穩定參照(`hooks/reader/useAnnotations.ts`、`hooks/reader/useBookmarks.ts`),是刻意的簡化,不是漏做。 -- 詳細信任邊界/debounce 設計/renderer 深連結認證流程/mobile Clerk Expo 整合,見上面提到的 plan 檔案。 +- pwa-next 現在的四個 service(`bookService.ts`/`progressService.ts`/`bookmarkService.ts`/ + `annotationService.ts`)都只剩 `{ local: {...} }` 這個形狀,是 renderer/mobile 本機版 + Service 層可以直接參考的藍本(各自平台的 storage API 不同:pwa-next 用 + IndexedDB+localStorage,renderer/mobile 要看現有的 `mobile/lib/library.ts` 之類實作用的 + 是什麼)。 +- Hook 層不再需要 TanStack Query:`useProgress.ts`/`useBookmarks.ts`/`useAnnotations.ts` + 現在都是單純的 `useState`/`useCallback`,這也是 renderer/mobile 應該沿用的形狀。 --- -## Phase 進度 - -### Phase 0 — pwa-next:Progress 垂直切片(已完成,已推上 dev) -- [x] `@tanstack/react-query` 安裝+`QueryProvider` -- [x] `clients/apiClient.ts`、`services/syncQueue.ts`、`services/syncGate.ts`、`services/progressService.ts` -- [x] `hooks/reader/useProgress.ts`(`useProgressQuery`/`useSaveProgress`,debounce 1400ms+flush-on-exit) -- [x] 順手修正背景章節頁數掃描器的既有 bug:`components/Reader/pageCountCache.ts`(快取上次完整掃描的每章頁數,開書時提前灌回;掃描完成時 total 不能比目前顯示的還小,可疑結果不寫入快取;快取加版本號讓修正前寫入的舊快取失效) -- [x] 使用者測試 OK(進度保存、頁數顯示都正常),已 commit + push - -### Phase 1 — pwa-next:Book/Bookmarks/Annotations 全量遷移(已完成,已推上 dev;跨裝置同步仍有已知問題) -- [x] Prisma schema:`Bookmark` 新增 `updatedAt`(nullable,舊資料列沒有可回填)/`deletedAt`,`Annotation` 新增 `deletedAt`(`db push` 同步到 Neon,**不能用 `migrate dev`**——這個專案沒有 migration 歷史,`migrate dev` 第一次跑會想整個 reset 資料庫,已經在對話中攔下沒讓它執行) -- [x] `bookmarks`/`annotations` route 從整包覆蓋改成逐筆 upsert + 軟刪除;後續又加固:`lib/validateSyncPayload.ts` 驗證 payload 形狀、`MAX_UPSERTS` 上限、`findMany` 批次查歸屬取代 N+1 `findUnique`、`deletedBookmarks`/`deletedAnnotations` 30 天保留期限內的軟刪除紀錄隨 GET 回傳(讓別裝置合併時知道「這筆已被刪除」,不會復活)、`$transaction` 加 timeout -- [x] `services/bookService.ts`/`bookmarkService.ts`/`annotationService.ts`(含 tombstone log、`restoreForBook(bookId)`——開書當下背景跟雲端合併這本書的資料,不阻塞開書;`local.clear(bookId)`——整本書刪除時一併清掉書籤/註記/進度的本機殘留,避免同內容重新匯入時憑空復活) -- [x] `store/useAnnotationStore.ts`(Zustand)、`utils/cloudSync.ts` 已刪除 -- [x] `hooks/useCloudRestore.ts`:`restoreCloudDataOnce(replaceRecords)`,模組層級(不是 React state/ref)防重複觸發+in-flight 去重,登入時、以及 `Notes.tsx`/`useBookmarks.ts`/`useReaderEngine.ts` 各自在自己的時機點呼叫 -- [x] `services/syncGate.ts` 加了「登入世代」(`generation`)概念:帳號切換時卡在 `syncQueue` 佇列裡還沒送出的舊帳號寫入,執行前重新比對世代,世代不符直接跳過,避免舊帳號資料被當成新帳號的寫入送出去 -- [x] `yarn build`/`yarn lint` 全程維持在改動前的基準值(29 problems, 10 errors, 19 warnings),沒有新增任何編譯/lint 問題 -- [ ] **跨裝置同步:使用者實測「A 端新增註記,B 端沒有同步顯示」「A 端新增書本,B 端書本正確顯示為缺檔,但該書註記沒有出現在 B 端」,根因還沒查出來,使用者已要求先擱置,不要繼續盲改**(見下方「已知問題」) +## Phase 進度(2026-08-18 全面改版:雲端相關 Phase 全部取消,改成本機版) -### Phase 2 — 後端 Bearer Token 驗證路徑(未開始) -給 renderer/mobile 用:`ApiToken`/`PairingCode` model、配對碼交換端點、`requireUserIdMulti()`(依序嘗試 Clerk cookie / Clerk JWT bearer / opaque PAT bearer)、`/pair` 頁面。可以完全用 curl 獨立測試,不依賴任何前端 App。 +### pwa-next — 拆除雲端同步(已完成,見上方「已放棄的雲端同步」;待使用者驗證後才能 commit) -### Phase 3 — renderer 登入(深連結,未開始) -`travelintime://` 協定註冊、`app.requestSingleInstanceLock()`、`open-url`/`second-instance` handler、`safeStorage` IPC bridge、登入 UI。**已知風險**:深連結在未打包的 dev 模式下協定關聯可能不穩定,測試這條路徑可能需要先跑過一次打包安裝。 +### renderer — 本機資料層分層(未開始) +拆掉雲端計畫後,renderer 現有 hooks 需要的改動應該不大:原本就是「pwa-next hooks 拿掉雲端 +呼叫的版本」,現在 pwa-next 拆完雲端後兩邊的形狀更接近了,可以直接比對 pwa-next 現有的 +`services/*.ts`/`hooks/reader/use*.ts` 決定 renderer 要不要抽出同樣的 Service/Hook 分層, +或者現有寫法已經夠接近就不用大動。 -### Phase 4 — renderer 資料層 + 同步 + 還原(未開始) -比照 Phase 1 的 pattern(`clients/apiClient.ts`+`services/*.ts`+tombstone log),renderer 現有 hooks 本來就是 pwa-next hooks 拿掉雲端呼叫的版本,改動面應該比 Phase 1 小。 - -### Phase 5 — mobile 登入(`@clerk/clerk-expo`,未開始) - -### Phase 6 — mobile 資料層 + 同步 + 還原(未開始) -拆 `mobile/lib/library.ts` 目前身兼「儲存 client」跟「資料模型定義」的雙重角色。 - -### Phase 7 — 收尾(未開始) -清殘留舊程式碼、三平台交叉測試矩陣(特別覆蓋跨裝置刪除場景——這正是 Phase 1 目前卡住的同一類問題,Phase 7 執行前這個問題必須先解掉)。 - ---- +### mobile — 本機資料層分層(未開始) +拆 `mobile/lib/library.ts` 目前身兼「儲存 client」跟「資料模型定義」的雙重角色,比照 pwa-next +`bookService.ts` 現在的本機版形狀(`{ local: {...} }`)拆開。 -## 已知問題(擱置中,下次接續時從這裡開始查) - -**2026-08-08 使用者回報,尚未解出根因**: -1. A 端(VSCode 內建瀏覽器)新增註記後,B 端(一般瀏覽器)重新整理/重開同一本書,沒有看到新註記。 -2. A 端新增書本後,B 端正確顯示該書為「缺檔」(需要使用者重新上傳),但該書在 A 端已有的註記, - 目前沒有在 B 端看到。 - -已經做過的修正(但使用者仍回報失敗,代表這些修正沒有完全解決,或還有其他根因沒找到): -- 補上「開書當下背景跟雲端合併」(`restoreForBook`),取代原本只有登入當下一次性還原的機制。 -- `Notes.tsx`(獨立路由,不跟 `App.tsx` 共用 React state)補上自己的還原 effect。 -- `useBookmarks.ts` 加了 `activeBookIdRef` 防止「舊書的合併結果晚到才回來,覆蓋掉已經在讀的新書」。 -- 後端補上 `deletedBookmarks`/`deletedAnnotations` 讓刪除紀錄能正確跨裝置傳播。 - -**下次排查時建議的方向**(沒有驗證過,是待測的假設): -- 兩端是不是真的登入**同一個** Clerk 帳號(不同 OAuth 方式登入同一個 Google 帳號,Clerk 那邊 - 會不會因為 `clerkUserId` 不同而被當成不同使用者?這是最值得先排除的低階可能性)。 -- `restoreForBook` 呼叫的時間點,是不是真的在使用者觀察的當下已經跑完(背景 fetch 不 await, - UI 可能還沒更新使用者就已經在看畫面了)——可以先加臨時的 `console.log` 或用瀏覽器 Network - 分頁直接確認 GET `/api/books/[bookId]/annotations` 有沒有真的發出去、回應內容是什麼。 -- A 端是「VSCode 內建瀏覽器」——這個環境本身對某些瀏覽器 API 支援不完整是本專案已知的坑 - (見 `~/.claude/CLAUDE.md` 全域規則的「裝置能力 API」段落),雖然這次測的是網路請求不是 - 裝置能力 API,但既然 A 端環境本來就比較特殊,不能排除是環境本身的問題,排查時第一步可以 - 先確認 A 端在一般瀏覽器分頁測試是否同樣失敗,把「環境」跟「程式碼」兩個變因分開。 +### 收尾(未開始) +三平台的本機資料層一致後,交叉檢查三個子專案的 Service 層介面形狀是否夠接近、有沒有值得抽共用 +邏輯的地方(不建 npm 套件,複製貼上即可,見上方範圍決策第 5 點)。 --- ## 討論紀錄 - 2026-08-08:完成 Phase 0(Progress 垂直切片)與 Phase 1(Book/Bookmarks/Annotations 全量遷移) - 的規劃與實作,經多輪使用者實測抓出並修正:`useAnnotations.ts` 回傳函式未穩定參照導致的 - `Maximum update depth exceeded` 無限迴圈;還原流程漏了先補推 Book 列造成的 `P2003` 外鍵違反; - `extractMeta` 用的獨立 epub.js book 實例沒套用既有的 `patchBookPrototype` destroy race 修補; - 劃線色塊選單在螢幕邊緣被裁切(clamp margin 抓太小);跳轉到既有註記後標記未重新渲染 - (epub.js 的 `hooks.render` 只在章節第一次渲染時自動重新掛標記,同章節內跳轉不會觸發)。 - 兩批修正都已 commit 並 push 到 `dev` 分支。用 `db push` 而非 `migrate dev` 同步 schema - 變更到 Neon(這個專案沒有 migration 歷史,`migrate dev` 第一次跑會想整個 reset 資料庫, - 已在對話中攔下沒有執行)。 - 最後一輪使用者做跨裝置測試(VSCode 內建瀏覽器 + 一般瀏覽器,同帳號登入)失敗,兩個情境 - (新增註記不同步、新書的既有註記不同步)都沒有解出根因,使用者要求先擱置這個問題, - 其餘已驗證的修正照常推上 `dev`。新增本文件作為跨對話追蹤起點。 + 的規劃與實作,經多輪使用者實測抓出並修正多個 bug,兩批修正都已 commit 並 push 到 `dev` 分支。 + 最後一輪使用者做跨裝置測試失敗,兩個情境(新增註記不同步、新書的既有註記不同步)都沒有解出 + 根因,使用者要求先擱置這個問題。 +- 2026-08-18:使用者決定不再追查跨裝置同步問題,直接要求移除所有雲端同步/登入功能, + renderer/mobile 之後只做本機儲存版的分層。當次對話完成 pwa-next 的全面拆除(見上方 + 「已放棄的雲端同步」段落),`yarn build`/`yarn lint` 驗證通過,**尚未經使用者瀏覽器實測**, + 依規則等驗證通過才能 commit。 --- ## 相關文件 -- 雲端同步的原始背景/技術棧決策/Neon vs Supabase 取捨:[CLOUD_SYNC_PROGRESS.md](CLOUD_SYNC_PROGRESS.md) -- 更早的函數式重構(action/calculation/data 分層,PWA/RENDERER/RN 三版 Reader.tsx 拆分):[FP_REFACTOR_PROGRESS.md](FP_REFACTOR_PROGRESS.md) -- 完整的三層架構設計書(renderer 深連結認證、mobile Clerk Expo、Bearer token 後端設計):`/Users/yutingchan/.claude/plans/fancy-frolicking-fairy.md`(Claude Code 本機檔案,不在 git repo 裡) +- 雲端同步的原始背景/技術棧決策/Neon vs Supabase 取捨(純歷史,機制已不存在): + [CLOUD_SYNC_PROGRESS.md](CLOUD_SYNC_PROGRESS.md) +- 更早的函數式重構(action/calculation/data 分層,PWA/RENDERER/RN 三版 Reader.tsx 拆分): + [FP_REFACTOR_PROGRESS.md](FP_REFACTOR_PROGRESS.md) diff --git a/pwa-next/package.json b/pwa-next/package.json index cc8e618..52ab7ba 100644 --- a/pwa-next/package.json +++ b/pwa-next/package.json @@ -9,18 +9,12 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint", - "postinstall": "prisma generate" + "lint": "eslint" }, "dependencies": { - "@clerk/nextjs": "^7.5.22", - "@prisma/adapter-pg": "^7.9.0", - "@prisma/client": "^7.9.0", - "@tanstack/react-query": "^5.101.4", "epubjs": "^0.3.93", "next": "16.2.11", "opencc-js": "^1.4.1", - "pg": "^8.22.0", "react": "19.2.4", "react-dom": "19.2.4", "zustand": "^5.0.14" @@ -28,12 +22,10 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", - "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.11", - "prisma": "^7.9.0", "tailwindcss": "^4", "typescript": "^5" } diff --git a/pwa-next/prisma.config.ts b/pwa-next/prisma.config.ts deleted file mode 100644 index 831a20f..0000000 --- a/pwa-next/prisma.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file was generated by Prisma, and assumes you have installed the following: -// npm install --save-dev prisma dotenv -import "dotenv/config"; -import { defineConfig } from "prisma/config"; - -export default defineConfig({ - schema: "prisma/schema.prisma", - migrations: { - path: "prisma/migrations", - }, - datasource: { - url: process.env["DATABASE_URL"], - }, -}); diff --git a/pwa-next/prisma/schema.prisma b/pwa-next/prisma/schema.prisma deleted file mode 100644 index 2a0e040..0000000 --- a/pwa-next/prisma/schema.prisma +++ /dev/null @@ -1,92 +0,0 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -generator client { - provider = "prisma-client" - output = "../src/generated/prisma" -} - -datasource db { - provider = "postgresql" -} - -// 使用者由 Clerk 管理,這裡不存本地 User 表,只存 clerkUserId 當外部索引鍵。 - -// 書本 metadata。id 是內容 hash(跟 PWA useLibrary.ts 前端算出來的一致), -// 同一本書內容不管哪個使用者匯入都會算出同一個 id,所以主鍵必須是 -// (clerkUserId, id) 複合鍵,避免不同使用者匯入同一本書時互相覆蓋。 -model Book { - id String @map("book_id") - clerkUserId String @map("clerk_user_id") - title String - author String @default("") - filename String - addedAt DateTime @default(now()) @map("added_at") - updatedAt DateTime @updatedAt @map("updated_at") - - progress ReadingProgress? - bookmarks Bookmark[] - annotations Annotation[] - - @@id([clerkUserId, id]) - @@map("books") -} - -// 閱讀進度:每本書每個使用者只有一筆,用 CFI 表示位置。 -model ReadingProgress { - bookId String @map("book_id") - clerkUserId String @map("clerk_user_id") - cfi String - updatedAt DateTime @updatedAt @map("updated_at") - - book Book @relation(fields: [bookId, clerkUserId], references: [id, clerkUserId], onDelete: Cascade) - - @@id([clerkUserId, bookId]) - @@unique([bookId, clerkUserId]) - @@map("reading_progress") -} - -// 書籤:CFI + 標籤,一本書可以有多個。 -// updatedAt/deletedAt 是這次從「整包覆蓋」改成逐筆 upsert + 軟刪除時新增的: -// 原本的 PUT 是 deleteMany+createMany 整批覆蓋,沒有可信的逐筆時間戳也沒有刪除紀錄, -// 一旦要做跨裝置讀取/還原就無法正確合併(無法分辨「這筆比較新」還是「這筆已經被刪除」)。 -model Bookmark { - id String @id @default(cuid()) - bookId String @map("book_id") - clerkUserId String @map("clerk_user_id") - cfi String - label String - addedAt DateTime @default(now()) @map("added_at") - // 開放成 nullable:既有的舊資料列(這次改動之前就存在的書籤)沒有這個欄位可以回填, - // 加 NOT NULL 會讓 `prisma db push` 直接擋下來(這個專案沒有 migration 歷史,只能用 - // db push 同步 schema,沒有欄位層級的資料回填手段)。舊資料列的 updatedAt 會是 null, - // Service 層的合併邏輯把 null 視為「時間戳未知、最舊」處理,下次編輯後就會補上真正的值。 - updatedAt DateTime? @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") - - book Book @relation(fields: [bookId, clerkUserId], references: [id, clerkUserId], onDelete: Cascade) - - @@index([clerkUserId, bookId]) - @@map("bookmarks") -} - -// 註記:劃線原文(text)+ 使用者筆記(note),本身帶足夠內容可獨立顯示, -// 不需要打開書本原文(見「我的筆記」列表頁面規劃)。 -model Annotation { - id String @id @default(cuid()) - bookId String @map("book_id") - clerkUserId String @map("clerk_user_id") - cfi String - text String - note String @default("") - color String @default("") - chapter String @default("") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") - - book Book @relation(fields: [bookId, clerkUserId], references: [id, clerkUserId], onDelete: Cascade) - - @@index([clerkUserId, bookId]) - @@map("annotations") -} diff --git a/pwa-next/src/App.tsx b/pwa-next/src/App.tsx index 1ee168d..9d30e4e 100644 --- a/pwa-next/src/App.tsx +++ b/pwa-next/src/App.tsx @@ -2,22 +2,15 @@ import { useEffect, useRef, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' -import { useUser } from '@clerk/nextjs' import Library from '@/page/Library' import Reader from '@/page/Reader' import MissingBookModal from '@/components/Library/MissingBookModal' import { useLibrary } from '@/hooks/useLibrary' -import { setSyncEnabled } from '@/services/syncGate' -import { useCloudRestoreMutation } from '@/hooks/useCloudRestore' type View = 'library' | 'reader' -// 一個瀏覽器分頁只自動還原一次,避免使用者掛著分頁時每次重新取得焦點/切換視圖 -// 都重新打一輪全量還原(GET 書庫 + 逐本書 GET 進度/書籤/註記)。 -const RESTORE_ONCE_KEY = 'tit-cloud-restore-done' - const App = () => { - const { records, addBook, getBookUrl, getCoverDataUrl, removeBook, touchBook, updateProgress, replaceRecords } = useLibrary() + const { records, addBook, getBookUrl, getCoverDataUrl, removeBook, touchBook, updateProgress } = useLibrary() const [view, setView] = useState('library') const [activeBookUrl, setActiveBookUrl] = useState(null) const [activeBookId, setActiveBookId] = useState('') @@ -25,32 +18,13 @@ const App = () => { const [darkMode, setDarkMode] = useState(true) const [missingBook, setMissingBook] = useState<{ id: string; title: string } | null>(null) const recoverFileInputRef = useRef(null) - const { isSignedIn } = useUser() - const restoreMutation = useCloudRestoreMutation(replaceRecords) - // 每個 Service 層的 sync 函式都要先查這個開關才會真的發請求,未登入時完全不送出, - // 不是「送出去再被 401 擋掉」。這個 effect 要排在下面的還原 effect 之前 - // (React 同一個元件裡的 effect 依宣告順序執行),不然還原當下開關還沒打開。 // 把 dark class 同步到 ,讓 body 的 background-color(globals.css 的 // var(--color-paper))能跟著切換,避免深色模式下 body 背景色停留在預設淺色。 useEffect(() => { document.documentElement.classList.toggle('dark', darkMode) }, [darkMode]) - useEffect(() => { - setSyncEnabled(!!isSignedIn) - }, [isSignedIn]) - - // 登入時觸發一次讀取/還原:跟雲端資料合併書庫清單/進度/書籤/註記,取代舊版 - // 只寫不讀的登入補推 effect(見 useCloudRestore.ts 的完整合併邏輯說明)。 - useEffect(() => { - if (!isSignedIn) return - if (sessionStorage.getItem(RESTORE_ONCE_KEY) === '1') return - sessionStorage.setItem(RESTORE_ONCE_KEY, '1') - restoreMutation.mutate() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSignedIn]) - const handleOpenBook = async (id: string, cfi?: string) => { const url = await getBookUrl(id) if (!url) { diff --git a/pwa-next/src/app/api/books/[bookId]/annotations/route.ts b/pwa-next/src/app/api/books/[bookId]/annotations/route.ts deleted file mode 100644 index fe10175..0000000 --- a/pwa-next/src/app/api/books/[bookId]/annotations/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; -import { requireUserId } from "@/lib/requireUserId"; - -interface RouteContext { - params: Promise<{ bookId: string }>; -} - -export const GET = async (_request: NextRequest, { params }: RouteContext) => { - const auth = await requireUserId(); - if (auth.unauthorized) return auth.unauthorized; - - const { bookId } = await params; - const annotations = await prisma.annotation.findMany({ - where: { clerkUserId: auth.userId, bookId, deletedAt: null }, - orderBy: { createdAt: "desc" }, - }); - return NextResponse.json({ annotations }); -}; - -// 逐筆 upsert + 軟刪除,取代原本「整包覆蓋」(deleteMany+createMany)的寫法,理由同 -// bookmarks/route.ts:整包覆蓋沒有可信的逐筆時間戳也沒有刪除紀錄,沒辦法正確跨裝置合併。 -// updatedAt 一樣由前端在編輯當下產生送上來,不用 Prisma 的 @updatedAt 自動管理。 -export const PUT = async (request: NextRequest, { params }: RouteContext) => { - const auth = await requireUserId(); - if (auth.unauthorized) return auth.unauthorized; - - const { bookId } = await params; - const body = (await request.json()) as { - upserts?: Array<{ - id: string; - cfi: string; - text: string; - note?: string; - color?: string; - chapter?: string; - createdAt: number; - updatedAt: number; - }>; - deletedIds?: string[]; - }; - if (!Array.isArray(body.upserts) || !Array.isArray(body.deletedIds)) { - return NextResponse.json({ error: "upserts and deletedIds must be arrays" }, { status: 400 }); - } - const upserts = body.upserts; - const deletedIds = body.deletedIds; - - await prisma.$transaction(async (tx) => { - for (const a of upserts) { - // 同 bookmarks/route.ts:upsert 的 where 只能用唯一鍵(id),這裡先明確檢查歸屬, - // 避免惡意送出別人的註記 id 時被拿來竄改別人的資料(防禦性檢查,不影響正常流程)。 - const existing = await tx.annotation.findUnique({ where: { id: a.id }, select: { clerkUserId: true } }); - if (existing && existing.clerkUserId !== auth.userId) continue; - await tx.annotation.upsert({ - where: { id: a.id }, - create: { - id: a.id, - bookId, - clerkUserId: auth.userId, - cfi: a.cfi, - text: a.text, - note: a.note ?? "", - color: a.color ?? "", - chapter: a.chapter ?? "", - createdAt: new Date(a.createdAt), - updatedAt: new Date(a.updatedAt), - }, - update: { - cfi: a.cfi, - text: a.text, - note: a.note ?? "", - color: a.color ?? "", - chapter: a.chapter ?? "", - updatedAt: new Date(a.updatedAt), - deletedAt: null, // 重新新增一筆先前被軟刪除的同 id 註記時要復活 - }, - }); - } - if (deletedIds.length > 0) { - await tx.annotation.updateMany({ - where: { id: { in: deletedIds }, clerkUserId: auth.userId }, - data: { deletedAt: new Date() }, - }); - } - }); - return NextResponse.json({ ok: true }); -}; diff --git a/pwa-next/src/app/api/books/[bookId]/bookmarks/route.ts b/pwa-next/src/app/api/books/[bookId]/bookmarks/route.ts deleted file mode 100644 index f56b312..0000000 --- a/pwa-next/src/app/api/books/[bookId]/bookmarks/route.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; -import { requireUserId } from "@/lib/requireUserId"; - -interface RouteContext { - params: Promise<{ bookId: string }>; -} - -export const GET = async (_request: NextRequest, { params }: RouteContext) => { - const auth = await requireUserId(); - if (auth.unauthorized) return auth.unauthorized; - - const { bookId } = await params; - const bookmarks = await prisma.bookmark.findMany({ - where: { clerkUserId: auth.userId, bookId, deletedAt: null }, - orderBy: { addedAt: "desc" }, - }); - return NextResponse.json({ bookmarks }); -}; - -// 逐筆 upsert + 軟刪除,取代原本「整包覆蓋」(deleteMany+createMany)的寫法:整包覆蓋沒有 -// 可信的逐筆時間戳,也沒有刪除紀錄,讀取/還原功能一旦要跨裝置合併就無法分辨「這筆比較新」 -// 或「這筆已經在別的裝置被刪除」。前端的書籤 id 是本機產生的 crypto.randomUUID(), -// updatedAt 由前端在編輯當下產生並送上來(不用 Prisma 的 @updatedAt 自動管理,因為那只反映 -// 「伺服器收到寫入的時間」,裝置離線一段時間才補推時會失真,變成「晚同步的舊資料看起來比較新」)。 -export const PUT = async (request: NextRequest, { params }: RouteContext) => { - const auth = await requireUserId(); - if (auth.unauthorized) return auth.unauthorized; - - const { bookId } = await params; - const body = (await request.json()) as { - upserts?: Array<{ id: string; cfi: string; label: string; addedAt: number; updatedAt: number }>; - deletedIds?: string[]; - }; - if (!Array.isArray(body.upserts) || !Array.isArray(body.deletedIds)) { - return NextResponse.json({ error: "upserts and deletedIds must be arrays" }, { status: 400 }); - } - const upserts = body.upserts; - const deletedIds = body.deletedIds; - - await prisma.$transaction(async (tx) => { - for (const b of upserts) { - // upsert 的 where 只能用唯一鍵(id),沒辦法直接在 where 裡再加 clerkUserId 限制。 - // 這裡先明確檢查歸屬,避免惡意送出別人的書籤 id 時被拿來竄改別人的資料——正常情況下 - // id 是本機 crypto.randomUUID() 產生不會撞到別人,這只是防禦性檢查,不影響正常流程。 - const existing = await tx.bookmark.findUnique({ where: { id: b.id }, select: { clerkUserId: true } }); - if (existing && existing.clerkUserId !== auth.userId) continue; - await tx.bookmark.upsert({ - where: { id: b.id }, - create: { - id: b.id, - bookId, - clerkUserId: auth.userId, - cfi: b.cfi, - label: b.label, - addedAt: new Date(b.addedAt), - updatedAt: new Date(b.updatedAt), - }, - update: { - cfi: b.cfi, - label: b.label, - updatedAt: new Date(b.updatedAt), - deletedAt: null, // 重新新增一筆先前被軟刪除的同 id 書籤時要復活 - }, - }); - } - if (deletedIds.length > 0) { - await tx.bookmark.updateMany({ - where: { id: { in: deletedIds }, clerkUserId: auth.userId }, - data: { deletedAt: new Date() }, - }); - } - }); - return NextResponse.json({ ok: true }); -}; diff --git a/pwa-next/src/app/api/books/[bookId]/progress/route.ts b/pwa-next/src/app/api/books/[bookId]/progress/route.ts deleted file mode 100644 index 267fa09..0000000 --- a/pwa-next/src/app/api/books/[bookId]/progress/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; -import { requireUserId } from "@/lib/requireUserId"; - -interface RouteContext { - params: Promise<{ bookId: string }>; -} - -export const GET = async (_request: NextRequest, { params }: RouteContext) => { - const auth = await requireUserId(); - if (auth.unauthorized) return auth.unauthorized; - - const { bookId } = await params; - const progress = await prisma.readingProgress.findUnique({ - where: { clerkUserId_bookId: { clerkUserId: auth.userId, bookId } }, - }); - return NextResponse.json({ progress }); -}; - -export const PUT = async (request: NextRequest, { params }: RouteContext) => { - const auth = await requireUserId(); - if (auth.unauthorized) return auth.unauthorized; - - const { bookId } = await params; - const body = (await request.json()) as { cfi?: string }; - if (!body.cfi) return NextResponse.json({ error: "cfi is required" }, { status: 400 }); - - const progress = await prisma.readingProgress.upsert({ - where: { clerkUserId_bookId: { clerkUserId: auth.userId, bookId } }, - create: { bookId, clerkUserId: auth.userId, cfi: body.cfi }, - update: { cfi: body.cfi }, - }); - return NextResponse.json({ progress }); -}; diff --git a/pwa-next/src/app/api/books/[bookId]/route.ts b/pwa-next/src/app/api/books/[bookId]/route.ts deleted file mode 100644 index 91d301b..0000000 --- a/pwa-next/src/app/api/books/[bookId]/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; -import { requireUserId } from "@/lib/requireUserId"; - -interface RouteContext { - params: Promise<{ bookId: string }>; -} - -export const PUT = async (request: NextRequest, { params }: RouteContext) => { - const auth = await requireUserId(); - if (auth.unauthorized) return auth.unauthorized; - - const { bookId } = await params; - const body = (await request.json()) as { title?: string; author?: string; filename?: string }; - if (!body.title || !body.filename) { - return NextResponse.json({ error: "title and filename are required" }, { status: 400 }); - } - - const book = await prisma.book.upsert({ - where: { clerkUserId_id: { clerkUserId: auth.userId, id: bookId } }, - create: { id: bookId, clerkUserId: auth.userId, title: body.title, author: body.author ?? "", filename: body.filename }, - update: { title: body.title, author: body.author ?? "", filename: body.filename }, - }); - return NextResponse.json({ book }); -}; - -export const DELETE = async (_request: NextRequest, { params }: RouteContext) => { - const auth = await requireUserId(); - if (auth.unauthorized) return auth.unauthorized; - - const { bookId } = await params; - await prisma.book.deleteMany({ where: { clerkUserId: auth.userId, id: bookId } }); - return NextResponse.json({ ok: true }); -}; diff --git a/pwa-next/src/app/api/books/route.ts b/pwa-next/src/app/api/books/route.ts deleted file mode 100644 index 184b218..0000000 --- a/pwa-next/src/app/api/books/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; -import { requireUserId } from "@/lib/requireUserId"; - -export const GET = async () => { - const auth = await requireUserId(); - if (auth.unauthorized) return auth.unauthorized; - - const books = await prisma.book.findMany({ - where: { clerkUserId: auth.userId }, - include: { progress: true }, - orderBy: { updatedAt: "desc" }, - }); - return NextResponse.json({ books }); -}; diff --git a/pwa-next/src/app/api/health/route.ts b/pwa-next/src/app/api/health/route.ts deleted file mode 100644 index 3eeaef5..0000000 --- a/pwa-next/src/app/api/health/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; - -export const GET = async () => { - await prisma.book.count(); - return NextResponse.json({ ok: true }); -}; diff --git a/pwa-next/src/app/api/me/route.ts b/pwa-next/src/app/api/me/route.ts deleted file mode 100644 index a202d25..0000000 --- a/pwa-next/src/app/api/me/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NextResponse } from "next/server"; -import { requireUserId } from "@/lib/requireUserId"; - -export const GET = async () => { - const auth = await requireUserId(); - if (auth.unauthorized) return auth.unauthorized; - return NextResponse.json({ userId: auth.userId }); -}; diff --git a/pwa-next/src/app/layout.tsx b/pwa-next/src/app/layout.tsx index 3d1b8a7..346b421 100644 --- a/pwa-next/src/app/layout.tsx +++ b/pwa-next/src/app/layout.tsx @@ -1,7 +1,5 @@ import type { Metadata, Viewport } from "next"; -import { ClerkProvider } from "@clerk/nextjs"; import ServiceWorkerRegister from "@/components/ServiceWorkerRegister"; -import QueryProvider from "@/components/QueryProvider"; import "./globals.css"; export const metadata: Metadata = { @@ -31,29 +29,25 @@ const RootLayout = ({ children: React.ReactNode; }>) => { return ( - - {/* darkMode 在 App.tsx / Notes.tsx / private/page.tsx 都是 useState(true) - 起始、且未做任何持久化讀取,SSR 當下就一定是深色,這裡直接同步套上 - dark class,避免 hydration 前 沒有 dark class、body 短暫 - 用到淺色 --color-paper 造成的白色閃爍。各元件的 runtime effect - 仍保留,用來處理使用者實際切換深色模式時的同步。 */} - - - - - - - - - {children} - - - - - + // darkMode 在 App.tsx / Notes.tsx / private/page.tsx 都是 useState(true) + // 起始、且未做任何持久化讀取,SSR 當下就一定是深色,這裡直接同步套上 + // dark class,避免 hydration 前 沒有 dark class、body 短暫 + // 用到淺色 --color-paper 造成的白色閃爍。各元件的 runtime effect + // 仍保留,用來處理使用者實際切換深色模式時的同步。 + + + + + + + + {children} + + + ); }; diff --git a/pwa-next/src/clients/apiClient.ts b/pwa-next/src/clients/apiClient.ts deleted file mode 100644 index 9224cb1..0000000 --- a/pwa-next/src/clients/apiClient.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Client 層:只管網路細節(fetch、timeout、401 處理、認證 header 注入), -// 不知道「書」「進度」這些是什麼,Service 層才負責資料模型跟業務語意。 -export class ApiError extends Error { - status: number | 'network' | 'timeout' - - constructor(status: number | 'network' | 'timeout', message: string) { - super(message) - this.status = status - } -} - -interface ApiClientConfig { - baseUrl: string - getAuthHeader?: () => Promise - onUnauthorized?: () => void - timeoutMs?: number -} - -export const createApiClient = (config: ApiClientConfig) => { - const request = async (method: string, path: string, body?: unknown): Promise => { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), config.timeoutMs ?? 10000) - try { - const authHeader = await config.getAuthHeader?.() - const res = await fetch(config.baseUrl + path, { - method, - headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...authHeader }, - body: body ? JSON.stringify(body) : undefined, - signal: controller.signal, - }) - if (res.status === 401) { - config.onUnauthorized?.() - throw new ApiError(401, 'unauthorized') - } - if (!res.ok) throw new ApiError(res.status, await res.text().catch(() => res.statusText)) - if (res.status === 204) return undefined as T - return (await res.json()) as T - } catch (e) { - if (e instanceof ApiError) throw e - throw new ApiError(controller.signal.aborted ? 'timeout' : 'network', String(e)) - } finally { - clearTimeout(timer) - } - } - - return { - get: (path: string) => request('GET', path), - put: (path: string, body?: unknown) => request('PUT', path, body), - delete: (path: string) => request('DELETE', path), - } -} - -// pwa-next 走 same-origin,Clerk session 靠瀏覽器自動帶的 cookie,不需要 getAuthHeader。 -export const apiClient = createApiClient({ baseUrl: '' }) diff --git a/pwa-next/src/components/Library/AuthStatus.tsx b/pwa-next/src/components/Library/AuthStatus.tsx deleted file mode 100644 index 3219240..0000000 --- a/pwa-next/src/components/Library/AuthStatus.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client' - -import { SignInButton, UserButton, useUser } from '@clerk/nextjs' - -// 雲端同步的登入狀態。未登入時仍可正常使用書庫/閱讀器(純本機功能不需要帳號), -// 登入只是為了啟用進度/書籤/註記的雲端備份。文字標籤不只是 hover title, -// 讓「登入可以雲端備份」這件事不用滑鼠移過去才看得到。 -const AuthStatus = () => { - const { isLoaded, isSignedIn } = useUser() - - if (!isLoaded) return null - - if (isSignedIn) { - return - } - - return ( - - - - ) -} - -export default AuthStatus diff --git a/pwa-next/src/components/QueryProvider.tsx b/pwa-next/src/components/QueryProvider.tsx deleted file mode 100644 index fd25451..0000000 --- a/pwa-next/src/components/QueryProvider.tsx +++ /dev/null @@ -1,13 +0,0 @@ -'use client' - -import { useState } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' - -// QueryClient 用 useState 的 lazy initializer 建立,確保每個瀏覽器分頁只有一個 instance、 -// 不會在每次 re-render 時被重新建立(官方建議寫法,避免元件重渲染時 cache 被重置)。 -const QueryProvider = ({ children }: { children: React.ReactNode }) => { - const [queryClient] = useState(() => new QueryClient()) - return {children} -} - -export default QueryProvider diff --git a/pwa-next/src/hooks/reader/useAnnotations.ts b/pwa-next/src/hooks/reader/useAnnotations.ts index 99f0ac8..6048fe8 100644 --- a/pwa-next/src/hooks/reader/useAnnotations.ts +++ b/pwa-next/src/hooks/reader/useAnnotations.ts @@ -27,7 +27,6 @@ export const useAnnotations = (bookId: string) => { const persist = useCallback((next: Annotation[]) => { annotationService.local.save(bookId, next) setAnnotations(next) - annotationService.pushNow(bookId) }, [bookId]) // 提供給 useReaderEngine.ts 在開書當下明確載入這本書已存的註記,並直接回傳同一份陣列—— @@ -44,9 +43,8 @@ export const useAnnotations = (bookId: string) => { }, [annotations, persist]) const removeAnnotation = useCallback((id: string) => { - annotationService.local.recordDeleted(bookId, id) persist(annotations.filter((a) => a.id !== id)) - }, [bookId, annotations, persist]) + }, [annotations, persist]) const updateColor = useCallback((id: string, color: string) => { persist(annotations.map((a) => (a.id === id ? { ...a, color, updatedAt: Date.now() } : a))) diff --git a/pwa-next/src/hooks/reader/useBookmarks.ts b/pwa-next/src/hooks/reader/useBookmarks.ts index c4d707a..3a47342 100644 --- a/pwa-next/src/hooks/reader/useBookmarks.ts +++ b/pwa-next/src/hooks/reader/useBookmarks.ts @@ -7,35 +7,23 @@ export const useBookmarks = (bookId: string) => { const [bookmarks, setBookmarks] = useState(() => bookmarkService.local.load(bookId)) const [pendingDeleteId, setPendingDeleteId] = useState(null) - // bookId 變更時(或 StrictMode 雙重 invoke 後)重新從 localStorage 載入書籤, - // 並背景跟雲端合併這本書的書籤——開書當下才是「查一次其他裝置有沒有新增/刪除」 - // 最自然的時機,登入時的整書庫還原一個分頁只觸發一次,涵蓋不到這個情境。 - // 不 await,避免開書卡在網路請求上;merge 完成後才更新畫面,離線時直接維持本機資料。 + // bookId 變更時(或 StrictMode 雙重 invoke 後)重新從 localStorage 載入書籤。 useEffect(() => { setBookmarks(bookmarkService.local.load(bookId)) - bookmarkService.restoreForBook(bookId).then(setBookmarks) }, [bookId]) const isBookmarked = (cfi: string) => bookmarks.some((b) => b.cfi === cfi) const toggle = (cfi: string, label: string) => { - // toggleBookmark 是「加入或移除」雙向行為:cfi 已存在時這次呼叫其實是移除, - // 要記一筆刪除紀錄(tombstone)讓下次推送時通知伺服器軟刪除,不然雲端那筆 - // 舊資料下次還原時會復活。 - const existing = bookmarks.find((b) => b.cfi === cfi) const next = toggleBookmark(bookmarks, cfi, label, crypto.randomUUID(), Date.now()) bookmarkService.local.save(bookId, next) - if (existing) bookmarkService.local.recordDeleted(bookId, existing.id) setBookmarks(next) - bookmarkService.pushNow(bookId) } const remove = (id: string) => { const next = removeBookmarkById(bookmarks, id) bookmarkService.local.save(bookId, next) - bookmarkService.local.recordDeleted(bookId, id) setBookmarks(next) - bookmarkService.pushNow(bookId) } const reset = () => { diff --git a/pwa-next/src/hooks/reader/useProgress.ts b/pwa-next/src/hooks/reader/useProgress.ts index 83d86de..678c1e0 100644 --- a/pwa-next/src/hooks/reader/useProgress.ts +++ b/pwa-next/src/hooks/reader/useProgress.ts @@ -1,39 +1,8 @@ -// Hook 層:把 progressService 包成給元件用的 TanStack Query API。 -'use client' +// Hook 層:把 progressService 包成給元件用的 API。 +import { progressService } from '@/services/progressService' -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { useUser } from '@clerk/nextjs' -import { progressService, type Progress } from '@/services/progressService' - -export const progressQueryKey = (bookId: string) => ['books', bookId, 'progress'] as const - -// initialData 直接同步讀本機(source of truth),未登入/離線時 UI 永遠有資料可畫。 -// 已登入時 queryFn 背景打遠端、跟本機 merge(LWW by updatedAt)後寫回本機再回傳—— -// 不是單純回傳遠端資料,因為本機才是真正的 source of truth,遠端只是拿來補齊/校正。 -export const useProgressQuery = (bookId: string) => { - const { isSignedIn } = useUser() - return useQuery({ - queryKey: progressQueryKey(bookId), - initialData: () => progressService.local.load(bookId), - queryFn: async (): Promise => { - const localProgress = progressService.local.load(bookId) - if (!isSignedIn) return localProgress - const remoteProgress = await progressService.remote.fetch(bookId) - const merged = progressService.merge(localProgress, remoteProgress) - if (merged) progressService.local.save(merged.bookId, merged.cfi, merged.updatedAt) - return merged - }, - staleTime: 60_000, - }) -} - -// 高頻熱路徑(每次翻頁)不走 useMutation——mutation 的 isLoading/重渲染語意 -// 跟這個呼叫頻率完全不匹配。改成:本機立即寫 + cache 立即同步 + debounce 後才打網路。 export const useSaveProgress = (bookId: string) => { - const queryClient = useQueryClient() return (cfi: string) => { - const saved = progressService.local.save(bookId, cfi) - queryClient.setQueryData(progressQueryKey(bookId), saved) - progressService.pushDebounced(bookId, cfi) + progressService.local.save(bookId, cfi) } } diff --git a/pwa-next/src/hooks/reader/useReaderEngine.ts b/pwa-next/src/hooks/reader/useReaderEngine.ts index 35595ac..158787f 100644 --- a/pwa-next/src/hooks/reader/useReaderEngine.ts +++ b/pwa-next/src/hooks/reader/useReaderEngine.ts @@ -114,18 +114,7 @@ export const useReaderEngine = (params: { const bookRecordRef = useRef(bookRecord) useEffect(() => { bookRecordRef.current = bookRecord }, [bookRecord]) - // 進度同步的 Hook 層:本機立即寫 + cache 立即同步 + debounce 1200–1500ms 後才打網路 - // (見 services/progressService.ts)。頁面離開前(分頁關閉/重新整理)或這個 hook 卸載 - // (換書、返回書庫)時強制 flush 最後一次,避免 debounce 視窗內的最後幾次翻頁沒推上雲端。 const saveProgress = useSaveProgress(bookId) - useEffect(() => { - const handlePageHide = () => progressService.flushAll() - window.addEventListener('pagehide', handlePageHide) - return () => { - window.removeEventListener('pagehide', handlePageHide) - progressService.flush(bookId) - } - }, [bookId]) const scriptRef = useRef