aimeat 1.16.2 → 1.18.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 (281) hide show
  1. package/dist/.env.example +6 -1
  2. package/dist/locales/en.json +166 -4
  3. package/dist/locales/fi.json +166 -4
  4. package/dist/public/components/Card.js +17 -3
  5. package/dist/public/components/Collapsible.js +39 -0
  6. package/dist/public/components/CopyButton.js +55 -24
  7. package/dist/public/components/DataTable.js +57 -0
  8. package/dist/public/components/EmptyState.js +28 -0
  9. package/dist/public/components/KeyValueRow.js +37 -0
  10. package/dist/public/components/Modal.js +22 -5
  11. package/dist/public/components/Pagination.js +70 -0
  12. package/dist/public/components/SearchBar.js +44 -0
  13. package/dist/public/components/StatusDot.js +29 -0
  14. package/dist/public/components/TagList.js +53 -0
  15. package/dist/public/components/ToggleSwitch.js +28 -0
  16. package/dist/public/components/index.js +41 -1
  17. package/dist/public/css/components/tags.css +24 -0
  18. package/dist/public/css/style-testbed.css +135 -0
  19. package/dist/public/css/theme.css +556 -34
  20. package/dist/public/css/views/admin-agent-integration.css +2 -33
  21. package/dist/public/css/views/admin.css +730 -686
  22. package/dist/public/css/views/agents-detail.css +402 -14
  23. package/dist/public/css/views/help.css +2 -1
  24. package/dist/public/css/views/hobbies.css +53 -33
  25. package/dist/public/css/views/marketplace.css +73 -60
  26. package/dist/public/css/views/openclaw.css +12 -6
  27. package/dist/public/css/views/portal-classic.css +34 -26
  28. package/dist/public/css/views/portal-dev.css +41 -32
  29. package/dist/public/css/views/portal.css +14 -18
  30. package/dist/public/css/views/profile.css +252 -164
  31. package/dist/public/css/views/public-knowledge-viewer.css +18 -6
  32. package/dist/public/css/views/scheduler.css +100 -0
  33. package/dist/public/js/format.js +41 -0
  34. package/dist/public/js/services/agent-tasks.js +14 -1
  35. package/dist/public/js/services/generator-spec-validate.js +5 -1
  36. package/dist/public/js/services/schedules.js +53 -0
  37. package/dist/public/js/style-testbed.js +170 -0
  38. package/dist/public/js/theme.js +114 -0
  39. package/dist/public/spa.html +74 -16
  40. package/dist/public/style-testbed.html +420 -0
  41. package/dist/public/views/admin/agent-integration-tab.js +23 -38
  42. package/dist/public/views/admin/agent-tasks-tab.js +14 -21
  43. package/dist/public/views/admin/chat-instances-tab.js +1 -1
  44. package/dist/public/views/admin/config-tab.js +7 -2
  45. package/dist/public/views/admin/cortex-tab.js +20 -14
  46. package/dist/public/views/admin/csm-tab.js +22 -18
  47. package/dist/public/views/admin/directory-tab.js +3 -3
  48. package/dist/public/views/admin/email-tab.js +23 -28
  49. package/dist/public/views/admin/federation-tab.js +15 -5
  50. package/dist/public/views/admin/generator-debug-tab.js +10 -8
  51. package/dist/public/views/admin/genesis-tab.js +4 -4
  52. package/dist/public/views/admin/ghii-tab.js +3 -3
  53. package/dist/public/views/admin/knowledge-tab.js +15 -5
  54. package/dist/public/views/admin/matching-tab.js +3 -3
  55. package/dist/public/views/admin/memory-tab.js +16 -20
  56. package/dist/public/views/admin/msm-tab.js +30 -17
  57. package/dist/public/views/admin/overview-tab.js +7 -7
  58. package/dist/public/views/admin/packages-tab.js +10 -9
  59. package/dist/public/views/admin/portal-tab.js +14 -4
  60. package/dist/public/views/admin/prompts-tab.js +5 -5
  61. package/dist/public/views/admin/push-tab.js +15 -4
  62. package/dist/public/views/admin/realtime-tab.js +3 -3
  63. package/dist/public/views/admin/services-tab.js +18 -27
  64. package/dist/public/views/admin/shared.js +48 -58
  65. package/dist/public/views/admin/sharing-groups-tab.js +5 -10
  66. package/dist/public/views/admin/stats-tab.js +3 -3
  67. package/dist/public/views/guides.js +9 -13
  68. package/dist/public/views/help.js +3 -24
  69. package/dist/public/views/hobbies.js +47 -41
  70. package/dist/public/views/marketplace.js +42 -34
  71. package/dist/public/views/openclaw.js +0 -1
  72. package/dist/public/views/portal-classic.js +4 -15
  73. package/dist/public/views/portal-dev.js +28 -30
  74. package/dist/public/views/portal.js +16 -21
  75. package/dist/public/views/portfolio.js +11 -12
  76. package/dist/public/views/profile/access-tab.js +250 -1
  77. package/dist/public/views/profile/agents/agent-card.js +75 -4
  78. package/dist/public/views/profile/agents/tab-activity.js +15 -13
  79. package/dist/public/views/profile/agents/tab-agent-config.js +60 -6
  80. package/dist/public/views/profile/agents/tab-integration.js +37 -60
  81. package/dist/public/views/profile/agents/tab-messages.js +10 -1
  82. package/dist/public/views/profile/agents/tab-quality.js +25 -19
  83. package/dist/public/views/profile/agents/tab-schedules.js +110 -0
  84. package/dist/public/views/profile/agents-activity-subtab.js +4 -4
  85. package/dist/public/views/profile/agents-services-subtab.js +5 -4
  86. package/dist/public/views/profile/agents-tab.js +141 -37
  87. package/dist/public/views/profile/agents-tasks-subtab.js +221 -26
  88. package/dist/public/views/profile/calibrator-batch.js +5 -11
  89. package/dist/public/views/profile/calibrator-tab.js +4 -9
  90. package/dist/public/views/profile/chat-sessions-tab.js +9 -5
  91. package/dist/public/views/profile/email-tab.js +2 -2
  92. package/dist/public/views/profile/extensions-tab.js +27 -22
  93. package/dist/public/views/profile/federation-tab.js +4 -1
  94. package/dist/public/views/profile/foundry-detail.js +22 -23
  95. package/dist/public/views/profile/foundry-tab.js +12 -13
  96. package/dist/public/views/profile/generator-dashboard/DebugPanel.js +4 -7
  97. package/dist/public/views/profile/generator-dashboard/use-dashboard-core.js +4 -1
  98. package/dist/public/views/profile/generator-dashboard/use-edit-mode.js +3 -2
  99. package/dist/public/views/profile/generator-detail.js +26 -30
  100. package/dist/public/views/profile/generator-tab.js +5 -5
  101. package/dist/public/views/profile/knowledge-tab.js +12 -12
  102. package/dist/public/views/profile/landing-page.js +100 -149
  103. package/dist/public/views/profile/memory-tab.js +15 -11
  104. package/dist/public/views/profile/node-stats-tab.js +3 -3
  105. package/dist/public/views/profile/nodes-tab.js +10 -3
  106. package/dist/public/views/profile/notifications-tab.js +3 -3
  107. package/dist/public/views/profile/packages-tab.js +14 -14
  108. package/dist/public/views/profile/scheduler-tab.js +340 -0
  109. package/dist/public/views/profile/services-tab.js +1 -1
  110. package/dist/public/views/profile/shared.js +16 -12
  111. package/dist/public/views/profile/wallet-tab.js +27 -50
  112. package/dist/public/views/profile/work-tab.js +29 -31
  113. package/dist/public/views/profile.js +24 -0
  114. package/dist/public/views/public-knowledge-viewer.js +9 -18
  115. package/dist/src/auth/middleware.d.ts +3 -2
  116. package/dist/src/auth/middleware.d.ts.map +1 -1
  117. package/dist/src/auth/middleware.js +78 -3
  118. package/dist/src/auth/middleware.js.map +1 -1
  119. package/dist/src/cli/init-wizard.d.ts.map +1 -1
  120. package/dist/src/cli/init-wizard.js +13 -1
  121. package/dist/src/cli/init-wizard.js.map +1 -1
  122. package/dist/src/config.d.ts +6 -0
  123. package/dist/src/config.d.ts.map +1 -1
  124. package/dist/src/config.js +12 -1
  125. package/dist/src/config.js.map +1 -1
  126. package/dist/src/generated/api-types.d.ts +708 -8
  127. package/dist/src/generated/api-types.d.ts.map +1 -1
  128. package/dist/src/mcp/agent-schedules.d.ts +23 -0
  129. package/dist/src/mcp/agent-schedules.d.ts.map +1 -0
  130. package/dist/src/mcp/agent-schedules.js +175 -0
  131. package/dist/src/mcp/agent-schedules.js.map +1 -0
  132. package/dist/src/mcp/annotations.d.ts.map +1 -1
  133. package/dist/src/mcp/annotations.js +6 -0
  134. package/dist/src/mcp/annotations.js.map +1 -1
  135. package/dist/src/mcp/catalog/definitions.d.ts.map +1 -1
  136. package/dist/src/mcp/catalog/definitions.js +55 -0
  137. package/dist/src/mcp/catalog/definitions.js.map +1 -1
  138. package/dist/src/mcp/catalog/scopes.d.ts.map +1 -1
  139. package/dist/src/mcp/catalog/scopes.js +12 -0
  140. package/dist/src/mcp/catalog/scopes.js.map +1 -1
  141. package/dist/src/mcp/catalog/surfaces.d.ts.map +1 -1
  142. package/dist/src/mcp/catalog/surfaces.js +2 -0
  143. package/dist/src/mcp/catalog/surfaces.js.map +1 -1
  144. package/dist/src/mcp/index.d.ts.map +1 -1
  145. package/dist/src/mcp/index.js +2 -0
  146. package/dist/src/mcp/index.js.map +1 -1
  147. package/dist/src/models/agent-task-schemas.d.ts +6 -0
  148. package/dist/src/models/agent-task-schemas.d.ts.map +1 -1
  149. package/dist/src/models/agent-task-schemas.js +6 -0
  150. package/dist/src/models/agent-task-schemas.js.map +1 -1
  151. package/dist/src/models/schemas.d.ts +1 -0
  152. package/dist/src/models/schemas.d.ts.map +1 -1
  153. package/dist/src/models/schemas.js +4 -0
  154. package/dist/src/models/schemas.js.map +1 -1
  155. package/dist/src/routes/access-tokens.d.ts +17 -0
  156. package/dist/src/routes/access-tokens.d.ts.map +1 -0
  157. package/dist/src/routes/access-tokens.js +164 -0
  158. package/dist/src/routes/access-tokens.js.map +1 -0
  159. package/dist/src/routes/agent-tasks.d.ts +2 -0
  160. package/dist/src/routes/agent-tasks.d.ts.map +1 -1
  161. package/dist/src/routes/agent-tasks.js +118 -23
  162. package/dist/src/routes/agent-tasks.js.map +1 -1
  163. package/dist/src/routes/agents.d.ts.map +1 -1
  164. package/dist/src/routes/agents.js +46 -0
  165. package/dist/src/routes/agents.js.map +1 -1
  166. package/dist/src/routes/ai.d.ts +6 -9
  167. package/dist/src/routes/ai.d.ts.map +1 -1
  168. package/dist/src/routes/ai.js +26 -175
  169. package/dist/src/routes/ai.js.map +1 -1
  170. package/dist/src/routes/apps.d.ts.map +1 -1
  171. package/dist/src/routes/apps.js +104 -34
  172. package/dist/src/routes/apps.js.map +1 -1
  173. package/dist/src/routes/auth.d.ts.map +1 -1
  174. package/dist/src/routes/auth.js +77 -4
  175. package/dist/src/routes/auth.js.map +1 -1
  176. package/dist/src/routes/cortex.d.ts +2 -1
  177. package/dist/src/routes/cortex.d.ts.map +1 -1
  178. package/dist/src/routes/cortex.js +13 -8
  179. package/dist/src/routes/cortex.js.map +1 -1
  180. package/dist/src/routes/extensions.d.ts.map +1 -1
  181. package/dist/src/routes/extensions.js +85 -51
  182. package/dist/src/routes/extensions.js.map +1 -1
  183. package/dist/src/routes/generator.d.ts.map +1 -1
  184. package/dist/src/routes/generator.js +109 -43
  185. package/dist/src/routes/generator.js.map +1 -1
  186. package/dist/src/routes/ghii.d.ts.map +1 -1
  187. package/dist/src/routes/ghii.js +34 -13
  188. package/dist/src/routes/ghii.js.map +1 -1
  189. package/dist/src/routes/libs.d.ts +15 -0
  190. package/dist/src/routes/libs.d.ts.map +1 -1
  191. package/dist/src/routes/libs.js +178 -55
  192. package/dist/src/routes/libs.js.map +1 -1
  193. package/dist/src/routes/portal.d.ts.map +1 -1
  194. package/dist/src/routes/portal.js +1 -0
  195. package/dist/src/routes/portal.js.map +1 -1
  196. package/dist/src/routes/schedules.d.ts +33 -0
  197. package/dist/src/routes/schedules.d.ts.map +1 -0
  198. package/dist/src/routes/schedules.js +380 -0
  199. package/dist/src/routes/schedules.js.map +1 -0
  200. package/dist/src/server-bootstrap/config-init.js +2 -2
  201. package/dist/src/server-bootstrap/config-init.js.map +1 -1
  202. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  203. package/dist/src/server-bootstrap/routes-loader.js +7 -0
  204. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  205. package/dist/src/server-bootstrap/service-init.d.ts.map +1 -1
  206. package/dist/src/server-bootstrap/service-init.js +2 -1
  207. package/dist/src/server-bootstrap/service-init.js.map +1 -1
  208. package/dist/src/services/access-token.d.ts +28 -0
  209. package/dist/src/services/access-token.d.ts.map +1 -0
  210. package/dist/src/services/access-token.js +37 -0
  211. package/dist/src/services/access-token.js.map +1 -0
  212. package/dist/src/services/ai-completion.d.ts +83 -0
  213. package/dist/src/services/ai-completion.d.ts.map +1 -0
  214. package/dist/src/services/ai-completion.js +187 -0
  215. package/dist/src/services/ai-completion.js.map +1 -0
  216. package/dist/src/services/config-schema.d.ts.map +1 -1
  217. package/dist/src/services/config-schema.js +3 -0
  218. package/dist/src/services/config-schema.js.map +1 -1
  219. package/dist/src/services/generator-prompt-seeds.d.ts.map +1 -1
  220. package/dist/src/services/generator-prompt-seeds.js +6 -3
  221. package/dist/src/services/generator-prompt-seeds.js.map +1 -1
  222. package/dist/src/services/generator-prompts/spec-validate.d.ts.map +1 -1
  223. package/dist/src/services/generator-prompts/spec-validate.js +6 -2
  224. package/dist/src/services/generator-prompts/spec-validate.js.map +1 -1
  225. package/dist/src/services/generator-registration.d.ts +12 -4
  226. package/dist/src/services/generator-registration.d.ts.map +1 -1
  227. package/dist/src/services/generator-registration.js +71 -0
  228. package/dist/src/services/generator-registration.js.map +1 -1
  229. package/dist/src/services/generator-validate.d.ts +10 -0
  230. package/dist/src/services/generator-validate.d.ts.map +1 -1
  231. package/dist/src/services/generator-validate.js +180 -36
  232. package/dist/src/services/generator-validate.js.map +1 -1
  233. package/dist/src/services/owner-session.d.ts +43 -0
  234. package/dist/src/services/owner-session.d.ts.map +1 -0
  235. package/dist/src/services/owner-session.js +175 -0
  236. package/dist/src/services/owner-session.js.map +1 -0
  237. package/dist/src/services/schedule-constraints.d.ts +65 -0
  238. package/dist/src/services/schedule-constraints.d.ts.map +1 -0
  239. package/dist/src/services/schedule-constraints.js +106 -0
  240. package/dist/src/services/schedule-constraints.js.map +1 -0
  241. package/dist/src/services/scheduler.d.ts +34 -0
  242. package/dist/src/services/scheduler.d.ts.map +1 -1
  243. package/dist/src/services/scheduler.js +266 -22
  244. package/dist/src/services/scheduler.js.map +1 -1
  245. package/dist/src/services/skill-bundle/generator.js +19 -0
  246. package/dist/src/services/skill-bundle/generator.js.map +1 -1
  247. package/dist/src/storage/interface.d.ts +48 -4
  248. package/dist/src/storage/interface.d.ts.map +1 -1
  249. package/dist/src/storage/interface.js.map +1 -1
  250. package/dist/src/storage/providers/mongodb/index.d.ts +31 -0
  251. package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
  252. package/dist/src/storage/providers/mongodb/index.js +187 -9
  253. package/dist/src/storage/providers/mongodb/index.js.map +1 -1
  254. package/dist/src/storage/providers/sqlite/index.d.ts +31 -0
  255. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  256. package/dist/src/storage/providers/sqlite/index.js +131 -8
  257. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  258. package/dist/src/storage/providers/sqlite/repos/agent-task.d.ts.map +1 -1
  259. package/dist/src/storage/providers/sqlite/repos/agent-task.js +6 -4
  260. package/dist/src/storage/providers/sqlite/repos/agent-task.js.map +1 -1
  261. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  262. package/dist/src/storage/providers/sqlite/schema.js +79 -8
  263. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  264. package/dist/src/storage/repositories/pat.repository.d.ts +40 -0
  265. package/dist/src/storage/repositories/pat.repository.d.ts.map +1 -0
  266. package/dist/src/storage/repositories/pat.repository.js +14 -0
  267. package/dist/src/storage/repositories/pat.repository.js.map +1 -0
  268. package/dist/src/storage/repositories/scheduler.repository.d.ts +2 -0
  269. package/dist/src/storage/repositories/scheduler.repository.d.ts.map +1 -1
  270. package/dist/src/storage/repositories/session.repository.d.ts +51 -0
  271. package/dist/src/storage/repositories/session.repository.d.ts.map +1 -1
  272. package/dist/src/storage/repositories/session.repository.js +12 -0
  273. package/dist/src/storage/repositories/session.repository.js.map +1 -1
  274. package/dist/src/utils/env-config.d.ts.map +1 -1
  275. package/dist/src/utils/env-config.js +25 -1
  276. package/dist/src/utils/env-config.js.map +1 -1
  277. package/dist/src/utils/env-validator.d.ts.map +1 -1
  278. package/dist/src/utils/env-validator.js +4 -0
  279. package/dist/src/utils/env-validator.js.map +1 -1
  280. package/package.json +1 -1
  281. package/prisma/schema.prisma +48 -0
