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/CHANGELOG.md +37 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +16 -41
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
- package/dist/web/public/_app.js +0 -248
- package/dist/web/public/memos.html +0 -352
- package/dist/web/public/reminders.html +0 -332
- package/dist/web/public/settings.html +0 -2488
- package/dist/web/public/tasks.html +0 -3724
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agim-cli",
|
|
3
|
-
"version": "1.2.
|
|
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": {
|
package/dist/web/public/_app.js
DELETED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
229
|
-
.replace(/"/g, '"').replace(/'/g, ''')
|
|
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>
|