agim-cli 1.1.4 → 1.1.6
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 +77 -0
- package/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/dist/core/message-sink.d.ts.map +1 -1
- package/dist/core/message-sink.js +48 -1
- package/dist/core/message-sink.js.map +1 -1
- package/dist/core/render-router.d.ts +49 -0
- package/dist/core/render-router.d.ts.map +1 -0
- package/dist/core/render-router.js +124 -0
- package/dist/core/render-router.js.map +1 -0
- package/dist/core/router.d.ts.map +1 -1
- package/dist/core/router.js +12 -1
- package/dist/core/router.js.map +1 -1
- package/dist/core/viewer-config.d.ts +22 -0
- package/dist/core/viewer-config.d.ts.map +1 -0
- package/dist/core/viewer-config.js +39 -0
- package/dist/core/viewer-config.js.map +1 -0
- package/dist/core/viewer-local.d.ts +34 -0
- package/dist/core/viewer-local.d.ts.map +1 -0
- package/dist/core/viewer-local.js +140 -0
- package/dist/core/viewer-local.js.map +1 -0
- package/dist/web/public/memos.html +57 -32
- package/dist/web/public/reminders.html +60 -36
- package/dist/web/public/settings.html +128 -0
- package/dist/web/public/tasks.html +17 -11
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +92 -0
- package/dist/web/server.js.map +1 -1
- package/dist/web/viewer-render.d.ts +4 -0
- package/dist/web/viewer-render.d.ts.map +1 -0
- package/dist/web/viewer-render.js +119 -0
- package/dist/web/viewer-render.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Viewer config — env-driven settings for the long-message render router.
|
|
2
|
+
//
|
|
3
|
+
// Kept minimal & env-only for v1: the operator sets two variables (a public
|
|
4
|
+
// base URL + an enable flag), and the defaults handle everything else. No
|
|
5
|
+
// JSON-config schema dance, no on-disk state — change env, restart, done.
|
|
6
|
+
import { DEFAULT_THRESHOLDS } from './render-router.js';
|
|
7
|
+
function parsePositiveInt(raw, fallback) {
|
|
8
|
+
if (!raw)
|
|
9
|
+
return fallback;
|
|
10
|
+
const n = parseInt(raw, 10);
|
|
11
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
12
|
+
}
|
|
13
|
+
export function getViewerConfig() {
|
|
14
|
+
const enabledRaw = (process.env.IMHUB_VIEWER_ENABLED || '').toLowerCase();
|
|
15
|
+
const enabled = enabledRaw === '1' || enabledRaw === 'true' || enabledRaw === 'yes';
|
|
16
|
+
const publicBaseUrl = (process.env.IMHUB_VIEWER_PUBLIC_BASE_URL || '').replace(/\/$/, '');
|
|
17
|
+
return {
|
|
18
|
+
enabled,
|
|
19
|
+
publicBaseUrl,
|
|
20
|
+
thresholds: {
|
|
21
|
+
chars: parsePositiveInt(process.env.IMHUB_VIEWER_CHARS, DEFAULT_THRESHOLDS.chars),
|
|
22
|
+
lines: parsePositiveInt(process.env.IMHUB_VIEWER_LINES, DEFAULT_THRESHOLDS.lines),
|
|
23
|
+
codeLines: parsePositiveInt(process.env.IMHUB_VIEWER_CODE_LINES, DEFAULT_THRESHOLDS.codeLines),
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build the URL placed in the IM reply for a saved paste. Returns null if
|
|
29
|
+
* no public base URL is configured AND no fallback was provided. Caller
|
|
30
|
+
* should treat null as "viewer can't produce a usable link — degrade".
|
|
31
|
+
*/
|
|
32
|
+
export function buildPasteUrl(id, fallbackBaseUrl) {
|
|
33
|
+
const cfg = getViewerConfig();
|
|
34
|
+
const base = cfg.publicBaseUrl || fallbackBaseUrl || '';
|
|
35
|
+
if (!base)
|
|
36
|
+
return null;
|
|
37
|
+
return `${base.replace(/\/$/, '')}/v/${id}`;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=viewer-config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"viewer-config.js","sourceRoot":"","sources":["../../src/core/viewer-config.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,EAAE;AACF,4EAA4E;AAC5E,0EAA0E;AAC1E,0EAA0E;AAE1E,OAAO,EAAE,kBAAkB,EAAyB,MAAM,oBAAoB,CAAA;AAgB9E,SAAS,gBAAgB,CAAC,GAAuB,EAAE,QAAgB;IACjE,IAAI,CAAC,GAAG;QAAE,OAAO,QAAQ,CAAA;IACzB,MAAM,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;IAC3B,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAA;AACnD,CAAC;AAED,MAAM,UAAU,eAAe;IAC7B,MAAM,UAAU,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,oBAAoB,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAA;IACzE,MAAM,OAAO,GAAG,UAAU,KAAK,GAAG,IAAI,UAAU,KAAK,MAAM,IAAI,UAAU,KAAK,KAAK,CAAA;IACnF,MAAM,aAAa,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,4BAA4B,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;IACzF,OAAO;QACL,OAAO;QACP,aAAa;QACb,UAAU,EAAE;YACV,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,KAAK,CAAC;YACjF,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,KAAK,CAAC;YACjF,SAAS,EAAE,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,uBAAuB,EAAE,kBAAkB,CAAC,SAAS,CAAC;SAC/F;KACF,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,EAAU,EAAE,eAAwB;IAChE,MAAM,GAAG,GAAG,eAAe,EAAE,CAAA;IAC7B,MAAM,IAAI,GAAG,GAAG,CAAC,aAAa,IAAI,eAAe,IAAI,EAAE,CAAA;IACvD,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAA;IACtB,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,EAAE,EAAE,CAAA;AAC7C,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type PasteContentType = 'markdown' | 'plain';
|
|
2
|
+
export interface PasteRow {
|
|
3
|
+
id: string;
|
|
4
|
+
content: string;
|
|
5
|
+
content_type: PasteContentType;
|
|
6
|
+
title: string | null;
|
|
7
|
+
source: string | null;
|
|
8
|
+
job_id: number | null;
|
|
9
|
+
created_at: number;
|
|
10
|
+
view_count: number;
|
|
11
|
+
}
|
|
12
|
+
export interface SavePasteOpts {
|
|
13
|
+
content: string;
|
|
14
|
+
content_type?: PasteContentType;
|
|
15
|
+
title?: string;
|
|
16
|
+
source?: string;
|
|
17
|
+
job_id?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Save a paste. Returns the new row's id (uuidv4). Returns null if the
|
|
21
|
+
* underlying DB is broken (caller should fall back to truncation).
|
|
22
|
+
*/
|
|
23
|
+
export declare function savePaste(opts: SavePasteOpts): string | null;
|
|
24
|
+
export declare function getPaste(id: string): PasteRow | null;
|
|
25
|
+
export declare function bumpViewCount(id: string): void;
|
|
26
|
+
export interface ListOpts {
|
|
27
|
+
limit?: number;
|
|
28
|
+
offset?: number;
|
|
29
|
+
}
|
|
30
|
+
export declare function listPastes(opts?: ListOpts): PasteRow[];
|
|
31
|
+
export declare function deletePaste(id: string): boolean;
|
|
32
|
+
export declare function countPastes(): number;
|
|
33
|
+
export declare function closeViewerDb(): void;
|
|
34
|
+
//# sourceMappingURL=viewer-local.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"viewer-local.d.ts","sourceRoot":"","sources":["../../src/core/viewer-local.ts"],"names":[],"mappings":"AA8CA,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG,OAAO,CAAA;AAEnD,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,YAAY,EAAE,gBAAgB,CAAA;IAC9B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAA;IACf,YAAY,CAAC,EAAE,gBAAgB,CAAA;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAWD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,GAAG,IAAI,CA8C5D;AAED,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAQpD;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAQ9C;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,wBAAgB,UAAU,CAAC,IAAI,GAAE,QAAa,GAAG,QAAQ,EAAE,CAS1D;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAK/C;AAED,wBAAgB,WAAW,IAAI,MAAM,CAKpC;AAED,wBAAgB,aAAa,IAAI,IAAI,CAEpC"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Viewer-local — local SQLite store for long IM-message payloads that get
|
|
2
|
+
// rendered as web pages via /v/:id on the agim web console.
|
|
3
|
+
//
|
|
4
|
+
// Why local: privacy — content stays on the operator's machine, full stop.
|
|
5
|
+
// IM messages carry only a short link to the operator's own publicly-reachable
|
|
6
|
+
// agim web (cloudflared / caddy / tailscale at operator's discretion). The
|
|
7
|
+
// link resolves to a row in this SQLite, rendered by the web console.
|
|
8
|
+
//
|
|
9
|
+
// Permanence: rows have NO TTL. They are kept until the operator hits the
|
|
10
|
+
// per-instance cap (`IMHUB_VIEWER_MAX_PASTES`, default 10_000), at which point
|
|
11
|
+
// the oldest rows are pruned LRU. Rationale: this is the operator's own data,
|
|
12
|
+
// equivalent to their memos / job history — they should keep it unless they
|
|
13
|
+
// explicitly delete.
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { logger as rootLogger } from './logger.js';
|
|
17
|
+
import { createSqliteHelper } from './sqlite-helper.js';
|
|
18
|
+
import { AGIM_HOME } from './agim-paths.js';
|
|
19
|
+
const VIEWER_DB = join(AGIM_HOME, 'viewer.db');
|
|
20
|
+
const SCHEMA = `
|
|
21
|
+
CREATE TABLE IF NOT EXISTS pastes (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
content TEXT NOT NULL,
|
|
24
|
+
content_type TEXT NOT NULL DEFAULT 'markdown',
|
|
25
|
+
title TEXT,
|
|
26
|
+
source TEXT,
|
|
27
|
+
job_id INTEGER,
|
|
28
|
+
created_at INTEGER NOT NULL,
|
|
29
|
+
view_count INTEGER NOT NULL DEFAULT 0
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_pastes_created ON pastes(created_at DESC);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_pastes_job ON pastes(job_id);
|
|
33
|
+
`;
|
|
34
|
+
const viewerLog = rootLogger.child({ component: 'viewer-local' });
|
|
35
|
+
const helper = createSqliteHelper({
|
|
36
|
+
file: VIEWER_DB,
|
|
37
|
+
schema: SCHEMA,
|
|
38
|
+
logger: viewerLog,
|
|
39
|
+
component: 'viewer-local',
|
|
40
|
+
});
|
|
41
|
+
const DEFAULT_MAX_PASTES = 10_000;
|
|
42
|
+
function maxPastes() {
|
|
43
|
+
const raw = process.env.IMHUB_VIEWER_MAX_PASTES;
|
|
44
|
+
if (!raw)
|
|
45
|
+
return DEFAULT_MAX_PASTES;
|
|
46
|
+
const n = parseInt(raw, 10);
|
|
47
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_PASTES;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Save a paste. Returns the new row's id (uuidv4). Returns null if the
|
|
51
|
+
* underlying DB is broken (caller should fall back to truncation).
|
|
52
|
+
*/
|
|
53
|
+
export function savePaste(opts) {
|
|
54
|
+
const db = helper.get();
|
|
55
|
+
if (!db)
|
|
56
|
+
return null;
|
|
57
|
+
const id = randomUUID();
|
|
58
|
+
const now = Math.floor(Date.now() / 1000);
|
|
59
|
+
const ct = opts.content_type === 'plain' ? 'plain' : 'markdown';
|
|
60
|
+
try {
|
|
61
|
+
db.prepare(`
|
|
62
|
+
INSERT INTO pastes (id, content, content_type, title, source, job_id, created_at)
|
|
63
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
64
|
+
`).run(id, opts.content, ct, opts.title ?? null, opts.source ?? null, opts.job_id ?? null, now);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
viewerLog.warn({ event: 'viewer.save.failed', err: String(err) });
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
// LRU prune (cheap — only fires when over cap)
|
|
71
|
+
try {
|
|
72
|
+
const cap = maxPastes();
|
|
73
|
+
const total = db.prepare('SELECT COUNT(*) AS n FROM pastes').get();
|
|
74
|
+
if (total.n > cap) {
|
|
75
|
+
const over = total.n - cap;
|
|
76
|
+
const r = db.prepare(`
|
|
77
|
+
DELETE FROM pastes WHERE id IN (
|
|
78
|
+
SELECT id FROM pastes ORDER BY created_at ASC LIMIT ?
|
|
79
|
+
)
|
|
80
|
+
`).run(over);
|
|
81
|
+
if (r.changes > 0) {
|
|
82
|
+
viewerLog.info({ event: 'viewer.prune.lru', removed: r.changes, cap });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
viewerLog.warn({ event: 'viewer.prune.failed', err: String(err) });
|
|
88
|
+
}
|
|
89
|
+
return id;
|
|
90
|
+
}
|
|
91
|
+
export function getPaste(id) {
|
|
92
|
+
const db = helper.get();
|
|
93
|
+
if (!db)
|
|
94
|
+
return null;
|
|
95
|
+
const row = db.prepare(`
|
|
96
|
+
SELECT id, content, content_type, title, source, job_id, created_at, view_count
|
|
97
|
+
FROM pastes WHERE id = ?
|
|
98
|
+
`).get(id);
|
|
99
|
+
return row ?? null;
|
|
100
|
+
}
|
|
101
|
+
export function bumpViewCount(id) {
|
|
102
|
+
const db = helper.get();
|
|
103
|
+
if (!db)
|
|
104
|
+
return;
|
|
105
|
+
try {
|
|
106
|
+
db.prepare('UPDATE pastes SET view_count = view_count + 1 WHERE id = ?').run(id);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
viewerLog.warn({ event: 'viewer.bump.failed', err: String(err) });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function listPastes(opts = {}) {
|
|
113
|
+
const db = helper.get();
|
|
114
|
+
if (!db)
|
|
115
|
+
return [];
|
|
116
|
+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
|
|
117
|
+
const offset = Math.max(opts.offset ?? 0, 0);
|
|
118
|
+
return db.prepare(`
|
|
119
|
+
SELECT id, content, content_type, title, source, job_id, created_at, view_count
|
|
120
|
+
FROM pastes ORDER BY created_at DESC LIMIT ? OFFSET ?
|
|
121
|
+
`).all(limit, offset);
|
|
122
|
+
}
|
|
123
|
+
export function deletePaste(id) {
|
|
124
|
+
const db = helper.get();
|
|
125
|
+
if (!db)
|
|
126
|
+
return false;
|
|
127
|
+
const r = db.prepare('DELETE FROM pastes WHERE id = ?').run(id);
|
|
128
|
+
return r.changes > 0;
|
|
129
|
+
}
|
|
130
|
+
export function countPastes() {
|
|
131
|
+
const db = helper.get();
|
|
132
|
+
if (!db)
|
|
133
|
+
return 0;
|
|
134
|
+
const row = db.prepare('SELECT COUNT(*) AS n FROM pastes').get();
|
|
135
|
+
return row.n;
|
|
136
|
+
}
|
|
137
|
+
export function closeViewerDb() {
|
|
138
|
+
helper.close();
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=viewer-local.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"viewer-local.js","sourceRoot":"","sources":["../../src/core/viewer-local.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,4DAA4D;AAC5D,EAAE;AACF,2EAA2E;AAC3E,+EAA+E;AAC/E,2EAA2E;AAC3E,sEAAsE;AACtE,EAAE;AACF,0EAA0E;AAC1E,+EAA+E;AAC/E,8EAA8E;AAC9E,4EAA4E;AAC5E,qBAAqB;AAErB,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,aAAa,CAAA;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AACvD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAE3C,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,EAAE,WAAW,CAAC,CAAA;AAE9C,MAAM,MAAM,GAAG;;;;;;;;;;;;;CAad,CAAA;AAED,MAAM,SAAS,GAAG,UAAU,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,cAAc,EAAE,CAAC,CAAA;AAEjE,MAAM,MAAM,GAAG,kBAAkB,CAAC;IAChC,IAAI,EAAE,SAAS;IACf,MAAM,EAAE,MAAM;IACd,MAAM,EAAE,SAAS;IACjB,SAAS,EAAE,cAAc;CAC1B,CAAC,CAAA;AAuBF,MAAM,kBAAkB,GAAG,MAAM,CAAA;AAEjC,SAAS,SAAS;IAChB,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,uBAAuB,CAAA;IAC/C,IAAI,CAAC,GAAG;QAAE,OAAO,kBAAkB,CAAA;IACnC,MAAM,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;IAC3B,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,kBAAkB,CAAA;AAC7D,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,IAAmB;IAC3C,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE;QAAE,OAAO,IAAI,CAAA;IAEpB,MAAM,EAAE,GAAG,UAAU,EAAE,CAAA;IACvB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAA;IACzC,MAAM,EAAE,GAAqB,IAAI,CAAC,YAAY,KAAK,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,UAAU,CAAA;IAEjF,IAAI,CAAC;QACH,EAAE,CAAC,OAAO,CAAC;;;KAGV,CAAC,CAAC,GAAG,CACJ,EAAE,EACF,IAAI,CAAC,OAAO,EACZ,EAAE,EACF,IAAI,CAAC,KAAK,IAAI,IAAI,EAClB,IAAI,CAAC,MAAM,IAAI,IAAI,EACnB,IAAI,CAAC,MAAM,IAAI,IAAI,EACnB,GAAG,CACJ,CAAA;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,SAAS,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QACjE,OAAO,IAAI,CAAA;IACb,CAAC;IAED,+CAA+C;IAC/C,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,SAAS,EAAE,CAAA;QACvB,MAAM,KAAK,GAAG,EAAE,CAAC,OAAO,CAAC,kCAAkC,CAAC,CAAC,GAAG,EAAmB,CAAA;QACnF,IAAI,KAAK,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC;YAClB,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,GAAG,GAAG,CAAA;YAC1B,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC;;;;OAIpB,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;YACZ,IAAI,CAAC,CAAC,OAAO,GAAG,CAAC,EAAE,CAAC;gBAClB,SAAS,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,kBAAkB,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,CAAA;YACxE,CAAC;QACH,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,SAAS,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qBAAqB,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IACpE,CAAC;IAED,OAAO,EAAE,CAAA;AACX,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,EAAU;IACjC,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE;QAAE,OAAO,IAAI,CAAA;IACpB,MAAM,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC;;;GAGtB,CAAC,CAAC,GAAG,CAAC,EAAE,CAAyB,CAAA;IAClC,OAAO,GAAG,IAAI,IAAI,CAAA;AACpB,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,EAAU;IACtC,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE;QAAE,OAAM;IACf,IAAI,CAAC;QACH,EAAE,CAAC,OAAO,CAAC,4DAA4D,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IAClF,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,SAAS,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IACnE,CAAC;AACH,CAAC;AAOD,MAAM,UAAU,UAAU,CAAC,OAAiB,EAAE;IAC5C,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE;QAAE,OAAO,EAAE,CAAA;IAClB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;IAC1D,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA;IAC5C,OAAO,EAAE,CAAC,OAAO,CAAC;;;GAGjB,CAAC,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAe,CAAA;AACrC,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,EAAU;IACpC,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE;QAAE,OAAO,KAAK,CAAA;IACrB,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,iCAAiC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IAC/D,OAAO,CAAC,CAAC,OAAO,GAAG,CAAC,CAAA;AACtB,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,CAAA;IACjB,MAAM,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC,kCAAkC,CAAC,CAAC,GAAG,EAAmB,CAAA;IACjF,OAAO,GAAG,CAAC,CAAC,CAAA;AACd,CAAC;AAED,MAAM,UAAU,aAAa;IAC3B,MAAM,CAAC,KAAK,EAAE,CAAA;AAChB,CAAC"}
|
|
@@ -14,20 +14,34 @@
|
|
|
14
14
|
document.documentElement.lang = window.__lang === 'zh' ? 'zh-CN' : 'en';
|
|
15
15
|
const T = {
|
|
16
16
|
en: {
|
|
17
|
-
title: 'Agim — Memos', h1: 'Memos',
|
|
18
|
-
backToChat: 'Chat', toTasks: 'Tasks', toReminders: 'Reminders', toSettings: 'Settings',
|
|
17
|
+
title: 'Agim — Memos', h1: '📋 Memos',
|
|
18
|
+
backToChat: '↩ Chat', toTasks: 'Tasks', toReminders: 'Reminders', toSettings: 'Settings',
|
|
19
19
|
filterAll: 'All', filterGeo: 'With location', filterExpired: 'Expired',
|
|
20
20
|
searchPlaceholder: 'search (what / memo / who / address …)',
|
|
21
|
-
|
|
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.',
|
|
22
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',
|
|
23
30
|
},
|
|
24
31
|
zh: {
|
|
25
|
-
title: 'Agim — 备忘', h1: '备忘',
|
|
26
|
-
backToChat: '对话', toTasks: '任务', toReminders: '提醒', toSettings: '设置',
|
|
32
|
+
title: 'Agim — 备忘', h1: '📋 备忘',
|
|
33
|
+
backToChat: '↩ 对话', toTasks: '任务', toReminders: '提醒', toSettings: '设置',
|
|
27
34
|
filterAll: '全部', filterGeo: '有位置', filterExpired: '已过期',
|
|
28
35
|
searchPlaceholder: '搜索内容(what / memo / who / 地址 …)',
|
|
29
|
-
|
|
36
|
+
loading: '加载中…',
|
|
37
|
+
loadFailed: '加载失败:{err}',
|
|
38
|
+
emptyNoMatch: '没匹配的 memo',
|
|
39
|
+
emptyNone: '还没记下任何 memo。在 IM 里说「记下…」让 agent 帮你存。',
|
|
30
40
|
delete: '删除', edit: '编辑',
|
|
41
|
+
confirmDelete: '确定删除 memo #{id}?',
|
|
42
|
+
deleted: '已删除 #{id}',
|
|
43
|
+
deleteFailed: '删除失败:{err}',
|
|
44
|
+
mapBaidu: '百度地图', mapAmap: '高德地图', mapGoogle: 'Google',
|
|
31
45
|
},
|
|
32
46
|
};
|
|
33
47
|
window.__t = T[window.__lang];
|
|
@@ -49,16 +63,29 @@
|
|
|
49
63
|
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
|
|
50
64
|
background: var(--bg); color: var(--fg); margin: 0; padding: 0;
|
|
51
65
|
}
|
|
66
|
+
/* Header — kept in sync with tasks.html / reminders.html so all
|
|
67
|
+
dashboard pages share the same layout. */
|
|
52
68
|
header {
|
|
53
|
-
display: flex;
|
|
54
|
-
|
|
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);
|
|
55
75
|
}
|
|
56
|
-
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
|
57
|
-
header
|
|
58
|
-
color: var(--
|
|
59
|
-
|
|
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;
|
|
60
86
|
}
|
|
61
|
-
header
|
|
87
|
+
header select { color: var(--fg); }
|
|
88
|
+
header a:hover, header button:hover { border-color: var(--accent); }
|
|
62
89
|
main { padding: 20px; max-width: 980px; margin: 0 auto; }
|
|
63
90
|
.toolbar {
|
|
64
91
|
display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap;
|
|
@@ -132,18 +159,16 @@
|
|
|
132
159
|
</head>
|
|
133
160
|
<body>
|
|
134
161
|
<header>
|
|
135
|
-
<h1
|
|
162
|
+
<h1 id="page-title"></h1>
|
|
136
163
|
<button id="theme-toggle" type="button" aria-label="Toggle color theme"></button>
|
|
137
|
-
<select id="langSelect" title="Language / 语言"
|
|
164
|
+
<select id="langSelect" title="Language / 语言">
|
|
138
165
|
<option value="en">EN</option>
|
|
139
166
|
<option value="zh">中文</option>
|
|
140
167
|
</select>
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<a href="/settings" id="lnk-settings"></a>
|
|
146
|
-
</nav>
|
|
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>
|
|
147
172
|
</header>
|
|
148
173
|
<main>
|
|
149
174
|
<div class="toolbar">
|
|
@@ -222,7 +247,7 @@
|
|
|
222
247
|
}
|
|
223
248
|
|
|
224
249
|
async function load() {
|
|
225
|
-
$list.innerHTML =
|
|
250
|
+
$list.innerHTML = `<div class="empty">${T.loading}</div>`
|
|
226
251
|
try {
|
|
227
252
|
const params = new URLSearchParams()
|
|
228
253
|
if (queryStr) params.set('query', queryStr)
|
|
@@ -237,16 +262,16 @@
|
|
|
237
262
|
items = items.filter(m => m.expiresAt && new Date(m.expiresAt.replace(' ', 'T') + '+08:00') < now)
|
|
238
263
|
}
|
|
239
264
|
render(items)
|
|
240
|
-
$stats.textContent = items.length ? `${items.length} 条` : ''
|
|
265
|
+
$stats.textContent = items.length ? (window.__lang === 'zh' ? `${items.length} 条` : `${items.length} memos`) : ''
|
|
241
266
|
} catch (err) {
|
|
242
267
|
$list.innerHTML = ''
|
|
243
|
-
toast(
|
|
268
|
+
toast(T.loadFailed.replace('{err}', err.message), true)
|
|
244
269
|
}
|
|
245
270
|
}
|
|
246
271
|
|
|
247
272
|
function render(items) {
|
|
248
273
|
if (!items.length) {
|
|
249
|
-
$list.innerHTML = `<div class="empty">${queryStr ?
|
|
274
|
+
$list.innerHTML = `<div class="empty">${queryStr ? T.emptyNoMatch : T.emptyNone}</div>`
|
|
250
275
|
return
|
|
251
276
|
}
|
|
252
277
|
$list.innerHTML = items.map(m => {
|
|
@@ -269,16 +294,16 @@
|
|
|
269
294
|
let maps = ''
|
|
270
295
|
if (m.mapUrls) {
|
|
271
296
|
maps = `<div class="maps">
|
|
272
|
-
<a href="${m.mapUrls.baidu}" target="_blank"
|
|
273
|
-
<a href="${m.mapUrls.amap}" target="_blank"
|
|
274
|
-
<a href="${m.mapUrls.google}" target="_blank"
|
|
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>
|
|
275
300
|
</div>`
|
|
276
301
|
}
|
|
277
302
|
|
|
278
303
|
return `<div class="memo" data-id="${m.id}">
|
|
279
304
|
<div class="body">${what}${original}${meta}${maps}</div>
|
|
280
305
|
<div class="actions">
|
|
281
|
-
<button type="button" class="btn danger" data-action="delete"
|
|
306
|
+
<button type="button" class="btn danger" data-action="delete">${T.delete}</button>
|
|
282
307
|
</div>
|
|
283
308
|
</div>`
|
|
284
309
|
}).join('')
|
|
@@ -292,12 +317,12 @@
|
|
|
292
317
|
const id = card.dataset.id
|
|
293
318
|
const action = btn.dataset.action
|
|
294
319
|
if (action === 'delete') {
|
|
295
|
-
if (!confirm(
|
|
320
|
+
if (!confirm(T.confirmDelete.replace('{id}', id))) return
|
|
296
321
|
try {
|
|
297
322
|
await window.imhub.api(`/api/memos/${id}`, { method: 'DELETE' })
|
|
298
|
-
toast(
|
|
323
|
+
toast(T.deleted.replace('{id}', id))
|
|
299
324
|
load()
|
|
300
|
-
} catch (err) { toast(
|
|
325
|
+
} catch (err) { toast(T.deleteFailed.replace('{err}', err.message), true) }
|
|
301
326
|
}
|
|
302
327
|
})
|
|
303
328
|
|
|
@@ -15,22 +15,32 @@
|
|
|
15
15
|
document.documentElement.lang = window.__lang === 'zh' ? 'zh-CN' : 'en';
|
|
16
16
|
const T = {
|
|
17
17
|
en: {
|
|
18
|
-
title: 'Agim — Reminders', h1: 'Reminders',
|
|
19
|
-
backToChat: 'Chat', toTasks: 'Tasks', toMemos: 'Memos', toSettings: 'Settings',
|
|
18
|
+
title: 'Agim — Reminders', h1: '🔔 Reminders',
|
|
19
|
+
backToChat: '↩ Chat', toTasks: 'Tasks', toMemos: 'Memos', toSettings: 'Settings',
|
|
20
20
|
statusPending: 'Pending', statusFired: 'Fired', statusCancelled: 'Cancelled', statusFailed: 'Failed',
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
loading: 'Loading…',
|
|
22
|
+
loadFailed: 'Load failed: {err}',
|
|
23
|
+
emptyStatus: 'No {status} reminders.',
|
|
24
|
+
cancel: 'Cancel', snooze: 'Snooze +5min', delete: 'Delete',
|
|
24
25
|
fireAt: 'Fires at', recur: 'Repeat',
|
|
26
|
+
literalText: 'literal',
|
|
27
|
+
confirmCancel: 'Cancel reminder #{id}? (Recurring reminders will stop the whole loop.)',
|
|
28
|
+
cancelled: '✅ Cancelled #{id}',
|
|
29
|
+
snoozed: '⏰ Snoozed #{id} +5min',
|
|
25
30
|
},
|
|
26
31
|
zh: {
|
|
27
|
-
title: 'Agim — 提醒', h1: '提醒',
|
|
28
|
-
backToChat: '对话', toTasks: '任务', toMemos: '备忘', toSettings: '设置',
|
|
32
|
+
title: 'Agim — 提醒', h1: '🔔 提醒',
|
|
33
|
+
backToChat: '↩ 对话', toTasks: '任务', toMemos: '备忘', toSettings: '设置',
|
|
29
34
|
statusPending: '待发', statusFired: '已发', statusCancelled: '已取消', statusFailed: '失败',
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
loading: '加载中…',
|
|
36
|
+
loadFailed: '加载失败:{err}',
|
|
37
|
+
emptyStatus: '没有{status}的提醒',
|
|
38
|
+
cancel: '取消', snooze: '延 5 分钟', delete: '删除',
|
|
33
39
|
fireAt: '触发时间', recur: '重复',
|
|
40
|
+
literalText: '字面文本',
|
|
41
|
+
confirmCancel: '取消提醒 #{id}?(循环提醒会终止整条循环)',
|
|
42
|
+
cancelled: '✅ 已取消 #{id}',
|
|
43
|
+
snoozed: '⏰ 已延后 #{id} 5 分钟',
|
|
34
44
|
},
|
|
35
45
|
};
|
|
36
46
|
window.__t = T[window.__lang];
|
|
@@ -53,17 +63,30 @@
|
|
|
53
63
|
background: var(--bg); color: var(--fg);
|
|
54
64
|
margin: 0; padding: 0;
|
|
55
65
|
}
|
|
66
|
+
/* Header — kept in sync with tasks.html so all dashboard pages share
|
|
67
|
+
the same layout. Flex with h1 flex:1 to push toggles + nav to the
|
|
68
|
+
right; uniform border-button look across <a> and <button>. */
|
|
56
69
|
header {
|
|
57
|
-
display: flex;
|
|
58
|
-
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 16px;
|
|
73
|
+
padding: 14px 24px;
|
|
74
|
+
border-bottom: 1px solid var(--border);
|
|
59
75
|
background: var(--card);
|
|
60
76
|
}
|
|
61
|
-
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
|
62
|
-
header
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
header h1 { margin: 0; font-size: 18px; font-weight: 600; flex: 1; }
|
|
78
|
+
header a, header button, header select {
|
|
79
|
+
color: var(--accent);
|
|
80
|
+
text-decoration: none;
|
|
81
|
+
font-size: 14px;
|
|
82
|
+
background: none;
|
|
83
|
+
border: 1px solid var(--border);
|
|
84
|
+
padding: 6px 12px;
|
|
85
|
+
border-radius: 4px;
|
|
86
|
+
cursor: pointer;
|
|
65
87
|
}
|
|
66
|
-
header
|
|
88
|
+
header select { color: var(--fg); }
|
|
89
|
+
header a:hover, header button:hover { border-color: var(--accent); }
|
|
67
90
|
main { max-width: 880px; margin: 24px auto; padding: 0 16px; }
|
|
68
91
|
.filters {
|
|
69
92
|
display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap;
|
|
@@ -118,18 +141,16 @@
|
|
|
118
141
|
</head>
|
|
119
142
|
<body>
|
|
120
143
|
<header>
|
|
121
|
-
<h1
|
|
144
|
+
<h1 id="page-title"></h1>
|
|
122
145
|
<button id="theme-toggle" type="button" aria-label="Toggle color theme"></button>
|
|
123
|
-
<select id="langSelect" title="Language / 语言"
|
|
146
|
+
<select id="langSelect" title="Language / 语言">
|
|
124
147
|
<option value="en">EN</option>
|
|
125
148
|
<option value="zh">中文</option>
|
|
126
149
|
</select>
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
<a href="/settings" id="lnk-settings"></a>
|
|
132
|
-
</nav>
|
|
150
|
+
<a href="/" id="lnk-chat"></a>
|
|
151
|
+
<a href="/tasks" id="lnk-tasks"></a>
|
|
152
|
+
<a href="/memos" id="lnk-memos"></a>
|
|
153
|
+
<a href="/settings" id="lnk-settings"></a>
|
|
133
154
|
</header>
|
|
134
155
|
<main>
|
|
135
156
|
<div class="filters" id="filters">
|
|
@@ -208,19 +229,19 @@
|
|
|
208
229
|
}
|
|
209
230
|
|
|
210
231
|
async function load() {
|
|
211
|
-
$list.innerHTML =
|
|
232
|
+
$list.innerHTML = `<div class="empty">${T.loading}</div>`
|
|
212
233
|
try {
|
|
213
234
|
const data = await window.imhub.api(`/api/reminders?status=${encodeURIComponent(currentStatus)}`)
|
|
214
235
|
render(data.reminders || [])
|
|
215
236
|
} catch (err) {
|
|
216
237
|
$list.innerHTML = ''
|
|
217
|
-
toast(
|
|
238
|
+
toast(T.loadFailed.replace('{err}', err.message), true)
|
|
218
239
|
}
|
|
219
240
|
}
|
|
220
241
|
|
|
221
242
|
function render(items) {
|
|
222
243
|
if (!items.length) {
|
|
223
|
-
$list.innerHTML = `<div class="empty"
|
|
244
|
+
$list.innerHTML = `<div class="empty">${T.emptyStatus.replace('{status}', statusLabel(currentStatus))}</div>`
|
|
224
245
|
return
|
|
225
246
|
}
|
|
226
247
|
$list.innerHTML = items.map((r) => {
|
|
@@ -228,14 +249,14 @@
|
|
|
228
249
|
const recurMeta = r.recurrence_label
|
|
229
250
|
? `<span class="recur">↻ ${escapeHtml(r.recurrence_label)}</span>` : ''
|
|
230
251
|
const literalMeta = r.prompt_mode === 'literal'
|
|
231
|
-
?
|
|
252
|
+
? `<span class="literal">${T.literalText}</span>` : ''
|
|
232
253
|
const platformMeta = r.platform
|
|
233
254
|
? `<span>${escapeHtml(r.platform)}</span>` : ''
|
|
234
255
|
const showActions = currentStatus === 'pending'
|
|
235
256
|
const actions = showActions
|
|
236
257
|
? `<div class="actions">
|
|
237
|
-
<button class="btn" data-act="snooze" data-id="${r.id}"
|
|
238
|
-
<button class="btn danger" data-act="cancel" data-id="${r.id}"
|
|
258
|
+
<button class="btn" data-act="snooze" data-id="${r.id}">${T.snooze}</button>
|
|
259
|
+
<button class="btn danger" data-act="cancel" data-id="${r.id}">${T.cancel}</button>
|
|
239
260
|
</div>`
|
|
240
261
|
: ''
|
|
241
262
|
return `<div class="reminder">
|
|
@@ -262,7 +283,10 @@
|
|
|
262
283
|
}
|
|
263
284
|
|
|
264
285
|
function statusLabel(s) {
|
|
265
|
-
return ({
|
|
286
|
+
return ({
|
|
287
|
+
pending: T.statusPending, fired: T.statusFired,
|
|
288
|
+
cancelled: T.statusCancelled, failed: T.statusFailed,
|
|
289
|
+
})[s] || s
|
|
266
290
|
}
|
|
267
291
|
|
|
268
292
|
$filters.addEventListener('click', (e) => {
|
|
@@ -281,19 +305,19 @@
|
|
|
281
305
|
btn.disabled = true
|
|
282
306
|
try {
|
|
283
307
|
if (act === 'cancel') {
|
|
284
|
-
if (!confirm(
|
|
308
|
+
if (!confirm(T.confirmCancel.replace('{id}', id))) return
|
|
285
309
|
await window.imhub.api(`/api/reminders/${id}/cancel`, { method: 'POST' })
|
|
286
|
-
toast(
|
|
310
|
+
toast(T.cancelled.replace('{id}', id))
|
|
287
311
|
} else if (act === 'snooze') {
|
|
288
312
|
await window.imhub.api(`/api/reminders/${id}/snooze`, {
|
|
289
313
|
method: 'POST',
|
|
290
314
|
body: JSON.stringify({ duration: '5m' }),
|
|
291
315
|
})
|
|
292
|
-
toast(
|
|
316
|
+
toast(T.snoozed.replace('{id}', id))
|
|
293
317
|
}
|
|
294
318
|
await load()
|
|
295
319
|
} catch (err) {
|
|
296
|
-
toast(
|
|
320
|
+
toast(T.loadFailed.replace('{err}', err.message), true)
|
|
297
321
|
} finally {
|
|
298
322
|
btn.disabled = false
|
|
299
323
|
}
|