@geminilight/mindos 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/.env.local.example +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +423 -0
  4. package/README_zh.md +423 -0
  5. package/app/README.md +152 -0
  6. package/app/app/api/ask/route.ts +170 -0
  7. package/app/app/api/ask-sessions/route.ts +90 -0
  8. package/app/app/api/auth/route.ts +37 -0
  9. package/app/app/api/backlinks/route.ts +22 -0
  10. package/app/app/api/bootstrap/route.ts +37 -0
  11. package/app/app/api/extract-pdf/route.ts +82 -0
  12. package/app/app/api/file/route.ts +138 -0
  13. package/app/app/api/files/route.ts +12 -0
  14. package/app/app/api/git/route.ts +42 -0
  15. package/app/app/api/graph/route.ts +113 -0
  16. package/app/app/api/recent-files/route.ts +10 -0
  17. package/app/app/api/search/route.ts +17 -0
  18. package/app/app/api/settings/reset-token/route.ts +21 -0
  19. package/app/app/api/settings/route.ts +123 -0
  20. package/app/app/error.tsx +33 -0
  21. package/app/app/globals.css +368 -0
  22. package/app/app/icon.svg +35 -0
  23. package/app/app/layout.tsx +103 -0
  24. package/app/app/login/page.tsx +120 -0
  25. package/app/app/page.tsx +12 -0
  26. package/app/app/view/[...path]/ViewPageClient.tsx +343 -0
  27. package/app/app/view/[...path]/error.tsx +33 -0
  28. package/app/app/view/[...path]/loading.tsx +15 -0
  29. package/app/app/view/[...path]/page.tsx +93 -0
  30. package/app/components/AskFab.tsx +59 -0
  31. package/app/components/AskModal.tsx +398 -0
  32. package/app/components/Backlinks.tsx +75 -0
  33. package/app/components/Breadcrumb.tsx +31 -0
  34. package/app/components/CsvView.tsx +325 -0
  35. package/app/components/DirView.tsx +138 -0
  36. package/app/components/Editor.tsx +124 -0
  37. package/app/components/EditorWrapper.tsx +17 -0
  38. package/app/components/ErrorBoundary.tsx +53 -0
  39. package/app/components/FileTree.tsx +369 -0
  40. package/app/components/HomeContent.tsx +262 -0
  41. package/app/components/JsonView.tsx +27 -0
  42. package/app/components/MarkdownEditor.tsx +95 -0
  43. package/app/components/MarkdownView.tsx +118 -0
  44. package/app/components/SearchModal.tsx +193 -0
  45. package/app/components/SettingsModal.tsx +237 -0
  46. package/app/components/Sidebar.tsx +136 -0
  47. package/app/components/SidebarLayout.tsx +36 -0
  48. package/app/components/TableOfContents.tsx +150 -0
  49. package/app/components/ThemeToggle.tsx +34 -0
  50. package/app/components/WysiwygEditor.tsx +75 -0
  51. package/app/components/ask/FileChip.tsx +30 -0
  52. package/app/components/ask/MentionPopover.tsx +52 -0
  53. package/app/components/ask/MessageList.tsx +126 -0
  54. package/app/components/ask/SessionHistory.tsx +49 -0
  55. package/app/components/renderers/AgentInspectorRenderer.tsx +277 -0
  56. package/app/components/renderers/BacklinksRenderer.tsx +147 -0
  57. package/app/components/renderers/ConfigRenderer.tsx +236 -0
  58. package/app/components/renderers/CsvRenderer.tsx +77 -0
  59. package/app/components/renderers/DiffRenderer.tsx +310 -0
  60. package/app/components/renderers/GraphRenderer.tsx +428 -0
  61. package/app/components/renderers/SummaryRenderer.tsx +251 -0
  62. package/app/components/renderers/TimelineRenderer.tsx +213 -0
  63. package/app/components/renderers/TodoRenderer.tsx +474 -0
  64. package/app/components/renderers/WorkflowRenderer.tsx +404 -0
  65. package/app/components/renderers/csv/BoardView.tsx +146 -0
  66. package/app/components/renderers/csv/ConfigPanel.tsx +117 -0
  67. package/app/components/renderers/csv/EditableCell.tsx +43 -0
  68. package/app/components/renderers/csv/GalleryView.tsx +40 -0
  69. package/app/components/renderers/csv/TableView.tsx +164 -0
  70. package/app/components/renderers/csv/types.ts +87 -0
  71. package/app/components/settings/AiTab.tsx +111 -0
  72. package/app/components/settings/AppearanceTab.tsx +101 -0
  73. package/app/components/settings/KnowledgeTab.tsx +157 -0
  74. package/app/components/settings/PluginsTab.tsx +82 -0
  75. package/app/components/settings/Primitives.tsx +60 -0
  76. package/app/components/settings/ShortcutsTab.tsx +22 -0
  77. package/app/components/settings/types.ts +41 -0
  78. package/app/components/ui/button.tsx +60 -0
  79. package/app/components/ui/dialog.tsx +157 -0
  80. package/app/components/ui/input.tsx +20 -0
  81. package/app/components/ui/scroll-area.tsx +55 -0
  82. package/app/components/ui/toggle.tsx +44 -0
  83. package/app/components/ui/tooltip.tsx +66 -0
  84. package/app/components.json +25 -0
  85. package/app/data/pages/home-dark.png +0 -0
  86. package/app/data/pages/home-mobile-crop.png +0 -0
  87. package/app/data/pages/home-mobile.png +0 -0
  88. package/app/data/pages/home.png +0 -0
  89. package/app/data/pages/view-dir.png +0 -0
  90. package/app/data/pages/view-file-bot.png +0 -0
  91. package/app/data/pages/view-file-dark-crop.png +0 -0
  92. package/app/data/pages/view-file-dark.png +0 -0
  93. package/app/data/pages/view-file-mobile.png +0 -0
  94. package/app/data/pages/view-file-sm.png +0 -0
  95. package/app/data/pages/view-file-top.png +0 -0
  96. package/app/data/pages/view-file.png +0 -0
  97. package/app/eslint.config.mjs +18 -0
  98. package/app/hooks/useAskSession.ts +181 -0
  99. package/app/hooks/useFileUpload.ts +126 -0
  100. package/app/hooks/useMention.ts +65 -0
  101. package/app/lib/LocaleContext.tsx +40 -0
  102. package/app/lib/actions.ts +40 -0
  103. package/app/lib/agent/index.ts +3 -0
  104. package/app/lib/agent/model.ts +18 -0
  105. package/app/lib/agent/prompt.ts +32 -0
  106. package/app/lib/agent/tools.ts +151 -0
  107. package/app/lib/api.ts +55 -0
  108. package/app/lib/core/backlinks.ts +40 -0
  109. package/app/lib/core/csv.ts +28 -0
  110. package/app/lib/core/fs-ops.ts +118 -0
  111. package/app/lib/core/git.ts +50 -0
  112. package/app/lib/core/index.ts +58 -0
  113. package/app/lib/core/lines.ts +89 -0
  114. package/app/lib/core/search.ts +79 -0
  115. package/app/lib/core/security.ts +43 -0
  116. package/app/lib/core/tree.ts +113 -0
  117. package/app/lib/core/types.ts +40 -0
  118. package/app/lib/fs.ts +467 -0
  119. package/app/lib/i18n.ts +300 -0
  120. package/app/lib/jwt.ts +58 -0
  121. package/app/lib/renderers/index.ts +79 -0
  122. package/app/lib/renderers/registry.ts +70 -0
  123. package/app/lib/settings.ts +150 -0
  124. package/app/lib/types.ts +32 -0
  125. package/app/lib/utils.ts +34 -0
  126. package/app/next-env.d.ts +6 -0
  127. package/app/next.config.ts +10 -0
  128. package/app/package-lock.json +15306 -0
  129. package/app/package.json +71 -0
  130. package/app/postcss.config.mjs +7 -0
  131. package/app/proxy.ts +64 -0
  132. package/app/public/file.svg +1 -0
  133. package/app/public/globe.svg +1 -0
  134. package/app/public/landing/index.html +353 -0
  135. package/app/public/landing/style.css +216 -0
  136. package/app/public/logo-square.svg +37 -0
  137. package/app/public/logo.svg +37 -0
  138. package/app/public/next.svg +1 -0
  139. package/app/public/vercel.svg +1 -0
  140. package/app/public/window.svg +1 -0
  141. package/app/scripts/extract-pdf.cjs +56 -0
  142. package/app/tsconfig.json +34 -0
  143. package/app/vitest.config.ts +14 -0
  144. package/assets/demo-flow-zh.html +622 -0
  145. package/assets/images/demo-flow-dark.png +0 -0
  146. package/assets/images/demo-flow-light.png +0 -0
  147. package/assets/images/demo-flow-zh-dark.png +0 -0
  148. package/assets/images/demo-flow-zh-light.png +0 -0
  149. package/assets/images/gui-sync-cv.png +0 -0
  150. package/assets/logo-square.svg +37 -0
  151. package/bin/cli.js +894 -0
  152. package/mcp/README.md +113 -0
  153. package/mcp/package-lock.json +1717 -0
  154. package/mcp/package.json +18 -0
  155. package/mcp/src/index.ts +494 -0
  156. package/mcp/tsconfig.json +13 -0
  157. package/package.json +49 -0
  158. package/scripts/setup.js +675 -0
  159. package/scripts/upgrade-prompt.md +147 -0
  160. package/skills/mindos/SKILL.md +319 -0
  161. package/skills/mindos-zh/SKILL.md +318 -0
  162. package/templates/README.md +31 -0
  163. package/templates/empty/CHANGELOG.md +9 -0
  164. package/templates/empty/CONFIG.json +197 -0
  165. package/templates/empty/CONFIG.md +73 -0
  166. package/templates/empty/INSTRUCTION.md +177 -0
  167. package/templates/empty/README.md +27 -0
  168. package/templates/en/CHANGELOG.md +9 -0
  169. package/templates/en/CONFIG.json +197 -0
  170. package/templates/en/CONFIG.md +73 -0
  171. package/templates/en/INSTRUCTION.md +177 -0
  172. package/templates/en/README.md +27 -0
  173. package/templates/en/TODO.md +13 -0
  174. package/templates/en//360/237/221/244 Profile/INSTRUCTION.md" +21 -0
  175. package/templates/en//360/237/221/244 Profile/README.md" +15 -0
  176. package/templates/en//360/237/221/244 Profile//342/232/231/357/270/217 Preferences.md" +21 -0
  177. package/templates/en//360/237/221/244 Profile//360/237/216/257 Focus.md" +31 -0
  178. package/templates/en//360/237/221/244 Profile//360/237/221/244 Identity.md" +22 -0
  179. package/templates/en//360/237/223/232 Resources/INSTRUCTION.md" +29 -0
  180. package/templates/en//360/237/223/232 Resources/README.md" +21 -0
  181. package/templates/en//360/237/223/232 Resources//360/237/247/276 AI Influencers.csv" +1 -0
  182. package/templates/en//360/237/223/232 Resources//360/237/247/276 AI Products.csv" +1 -0
  183. package/templates/en//360/237/223/232 Resources//360/237/247/276 AI Scholars.csv" +1 -0
  184. package/templates/en//360/237/223/232 Resources//360/237/247/276 AI Tools.csv" +1 -0
  185. package/templates/en//360/237/223/235 Notes/INSTRUCTION.md" +31 -0
  186. package/templates/en//360/237/223/235 Notes/Ideas/README.md" +8 -0
  187. package/templates/en//360/237/223/235 Notes/Ideas//360/237/247/252_example_product_idea.md" +16 -0
  188. package/templates/en//360/237/223/235 Notes/Inbox/README.md" +8 -0
  189. package/templates/en//360/237/223/235 Notes/Inbox//360/237/247/252_example_quick_capture.md" +14 -0
  190. package/templates/en//360/237/223/235 Notes/Meetings/README.md" +8 -0
  191. package/templates/en//360/237/223/235 Notes/Meetings//360/237/247/252_example_meeting_note.md" +17 -0
  192. package/templates/en//360/237/223/235 Notes/README.md" +24 -0
  193. package/templates/en//360/237/223/235 Notes/Waiting/README.md" +8 -0
  194. package/templates/en//360/237/223/235 Notes/Waiting//360/237/247/252_example_blocked_item.md" +16 -0
  195. package/templates/en//360/237/224/204 Workflows/Configurations/README.md" +3 -0
  196. package/templates/en//360/237/224/204 Workflows/Configurations//360/237/247/252_example_config_update_sop.md" +14 -0
  197. package/templates/en//360/237/224/204 Workflows/INSTRUCTION.md" +21 -0
  198. package/templates/en//360/237/224/204 Workflows/Information/README.md" +16 -0
  199. package/templates/en//360/237/224/204 Workflows/Information//360/237/247/252_example_info_capture_sop.md" +13 -0
  200. package/templates/en//360/237/224/204 Workflows/Media/README.md" +16 -0
  201. package/templates/en//360/237/224/204 Workflows/Media//360/237/247/252_example_content_publish_sop.md" +13 -0
  202. package/templates/en//360/237/224/204 Workflows/README.md" +22 -0
  203. package/templates/en//360/237/224/204 Workflows/Research/README.md" +16 -0
  204. package/templates/en//360/237/224/204 Workflows/Research//360/237/247/252_example_lit_review_sop.md" +16 -0
  205. package/templates/en//360/237/224/204 Workflows/Startup/README.md" +3 -0
  206. package/templates/en//360/237/224/204 Workflows/Startup//360/237/247/252_example_weekly_founder_ops.md" +22 -0
  207. package/templates/en//360/237/224/227 Connections/Classmates/README.md" +11 -0
  208. package/templates/en//360/237/224/227 Connections/Classmates//360/237/247/252_example_leo_chen.md" +16 -0
  209. package/templates/en//360/237/224/227 Connections/Colleagues/README.md" +11 -0
  210. package/templates/en//360/237/224/227 Connections/Colleagues//360/237/247/252_example_ethan_zhao.md" +16 -0
  211. package/templates/en//360/237/224/227 Connections/Connections Overview.csv" +5 -0
  212. package/templates/en//360/237/224/227 Connections/Family/README.md" +11 -0
  213. package/templates/en//360/237/224/227 Connections/Family//360/237/247/252_example_james_wang.md" +16 -0
  214. package/templates/en//360/237/224/227 Connections/Friends/README.md" +11 -0
  215. package/templates/en//360/237/224/227 Connections/Friends//360/237/247/252_example_lily_lin.md" +16 -0
  216. package/templates/en//360/237/224/227 Connections/INSTRUCTION.md" +56 -0
  217. package/templates/en//360/237/224/227 Connections/README.md" +20 -0
  218. package/templates/en//360/237/232/200 Projects/Archived/README.md" +9 -0
  219. package/templates/en//360/237/232/200 Projects/Archived//360/237/247/252_example_archived_project_note.md" +14 -0
  220. package/templates/en//360/237/232/200 Projects/INSTRUCTION.md" +29 -0
  221. package/templates/en//360/237/232/200 Projects/Products/README.md" +16 -0
  222. package/templates/en//360/237/232/200 Projects/Products//360/237/247/252_example_product_project_brief.md" +20 -0
  223. package/templates/en//360/237/232/200 Projects/README.md" +21 -0
  224. package/templates/en//360/237/232/200 Projects/Research/README.md" +16 -0
  225. package/templates/en//360/237/232/200 Projects/Research//360/237/247/252_example_research_project_brief.md" +16 -0
  226. package/templates/template-generation-skill.md +79 -0
  227. package/templates/zh/CHANGELOG.md +9 -0
  228. package/templates/zh/CONFIG.json +197 -0
  229. package/templates/zh/CONFIG.md +66 -0
  230. package/templates/zh/INSTRUCTION.md +177 -0
  231. package/templates/zh/README.md +27 -0
  232. package/templates/zh/TODO.md +13 -0
  233. package/templates/zh//360/237/221/244 /347/224/273/345/203/217/INSTRUCTION.md" +28 -0
  234. package/templates/zh//360/237/221/244 /347/224/273/345/203/217/README.md" +20 -0
  235. package/templates/zh//360/237/221/244 /347/224/273/345/203/217//342/232/231/357/270/217 Preferences.md" +21 -0
  236. package/templates/zh//360/237/221/244 /347/224/273/345/203/217//360/237/216/257 Focus.md" +31 -0
  237. package/templates/zh//360/237/221/244 /347/224/273/345/203/217//360/237/221/244 Identity.md" +22 -0
  238. package/templates/zh//360/237/223/232 /350/265/204/346/272/220/INSTRUCTION.md" +29 -0
  239. package/templates/zh//360/237/223/232 /350/265/204/346/272/220/README.md" +21 -0
  240. package/templates/zh//360/237/223/232 /350/265/204/346/272/220//360/237/247/276 AI Inferencers.csv" +1 -0
  241. package/templates/zh//360/237/223/232 /350/265/204/346/272/220//360/237/247/276 AI /344/272/247/345/223/201.csv" +1 -0
  242. package/templates/zh//360/237/223/232 /350/265/204/346/272/220//360/237/247/276 AI /345/255/246/350/200/205/346/270/205/345/215/225.csv" +1 -0
  243. package/templates/zh//360/237/223/232 /350/265/204/346/272/220//360/237/247/276 AI /345/267/245/345/205/267/346/270/205/345/215/225.csv" +1 -0
  244. package/templates/zh//360/237/223/235 /347/254/224/350/256/260/INSTRUCTION.md" +31 -0
  245. package/templates/zh//360/237/223/235 /347/254/224/350/256/260/README.md" +24 -0
  246. package/templates/zh//360/237/223/235 /347/254/224/350/256/260//344/274/232/350/256/256/README.md" +8 -0
  247. package/templates/zh//360/237/223/235 /347/254/224/350/256/260//344/274/232/350/256/256//360/237/247/252_example_/344/274/232/350/256/256/347/272/252/350/246/201.md" +17 -0
  248. package/templates/zh//360/237/223/235 /347/254/224/350/256/260//345/276/205/345/217/215/351/246/210/README.md" +8 -0
  249. package/templates/zh//360/237/223/235 /347/254/224/350/256/260//345/276/205/345/217/215/351/246/210//360/237/247/252_example_/345/276/205/345/217/215/351/246/210/344/272/213/351/241/271.md" +16 -0
  250. package/templates/zh//360/237/223/235 /347/254/224/350/256/260//346/203/263/346/263/225/README.md" +8 -0
  251. package/templates/zh//360/237/223/235 /347/254/224/350/256/260//346/203/263/346/263/225//360/237/247/252_example_/344/272/247/345/223/201/346/203/263/346/263/225.md" +16 -0
  252. package/templates/zh//360/237/223/235 /347/254/224/350/256/260//346/224/266/344/273/266/347/256/261/README.md" +8 -0
  253. package/templates/zh//360/237/223/235 /347/254/224/350/256/260//346/224/266/344/273/266/347/256/261//360/237/247/252_example_/344/270/264/346/227/266/351/200/237/350/256/260.md" +13 -0
  254. package/templates/zh//360/237/224/204 /346/265/201/347/250/213/INSTRUCTION.md" +29 -0
  255. package/templates/zh//360/237/224/204 /346/265/201/347/250/213/README.md" +21 -0
  256. package/templates/zh//360/237/224/204 /346/265/201/347/250/213//344/277/241/346/201/257/README.md" +16 -0
  257. package/templates/zh//360/237/224/204 /346/265/201/347/250/213//344/277/241/346/201/257//360/237/247/252_example_/344/277/241/346/201/257/351/207/207/351/233/206/346/265/201/347/250/213.md" +13 -0
  258. package/templates/zh//360/237/224/204 /346/265/201/347/250/213//345/252/222/344/275/223/README.md" +16 -0
  259. package/templates/zh//360/237/224/204 /346/265/201/347/250/213//345/252/222/344/275/223//360/237/247/252_example_/345/206/205/345/256/271/345/217/221/345/270/203/346/265/201/347/250/213.md" +13 -0
  260. package/templates/zh//360/237/224/204 /346/265/201/347/250/213//347/247/221/347/240/224/README.md" +16 -0
  261. package/templates/zh//360/237/224/204 /346/265/201/347/250/213//347/247/221/347/240/224//360/237/247/252_example_/346/226/207/347/214/256/347/273/274/350/277/260/346/265/201/347/250/213.md" +16 -0
  262. package/templates/zh//360/237/224/204 /346/265/201/347/250/213//351/205/215/347/275/256/README.md" +3 -0
  263. package/templates/zh//360/237/224/204 /346/265/201/347/250/213//351/205/215/347/275/256//360/237/247/252_example_/351/205/215/347/275/256/346/233/264/346/226/260/346/265/201/347/250/213.md" +26 -0
  264. package/templates/zh//360/237/224/227 /345/205/263/347/263/273/INSTRUCTION.md" +62 -0
  265. package/templates/zh//360/237/224/227 /345/205/263/347/263/273/README.md" +20 -0
  266. package/templates/zh//360/237/224/227 /345/205/263/347/263/273//345/205/263/347/263/273/346/200/273/350/247/210.csv" +5 -0
  267. package/templates/zh//360/237/224/227 /345/205/263/347/263/273//345/220/214/344/272/213/README.md" +11 -0
  268. package/templates/zh//360/237/224/227 /345/205/263/347/263/273//345/220/214/344/272/213//360/237/247/252_example_/345/220/214/344/272/213/350/265/265/344/270/200/350/276/260.md" +16 -0
  269. package/templates/zh//360/237/224/227 /345/205/263/347/263/273//345/220/214/345/255/246/README.md" +11 -0
  270. package/templates/zh//360/237/224/227 /345/205/263/347/263/273//345/220/214/345/255/246//360/237/247/252_example_/345/220/214/345/255/246/351/231/210/347/253/213/346/254/247.md" +16 -0
  271. package/templates/zh//360/237/224/227 /345/205/263/347/263/273//345/256/266/344/272/272/README.md" +11 -0
  272. package/templates/zh//360/237/224/227 /345/205/263/347/263/273//345/256/266/344/272/272//360/237/247/252_example_/345/256/266/344/272/272/347/216/213/345/273/272/345/233/275.md" +16 -0
  273. package/templates/zh//360/237/224/227 /345/205/263/347/263/273//346/234/213/345/217/213/README.md" +11 -0
  274. package/templates/zh//360/237/224/227 /345/205/263/347/263/273//346/234/213/345/217/213//360/237/247/252_example_/346/234/213/345/217/213/346/236/227/345/260/217/344/270/275.md" +16 -0
  275. package/templates/zh//360/237/232/200 /351/241/271/347/233/256/INSTRUCTION.md" +31 -0
  276. package/templates/zh//360/237/232/200 /351/241/271/347/233/256/README.md" +21 -0
  277. package/templates/zh//360/237/232/200 /351/241/271/347/233/256//344/272/247/345/223/201/README.md" +16 -0
  278. package/templates/zh//360/237/232/200 /351/241/271/347/233/256//344/272/247/345/223/201//360/237/247/252_example_/344/272/247/345/223/201/351/241/271/347/233/256/347/256/200/346/212/245.md" +20 -0
  279. package/templates/zh//360/237/232/200 /351/241/271/347/233/256//345/267/262/345/275/222/346/241/243/README.md" +9 -0
  280. package/templates/zh//360/237/232/200 /351/241/271/347/233/256//345/267/262/345/275/222/346/241/243//360/237/247/252_example_/345/275/222/346/241/243/351/241/271/347/233/256/350/256/260/345/275/225.md" +15 -0
  281. package/templates/zh//360/237/232/200 /351/241/271/347/233/256//347/247/221/347/240/224/README.md" +16 -0
  282. package/templates/zh//360/237/232/200 /351/241/271/347/233/256//347/247/221/347/240/224//360/237/247/252_example_/347/247/221/347/240/224/351/241/271/347/233/256/347/256/200/346/212/245.md" +16 -0