@@ -41,8 +41,8 @@ export default function PackagesTab({ session, showToast, navigate, locale }) {
41
41
  const [search, setSearch] = useState('');
42
42
  const [category, setCategory] = useState('');
43
43
 
44
- const loadData = useCallback(async () => {
45
- setLoading(true);
44
+ const loadData = useCallback(async ({ showSpinner = true } = {}) => {
45
+ if (showSpinner) setLoading(true);
46
46
  try {
47
47
  const [instRes, pkgRes, tplRes] = await Promise.all([
48
48
  pkgService.listInstances({ status: 'installed' }),
@@ -65,7 +65,7 @@ export default function PackagesTab({ session, showToast, navigate, locale }) {
65
65
 
66
66
  // Live updates
67
67
  useEffect(() => {
68
- const handler = () => { loadData(); };
68
+ const handler = () => { loadData({ showSpinner: false }); }; // silent: don't flash blank
69
69
  window.addEventListener('aimeat-live-update', handler);
70
70
  return () => window.removeEventListener('aimeat-live-update', handler);
71
71
  }, [loadData]);
@@ -268,8 +268,8 @@ export default function PackagesTab({ session, showToast, navigate, locale }) {
268
268
  </div>
269
269
  <div class="pkg-card-footer">
270
270
  <div class="pkg-card-actions">
271
- <button onClick=${() => handleCheckUpdate(inst.id)}>${t('packages.checkUpdate') || 'Check Update'}</button>
272
- <button class="danger" onClick=${() => handleRemove(inst.id)}>${t('packages.remove') || 'Remove'}</button>
271
+ <button class="btn-outline btn-sm" onClick=${() => handleCheckUpdate(inst.id)}>${t('packages.checkUpdate') || 'Check Update'}</button>
272
+ <button class="btn-danger btn-sm" onClick=${() => handleRemove(inst.id)}>${t('packages.remove') || 'Remove'}</button>
273
273
  </div>
274
274
  </div>
275
275
  </div>
@@ -325,19 +325,19 @@ export default function PackagesTab({ session, showToast, navigate, locale }) {
325
325
  `}
326
326
  <div class="pkg-card-footer">
327
327
  <div class="pkg-card-actions">
328
- <button class="primary" onClick=${() => handleInstall(pkg.packageGroupId)}>
328
+ <button class="btn-primary btn-sm" onClick=${() => handleInstall(pkg.packageGroupId)}>
329
329
  ${t('packages.install') || 'Install'}
330
330
  </button>
331
- <button onClick=${() => handleDownloadZip(pkg.packageGroupId, pkg.name)}>
331
+ <button class="btn-outline btn-sm" onClick=${() => handleDownloadZip(pkg.packageGroupId, pkg.name)}>
332
332
  ${t('packages.downloadZip') || 'Download ZIP'}
333
333
  </button>
334
- <button onClick=${() => handleOpenInGenerator(pkg.packageGroupId, pkg.author)}>
334
+ <button class="btn-outline btn-sm" onClick=${() => handleOpenInGenerator(pkg.packageGroupId, pkg.author)}>
335
335
  ${pkg.author === session?.owner
336
336
  ? (t('packages.editInGenerator') || 'Edit in Generator')
337
337
  : (t('packages.openInGenerator') || 'Open in Generator')}
338
338
  </button>
339
339
  ${pkg.status === 'published' && !pkg.templateStatus && pkg.author === session?.owner && html`
340
- <button class="btn-outline" onClick=${() => handleProposeAsTemplate(pkg.packageGroupId)}>
340
+ <button class="btn-outline btn-sm" onClick=${() => handleProposeAsTemplate(pkg.packageGroupId)}>
341
341
  ${t('packages.proposeAsTemplate') || 'Propose as Template'}
342
342
  </button>
343
343
  `}
@@ -371,13 +371,13 @@ export default function PackagesTab({ session, showToast, navigate, locale }) {
371
371
  </div>
372
372
  <div class="pkg-card-footer">
373
373
  <div class="pkg-card-actions">
374
- <button class="primary" onClick=${() => handleInstall(tpl.packageGroupId)}>
374
+ <button class="btn-primary btn-sm" onClick=${() => handleInstall(tpl.packageGroupId)}>
375
375
  ${t('packages.install') || 'Install'}
376
376
  </button>
377
- <button onClick=${() => handleDownloadZip(tpl.packageGroupId, tpl.title)}>
377
+ <button class="btn-outline btn-sm" onClick=${() => handleDownloadZip(tpl.packageGroupId, tpl.title)}>
378
378
  ${t('packages.downloadZip') || 'Download ZIP'}
379
379
  </button>
380
- <button onClick=${() => handleOpenInGenerator(tpl.packageGroupId, tpl.packageAuthor)}>
380
+ <button class="btn-outline btn-sm" onClick=${() => handleOpenInGenerator(tpl.packageGroupId, tpl.packageAuthor)}>
381
381
  ${tpl.packageAuthor === session?.owner
382
382
  ? (t('packages.editInGenerator') || 'Edit in Generator')
383
383
  : (t('packages.openInGenerator') || 'Open in Generator')}
@@ -416,10 +416,10 @@ export default function PackagesTab({ session, showToast, navigate, locale }) {
416
416
  <div class="pkg-card-footer">
417
417
  <div class="pkg-card-actions">
418
418
  ${rt.packageGroupId ? html`
419
- <button class="primary" onClick=${() => handleInstall(rt.packageGroupId)}>
419
+ <button class="btn-primary btn-sm" onClick=${() => handleInstall(rt.packageGroupId)}>
420
420
  ${t('packages.federation.installFromRemote') || 'Install'}
421
421
  </button>
422
- <button onClick=${() => handleDownloadZip(rt.packageGroupId, rt.title || rt.name)}>
422
+ <button class="btn-outline btn-sm" onClick=${() => handleDownloadZip(rt.packageGroupId, rt.title || rt.name)}>
423
423
  ${t('packages.downloadZip') || 'Download ZIP'}
424
424
  </button>
425
425
  ` : null}
@@ -0,0 +1,340 @@
1
+ /**
2
+ * @file scheduler-tab.js
3
+ * @description Profile › Scheduler — the master view of every recurring schedule
4
+ * the owner has running: AIMEAT-managed (ai / agent_task / extension), the
5
+ * owner's extension cron jobs, and each agent's self-reported internal
6
+ * scheduler. The server owns the clock; the owner can create, pause/resume,
7
+ * run-now, and cancel managed schedules (including any an agent created).
8
+ * @structure
9
+ * - SchedulerTab (default) — loads /v1/schedules, renders grouped sections + create form
10
+ * @usage Registered in profile.js TABS as { id:'scheduler', component: SchedulerTab }.
11
+ * @version-history
12
+ * v1.0.0 -- 2026-06-03 -- Initial master scheduler view
13
+ */
14
+ import { h } from 'preact';
15
+ import { useState, useEffect, useRef, useCallback } from 'preact/hooks';
16
+ import htm from 'htm';
17
+ import { t } from '/js/i18n.js';
18
+ import { timeAgo } from '/js/utils.js';
19
+ import { listAgents } from '/js/services/agents.js';
20
+ import {
21
+ listAllSchedules, createSchedule, setScheduleEnabled,
22
+ triggerSchedule, deleteSchedule,
23
+ } from '/js/services/schedules.js';
24
+
25
+ const html = htm.bind(h);
26
+
27
+ const CRON_PRESETS = [
28
+ { key: 'morning', cron: '0 7 * * *' },
29
+ { key: 'evening', cron: '0 19 * * *' },
30
+ { key: 'hourly', cron: '0 * * * *' },
31
+ { key: 'custom', cron: '' },
32
+ ];
33
+
34
+ const KIND_BADGE = {
35
+ ai: { cls: 'sch-badge--ai', key: 'profile.scheduler.kind.ai' },
36
+ agent_task: { cls: 'sch-badge--agent', key: 'profile.scheduler.kind.agent_task' },
37
+ extension: { cls: 'sch-badge--ext', key: 'profile.scheduler.kind.extension' },
38
+ core: { cls: 'sch-badge--core', key: 'profile.scheduler.kind.core' },
39
+ };
40
+
41
+ function kindBadge(kind) {
42
+ const b = KIND_BADGE[kind] || KIND_BADGE.core;
43
+ return html`<span class="sch-badge ${b.cls}">${t(b.key)}</span>`;
44
+ }
45
+
46
+ function resultBadge(result) {
47
+ if (!result) return html`<span class="sch-muted">—</span>`;
48
+ const cls = result === 'error' ? 'sch-badge--err' : 'sch-badge--ok';
49
+ return html`<span class="sch-badge ${cls}">${result}</span>`;
50
+ }
51
+
52
+ export default function SchedulerTab({ showToast }) {
53
+ const [data, setData] = useState({ managed: [], extensions: [], agentInternal: [] });
54
+ const [agents, setAgents] = useState([]);
55
+ const [loading, setLoading] = useState(true);
56
+ const [error, setError] = useState(null);
57
+ const [showForm, setShowForm] = useState(false);
58
+
59
+ const loadData = useCallback(async () => {
60
+ try {
61
+ const [schedRes, agentsRes] = await Promise.all([listAllSchedules(), listAgents().catch(() => null)]);
62
+ setData(schedRes?.data || { managed: [], extensions: [], agentInternal: [] });
63
+ setAgents(agentsRes?.data?.agents || []);
64
+ setError(null);
65
+ } catch (e) {
66
+ setError(e.message || 'Failed to load schedules');
67
+ } finally {
68
+ setLoading(false);
69
+ }
70
+ }, []);
71
+
72
+ const loadRef = useRef(loadData);
73
+ loadRef.current = loadData;
74
+ useEffect(() => {
75
+ loadData();
76
+ const handler = () => loadRef.current();
77
+ window.addEventListener('aimeat-live-update', handler);
78
+ const poller = setInterval(() => loadRef.current(), 15000);
79
+ return () => { window.removeEventListener('aimeat-live-update', handler); clearInterval(poller); };
80
+ }, [loadData]);
81
+
82
+ const onToggle = async (job) => {
83
+ try { await setScheduleEnabled(job.id, !job.enabled); await loadData(); }
84
+ catch (e) { showToast?.(e.message, true); }
85
+ };
86
+ const onTrigger = async (job) => {
87
+ try { await triggerSchedule(job.id); showToast?.(t('profile.scheduler.triggered')); await loadData(); }
88
+ catch (e) { showToast?.(e.message, true); }
89
+ };
90
+ const onCancel = async (job) => {
91
+ if (!window.confirm(t('profile.scheduler.confirmCancel'))) return;
92
+ try { await deleteSchedule(job.id); showToast?.(t('profile.scheduler.cancelled')); await loadData(); }
93
+ catch (e) { showToast?.(e.message, true); }
94
+ };
95
+
96
+ const renderManagedRow = (job) => html`
97
+ <tr key=${job.id}>
98
+ <td>
99
+ <div class="sch-name">${job.displayName || job.name}</div>
100
+ ${job.purpose && html`<div class="sch-muted sch-purpose">${job.purpose}</div>`}
101
+ ${job.createdByAgent && html`<span class="sch-tag">${t('profile.scheduler.byAgent')}</span>`}
102
+ </td>
103
+ <td>${kindBadge(job.type)}</td>
104
+ <td><code class="sch-cron">${job.cron}</code>${job.timezone && html`<div class="sch-muted">${job.timezone}</div>`}</td>
105
+ <td>${job.agentName || html`<span class="sch-muted">—</span>`}</td>
106
+ <td>${job.lastRunAt ? timeAgo(job.lastRunAt) : html`<span class="sch-muted">${t('profile.scheduler.never')}</span>`} ${resultBadge(job.lastRunResult)}</td>
107
+ <td>${job.nextRunAt ? timeAgo(job.nextRunAt) : html`<span class="sch-muted">—</span>`}</td>
108
+ <td>${job.runCount ?? 0}</td>
109
+ <td class="sch-actions">
110
+ <button class="btn-ghost sch-btn" onClick=${() => onToggle(job)} title=${job.enabled ? t('profile.scheduler.pause') : t('profile.scheduler.resume')}>
111
+ ${job.enabled ? t('profile.scheduler.pause') : t('profile.scheduler.resume')}
112
+ </button>
113
+ <button class="btn-ghost sch-btn" onClick=${() => onTrigger(job)}>${t('profile.scheduler.runNow')}</button>
114
+ <button class="btn-danger sch-btn" onClick=${() => onCancel(job)}>${t('profile.scheduler.cancel')}</button>
115
+ </td>
116
+ </tr>`;
117
+
118
+ if (loading) return html`<div class="sch-loading">${t('profile.scheduler.loading')}</div>`;
119
+
120
+ return html`
121
+ <div class="sch-tab">
122
+ <div class="sch-head">
123
+ <div>
124
+ <div class="section-title">${t('profile.scheduler.title')}</div>
125
+ <div class="section-desc">${t('profile.scheduler.desc')}</div>
126
+ </div>
127
+ <button class="btn-primary" onClick=${() => setShowForm(v => !v)}>
128
+ ${showForm ? t('profile.scheduler.close') : t('profile.scheduler.newSchedule')}
129
+ </button>
130
+ </div>
131
+
132
+ ${error && html`<div class="sch-error">${error}</div>`}
133
+
134
+ ${showForm && html`<${CreateForm} agents=${agents} showToast=${showToast}
135
+ onCreated=${() => { setShowForm(false); loadData(); }} />`}
136
+
137
+ <!-- AIMEAT-managed -->
138
+ <div class="sch-section">
139
+ <h3 class="sch-section-title">${t('profile.scheduler.managedTitle')}</h3>
140
+ ${data.managed.length === 0
141
+ ? html`<div class="sch-empty">${t('profile.scheduler.noManaged')}</div>`
142
+ : html`<table class="sch-table"><thead><tr>
143
+ <th>${t('profile.scheduler.col.name')}</th>
144
+ <th>${t('profile.scheduler.col.kind')}</th>
145
+ <th>${t('profile.scheduler.col.cron')}</th>
146
+ <th>${t('profile.scheduler.col.agent')}</th>
147
+ <th>${t('profile.scheduler.col.lastRun')}</th>
148
+ <th>${t('profile.scheduler.col.nextRun')}</th>
149
+ <th>${t('profile.scheduler.col.runs')}</th>
150
+ <th></th>
151
+ </tr></thead><tbody>${data.managed.map(renderManagedRow)}</tbody></table>`}
152
+ </div>
153
+
154
+ <!-- Owner extension cron -->
155
+ ${data.extensions.length > 0 && html`<div class="sch-section">
156
+ <h3 class="sch-section-title">${t('profile.scheduler.extTitle')}</h3>
157
+ <table class="sch-table"><thead><tr>
158
+ <th>${t('profile.scheduler.col.name')}</th>
159
+ <th>${t('profile.scheduler.col.extension')}</th>
160
+ <th>${t('profile.scheduler.col.cron')}</th>
161
+ <th>${t('profile.scheduler.col.lastRun')}</th>
162
+ <th>${t('profile.scheduler.col.nextRun')}</th>
163
+ </tr></thead><tbody>
164
+ ${data.extensions.map(j => html`<tr key=${j.id}>
165
+ <td>${j.displayName || j.name}</td>
166
+ <td><code class="sch-cron">${j.extensionName}${j.actionId ? '/' + j.actionId : ''}</code></td>
167
+ <td><code class="sch-cron">${j.cron}</code></td>
168
+ <td>${j.lastRunAt ? timeAgo(j.lastRunAt) : '—'} ${resultBadge(j.lastRunResult)}</td>
169
+ <td>${j.nextRunAt ? timeAgo(j.nextRunAt) : '—'}</td>
170
+ </tr>`)}
171
+ </tbody></table>
172
+ </div>`}
173
+
174
+ <!-- Agent internal (read-only mirror) -->
175
+ ${data.agentInternal.length > 0 && html`<div class="sch-section">
176
+ <h3 class="sch-section-title">${t('profile.scheduler.internalTitle')}</h3>
177
+ <div class="sch-muted sch-internal-note">${t('profile.scheduler.internalNote')}</div>
178
+ ${data.agentInternal.map(grp => html`<div class="sch-internal-group" key=${grp.gaii}>
179
+ <div class="sch-internal-agent">${grp.agentName}</div>
180
+ <table class="sch-table"><tbody>
181
+ ${grp.entries.map((e, i) => html`<tr key=${e.id || i}>
182
+ <td>${e.name}${e.purpose && html`<div class="sch-muted">${e.purpose}</div>`}</td>
183
+ <td><code class="sch-cron">${e.cron || e.schedule || '—'}</code>${e.timezone && html`<div class="sch-muted">${e.timezone}</div>`}</td>
184
+ <td>${e.status || 'active'}</td>
185
+ </tr>`)}
186
+ </tbody></table>
187
+ </div>`)}
188
+ </div>`}
189
+ </div>`;
190
+ }
191
+
192
+ // ── Create form (exported so the agent Schedules sub-tab can reuse it) ──────
193
+ export function CreateForm({ agents = [], showToast, onCreated, lockedAgent }) {
194
+ const [kind, setKind] = useState(lockedAgent ? 'agent_task' : 'ai');
195
+ const [preset, setPreset] = useState('morning');
196
+ const [form, setForm] = useState({
197
+ display_name: '', cron: '0 7 * * *', timezone: '', purpose: '',
198
+ agent_name: lockedAgent || '', prompt: '', input_keys: '', output_key: '',
199
+ task_title: '', task_description: '', extension_name: '', action_id: '',
200
+ });
201
+ const [maxRuns, setMaxRuns] = useState({ enabled: false, limit: 7 });
202
+ const [dailyLimit, setDailyLimit] = useState({ enabled: false, limit: 1 });
203
+ const [saving, setSaving] = useState(false);
204
+
205
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
206
+ const onPreset = (p) => { setPreset(p); const found = CRON_PRESETS.find(c => c.key === p); if (found && found.cron) set('cron', found.cron); };
207
+
208
+ const buildConstraints = () => {
209
+ const c = [];
210
+ if (maxRuns.enabled) c.push({ type: 'max_runs', enabled: true, params: { limit: Number(maxRuns.limit) } });
211
+ if (dailyLimit.enabled) c.push({ type: 'daily_limit', enabled: true, params: { limit: Number(dailyLimit.limit) } });
212
+ return c;
213
+ };
214
+
215
+ const submit = async () => {
216
+ if (!form.display_name.trim()) { showToast?.(t('profile.scheduler.err.name'), true); return; }
217
+ if (!form.cron.trim()) { showToast?.(t('profile.scheduler.err.cron'), true); return; }
218
+ const body = {
219
+ kind, cron: form.cron.trim(), display_name: form.display_name.trim(),
220
+ timezone: form.timezone.trim() || undefined, purpose: form.purpose.trim() || undefined,
221
+ constraints: buildConstraints(),
222
+ };
223
+ if (kind === 'ai') {
224
+ if (!form.prompt.trim()) { showToast?.(t('profile.scheduler.err.prompt'), true); return; }
225
+ body.prompt = form.prompt.trim();
226
+ body.input_keys = form.input_keys.split(',').map(s => s.trim()).filter(Boolean);
227
+ if (form.output_key.trim()) body.output_key = form.output_key.trim();
228
+ if (form.agent_name) body.agent_name = form.agent_name;
229
+ } else if (kind === 'agent_task') {
230
+ if (!form.agent_name) { showToast?.(t('profile.scheduler.err.agent'), true); return; }
231
+ if (!form.task_title.trim()) { showToast?.(t('profile.scheduler.err.taskTitle'), true); return; }
232
+ body.agent_name = form.agent_name;
233
+ body.task_template = { title: form.task_title.trim(), description: form.task_description.trim() };
234
+ } else if (kind === 'extension') {
235
+ if (!form.extension_name.trim() || !form.action_id.trim()) { showToast?.(t('profile.scheduler.err.ext'), true); return; }
236
+ body.extension_name = form.extension_name.trim();
237
+ body.action_id = form.action_id.trim();
238
+ if (form.agent_name) body.agent_name = form.agent_name;
239
+ }
240
+ setSaving(true);
241
+ try {
242
+ await createSchedule(body);
243
+ showToast?.(t('profile.scheduler.created'));
244
+ onCreated?.();
245
+ } catch (e) { showToast?.(e.message, true); }
246
+ finally { setSaving(false); }
247
+ };
248
+
249
+ return html`
250
+ <div class="sch-form">
251
+ <div class="sch-form-row">
252
+ <label>${t('profile.scheduler.field.kind')}</label>
253
+ <select value=${kind} onChange=${e => setKind(e.target.value)}>
254
+ <option value="ai">${t('profile.scheduler.kind.ai')} — ${t('profile.scheduler.kindHint.ai')}</option>
255
+ <option value="agent_task">${t('profile.scheduler.kind.agent_task')} — ${t('profile.scheduler.kindHint.agent_task')}</option>
256
+ <option value="extension">${t('profile.scheduler.kind.extension')} — ${t('profile.scheduler.kindHint.extension')}</option>
257
+ </select>
258
+ </div>
259
+
260
+ <div class="sch-form-row">
261
+ <label>${t('profile.scheduler.field.displayName')}</label>
262
+ <input type="text" value=${form.display_name} onInput=${e => set('display_name', e.target.value)}
263
+ placeholder=${t('profile.scheduler.ph.displayName')} />
264
+ </div>
265
+
266
+ <div class="sch-form-row">
267
+ <label>${t('profile.scheduler.field.schedule')}</label>
268
+ <div class="sch-presets">
269
+ ${CRON_PRESETS.map(p => html`<button type="button"
270
+ class="sch-preset ${preset === p.key ? 'sch-preset--on' : ''}"
271
+ onClick=${() => onPreset(p.key)}>${t('profile.scheduler.preset.' + p.key)}</button>`)}
272
+ </div>
273
+ <input type="text" class="sch-cron-input" value=${form.cron} onInput=${e => set('cron', e.target.value)}
274
+ placeholder="0 7 * * *" />
275
+ <input type="text" value=${form.timezone} onInput=${e => set('timezone', e.target.value)}
276
+ placeholder=${t('profile.scheduler.ph.timezone')} />
277
+ </div>
278
+
279
+ ${kind === 'ai' && html`<div class="sch-kind-fields">
280
+ <div class="sch-form-row"><label>${t('profile.scheduler.field.inputKeys')}</label>
281
+ <input type="text" value=${form.input_keys} onInput=${e => set('input_keys', e.target.value)}
282
+ placeholder=${t('profile.scheduler.ph.inputKeys')} /></div>
283
+ <div class="sch-form-row"><label>${t('profile.scheduler.field.prompt')}</label>
284
+ <textarea rows="3" value=${form.prompt} onInput=${e => set('prompt', e.target.value)}
285
+ placeholder=${t('profile.scheduler.ph.prompt')}></textarea></div>
286
+ <div class="sch-form-row"><label>${t('profile.scheduler.field.outputKey')}</label>
287
+ <input type="text" value=${form.output_key} onInput=${e => set('output_key', e.target.value)}
288
+ placeholder=${t('profile.scheduler.ph.outputKey')} /></div>
289
+ </div>`}
290
+
291
+ ${kind === 'agent_task' && html`<div class="sch-kind-fields">
292
+ <div class="sch-form-row"><label>${t('profile.scheduler.field.agent')}</label>
293
+ ${lockedAgent
294
+ ? html`<input type="text" value=${lockedAgent} disabled />`
295
+ : html`<select value=${form.agent_name} onChange=${e => set('agent_name', e.target.value)}>
296
+ <option value="">${t('profile.scheduler.ph.agent')}</option>
297
+ ${agents.map(a => html`<option value=${a.name}>${a.name}</option>`)}
298
+ </select>`}</div>
299
+ <div class="sch-form-row"><label>${t('profile.scheduler.field.taskTitle')}</label>
300
+ <input type="text" value=${form.task_title} onInput=${e => set('task_title', e.target.value)} /></div>
301
+ <div class="sch-form-row"><label>${t('profile.scheduler.field.taskDescription')}</label>
302
+ <textarea rows="3" value=${form.task_description} onInput=${e => set('task_description', e.target.value)}></textarea></div>
303
+ </div>`}
304
+
305
+ ${kind === 'extension' && html`<div class="sch-kind-fields">
306
+ <div class="sch-form-row"><label>${t('profile.scheduler.field.extensionName')}</label>
307
+ <input type="text" value=${form.extension_name} onInput=${e => set('extension_name', e.target.value)} /></div>
308
+ <div class="sch-form-row"><label>${t('profile.scheduler.field.actionId')}</label>
309
+ <input type="text" value=${form.action_id} onInput=${e => set('action_id', e.target.value)} /></div>
310
+ </div>`}
311
+
312
+ <div class="sch-form-row">
313
+ <label>${t('profile.scheduler.field.purpose')}</label>
314
+ <input type="text" value=${form.purpose} onInput=${e => set('purpose', e.target.value)}
315
+ placeholder=${t('profile.scheduler.ph.purpose')} />
316
+ </div>
317
+
318
+ <div class="sch-constraints">
319
+ <div class="sch-constraints-title">${t('profile.scheduler.constraints')}</div>
320
+ <label class="sch-check">
321
+ <input type="checkbox" checked=${maxRuns.enabled} onChange=${e => setMaxRuns(s => ({ ...s, enabled: e.target.checked }))} />
322
+ ${t('profile.scheduler.maxRuns')}
323
+ <input type="number" min="1" value=${maxRuns.limit} disabled=${!maxRuns.enabled}
324
+ onInput=${e => setMaxRuns(s => ({ ...s, limit: e.target.value }))} class="sch-num" />
325
+ </label>
326
+ <label class="sch-check">
327
+ <input type="checkbox" checked=${dailyLimit.enabled} onChange=${e => setDailyLimit(s => ({ ...s, enabled: e.target.checked }))} />
328
+ ${t('profile.scheduler.dailyLimit')}
329
+ <input type="number" min="0" step="0.1" value=${dailyLimit.limit} disabled=${!dailyLimit.enabled}
330
+ onInput=${e => setDailyLimit(s => ({ ...s, limit: e.target.value }))} class="sch-num" />
331
+ </label>
332
+ </div>
333
+
334
+ <div class="sch-form-actions">
335
+ <button class="btn-primary" disabled=${saving} onClick=${submit}>
336
+ ${saving ? t('profile.scheduler.saving') : t('profile.scheduler.create')}
337
+ </button>
338
+ </div>
339
+ </div>`;
340
+ }
@@ -87,7 +87,7 @@ function ServiceDetail({ svc }) {
87
87
  return () => { cancelled = true; };
88
88
  }, [svcId]);
89
89
 
90
- if (loading) return html`<div class="svc-detail" onClick=${e => e.stopPropagation()}><${Spinner} text="Loading details..." /></div>`;
90
+ if (loading) return html`<div class="svc-detail" onClick=${e => e.stopPropagation()}><${Spinner} text=${t('common.loading')} /></div>`;
91
91
  if (error_) return html`<div class="svc-detail" onClick=${e => e.stopPropagation()}><div class="svc-detail-error">${error_}</div></div>`;
92
92
 
93
93
  // Merge local svc data with fetched detail (detail may have more fields)
@@ -5,15 +5,23 @@
5
5
  * @version-history
6
6
  * v1.0.0 — 2026-03-07 — Initial shared helpers (Spinner, recipientBadge, isExpiringSoon)
7
7
  * v1.1.0 — 2026-03-17 — Add VisibilityPill, ToggleSwitch, GlassCard components; refactor recipientBadge to CSS classes
8
+ * v1.2.0 — 2026-06-02 — Component unification (§2): Spinner now delegates to the
9
+ * canonical /components/Spinner.js (single source of the .spinner markup).
10
+ * v1.3.0 — 2026-06-02 — Component unification (#22): GlassCard now delegates to the
11
+ * canonical /components/Card.js via variant="glass" (.card-glass); call sites unchanged.
8
12
  */
9
13
  import { h } from 'preact';
10
14
  import htm from 'htm';
11
15
  const html = htm.bind(h);
12
16
  import { t } from '/js/i18n.js';
17
+ import { Spinner as BaseSpinner } from '/components/Spinner.js';
18
+ import { Card } from '/components/Card.js';
13
19
 
14
- /** Loading spinner. */
20
+ /** Loading spinner — delegates to the canonical /components/Spinner.js (single
21
+ * source of the .spinner markup); keeps the profile default loading label so the
22
+ * 23 profile call sites that rely on it are unchanged. */
15
23
  export function Spinner({ text }) {
16
- return html`<span class="spinner"></span><span class="loading-text">${text || t('profile.loading')}</span>`;
24
+ return html`<${BaseSpinner} text=${text || t('profile.loading')} />`;
17
25
  }
18
26
 
19
27
  /** Render a colored recipient-type badge. */
@@ -43,16 +51,12 @@ export function VisibilityPill({ visibility, onClick }) {
43
51
  </button>`;
44
52
  }
45
53
 
46
- /** Shared toggle switch (notifications-tab, email-tab). */
47
- export function ToggleSwitch({ checked, onChange, label }) {
48
- return html`<label class="pf-toggle">
49
- <input type="checkbox" checked=${checked} onChange=${onChange} class="pf-toggle-input" />
50
- <span class="pf-toggle-slider"></span>
51
- ${label && html`<span class="pf-toggle-label">${label}</span>`}
52
- </label>`;
53
- }
54
+ /** Shared toggle switch — relocated to the canonical /components/ToggleSwitch.js (#14);
55
+ * re-exported here so notifications-tab/email-tab imports are unchanged. */
56
+ export { ToggleSwitch } from '/components/ToggleSwitch.js';
54
57
 
55
- /** Glass-style card container (email-tab, notifications-tab). */
58
+ /** Glass-style card container (email-tab, notifications-tab) — delegates to the
59
+ * canonical /components/Card.js (variant="glass" → .card-glass); call sites unchanged. */
56
60
  export function GlassCard({ children }) {
57
- return html`<div class="pf-glass-card">${children}</div>`;
61
+ return html`<${Card} variant="glass" hoverable=${false}>${children}<//>`;
58
62
  }
@@ -6,6 +6,8 @@
6
6
  * v1.0.0 — 2026-03-16 — Initial wallet tab
7
7
  * v1.1.0 — 2026-03-17 — Replace inline styles with CSS classes
8
8
  * v1.2.0 — 2026-05-22 — Show info message for federated users instead of infinite spinner
9
+ * v1.3.0 — 2026-06-02 — Component unification (#1): copy buttons use canonical
10
+ * <CopyButton>; delete local copyToClipboard helper + dead copied-state/handlers.
9
11
  */
10
12
  import { h } from 'preact';
11
13
  import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
@@ -14,18 +16,9 @@ const html = htm.bind(h);
14
16
  import { t } from '/js/i18n.js';
15
17
  import { escHtml, timeAgo } from '/js/utils.js';
16
18
  import { Spinner } from './shared.js';
19
+ import { CopyButton } from '/components/CopyButton.js';
17
20
  import { getWallet, getTransactions, requestMorsels } from '/js/services/wallet.js';
18
21
 
19
- /**
20
- * Copy text to clipboard, return true on success.
21
- */
22
- async function copyToClipboard(text) {
23
- try {
24
- await navigator.clipboard.writeText(text);
25
- return true;
26
- } catch { return false; }
27
- }
28
-
29
22
  /**
30
23
  * Map backend transaction type to a display label.
31
24
  */
@@ -43,8 +36,6 @@ export default function WalletTab({ session, showToast, onStats }) {
43
36
  const [walletData, setWalletData] = useState(null);
44
37
  const [walletTx, setWalletTx] = useState(null);
45
38
  const [expandedTx, setExpandedTx] = useState(null);
46
- const [copiedId, setCopiedId] = useState(null);
47
- const [balanceCopied, setBalanceCopied] = useState(false);
48
39
 
49
40
  // Request form state
50
41
  const [reqAmount, setReqAmount] = useState('');
@@ -81,33 +72,6 @@ export default function WalletTab({ session, showToast, onStats }) {
81
72
  setExpandedTx(prev => prev === id ? null : id);
82
73
  }, []);
83
74
 
84
- async function handleCopyBalance() {
85
- const w = walletData;
86
- const text = `Balance: ${w.balance ?? 0} | Available: ${(w.balance ?? 0) - (w.in_escrow ?? w.escrow ?? 0)} | Escrow: ${w.in_escrow ?? w.escrow ?? 0}`;
87
- const ok = await copyToClipboard(text);
88
- if (ok) {
89
- setBalanceCopied(true);
90
- setTimeout(() => setBalanceCopied(false), 2000);
91
- }
92
- }
93
-
94
- async function handleCopyTx(tx, e) {
95
- e.stopPropagation();
96
- const lines = [
97
- `ID: ${tx.id || '-'}`,
98
- `Type: ${tx.type || '-'}`,
99
- `Amount: ${tx.amount}`,
100
- tx.counterparty_gaii ? `Counterparty: ${tx.counterparty_gaii}` : null,
101
- tx.tracking_code ? `Tracking: ${tx.tracking_code}` : null,
102
- tx.timestamp ? `Time: ${tx.timestamp}` : null,
103
- ].filter(Boolean).join('\n');
104
- const ok = await copyToClipboard(lines);
105
- if (ok) {
106
- setCopiedId(tx.id);
107
- setTimeout(() => setCopiedId(null), 2000);
108
- }
109
- }
110
-
111
75
  async function handleRequestMorsels(e) {
112
76
  e.preventDefault();
113
77
  const amount = parseInt(reqAmount, 10);
@@ -176,19 +140,21 @@ export default function WalletTab({ session, showToast, onStats }) {
176
140
 
177
141
  <!-- Copy balance button -->
178
142
  <div class="mb-1">
179
- <button class="btn-outline btn-sm" onClick=${handleCopyBalance}>
180
- ${balanceCopied ? t('profile.wallet.copied') : t('profile.wallet.copyBalance')}
181
- </button>
143
+ <${CopyButton}
144
+ text=${`Balance: ${w.balance ?? 0} | Available: ${(w.balance ?? 0) - escrow} | Escrow: ${escrow}`}
145
+ label=${t('profile.wallet.copyBalance')}
146
+ copiedLabel=${t('profile.wallet.copied')}
147
+ className="btn-outline btn-sm" />
182
148
  </div>
183
149
 
184
150
  <!-- Lifetime stats -->
185
151
  ${(lifetime.earned != null || lifetime.spent != null) && html`
186
152
  <div class="section-title">${t('profile.wallet.lifetime')}</div>
187
153
  <div class="wallet-lifetime">
188
- <div class="wl-stat"><div class="wl-val wl-val-success">${lifetime.earned ?? 0}</div><div class="wl-label">${t('profile.wallet.lifetimeEarned')}</div></div>
189
- <div class="wl-stat"><div class="wl-val wl-val-danger">${lifetime.spent ?? 0}</div><div class="wl-label">${t('profile.wallet.lifetimeSpent')}</div></div>
190
- <div class="wl-stat"><div class="wl-val">${lifetime.received_allowance ?? 0}</div><div class="wl-label">${t('profile.wallet.lifetimeAllowance')}</div></div>
191
- <div class="wl-stat"><div class="wl-val">${lifetime.welcome_bonus ?? 0}</div><div class="wl-label">${t('profile.wallet.lifetimeWelcome')}</div></div>
154
+ <div class="stat-card"><div class="stat-card-value mono success">${lifetime.earned ?? 0}</div><div class="stat-card-label">${t('profile.wallet.lifetimeEarned')}</div></div>
155
+ <div class="stat-card"><div class="stat-card-value mono danger">${lifetime.spent ?? 0}</div><div class="stat-card-label">${t('profile.wallet.lifetimeSpent')}</div></div>
156
+ <div class="stat-card"><div class="stat-card-value mono">${lifetime.received_allowance ?? 0}</div><div class="stat-card-label">${t('profile.wallet.lifetimeAllowance')}</div></div>
157
+ <div class="stat-card"><div class="stat-card-value mono">${lifetime.welcome_bonus ?? 0}</div><div class="stat-card-label">${t('profile.wallet.lifetimeWelcome')}</div></div>
192
158
  </div>
193
159
  `}
194
160
 
@@ -230,7 +196,14 @@ export default function WalletTab({ session, showToast, onStats }) {
230
196
  const isCredit = tx.amount > 0;
231
197
  const typeLabel = txTypeLabel(tx);
232
198
  const isExpanded = expandedTx === tx.id;
233
- const isCopied = copiedId === tx.id;
199
+ const txCopyText = [
200
+ `ID: ${tx.id || '-'}`,
201
+ `Type: ${tx.type || '-'}`,
202
+ `Amount: ${tx.amount}`,
203
+ tx.counterparty_gaii ? `Counterparty: ${tx.counterparty_gaii}` : null,
204
+ tx.tracking_code ? `Tracking: ${tx.tracking_code}` : null,
205
+ tx.timestamp ? `Time: ${tx.timestamp}` : null,
206
+ ].filter(Boolean).join('\n');
234
207
  return html`
235
208
  <div class="tx-item-wrap">
236
209
  <div class="tx-item" onClick=${() => toggleTx(tx.id)}>
@@ -273,9 +246,13 @@ export default function WalletTab({ session, showToast, onStats }) {
273
246
  <span class="tx-detail-value work-grid-span">${t('profile.wallet.noDetails')}</span>
274
247
  `}
275
248
  <div class="work-grid-span">
276
- <button class="btn-outline btn-sm" onClick=${(e) => handleCopyTx(tx, e)}>
277
- ${isCopied ? t('profile.wallet.copied') : t('profile.wallet.copyTx')}
278
- </button>
249
+ <span onClick=${(e) => e.stopPropagation()}>
250
+ <${CopyButton}
251
+ text=${txCopyText}
252
+ label=${t('profile.wallet.copyTx')}
253
+ copiedLabel=${t('profile.wallet.copied')}
254
+ className="btn-outline btn-sm" />
255
+ </span>
279
256
  </div>
280
257
  </div>
281
258
  `}