agim-cli 1.2.71 → 1.2.72

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agim-cli",
3
- "version": "1.2.71",
3
+ "version": "1.2.72",
4
4
  "description": "Agim (阿吉姆) — universal messenger-to-agent bridge. Connect WeChat / Feishu / DingTalk / Email to Claude Code / Codex / OpenCode, or any custom agent via ACP. Installs the `agim` command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,248 +0,0 @@
1
- // Agim web console — shared utilities (theme / i18n / error boundary / api).
2
- //
3
- // Loaded as a CLASSIC script in <head> by every page before any page-specific
4
- // code, so it sits in the same global lexical scope. Wrapped in an IIFE so the
5
- // only thing that leaks is `window.imhub`.
6
- //
7
- // Served as `/_app.js` by web/server.ts. Page scripts should call `imhub.api`
8
- // from inside DOMContentLoaded or a deferred script so `window.__lang` is set.
9
-
10
- (() => {
11
- // ============================================================
12
- // Error boundary
13
- // ============================================================
14
- // Sticky red banner at the top of the viewport whenever a script error
15
- // or unhandled promise rejection escapes the page-level try/catch. The
16
- // tasks.html SyntaxError that produced a fully blank page (because the
17
- // script that fills tab labels / button text never ran) was the
18
- // motivating example — silent JS death must surface visually.
19
- let errorBar = null
20
- function showError(msg) {
21
- if (!errorBar) {
22
- errorBar = document.createElement('div')
23
- errorBar.id = 'imhub-error-bar'
24
- errorBar.setAttribute('role', 'alert')
25
- errorBar.style.cssText = [
26
- 'position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:99999',
27
- 'padding:8px 16px', 'background:#dc3545', 'color:#fff',
28
- 'font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
29
- 'box-shadow:0 2px 8px rgba(0,0,0,.25)',
30
- 'display:flex', 'align-items:center', 'gap:12px',
31
- ].join(';')
32
- errorBar.innerHTML =
33
- '<span style="flex:1;white-space:pre-wrap;word-break:break-word" data-imhub-error-msg></span>'
34
- + '<button data-imhub-error-close style="background:rgba(255,255,255,.18);'
35
- + 'color:#fff;border:none;padding:4px 10px;border-radius:3px;cursor:pointer">'
36
- + '×</button>'
37
- const init = () => {
38
- if (!document.body) return setTimeout(init, 30)
39
- document.body.prepend(errorBar)
40
- errorBar.querySelector('[data-imhub-error-close]')
41
- .addEventListener('click', () => { errorBar.remove(); errorBar = null })
42
- }
43
- init()
44
- }
45
- const slot = errorBar?.querySelector('[data-imhub-error-msg]')
46
- if (slot) slot.textContent = String(msg).slice(0, 600)
47
- }
48
-
49
- function installErrorBoundary() {
50
- window.addEventListener('error', (e) => {
51
- const file = (e.filename || '?').split('/').pop()
52
- showError(`JS error: ${e.message} (${file}:${e.lineno})`)
53
- })
54
- window.addEventListener('unhandledrejection', (e) => {
55
- const r = e.reason
56
- const msg = r && (r.message || r.toString?.())
57
- showError(`Unhandled rejection: ${msg ?? '(no message)'}`)
58
- })
59
- }
60
- installErrorBoundary()
61
-
62
- // ============================================================
63
- // Theme manager (light / dark / system three-state)
64
- // ============================================================
65
- // Three-state toggle persists in localStorage. `system` removes
66
- // `[data-theme]` and lets `prefers-color-scheme` decide; `light` /
67
- // `dark` set the attribute explicitly so CSS `:root[data-theme="..."]`
68
- // selectors win regardless of OS preference.
69
- //
70
- // Applied as early as possible (before first paint) so the page doesn't
71
- // flash the wrong theme on load. This file is loaded synchronously in
72
- // <head> precisely for that reason.
73
- const THEME_KEY = 'agim-theme'
74
- const VALID_MODES = ['system', 'light', 'dark']
75
-
76
- function getThemeMode() {
77
- const stored = localStorage.getItem(THEME_KEY)
78
- return VALID_MODES.includes(stored) ? stored : 'system'
79
- }
80
-
81
- function applyTheme() {
82
- const mode = getThemeMode()
83
- if (mode === 'system') document.documentElement.removeAttribute('data-theme')
84
- else document.documentElement.setAttribute('data-theme', mode)
85
- }
86
-
87
- function setThemeMode(mode) {
88
- if (!VALID_MODES.includes(mode)) mode = 'system'
89
- if (mode === 'system') localStorage.removeItem(THEME_KEY)
90
- else localStorage.setItem(THEME_KEY, mode)
91
- applyTheme()
92
- }
93
-
94
- function cycleThemeMode() {
95
- const cur = getThemeMode()
96
- const next = VALID_MODES[(VALID_MODES.indexOf(cur) + 1) % VALID_MODES.length]
97
- setThemeMode(next)
98
- return next
99
- }
100
-
101
- function bindThemeToggle(el) {
102
- if (!el) return
103
- const ICONS = { system: '🖥', light: '☀', dark: '🌙' }
104
- const LABELS = {
105
- en: { system: 'System', light: 'Light', dark: 'Dark', tip: 'Click to cycle theme' },
106
- zh: { system: '跟随系统', light: '浅色', dark: '深色', tip: '点击切换主题' },
107
- }
108
- const lang = (window.__lang === 'zh') ? 'zh' : 'en'
109
- const L = LABELS[lang]
110
- function refresh() {
111
- const m = getThemeMode()
112
- el.innerHTML = `<span style="font-size:14px">${ICONS[m]}</span> <span>${L[m]}</span>`
113
- el.title = `${L[m]} — ${L.tip}`
114
- el.setAttribute('data-theme-mode', m)
115
- }
116
- el.style.cursor = 'pointer'
117
- el.addEventListener('click', () => { cycleThemeMode(); refresh() })
118
- // Refresh when the OS preference flips (relevant only when mode='system').
119
- if (window.matchMedia) {
120
- window.matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change', refresh)
121
- }
122
- refresh()
123
- }
124
-
125
- applyTheme()
126
-
127
- // ============================================================
128
- // i18n (data-attribute driven)
129
- // ============================================================
130
- // Replaces the previous N×getElementById().textContent= boilerplate.
131
- // Pages mark elements with `data-i18n="key"` (sets textContent) and
132
- // optional `data-i18n-attr="placeholder:key1;title:key2"` (sets one or
133
- // more attributes from the same dictionary). Missing keys leave the
134
- // element's existing text/attribute intact, so a partial dictionary
135
- // doesn't blank out the UI.
136
- function applyI18n(T, root = document) {
137
- if (!T || typeof T !== 'object') return
138
- root.querySelectorAll('[data-i18n]').forEach((el) => {
139
- const key = el.getAttribute('data-i18n')
140
- if (key && T[key] !== undefined) el.textContent = T[key]
141
- })
142
- root.querySelectorAll('[data-i18n-attr]').forEach((el) => {
143
- const spec = el.getAttribute('data-i18n-attr') || ''
144
- for (const pair of spec.split(';')) {
145
- const [attr, key] = pair.split(':').map((s) => (s || '').trim())
146
- if (attr && key && T[key] !== undefined) el.setAttribute(attr, T[key])
147
- }
148
- })
149
- }
150
-
151
- // ============================================================
152
- // API helper
153
- // ============================================================
154
- // Throws on non-2xx with the server-provided `error` field as the message
155
- // when present. Returns parsed JSON on success.
156
- async function apiFetch(path, init = {}) {
157
- const headers = { ...(init.headers || {}) }
158
- if (init.body && !headers['Content-Type']) {
159
- headers['Content-Type'] = 'application/json'
160
- }
161
- // v1.1.10: attach the web access token from localStorage (set after a
162
- // successful /login). Cookie is also set by /api/auth/login, but adding
163
- // the header makes the API directly usable via curl + a bearer prefix.
164
- if (!headers['Authorization']) {
165
- try {
166
- const tk = localStorage.getItem('agim_token')
167
- if (tk) headers['Authorization'] = `Bearer ${tk}`
168
- } catch { /* ignore */ }
169
- }
170
- const res = await fetch(path, { ...init, headers, credentials: 'same-origin' })
171
- if (res.status === 401) {
172
- // Token missing / invalid / expired — kick the user to /login.
173
- try { localStorage.removeItem('agim_token') } catch { /* ignore */ }
174
- const next = encodeURIComponent(location.pathname + location.search)
175
- location.href = `/login?next=${next}`
176
- const err = new Error('unauthorized')
177
- err.status = 401
178
- throw err
179
- }
180
- if (!res.ok) {
181
- let msg = `${res.status} ${res.statusText}`
182
- try { const j = await res.json(); if (j?.error) msg = j.error } catch { /* ignore */ }
183
- const err = new Error(msg)
184
- err.status = res.status
185
- throw err
186
- }
187
- if (res.status === 204) return null
188
- const ct = res.headers.get('content-type') || ''
189
- return ct.includes('application/json') ? res.json() : res.text()
190
- }
191
-
192
- // ============================================================
193
- // Global fetch interceptor (v1.1.10)
194
- // ============================================================
195
- // Static pages (tasks.html / reminders.html / memos.html) often call
196
- // `fetch('/api/foo')` directly without going through apiFetch. Patch
197
- // window.fetch so the access token attaches automatically + 401 kicks
198
- // the user back to /login.
199
- ;(function installFetchAuth(){
200
- if (window.__agimFetchPatched) return
201
- window.__agimFetchPatched = true
202
- var origFetch = window.fetch.bind(window)
203
- window.fetch = function(input, init){
204
- var opts = init || {}
205
- var url = typeof input === 'string' ? input : (input && input.url) || ''
206
- // Only inject for same-origin API paths (/api/...) and the static
207
- // route serving — leave external fetches (CDN) alone.
208
- var isLocalApi = url.startsWith('/') || url.startsWith(location.origin)
209
- if (isLocalApi) {
210
- var headers = new Headers(opts.headers || (typeof input !== 'string' ? input.headers : undefined))
211
- if (!headers.has('Authorization')) {
212
- try {
213
- var tk = localStorage.getItem('agim_token')
214
- if (tk) headers.set('Authorization', 'Bearer ' + tk)
215
- } catch { /* ignore */ }
216
- }
217
- opts = Object.assign({}, opts, { headers, credentials: opts.credentials || 'same-origin' })
218
- }
219
- return origFetch(input, opts).then(function(res){
220
- if (res.status === 401 && isLocalApi && !url.startsWith('/api/auth/')) {
221
- try { localStorage.removeItem('agim_token') } catch {}
222
- var next = encodeURIComponent(location.pathname + location.search)
223
- // Only redirect from non-login pages to avoid loops.
224
- if (!location.pathname.startsWith('/login')) {
225
- location.href = '/login?next=' + next
226
- }
227
- }
228
- return res
229
- })
230
- }
231
- })()
232
-
233
- // ============================================================
234
- // Expose
235
- // ============================================================
236
- window.imhub = Object.freeze({
237
- showError,
238
- theme: {
239
- get: getThemeMode,
240
- set: setThemeMode,
241
- cycle: cycleThemeMode,
242
- bindToggle: bindThemeToggle,
243
- apply: applyTheme,
244
- },
245
- i18n: { apply: applyI18n },
246
- api: apiFetch,
247
- })
248
- })()
@@ -1,352 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Agim — Memos</title>
7
- <script src="/_app.js"></script>
8
- <script>
9
- (function () {
10
- const LANGS = { en: 'en', zh: 'zh' };
11
- const savedLang = localStorage.getItem('im-hub-lang');
12
- const browserLang = navigator.language.startsWith('zh') ? 'zh' : 'en';
13
- window.__lang = savedLang && LANGS[savedLang] ? savedLang : browserLang;
14
- document.documentElement.lang = window.__lang === 'zh' ? 'zh-CN' : 'en';
15
- const T = {
16
- en: {
17
- title: 'Agim — Memos', h1: '📋 Memos',
18
- backToChat: '↩ Chat', toTasks: 'Tasks', toReminders: 'Reminders', toSettings: 'Settings',
19
- filterAll: 'All', filterGeo: 'With location', filterExpired: 'Expired',
20
- searchPlaceholder: 'search (what / memo / who / address …)',
21
- loading: 'Loading…',
22
- loadFailed: 'Load failed: {err}',
23
- emptyNoMatch: 'No memos match your search.',
24
- emptyNone: 'No memos yet. Just tell an agent in IM "记下…" / "remember that…" and they will save it.',
25
- delete: 'Delete', edit: 'Edit',
26
- confirmDelete: 'Really delete memo #{id}?',
27
- deleted: 'Deleted #{id}',
28
- deleteFailed: 'Delete failed: {err}',
29
- mapBaidu: 'Baidu Map', mapAmap: 'Amap', mapGoogle: 'Google Maps',
30
- },
31
- zh: {
32
- title: 'Agim — 备忘', h1: '📋 备忘',
33
- backToChat: '↩ 对话', toTasks: '任务', toReminders: '提醒', toSettings: '设置',
34
- filterAll: '全部', filterGeo: '有位置', filterExpired: '已过期',
35
- searchPlaceholder: '搜索内容(what / memo / who / 地址 …)',
36
- loading: '加载中…',
37
- loadFailed: '加载失败:{err}',
38
- emptyNoMatch: '没匹配的 memo',
39
- emptyNone: '还没记下任何 memo。在 IM 里说「记下…」让 agent 帮你存。',
40
- delete: '删除', edit: '编辑',
41
- confirmDelete: '确定删除 memo #{id}?',
42
- deleted: '已删除 #{id}',
43
- deleteFailed: '删除失败:{err}',
44
- mapBaidu: '百度地图', mapAmap: '高德地图', mapGoogle: 'Google',
45
- },
46
- };
47
- window.__t = T[window.__lang];
48
- document.title = T[window.__lang].title;
49
- })();
50
- </script>
51
- <style>
52
- :root {
53
- --bg: #fafafa; --fg: #222; --muted: #666; --border: #e5e7eb;
54
- --card: #fff; --accent: #2563eb; --danger: #dc2626; --warn: #d97706;
55
- --geo: #059669;
56
- }
57
- [data-theme="dark"] {
58
- --bg: #1a1a1a; --fg: #e5e5e5; --muted: #999; --border: #333;
59
- --card: #242424; --accent: #60a5fa; --danger: #f87171; --warn: #fbbf24;
60
- --geo: #34d399;
61
- }
62
- body {
63
- font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
64
- background: var(--bg); color: var(--fg); margin: 0; padding: 0;
65
- }
66
- /* Header — kept in sync with tasks.html / reminders.html so all
67
- dashboard pages share the same layout. */
68
- header {
69
- display: flex;
70
- align-items: center;
71
- gap: 16px;
72
- padding: 14px 24px;
73
- border-bottom: 1px solid var(--border);
74
- background: var(--card);
75
- }
76
- header h1 { margin: 0; font-size: 18px; font-weight: 600; flex: 1; }
77
- header a, header button, header select {
78
- color: var(--accent);
79
- text-decoration: none;
80
- font-size: 14px;
81
- background: none;
82
- border: 1px solid var(--border);
83
- padding: 6px 12px;
84
- border-radius: 4px;
85
- cursor: pointer;
86
- }
87
- header select { color: var(--fg); }
88
- header a:hover, header button:hover { border-color: var(--accent); }
89
- main { padding: 20px; max-width: 980px; margin: 0 auto; }
90
- .toolbar {
91
- display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap;
92
- align-items: center;
93
- }
94
- .toolbar input[type="text"] {
95
- flex: 1; min-width: 200px;
96
- padding: 7px 12px; border: 1px solid var(--border); border-radius: 6px;
97
- background: var(--card); color: var(--fg); font-size: 14px;
98
- }
99
- .toolbar input[type="text"]:focus { outline: none; border-color: var(--accent); }
100
- .filter-btn {
101
- padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
102
- background: transparent; color: var(--muted); cursor: pointer;
103
- font-size: 13px; transition: all 0.15s;
104
- }
105
- .filter-btn:hover { color: var(--fg); border-color: var(--fg); }
106
- .filter-btn.active { background: var(--accent); color: white; border-color: var(--accent); }
107
- .stats { color: var(--muted); font-size: 12px; }
108
- .memo {
109
- background: var(--card); border: 1px solid var(--border); border-radius: 8px;
110
- padding: 12px 16px; margin-bottom: 10px;
111
- display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
112
- }
113
- .memo .body { flex: 1; min-width: 0; }
114
- .memo .what {
115
- font-weight: 600; margin-bottom: 4px;
116
- display: flex; align-items: center; gap: 8px;
117
- }
118
- .memo .icon { font-size: 16px; }
119
- .memo .id { color: var(--muted); font-size: 11px; font-weight: normal; }
120
- .memo .original {
121
- color: var(--muted); margin-bottom: 6px; font-size: 13px;
122
- border-left: 2px solid var(--border); padding-left: 8px;
123
- }
124
- .memo .meta {
125
- color: var(--muted); font-size: 12px; margin-top: 4px;
126
- display: flex; gap: 12px; flex-wrap: wrap;
127
- }
128
- .memo .meta .who::before { content: '👤 '; }
129
- .memo .meta .when::before { content: '🗓 '; }
130
- .memo .meta .geo { color: var(--geo); }
131
- .memo .meta .geo::before { content: '📍 '; }
132
- .memo .meta .platform { opacity: 0.7; }
133
- .memo .meta .expires { color: var(--warn); }
134
- .memo .meta .expires::before { content: '⏳ '; }
135
- .memo .maps { margin-top: 6px; font-size: 12px; display: flex; gap: 12px; flex-wrap: wrap; }
136
- .memo .maps a { color: var(--accent); text-decoration: none; }
137
- .memo .maps a:hover { text-decoration: underline; }
138
- .actions { display: flex; gap: 6px; flex-shrink: 0; }
139
- .btn {
140
- padding: 5px 10px; border: 1px solid var(--border); border-radius: 5px;
141
- background: transparent; color: var(--fg); cursor: pointer; font-size: 12px;
142
- }
143
- .btn:hover { border-color: var(--accent); color: var(--accent); }
144
- .btn.danger:hover { border-color: var(--danger); color: var(--danger); }
145
- .empty { color: var(--muted); text-align: center; padding: 60px 0; }
146
- .toast {
147
- position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
148
- background: var(--card); border: 1px solid var(--border); padding: 10px 16px;
149
- border-radius: 6px; font-size: 13px;
150
- box-shadow: 0 4px 12px rgba(0,0,0,0.1);
151
- transition: opacity 0.2s;
152
- }
153
- .toast.error { color: var(--danger); border-color: var(--danger); }
154
- @media (max-width: 600px) {
155
- .memo { flex-direction: column; }
156
- .actions { width: 100%; justify-content: flex-end; }
157
- }
158
- </style>
159
- </head>
160
- <body>
161
- <header>
162
- <h1 id="page-title"></h1>
163
- <button id="theme-toggle" type="button" aria-label="Toggle color theme"></button>
164
- <select id="langSelect" title="Language / 语言">
165
- <option value="en">EN</option>
166
- <option value="zh">中文</option>
167
- </select>
168
- <a href="/" id="lnk-chat"></a>
169
- <a href="/tasks" id="lnk-tasks"></a>
170
- <a href="/reminders" id="lnk-reminders"></a>
171
- <a href="/settings" id="lnk-settings"></a>
172
- </header>
173
- <main>
174
- <div class="toolbar">
175
- <input type="text" id="q" />
176
- <button type="button" class="filter-btn active" data-filter="all" id="btn-filter-all"></button>
177
- <button type="button" class="filter-btn" data-filter="geo" id="btn-filter-geo"></button>
178
- <button type="button" class="filter-btn" data-filter="expired" id="btn-filter-expired"></button>
179
- <span class="stats" id="stats"></span>
180
- </div>
181
- <div id="list"></div>
182
- </main>
183
- <div id="toast" class="toast" style="display:none"></div>
184
-
185
- <script>
186
- (() => {
187
- const T = window.__t;
188
- const $list = document.getElementById('list')
189
- const $toast = document.getElementById('toast')
190
- const $q = document.getElementById('q')
191
- const $stats = document.getElementById('stats')
192
- let currentFilter = 'all'
193
- let queryStr = ''
194
- let debounceTimer = null
195
-
196
- // i18n bind
197
- document.getElementById('page-title').textContent = T.h1;
198
- document.getElementById('lnk-chat').textContent = T.backToChat;
199
- document.getElementById('lnk-tasks').textContent = T.toTasks;
200
- document.getElementById('lnk-reminders').textContent = T.toReminders;
201
- document.getElementById('lnk-settings').textContent = T.toSettings;
202
- document.getElementById('btn-filter-all').textContent = T.filterAll;
203
- document.getElementById('btn-filter-geo').textContent = T.filterGeo;
204
- document.getElementById('btn-filter-expired').textContent = T.filterExpired;
205
- $q.placeholder = T.searchPlaceholder;
206
- if (window.imhub) imhub.theme.bindToggle(document.getElementById('theme-toggle'));
207
- (function setupLangSwitcher() {
208
- const sel = document.getElementById('langSelect');
209
- if (!sel) return;
210
- sel.value = window.__lang;
211
- sel.addEventListener('change', () => {
212
- if (sel.value === window.__lang) return;
213
- localStorage.setItem('im-hub-lang', sel.value);
214
- window.location.reload();
215
- });
216
- })();
217
-
218
- function toast(msg, isErr = false) {
219
- $toast.textContent = msg
220
- $toast.className = 'toast' + (isErr ? ' error' : '')
221
- $toast.style.display = 'block'
222
- setTimeout(() => { $toast.style.display = 'none' }, 2400)
223
- }
224
-
225
- function escapeHtml(s) {
226
- if (s == null) return ''
227
- return String(s)
228
- .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
229
- .replace(/"/g, '&quot;').replace(/'/g, '&#39;')
230
- }
231
-
232
- function fmtTime(iso) {
233
- if (!iso) return ''
234
- // memos use 'YYYY-MM-DD HH:MM:SS' (UTC+8 bare-local).
235
- const d = new Date(iso.replace(' ', 'T') + '+08:00')
236
- if (Number.isNaN(d.getTime())) return iso
237
- const isZh = window.__lang === 'zh'
238
- const now = new Date()
239
- const diffMs = now.getTime() - d.getTime()
240
- const absMin = Math.abs(Math.round(diffMs / 60000))
241
- let rel = ''
242
- if (Math.abs(diffMs) < 60_000) rel = isZh ? '刚才' : 'just now'
243
- else if (absMin < 60) rel = isZh ? `${absMin} 分钟前` : `${absMin} min ago`
244
- else if (absMin < 1440) rel = isZh ? `${(absMin / 60).toFixed(1)} 小时前` : `${(absMin / 60).toFixed(1)} h ago`
245
- else rel = isZh ? `${(absMin / 1440).toFixed(1)} 天前` : `${(absMin / 1440).toFixed(1)} d ago`
246
- return `${d.toLocaleString()} · ${rel}`
247
- }
248
-
249
- async function load() {
250
- $list.innerHTML = `<div class="empty">${T.loading}</div>`
251
- try {
252
- const params = new URLSearchParams()
253
- if (queryStr) params.set('query', queryStr)
254
- if (currentFilter === 'geo') params.set('has_location', 'true')
255
- if (currentFilter === 'expired') params.set('include_expired', 'true')
256
- params.set('limit', '200')
257
- const data = await window.imhub.api(`/api/memos?${params.toString()}`)
258
- let items = data.memos || []
259
- if (currentFilter === 'expired') {
260
- // Only the rows that have an expires_at in the past.
261
- const now = new Date()
262
- items = items.filter(m => m.expiresAt && new Date(m.expiresAt.replace(' ', 'T') + '+08:00') < now)
263
- }
264
- render(items)
265
- $stats.textContent = items.length ? (window.__lang === 'zh' ? `${items.length} 条` : `${items.length} memos`) : ''
266
- } catch (err) {
267
- $list.innerHTML = ''
268
- toast(T.loadFailed.replace('{err}', err.message), true)
269
- }
270
- }
271
-
272
- function render(items) {
273
- if (!items.length) {
274
- $list.innerHTML = `<div class="empty">${queryStr ? T.emptyNoMatch : T.emptyNone}</div>`
275
- return
276
- }
277
- $list.innerHTML = items.map(m => {
278
- const icon = m.where_lat != null ? '📍' : '📝'
279
- const idLbl = `<span class="id">#${m.id}</span>`
280
- const what = `<div class="what"><span class="icon">${icon}</span>${escapeHtml(m.what)} ${idLbl}</div>`
281
- const original = (m.memo && m.memo !== m.what)
282
- ? `<div class="original">${escapeHtml(m.memo)}</div>` : ''
283
- const metaBits = []
284
- if (m.who) metaBits.push(`<span class="who">${escapeHtml(m.who)}</span>`)
285
- if (m.whenAt) metaBits.push(`<span class="when">${escapeHtml(m.whenAt)}</span>`)
286
- else if (m.whenText) metaBits.push(`<span class="when">${escapeHtml(m.whenText)}</span>`)
287
- if (m.where_label) metaBits.push(`<span class="geo">${escapeHtml(m.where_label)}</span>`)
288
- else if (m.where_lat != null) metaBits.push(`<span class="geo">${m.where_lat.toFixed(5)}, ${m.where_lng.toFixed(5)}</span>`)
289
- if (m.platform) metaBits.push(`<span class="platform">${escapeHtml(m.platform)}</span>`)
290
- if (m.expiresAt) metaBits.push(`<span class="expires">${escapeHtml(m.expiresAt)}</span>`)
291
- metaBits.push(`<span class="when">${fmtTime(m.createdAt)}</span>`)
292
- const meta = `<div class="meta">${metaBits.join('')}</div>`
293
-
294
- let maps = ''
295
- if (m.mapUrls) {
296
- maps = `<div class="maps">
297
- <a href="${m.mapUrls.baidu}" target="_blank">${T.mapBaidu}</a>
298
- <a href="${m.mapUrls.amap}" target="_blank">${T.mapAmap}</a>
299
- <a href="${m.mapUrls.google}" target="_blank">${T.mapGoogle}</a>
300
- </div>`
301
- }
302
-
303
- return `<div class="memo" data-id="${m.id}">
304
- <div class="body">${what}${original}${meta}${maps}</div>
305
- <div class="actions">
306
- <button type="button" class="btn danger" data-action="delete">${T.delete}</button>
307
- </div>
308
- </div>`
309
- }).join('')
310
- }
311
-
312
- $list.addEventListener('click', async (e) => {
313
- const btn = e.target.closest('button[data-action]')
314
- if (!btn) return
315
- const card = btn.closest('.memo')
316
- if (!card) return
317
- const id = card.dataset.id
318
- const action = btn.dataset.action
319
- if (action === 'delete') {
320
- if (!confirm(T.confirmDelete.replace('{id}', id))) return
321
- try {
322
- await window.imhub.api(`/api/memos/${id}`, { method: 'DELETE' })
323
- toast(T.deleted.replace('{id}', id))
324
- load()
325
- } catch (err) { toast(T.deleteFailed.replace('{err}', err.message), true) }
326
- }
327
- })
328
-
329
- document.querySelectorAll('.filter-btn').forEach(btn => {
330
- btn.addEventListener('click', () => {
331
- document.querySelectorAll('.filter-btn').forEach(b => { b.classList.remove('active') })
332
- btn.classList.add('active')
333
- currentFilter = btn.dataset.filter
334
- load()
335
- })
336
- })
337
-
338
- $q.addEventListener('input', () => {
339
- clearTimeout(debounceTimer)
340
- debounceTimer = setTimeout(() => {
341
- queryStr = $q.value.trim()
342
- load()
343
- }, 280)
344
- })
345
-
346
- load()
347
- // Auto-refresh every 30s so newly-saved memos show up.
348
- setInterval(load, 30_000)
349
- })()
350
- </script>
351
- </body>
352
- </html>