@@ -0,0 +1,213 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+ import type { RendererContext } from '@/lib/renderers/registry';
5
+
6
+ // ─── Parser ───────────────────────────────────────────────────────────────────
7
+
8
+ interface TimelineEntry {
9
+ heading: string;
10
+ date: Date | null;
11
+ body: string; // raw markdown lines joined
12
+ tags: string[];
13
+ }
14
+
15
+ // Detect date-like H2 headings: ## 2025-01-15, ## Jan 2025, ## 2025/01/15, etc.
16
+ const DATE_RE = /(\d{4}[-/]\d{1,2}(?:[-/]\d{1,2})?|[A-Za-z]+ \d{4}|\d{4}年\d{1,2}月(?:\d{1,2}日)?)/;
17
+
18
+ function parseDate(s: string): Date | null {
19
+ const m = DATE_RE.exec(s);
20
+ if (!m) return null;
21
+ const d = new Date(m[1].replace(/[/年月]/g, '-').replace('日', ''));
22
+ return isNaN(d.getTime()) ? null : d;
23
+ }
24
+
25
+ // Extract #tag or **tag** markers from body text
26
+ function extractTags(body: string): string[] {
27
+ const tags: string[] = [];
28
+ const hashTags = body.match(/#([\w\u4e00-\u9fff]+)/g);
29
+ if (hashTags) tags.push(...hashTags.map(t => t.slice(1)));
30
+ return [...new Set(tags)];
31
+ }
32
+
33
+ function parseTimeline(content: string): TimelineEntry[] {
34
+ const lines = content.split('\n');
35
+ const entries: TimelineEntry[] = [];
36
+ let current: TimelineEntry | null = null;
37
+ let bodyLines: string[] = [];
38
+
39
+ const flush = () => {
40
+ if (!current) return;
41
+ const body = bodyLines.join('\n').trim();
42
+ current.body = body;
43
+ current.tags = extractTags(body);
44
+ entries.push(current);
45
+ current = null;
46
+ bodyLines = [];
47
+ };
48
+
49
+ for (const line of lines) {
50
+ // H1 is the document title — skip
51
+ if (/^# /.test(line)) continue;
52
+
53
+ // H2 = timeline entry
54
+ if (/^## /.test(line)) {
55
+ flush();
56
+ const heading = line.slice(3).trim();
57
+ current = { heading, date: parseDate(heading), body: '', tags: [] };
58
+ continue;
59
+ }
60
+
61
+ if (current) bodyLines.push(line);
62
+ }
63
+ flush();
64
+
65
+ return entries;
66
+ }
67
+
68
+ // ─── Markdown inline renderer (no extra dep) ──────────────────────────────────
69
+
70
+ function renderInline(text: string): string {
71
+ return text
72
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
73
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
74
+ .replace(/`(.+?)`/g, '<code style="font-family:\'IBM Plex Mono\',monospace;font-size:0.85em;padding:1px 5px;border-radius:4px;background:var(--muted)">$1</code>')
75
+ .replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, target, alias) =>
76
+ `<span style="color:var(--amber);cursor:pointer" title="${target}">${alias ?? target}</span>`)
77
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color:var(--amber)">$1</a>');
78
+ }
79
+
80
+ function renderBody(body: string): string {
81
+ const lines = body.split('\n');
82
+ const out: string[] = [];
83
+ let inList = false;
84
+
85
+ const closeList = () => { if (inList) { out.push('</ul>'); inList = false; } };
86
+
87
+ for (const raw of lines) {
88
+ const line = raw.trimEnd();
89
+ if (!line) { closeList(); out.push('<br/>'); continue; }
90
+
91
+ if (/^### /.test(line)) { closeList(); out.push(`<h3 style="font-size:0.8rem;font-weight:600;color:var(--muted-foreground);text-transform:uppercase;letter-spacing:.06em;margin:.9em 0 .3em">${renderInline(line.slice(4))}</h3>`); continue; }
92
+ if (/^- /.test(line) || /^\* /.test(line)) {
93
+ if (!inList) { out.push('<ul style="margin:.3em 0;padding-left:1.2em;list-style:disc">'); inList = true; }
94
+ out.push(`<li style="margin:.15em 0;font-size:.82rem;color:var(--foreground)">${renderInline(line.slice(2))}</li>`);
95
+ continue;
96
+ }
97
+ if (/^\d+\. /.test(line)) {
98
+ if (!inList) { out.push('<ol style="margin:.3em 0;padding-left:1.2em">'); inList = true; }
99
+ out.push(`<li style="margin:.15em 0;font-size:.82rem;color:var(--foreground)">${renderInline(line.replace(/^\d+\. /, ''))}</li>`);
100
+ continue;
101
+ }
102
+ closeList();
103
+ out.push(`<p style="margin:.25em 0;font-size:.82rem;line-height:1.6;color:var(--foreground)">${renderInline(line)}</p>`);
104
+ }
105
+ closeList();
106
+ return out.join('');
107
+ }
108
+
109
+ // ─── Tag color ────────────────────────────────────────────────────────────────
110
+
111
+ const TAG_PALETTE = [
112
+ { bg: 'rgba(200,135,58,0.12)', text: 'var(--amber)' },
113
+ { bg: 'rgba(122,173,128,0.12)', text: '#7aad80' },
114
+ { bg: 'rgba(138,180,216,0.12)', text: '#8ab4d8' },
115
+ { bg: 'rgba(200,160,216,0.12)', text: '#c8a0d8' },
116
+ ];
117
+ function tagColor(tag: string) {
118
+ let h = 0;
119
+ for (let i = 0; i < tag.length; i++) h = (h * 31 + tag.charCodeAt(i)) & 0xffff;
120
+ return TAG_PALETTE[h % TAG_PALETTE.length];
121
+ }
122
+
123
+ function formatDate(d: Date): string {
124
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
125
+ }
126
+
127
+ // ─── Component ────────────────────────────────────────────────────────────────
128
+
129
+ export function TimelineRenderer({ content }: RendererContext) {
130
+ const entries = useMemo(() => parseTimeline(content), [content]);
131
+
132
+ if (entries.length === 0) {
133
+ return (
134
+ <div style={{ padding: '3rem 1rem', textAlign: 'center', color: 'var(--muted-foreground)', fontFamily: "'IBM Plex Mono',monospace", fontSize: 13 }}>
135
+ No timeline entries found. Add <code style={{ background: 'var(--muted)', padding: '1px 6px', borderRadius: 4 }}>## 2025-01-15</code> headings to create entries.
136
+ </div>
137
+ );
138
+ }
139
+
140
+ return (
141
+ <div style={{ maxWidth: 720, margin: '0 auto', padding: '1.5rem 0' }}>
142
+ {/* count pill */}
143
+ <div style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: 8 }}>
144
+ <span style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: 11, color: 'var(--muted-foreground)' }}>
145
+ {entries.length} {entries.length === 1 ? 'entry' : 'entries'}
146
+ </span>
147
+ </div>
148
+
149
+ {/* timeline */}
150
+ <div style={{ position: 'relative', paddingLeft: 28 }}>
151
+ {/* vertical line */}
152
+ <div style={{ position: 'absolute', left: 6, top: 8, bottom: 8, width: 1, background: 'var(--border)' }} />
153
+
154
+ {entries.map((entry, idx) => (
155
+ <div key={idx} style={{ position: 'relative', marginBottom: '1.5rem' }}>
156
+ {/* dot */}
157
+ <div style={{
158
+ position: 'absolute',
159
+ left: -22,
160
+ top: 10,
161
+ width: 9,
162
+ height: 9,
163
+ borderRadius: '50%',
164
+ background: entry.date ? 'var(--amber)' : 'var(--border)',
165
+ outline: entry.date ? '2px solid var(--amber-dim)' : 'none',
166
+ zIndex: 1,
167
+ }} />
168
+
169
+ {/* card */}
170
+ <div style={{
171
+ background: 'var(--card)',
172
+ border: '1px solid var(--border)',
173
+ borderRadius: 10,
174
+ padding: '14px 18px',
175
+ transition: 'border-color .15s',
176
+ }}>
177
+ {/* header */}
178
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 8 }}>
179
+ <span style={{ fontFamily: "'IBM Plex Sans',sans-serif", fontWeight: 600, fontSize: '0.9rem', color: 'var(--foreground)' }}>
180
+ {entry.heading}
181
+ </span>
182
+ {entry.date && (
183
+ <span style={{ fontFamily: "'IBM Plex Mono',monospace", fontSize: '0.7rem', color: 'var(--muted-foreground)', opacity: 0.7, flexShrink: 0 }}>
184
+ {formatDate(entry.date)}
185
+ </span>
186
+ )}
187
+ </div>
188
+
189
+ {/* body */}
190
+ {entry.body && (
191
+ <div dangerouslySetInnerHTML={{ __html: renderBody(entry.body) }} />
192
+ )}
193
+
194
+ {/* tags */}
195
+ {entry.tags.length > 0 && (
196
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginTop: 10 }}>
197
+ {entry.tags.map(tag => {
198
+ const c = tagColor(tag);
199
+ return (
200
+ <span key={tag} style={{ fontSize: '0.68rem', padding: '1px 8px', borderRadius: 999, fontFamily: "'IBM Plex Mono',monospace", background: c.bg, color: c.text }}>
201
+ #{tag}
202
+ </span>
203
+ );
204
+ })}
205
+ </div>
206
+ )}
207
+ </div>
208
+ </div>
209
+ ))}
210
+ </div>
211
+ </div>
212
+ );
213
+ }
@@ -0,0 +1,474 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
4
+ import { CheckSquare, Square, ChevronDown, ChevronRight, Plus, Trash2 } from 'lucide-react';
5
+ import type { RendererContext } from '@/lib/renderers/registry';
6
+
7
+ // ─── Parser ──────────────────────────────────────────────────────────────────
8
+
9
+ interface TodoItem {
10
+ id: string;
11
+ text: string;
12
+ checked: boolean;
13
+ indent: number;
14
+ lineIndex: number;
15
+ section: string;
16
+ children: TodoItem[];
17
+ }
18
+
19
+ interface SectionMeta {
20
+ items: TodoItem[];
21
+ lastLineIndex: number; // line index of the last todo in this section (for append)
22
+ }
23
+
24
+ const SECTION_RE = /^#{1,6}\s+(.+)/;
25
+ const TODO_RE = /^(\s*)- \[([ xX])\]\s+(.*)$/;
26
+
27
+ function parseMarkdownTodos(raw: string): {
28
+ sections: Record<string, SectionMeta>;
29
+ lines: string[];
30
+ } {
31
+ const lines = raw.split('\n');
32
+ let currentSection = 'General';
33
+ const sections: Record<string, SectionMeta> = {};
34
+ const stack: TodoItem[] = [];
35
+
36
+ lines.forEach((line, lineIndex) => {
37
+ const sectionMatch = line.match(SECTION_RE);
38
+ if (sectionMatch) {
39
+ currentSection = sectionMatch[1].replace(/^[^\w\s]+\s*/, '').trim();
40
+ return;
41
+ }
42
+
43
+ const todoMatch = line.match(TODO_RE);
44
+ if (!todoMatch) return;
45
+
46
+ const [, indentStr, checkChar, rawText] = todoMatch;
47
+ const indent = Math.floor(indentStr.length / 2);
48
+ const checked = checkChar.toLowerCase() === 'x';
49
+ const text = rawText.replace(/\*\*(.*?)\*\*/g, '$1').trim();
50
+
51
+ const item: TodoItem = {
52
+ id: `${lineIndex}`,
53
+ text,
54
+ checked,
55
+ indent,
56
+ lineIndex,
57
+ section: currentSection,
58
+ children: [],
59
+ };
60
+
61
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
62
+ stack.pop();
63
+ }
64
+
65
+ if (!sections[currentSection]) {
66
+ sections[currentSection] = { items: [], lastLineIndex: -1 };
67
+ }
68
+
69
+ if (stack.length > 0) {
70
+ stack[stack.length - 1].children.push(item);
71
+ } else {
72
+ sections[currentSection].items.push(item);
73
+ }
74
+
75
+ // Track the furthest line in this section
76
+ sections[currentSection].lastLineIndex = Math.max(
77
+ sections[currentSection].lastLineIndex,
78
+ lineIndex,
79
+ );
80
+
81
+ stack.push(item);
82
+ });
83
+
84
+ return { sections, lines };
85
+ }
86
+
87
+ function applyLines(lines: string[], ops: (l: string[]) => void): string {
88
+ const next = [...lines];
89
+ ops(next);
90
+ return next.join('\n');
91
+ }
92
+
93
+ function toggleInLines(lines: string[], lineIndex: number, checked: boolean): string {
94
+ return applyLines(lines, (l) => {
95
+ l[lineIndex] = checked
96
+ ? l[lineIndex].replace(/- \[ \]/, '- [x]')
97
+ : l[lineIndex].replace(/- \[[xX]\]/, '- [ ]');
98
+ });
99
+ }
100
+
101
+ function renameInLines(lines: string[], lineIndex: number, newText: string): string {
102
+ return applyLines(lines, (l) => {
103
+ l[lineIndex] = l[lineIndex].replace(/(- \[[ xX]\]\s*)(.*)$/, `$1${newText}`);
104
+ });
105
+ }
106
+
107
+ function deleteInLines(lines: string[], lineIndex: number): string {
108
+ return applyLines(lines, (l) => {
109
+ l.splice(lineIndex, 1);
110
+ });
111
+ }
112
+
113
+ function addInLines(lines: string[], afterLineIndex: number, text: string): string {
114
+ return applyLines(lines, (l) => {
115
+ const insertAt = afterLineIndex < 0 ? l.length : afterLineIndex + 1;
116
+ l.splice(insertAt, 0, `- [ ] ${text}`);
117
+ });
118
+ }
119
+
120
+ // ─── Components ──────────────────────────────────────────────────────────────
121
+
122
+ const SECTION_STYLES: Record<string, { dot: string; label: string }> = {
123
+ 'TODAY': { dot: 'bg-red-400', label: 'text-red-400' },
124
+ 'Workflows': { dot: 'bg-amber-400', label: 'text-amber-400' },
125
+ 'Backlog': { dot: 'bg-blue-400', label: 'text-blue-400' },
126
+ 'Maintenance': { dot: 'bg-zinc-400', label: 'text-zinc-400' },
127
+ };
128
+
129
+ function sectionStyle(name: string) {
130
+ const key = Object.keys(SECTION_STYLES).find(k => name.includes(k));
131
+ return key ? SECTION_STYLES[key] : { dot: 'bg-zinc-500', label: 'text-zinc-400' };
132
+ }
133
+
134
+ function countDone(items: TodoItem[]): number {
135
+ return items.reduce((n, item) => n + (item.checked ? 1 : 0) + countDone(item.children), 0);
136
+ }
137
+
138
+ function countTotal(items: TodoItem[]): number {
139
+ return items.reduce((n, item) => n + 1 + countTotal(item.children), 0);
140
+ }
141
+
142
+ // ─── Inline editable text ────────────────────────────────────────────────────
143
+
144
+ function InlineText({
145
+ text,
146
+ checked,
147
+ onRename,
148
+ }: {
149
+ text: string;
150
+ checked: boolean;
151
+ onRename: (newText: string) => void;
152
+ }) {
153
+ const [editing, setEditing] = useState(false);
154
+ const [value, setValue] = useState(text);
155
+ const inputRef = useRef<HTMLInputElement>(null);
156
+
157
+ useEffect(() => { setValue(text); }, [text]);
158
+
159
+ useEffect(() => {
160
+ if (editing) inputRef.current?.select();
161
+ }, [editing]);
162
+
163
+ function commit() {
164
+ const trimmed = value.trim();
165
+ if (trimmed && trimmed !== text) onRename(trimmed);
166
+ else setValue(text);
167
+ setEditing(false);
168
+ }
169
+
170
+ if (editing) {
171
+ return (
172
+ <input
173
+ ref={inputRef}
174
+ value={value}
175
+ onChange={e => setValue(e.target.value)}
176
+ onBlur={commit}
177
+ onKeyDown={e => {
178
+ if (e.key === 'Enter') commit();
179
+ if (e.key === 'Escape') { setValue(text); setEditing(false); }
180
+ }}
181
+ className="flex-1 bg-transparent border-b text-sm leading-relaxed outline-none min-w-0"
182
+ style={{ borderColor: 'var(--amber)', color: 'var(--foreground)' }}
183
+ onClick={e => e.stopPropagation()}
184
+ />
185
+ );
186
+ }
187
+
188
+ return (
189
+ <span
190
+ className={`flex-1 text-sm leading-relaxed cursor-text min-w-0 ${checked ? 'line-through text-muted-foreground' : 'text-foreground'}`}
191
+ onDoubleClick={() => setEditing(true)}
192
+ title="Double-click to edit"
193
+ >
194
+ {text}
195
+ </span>
196
+ );
197
+ }
198
+
199
+ // ─── Add item input ───────────────────────────────────────────────────────────
200
+
201
+ function AddItemRow({ onAdd }: { onAdd: (text: string) => void }) {
202
+ const [active, setActive] = useState(false);
203
+ const [value, setValue] = useState('');
204
+ const inputRef = useRef<HTMLInputElement>(null);
205
+
206
+ useEffect(() => {
207
+ if (active) inputRef.current?.focus();
208
+ }, [active]);
209
+
210
+ function commit() {
211
+ const trimmed = value.trim();
212
+ if (trimmed) onAdd(trimmed);
213
+ setValue('');
214
+ setActive(false);
215
+ }
216
+
217
+ if (!active) {
218
+ return (
219
+ <button
220
+ onClick={() => setActive(true)}
221
+ className="flex items-center gap-1.5 w-full px-3 py-1.5 rounded-lg text-xs transition-colors hover:bg-muted/60 mt-1"
222
+ style={{ color: 'var(--muted-foreground)' }}
223
+ >
224
+ <Plus size={12} />
225
+ <span>Add item</span>
226
+ </button>
227
+ );
228
+ }
229
+
230
+ return (
231
+ <div className="flex items-center gap-2 px-3 py-1.5 mt-1">
232
+ <span className="w-[13px] shrink-0" />
233
+ <span className="shrink-0" style={{ color: 'var(--muted-foreground)' }}>
234
+ <Square size={15} />
235
+ </span>
236
+ <input
237
+ ref={inputRef}
238
+ value={value}
239
+ onChange={e => setValue(e.target.value)}
240
+ onBlur={commit}
241
+ onKeyDown={e => {
242
+ if (e.key === 'Enter') commit();
243
+ if (e.key === 'Escape') { setValue(''); setActive(false); }
244
+ }}
245
+ placeholder="New item…"
246
+ className="flex-1 bg-transparent border-b text-sm outline-none"
247
+ style={{ borderColor: 'var(--amber)', color: 'var(--foreground)' }}
248
+ />
249
+ </div>
250
+ );
251
+ }
252
+
253
+ // ─── Todo item row ────────────────────────────────────────────────────────────
254
+
255
+ function TodoItemRow({
256
+ item,
257
+ depth,
258
+ onToggle,
259
+ onRename,
260
+ onDelete,
261
+ }: {
262
+ item: TodoItem;
263
+ depth: number;
264
+ onToggle: (lineIndex: number, checked: boolean) => void;
265
+ onRename: (lineIndex: number, newText: string) => void;
266
+ onDelete: (lineIndex: number) => void;
267
+ }) {
268
+ const [open, setOpen] = useState(true);
269
+ const hasChildren = item.children.length > 0;
270
+
271
+ return (
272
+ <div>
273
+ <div
274
+ className={`group flex items-center gap-2 py-1.5 px-2 rounded-lg transition-colors hover:bg-muted/60 ${item.checked ? 'opacity-50' : ''}`}
275
+ style={{ paddingLeft: `${8 + depth * 20}px` }}
276
+ >
277
+ {hasChildren ? (
278
+ <button
279
+ onClick={() => setOpen(v => !v)}
280
+ className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
281
+ >
282
+ {open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
283
+ </button>
284
+ ) : (
285
+ <span className="w-[13px] shrink-0" />
286
+ )}
287
+
288
+ <button
289
+ onClick={() => onToggle(item.lineIndex, !item.checked)}
290
+ className="shrink-0 transition-colors"
291
+ style={{ color: item.checked ? 'var(--amber)' : 'var(--muted-foreground)' }}
292
+ >
293
+ {item.checked ? <CheckSquare size={15} /> : <Square size={15} />}
294
+ </button>
295
+
296
+ <InlineText
297
+ text={item.text}
298
+ checked={item.checked}
299
+ onRename={(newText) => onRename(item.lineIndex, newText)}
300
+ />
301
+
302
+ <button
303
+ onClick={() => onDelete(item.lineIndex)}
304
+ className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-destructive/10"
305
+ style={{ color: 'var(--muted-foreground)' }}
306
+ title="Delete item"
307
+ >
308
+ <Trash2 size={12} />
309
+ </button>
310
+ </div>
311
+
312
+ {hasChildren && open && (
313
+ <div>
314
+ {item.children.map(child => (
315
+ <TodoItemRow
316
+ key={child.id}
317
+ item={child}
318
+ depth={depth + 1}
319
+ onToggle={onToggle}
320
+ onRename={onRename}
321
+ onDelete={onDelete}
322
+ />
323
+ ))}
324
+ </div>
325
+ )}
326
+ </div>
327
+ );
328
+ }
329
+
330
+ // ─── Section card ─────────────────────────────────────────────────────────────
331
+
332
+ function SectionCard({
333
+ name,
334
+ meta,
335
+ onToggle,
336
+ onRename,
337
+ onDelete,
338
+ onAdd,
339
+ }: {
340
+ name: string;
341
+ meta: SectionMeta;
342
+ onToggle: (lineIndex: number, checked: boolean) => void;
343
+ onRename: (lineIndex: number, newText: string) => void;
344
+ onDelete: (lineIndex: number) => void;
345
+ onAdd: (afterLineIndex: number, text: string) => void;
346
+ }) {
347
+ const [collapsed, setCollapsed] = useState(false);
348
+ const style = sectionStyle(name);
349
+ const { items, lastLineIndex } = meta;
350
+ const done = countDone(items);
351
+ const total = countTotal(items);
352
+
353
+ return (
354
+ <div className="border border-border rounded-xl overflow-hidden bg-card">
355
+ <button
356
+ onClick={() => setCollapsed(v => !v)}
357
+ className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/40 transition-colors"
358
+ >
359
+ <div className="flex items-center gap-2">
360
+ <span className={`w-2 h-2 rounded-full shrink-0 ${style.dot}`} />
361
+ <span className={`text-xs font-semibold uppercase tracking-wider ${style.label}`} style={{ fontFamily: "'IBM Plex Mono', monospace" }}>
362
+ {name}
363
+ </span>
364
+ <span className="text-xs text-muted-foreground">{done}/{total}</span>
365
+ </div>
366
+ {collapsed ? <ChevronRight size={14} className="text-muted-foreground" /> : <ChevronDown size={14} className="text-muted-foreground" />}
367
+ </button>
368
+
369
+ <div className="h-0.5 bg-muted mx-4">
370
+ <div
371
+ className="h-full rounded-full transition-all duration-500"
372
+ style={{ width: total ? `${(done / total) * 100}%` : '0%', background: 'var(--amber)' }}
373
+ />
374
+ </div>
375
+
376
+ {!collapsed && (
377
+ <div className="px-2 py-2">
378
+ {items.map(item => (
379
+ <TodoItemRow
380
+ key={item.id}
381
+ item={item}
382
+ depth={0}
383
+ onToggle={onToggle}
384
+ onRename={onRename}
385
+ onDelete={onDelete}
386
+ />
387
+ ))}
388
+ {items.length === 0 && (
389
+ <p className="text-xs text-muted-foreground px-4 py-2">No items</p>
390
+ )}
391
+ <AddItemRow onAdd={(text) => onAdd(lastLineIndex, text)} />
392
+ </div>
393
+ )}
394
+ </div>
395
+ );
396
+ }
397
+
398
+ // ─── Main Renderer ────────────────────────────────────────────────────────────
399
+
400
+ export function TodoRenderer({ content, saveAction }: RendererContext) {
401
+ const [localContent, setLocalContent] = useState(content);
402
+
403
+ const { sections, lines } = useMemo(
404
+ () => parseMarkdownTodos(localContent),
405
+ [localContent],
406
+ );
407
+
408
+ const totalDone = useMemo(
409
+ () => Object.values(sections).reduce((n, m) => n + countDone(m.items), 0),
410
+ [sections],
411
+ );
412
+ const totalItems = useMemo(
413
+ () => Object.values(sections).reduce((n, m) => n + countTotal(m.items), 0),
414
+ [sections],
415
+ );
416
+
417
+ const persist = useCallback(async (next: string) => {
418
+ setLocalContent(next);
419
+ await saveAction(next);
420
+ }, [saveAction]);
421
+
422
+ const handleToggle = useCallback(async (lineIndex: number, checked: boolean) => {
423
+ await persist(toggleInLines(lines, lineIndex, checked));
424
+ }, [lines, persist]);
425
+
426
+ const handleRename = useCallback(async (lineIndex: number, newText: string) => {
427
+ await persist(renameInLines(lines, lineIndex, newText));
428
+ }, [lines, persist]);
429
+
430
+ const handleDelete = useCallback(async (lineIndex: number) => {
431
+ await persist(deleteInLines(lines, lineIndex));
432
+ }, [lines, persist]);
433
+
434
+ const handleAdd = useCallback(async (afterLineIndex: number, text: string) => {
435
+ await persist(addInLines(lines, afterLineIndex, text));
436
+ }, [lines, persist]);
437
+
438
+ const sectionEntries = Object.entries(sections);
439
+
440
+ return (
441
+ <div className="max-w-[900px] mx-auto xl:mr-[220px] px-0 py-2">
442
+ {/* Summary header */}
443
+ <div className="mb-6">
444
+ <p className="text-xs text-muted-foreground" style={{ fontFamily: "'IBM Plex Mono', monospace" }}>
445
+ {totalDone} / {totalItems} completed
446
+ </p>
447
+ <div className="mt-1.5 w-48 h-1.5 bg-muted rounded-full overflow-hidden">
448
+ <div
449
+ className="h-full rounded-full transition-all duration-500"
450
+ style={{ width: totalItems ? `${(totalDone / totalItems) * 100}%` : '0%', background: 'var(--amber)' }}
451
+ />
452
+ </div>
453
+ </div>
454
+
455
+ {/* Sections */}
456
+ <div className="flex flex-col gap-4">
457
+ {sectionEntries.map(([name, meta]) => (
458
+ <SectionCard
459
+ key={name}
460
+ name={name}
461
+ meta={meta}
462
+ onToggle={handleToggle}
463
+ onRename={handleRename}
464
+ onDelete={handleDelete}
465
+ onAdd={handleAdd}
466
+ />
467
+ ))}
468
+ {sectionEntries.length === 0 && (
469
+ <p className="text-sm text-muted-foreground">No TODO items found in this file.</p>
470
+ )}
471
+ </div>
472
+ </div>
473
+ );
474
+ }