@yaebal/panel 0.0.2 → 0.0.4

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/lib/panel-html.js CHANGED
@@ -1,4 +1,4 @@
1
- /** the operator panel ui a single static page: token login, then the live chat view. */
1
+ /** the operator panel ui - a single static page: token login, then the live chat view. */
2
2
  export const PANEL_HTML = `<!doctype html>
3
3
  <html lang="en">
4
4
  <head>
@@ -6,82 +6,266 @@ export const PANEL_HTML = `<!doctype html>
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
7
  <title>yaebal panel</title>
8
8
  <style>
9
- :root { color-scheme: light dark; --bg:#0f1115; --panel:#171a21; --line:#252a33; --muted:#8b93a1; --accent:#229ED9; --accent-2:#1b87ba; --text:#e6e8eb; --danger:#e5484d; }
10
- * { box-sizing: border-box; }
11
- body { margin:0; font:14px/1.5 system-ui,-apple-system,sans-serif; background:var(--bg); color:var(--text); height:100vh; display:flex; }
12
-
13
- /* ---- login ---- */
14
- #login { margin:auto; width:300px; display:flex; flex-direction:column; gap:14px; padding:24px; }
15
- #login .brand { text-align:center; font-size:20px; font-weight:600; letter-spacing:-.2px; }
16
- #login .brand b { color:var(--accent); }
17
- #login .sub { text-align:center; color:var(--muted); font-size:12px; margin-top:-8px; }
18
- #login input, #login button { width:100%; height:44px; border-radius:10px; font:inherit; padding:0 14px; }
19
- #login input { background:var(--panel); border:1px solid var(--line); color:var(--text); text-align:center; }
20
- #login input:focus { outline:none; border-color:var(--accent); }
21
- #login button { background:var(--accent); color:#fff; border:0; cursor:pointer; font-weight:600; transition:background .15s; }
22
- #login button:hover { background:var(--accent-2); }
23
- #login button:disabled { opacity:.6; cursor:default; }
24
- #login .err { color:var(--danger); font-size:12px; text-align:center; min-height:16px; }
25
-
26
- /* ---- app ---- */
27
- #app { display:none; flex:1; }
9
+ :root {
10
+ color-scheme: light;
11
+ --primary:#ffffff; --secondary:#000000; --white:#ffffff; --gray:#75757e;
12
+ --blue:#2f8af9; --red:#ed2236;
13
+ --button:#f4f4f4; --button-hover:#ededed; --button-press:#e8e8e8;
14
+ --button-stroke:rgba(0,0,0,.06); --button-text:#282828;
15
+ --sidebar-bg:#fbfbfb; --sidebar-stroke:rgba(0,0,0,.06);
16
+ --content-border:rgba(0,0,0,.08); --input-border:#adadb7;
17
+ --radius:18px; --radius-sm:12px; --sidebar-width:320px;
18
+ --shadow:0 18px 70px rgba(0,0,0,.14);
19
+ }
20
+ @media (prefers-color-scheme: dark) {
21
+ :root {
22
+ color-scheme: dark;
23
+ --primary:#000000; --secondary:#e1e1e1; --gray:#818181;
24
+ --blue:#2a7ce1; --red:#ff5b70;
25
+ --button:#191919; --button-hover:#242424; --button-press:#2a2a2a;
26
+ --button-stroke:rgba(255,255,255,.05); --button-text:#e1e1e1;
27
+ --sidebar-bg:#0c0c0c; --sidebar-stroke:rgba(255,255,255,.05);
28
+ --content-border:rgba(255,255,255,.08); --input-border:#383838;
29
+ --shadow:0 18px 70px rgba(0,0,0,.42);
30
+ }
31
+ }
32
+ * { box-sizing:border-box; margin:0; }
33
+ html, body { height:100%; }
34
+ body {
35
+ min-height:100%; overflow:hidden; background:var(--primary); color:var(--secondary);
36
+ font:15px/1.5 "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
37
+ -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
38
+ }
39
+ button, input { font:inherit; }
40
+ button { color:inherit; }
41
+ svg { display:block; width:18px; height:18px; }
42
+ .ico { display:inline-flex; align-items:center; justify-content:center; flex:none; }
43
+ :focus-visible { outline:solid 2px var(--blue); outline-offset:-2px; }
44
+ ::selection { background:var(--secondary); color:var(--primary); }
45
+ ::-webkit-scrollbar { width:10px; height:10px; }
46
+ ::-webkit-scrollbar-thumb { background:var(--button-press); border-radius:10px; border:2px solid var(--primary); }
47
+
48
+ @keyframes fade-in { from { opacity:0; } to { opacity:1; } }
49
+ @keyframes rise-in { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
50
+ @keyframes slide-in { from { opacity:0; transform:translateX(-10px); } to { opacity:1; transform:translateX(0); } }
51
+ @keyframes msg-in { from { opacity:0; transform:translateY(8px) scale(.985); } to { opacity:1; transform:translateY(0) scale(1); } }
52
+ @keyframes viewer-in { from { opacity:0; backdrop-filter:blur(0); } to { opacity:1; backdrop-filter:blur(10px); } }
53
+ @keyframes soft-pulse { 0%, 100% { transform:scaleY(.72); opacity:.45; } 50% { transform:scaleY(1); opacity:.95; } }
54
+
55
+ #login { width:min(380px, calc(100vw - 32px)); min-height:100%; margin:auto; display:flex; align-items:center; justify-content:center; padding:24px; }
56
+ #login .card { width:100%; padding:24px; border:1px solid var(--content-border); border-radius:20px; background:var(--sidebar-bg); animation:rise-in .32s ease both; }
57
+ #login .mark { width:44px; height:44px; border-radius:14px; display:grid; place-items:center; margin:0 auto 18px; background:var(--secondary); color:var(--primary); }
58
+ #login .mark svg { width:22px; height:22px; }
59
+ #login .brand { text-align:center; font-size:20px; font-weight:700; letter-spacing:-.5px; }
60
+ #login .brand b { font-weight:700; }
61
+ #login .sub { margin:4px 0 20px; text-align:center; color:var(--gray); font-size:13px; font-weight:500; }
62
+ #login input, #login button { width:100%; height:44px; border-radius:14px; padding:0 14px; }
63
+ #login input { border:1px solid var(--input-border); background:var(--primary); color:var(--secondary); text-align:center; outline:none; transition:border-color .16s ease, box-shadow .16s ease; }
64
+ #login input:focus { border-color:var(--blue); box-shadow:0 0 0 4px color-mix(in srgb, var(--blue) 15%, transparent); }
65
+ #login button { margin-top:10px; border:0; background:var(--secondary); color:var(--primary); font-weight:600; cursor:pointer; transition:opacity .16s ease, transform .16s ease; }
66
+ #login button:hover { opacity:.86; }
67
+ #login button:active { transform:translateY(1px); }
68
+ #login button:disabled { opacity:.55; cursor:default; transform:none; }
69
+ #login .err { min-height:18px; margin-top:12px; text-align:center; color:var(--red); font-size:12px; }
70
+
71
+ #app { height:100%; display:none; animation:fade-in .2s ease both; }
28
72
  body.authed #login { display:none; }
29
73
  body.authed #app { display:flex; }
30
- #chats { width:280px; border-right:1px solid var(--line); overflow-y:auto; flex:none; display:flex; flex-direction:column; }
31
- #chats .top { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; }
32
- #chats .top h1 { font-size:13px; color:var(--muted); margin:0; letter-spacing:.5px; text-transform:lowercase; }
33
- #chats .top button { background:none; border:0; color:var(--muted); cursor:pointer; font:inherit; font-size:12px; }
34
- #chats .top button:hover { color:var(--text); }
35
- .chat { padding:10px 16px; border-top:1px solid var(--line); cursor:pointer; }
36
- .chat:hover, .chat.on { background:var(--panel); }
37
- .chat .n { font-weight:500; }
38
- .chat .l { color:var(--muted); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
39
- #main { flex:1; display:flex; flex-direction:column; min-width:0; }
40
- #log { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:8px; }
41
- #log .more { align-self:center; background:none; border:1px solid var(--line); color:var(--muted); border-radius:999px; padding:4px 14px; cursor:pointer; font:inherit; font-size:12px; }
42
- .msg { max-width:70%; padding:8px 12px; border-radius:12px; white-space:pre-wrap; word-break:break-word; }
43
- .msg.in { background:var(--panel); align-self:flex-start; }
44
- .msg.out { background:var(--accent); color:#fff; align-self:flex-end; }
45
- .msg img, .msg video { max-width:280px; max-height:320px; border-radius:8px; display:block; }
46
- .msg audio { width:260px; display:block; }
47
- .msg .cap { margin-top:6px; }
48
- .msg .media + .media { margin-top:6px; }
49
- .msg .doc { display:inline-flex; align-items:center; gap:6px; color:inherit; text-decoration:none; border-bottom:1px dotted currentColor; }
50
- .msg.album { display:flex; flex-wrap:wrap; gap:4px; max-width:300px; }
51
- .msg.album img, .msg.album video { max-width:140px; max-height:140px; margin:0; }
52
- .msg.album .cap { flex-basis:100%; }
53
- #composer { display:flex; gap:8px; padding:12px; border-top:1px solid var(--line); }
54
- #composer input.text { flex:1; background:var(--panel); border:1px solid var(--line); color:var(--text); border-radius:8px; padding:9px 12px; font:inherit; }
55
- #composer button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:0 16px; cursor:pointer; font:inherit; }
56
- #composer .attach { background:var(--panel); border:1px solid var(--line); color:var(--muted); padding:0 12px; }
57
- #composer .attach:hover { color:var(--text); }
58
- #empty { margin:auto; color:var(--muted); }
74
+ #chats { width:var(--sidebar-width); flex:none; display:flex; flex-direction:column; min-height:0; background:var(--sidebar-bg); border-right:1px solid var(--sidebar-stroke); }
75
+ #main { flex:1; min-width:0; min-height:0; display:flex; flex-direction:column; background:var(--primary); }
76
+
77
+ .side-top { flex:none; padding:20px 16px 18px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--sidebar-stroke); }
78
+ .side-title { min-width:0; display:flex; align-items:center; gap:12px; }
79
+ .side-logo { width:38px; height:38px; border-radius:13px; display:grid; place-items:center; background:var(--secondary); color:var(--primary); flex:none; }
80
+ .side-logo svg { width:18px; height:18px; }
81
+ .side-title h1 { font-size:15px; font-weight:600; letter-spacing:-.3px; }
82
+ .side-title p { margin-top:1px; color:var(--gray); font-size:12.5px; font-weight:500; }
83
+ .ghost { width:38px; height:38px; padding:0; border:0; border-radius:14px; display:grid; place-items:center; background:var(--button); color:var(--button-text); box-shadow:0 0 0 1px var(--button-stroke) inset; cursor:pointer; transition:background .16s ease, transform .16s ease; }
84
+ .ghost:hover { background:var(--button-hover); }
85
+ .ghost:active { transform:scale(.97); }
86
+
87
+ #chat-list { flex:1; min-height:0; overflow:auto; padding:10px 8px; }
88
+ .chat { width:100%; border:0; background:transparent; color:inherit; display:grid; grid-template-columns:42px 1fr; gap:11px; padding:9px 10px; border-radius:13px; text-align:left; cursor:pointer; animation:slide-in .24s ease both; transition:background .16s ease, color .16s ease, transform .16s ease; }
89
+ .chat:hover { background:var(--button-hover); }
90
+ .chat:active { transform:scale(.99); }
91
+ .chat.on { background:var(--secondary); color:var(--primary); }
92
+ .avatar { width:42px; height:42px; border-radius:13px; display:grid; place-items:center; background:var(--button); color:var(--button-text); box-shadow:0 0 0 1px var(--button-stroke) inset; flex:none; }
93
+ .chat.on .avatar { background:var(--primary); color:var(--secondary); }
94
+ .avatar.small { width:38px; height:38px; border-radius:12px; }
95
+ .avatar svg { width:18px; height:18px; }
96
+ .chat-body { min-width:0; align-self:center; }
97
+ .chat-line { display:flex; align-items:baseline; justify-content:space-between; gap:8px; min-width:0; }
98
+ .chat-name { font-weight:600; letter-spacing:-.2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
99
+ .chat-handle { color:var(--gray); font-size:12.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:1px; }
100
+ .chat.on .chat-handle, .chat.on .chat-time, .chat.on .preview { color:color-mix(in srgb, var(--primary) 70%, transparent); }
101
+ .chat-time { color:var(--gray); font-size:11.5px; flex:none; }
102
+ .preview { min-width:0; display:flex; align-items:center; gap:5px; margin-top:4px; color:var(--gray); }
103
+ .preview .ico svg { width:13px; height:13px; }
104
+ .preview-label { font-weight:600; font-size:12px; flex:none; color:inherit; }
105
+ .preview-text { min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:12.5px; }
106
+
107
+ #empty { flex:1; display:grid; place-items:center; color:var(--gray); animation:fade-in .22s ease both; }
108
+ #empty .empty-card { text-align:center; padding:28px; }
109
+ #empty .empty-icon { width:58px; height:58px; border:1px solid var(--content-border); border-radius:18px; display:grid; place-items:center; margin:0 auto 14px; background:var(--sidebar-bg); }
110
+ #empty svg { width:26px; height:26px; color:var(--gray); }
111
+ .thread-head { flex:none; min-height:68px; padding:14px 20px; display:flex; align-items:center; gap:12px; border-bottom:1px solid var(--content-border); background:var(--primary); }
112
+ .thread-head .meta { min-width:0; }
113
+ .thread-head .name { font-weight:600; letter-spacing:-.25px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
114
+ .thread-head .sub { color:var(--gray); font-size:12.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
115
+ .back { display:none; }
116
+ #log { flex:1; min-height:0; overflow:auto; padding:22px 24px; display:flex; flex-direction:column; gap:8px; }
117
+ #log .more { align-self:center; border:1px solid var(--content-border); border-radius:999px; background:var(--sidebar-bg); color:var(--gray); padding:6px 13px; cursor:pointer; font-size:12px; transition:border-color .16s ease, color .16s ease; }
118
+ #log .more:hover { border-color:var(--blue); color:var(--secondary); }
119
+ .msg { display:flex; flex-direction:column; max-width:min(560px, 72%); gap:4px; animation:msg-in .22s ease both; }
120
+ .msg.in { align-self:flex-start; transform-origin:left bottom; }
121
+ .msg.out { align-self:flex-end; align-items:flex-end; transform-origin:right bottom; }
122
+ .bubble { border:1px solid var(--content-border); border-radius:16px; padding:9px 11px; background:var(--sidebar-bg); color:var(--secondary); }
123
+ .out .bubble { border-color:var(--blue); background:var(--blue); color:var(--white); }
124
+ .cap { padding:3px 0 0; white-space:pre-wrap; word-break:break-word; }
125
+ .time { color:var(--gray); font-size:11.5px; padding:0 4px; }
126
+ .out .time { color:var(--gray); }
127
+
128
+ .media-frame { position:relative; overflow:hidden; border:0; padding:0; cursor:pointer; color:inherit; background:var(--button); border-radius:14px; display:block; max-width:340px; transition:transform .18s ease, filter .18s ease; }
129
+ .media-frame:hover { transform:translateY(-1px); filter:brightness(1.03); }
130
+ .media-frame img, .media-frame video { display:block; width:100%; max-width:340px; max-height:380px; object-fit:cover; border-radius:14px; }
131
+ .expand { position:absolute; right:8px; top:8px; width:30px; height:30px; border:1px solid rgba(255,255,255,.28); border-radius:10px; background:rgba(0,0,0,.42); color:#fff; display:grid; place-items:center; opacity:0; transform:translateY(-4px); transition:opacity .16s ease, transform .16s ease; }
132
+ .media-frame:hover .expand, .video-card:hover .expand { opacity:1; transform:translateY(0); }
133
+ .video-card { position:relative; border:1px solid var(--content-border); border-radius:15px; padding:4px; background:var(--button); transition:transform .18s ease; }
134
+ .video-card:hover { transform:translateY(-1px); }
135
+ .video-card video { border-radius:12px; background:#000; width:min(390px, 70vw); max-height:380px; display:block; }
136
+ .video-badge { position:absolute; left:12px; top:12px; display:inline-flex; align-items:center; gap:6px; padding:4px 8px; border-radius:999px; background:rgba(0,0,0,.56); color:#fff; font-size:11px; font-weight:600; }
137
+ .video-badge svg { width:13px; height:13px; }
138
+ .voice-card, .audio-card, .doc-card { display:flex; align-items:center; gap:11px; min-width:min(300px, 68vw); padding:10px; border-radius:14px; background:var(--button); border:1px solid var(--button-stroke); color:inherit; text-decoration:none; }
139
+ .out .voice-card, .out .audio-card, .out .doc-card { background:rgba(255,255,255,.14); border-color:rgba(255,255,255,.18); }
140
+ .voice-mark, .doc-mark { width:38px; height:38px; border-radius:12px; display:grid; place-items:center; flex:none; background:var(--primary); color:var(--secondary); box-shadow:0 0 0 1px var(--button-stroke) inset; }
141
+ .out .voice-mark, .out .doc-mark { background:rgba(255,255,255,.18); color:#fff; }
142
+ .wave { display:flex; align-items:center; gap:3px; height:26px; flex:1; min-width:62px; }
143
+ .wave i { display:block; width:3px; border-radius:999px; background:currentColor; transform-origin:center; animation:soft-pulse 1.45s ease-in-out infinite; }
144
+ .wave i:nth-child(1) { height:10px; animation-delay:0s; } .wave i:nth-child(2) { height:18px; animation-delay:.08s; } .wave i:nth-child(3) { height:24px; animation-delay:.16s; }
145
+ .wave i:nth-child(4) { height:14px; animation-delay:.24s; } .wave i:nth-child(5) { height:22px; animation-delay:.32s; } .wave i:nth-child(6) { height:12px; animation-delay:.40s; }
146
+ .voice-card audio, .audio-card audio { width:142px; max-width:36vw; height:30px; }
147
+ .doc-title { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:600; }
148
+ .doc-sub { color:var(--gray); font-size:12px; }
149
+ .out .doc-sub { color:rgba(255,255,255,.72); }
150
+ .album-grid { display:grid; grid-template-columns:repeat(2, minmax(108px, 1fr)); gap:5px; max-width:340px; }
151
+ .album-grid .media-frame, .album-grid .video-card { max-width:none; }
152
+ .album-grid img, .album-grid video { height:142px; max-height:142px; }
153
+ .keyboard { display:flex; flex-direction:column; gap:5px; margin-top:8px; min-width:min(280px, 58vw); }
154
+ .keyboard-row { display:flex; gap:5px; }
155
+ .key { flex:1; min-width:0; display:flex; align-items:center; justify-content:center; gap:6px; padding:7px 9px; border-radius:11px; border:1px solid var(--content-border); background:var(--primary); color:inherit; font-size:12.5px; font-weight:600; text-decoration:none; transition:background .16s ease, transform .16s ease; }
156
+ .out .key { background:rgba(255,255,255,.14); border-color:rgba(255,255,255,.18); }
157
+ .key:hover { background:var(--button-hover); transform:translateY(-1px); }
158
+ .out .key:hover { background:rgba(255,255,255,.2); }
159
+ .key span:last-child { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
160
+ .key svg { width:13px; height:13px; opacity:.82; }
161
+ .eventline { align-self:center; max-width:min(520px, 86%); display:flex; align-items:center; gap:9px; padding:7px 12px; border:1px solid var(--content-border); border-radius:999px; background:var(--sidebar-bg); color:var(--gray); font-size:12.5px; animation:msg-in .22s ease both; }
162
+ .eventline .event-title { color:var(--secondary); font-weight:600; }
163
+ .eventline svg { width:14px; height:14px; color:var(--blue); }
164
+
165
+ #composer { flex:none; display:flex; align-items:center; gap:10px; padding:14px 18px; border-top:1px solid var(--content-border); background:var(--primary); }
166
+ #composer input.text { flex:1; height:44px; min-width:0; background:var(--button); border:1px solid var(--button-stroke); color:var(--secondary); border-radius:15px; padding:0 14px; outline:none; transition:border-color .16s ease, box-shadow .16s ease, background .16s ease; }
167
+ #composer input.text:focus { background:var(--primary); border-color:var(--blue); box-shadow:0 0 0 4px color-mix(in srgb, var(--blue) 14%, transparent); }
168
+ #composer button { height:44px; border:0; border-radius:15px; cursor:pointer; transition:background .16s ease, transform .16s ease, opacity .16s ease; }
169
+ #composer button:active { transform:scale(.98); }
170
+ #composer .attach { width:44px; background:var(--button); color:var(--button-text); box-shadow:0 0 0 1px var(--button-stroke) inset; display:grid; place-items:center; }
171
+ #composer .attach:hover { background:var(--button-hover); }
172
+ #composer .send { display:inline-flex; align-items:center; gap:8px; padding:0 17px; font-weight:600; background:var(--secondary); color:var(--primary); }
173
+ #composer .send:hover { opacity:.86; }
174
+
175
+ #viewer[hidden] { display:none; }
176
+ #viewer { position:fixed; inset:0; z-index:20; display:grid; place-items:center; padding:22px; background:rgba(0,0,0,.72); animation:viewer-in .18s ease both; }
177
+ #viewer .viewer-card { max-width:min(980px, 100%); max-height:100%; display:flex; flex-direction:column; gap:12px; animation:rise-in .22s ease both; }
178
+ #viewer .viewer-top { display:flex; justify-content:flex-end; }
179
+ #viewer .viewer-close { width:40px; height:40px; border:1px solid rgba(255,255,255,.16); border-radius:14px; background:rgba(255,255,255,.08); color:#fff; display:grid; place-items:center; cursor:pointer; }
180
+ #viewer .viewer-frame { display:grid; place-items:center; min-height:0; }
181
+ #viewer img, #viewer video { max-width:100%; max-height:78vh; border-radius:18px; box-shadow:var(--shadow); background:#000; }
182
+ #viewer .viewer-caption { color:rgba(255,255,255,.82); text-align:center; max-width:760px; }
183
+
184
+ @media (max-width: 780px) {
185
+ #chats { width:100%; }
186
+ #main { display:none; }
187
+ body.chat-open #chats { display:none; }
188
+ body.chat-open #main { display:flex; }
189
+ .back { display:grid; }
190
+ .msg { max-width:88%; }
191
+ #log { padding:16px 14px; }
192
+ #composer { padding:10px; }
193
+ #composer .send span:last-child { display:none; }
194
+ }
195
+ @media (prefers-reduced-motion: reduce) {
196
+ *, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; scroll-behavior:auto !important; }
197
+ }
59
198
  </style>
60
199
  </head>
61
200
  <body>
62
201
  <form id="login">
63
- <div class="brand"><b>@yaebal</b>/panel</div>
64
- <div class="sub">operator panel</div>
65
- <input id="token" type="password" placeholder="access token" autocomplete="off" autofocus />
66
- <button id="go" type="submit">authorize</button>
67
- <div class="err" id="err"></div>
202
+ <div class="card">
203
+ <div class="mark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 7.5 12 3l8 4.5v9L12 21l-8-4.5v-9Z"/><path d="M8.5 10.5h7M8.5 14h4.25"/></svg></div>
204
+ <div class="brand"><b>@yaebal</b>/panel</div>
205
+ <div class="sub">secure operator console</div>
206
+ <input id="token" type="password" placeholder="access token" autocomplete="off" autofocus />
207
+ <button id="go" type="submit">authorize</button>
208
+ <div class="err" id="err"></div>
209
+ </div>
68
210
  </form>
69
211
 
70
212
  <div id="app">
71
- <div id="chats"><div class="top"><h1>chats</h1><button id="logout" type="button">log out</button></div></div>
72
- <div id="main"><div id="empty">select a chat</div></div>
213
+ <aside id="chats">
214
+ <div class="side-top">
215
+ <div class="side-title">
216
+ <div class="side-logo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 7.5 12 3l8 4.5v9L12 21l-8-4.5v-9Z"/><path d="M8.5 10.5h7M8.5 14h4.25"/></svg></div>
217
+ <div><h1>operator panel</h1><p>live private chats</p></div>
218
+ </div>
219
+ <button id="logout" class="ghost" type="button" aria-label="log out"></button>
220
+ </div>
221
+ <div id="chat-list"></div>
222
+ </aside>
223
+ <main id="main"><div id="empty"><div class="empty-card"><div class="empty-icon"></div><div>select a chat</div></div></div></main>
224
+ </div>
225
+
226
+ <div id="viewer" hidden>
227
+ <div class="viewer-card">
228
+ <div class="viewer-top"><button class="viewer-close" type="button" aria-label="close viewer"></button></div>
229
+ <div class="viewer-frame"></div>
230
+ <div class="viewer-caption"></div>
231
+ </div>
73
232
  </div>
74
233
 
75
234
  <script>
76
235
  const BASE = "__BASE__";
77
236
  const KEY = "yaebal-panel-token" + BASE;
78
237
  let token = sessionStorage.getItem(KEY) || "";
79
- let active = null, oldest = null, es = null;
238
+ let active = null, oldest = null, es = null, chatsCache = [];
239
+
240
+ const ICONS = {
241
+ logout:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M10 6H6.8A1.8 1.8 0 0 0 5 7.8v8.4A1.8 1.8 0 0 0 6.8 18H10"/><path d="M14 8l4 4-4 4"/><path d="M18 12H9"/></svg>',
242
+ empty:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M5 7.5A3.5 3.5 0 0 1 8.5 4h7A3.5 3.5 0 0 1 19 7.5v4A3.5 3.5 0 0 1 15.5 15H12l-4.2 3.6V15A3.5 3.5 0 0 1 5 11.5v-4Z"/></svg>',
243
+ attach:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="m8.5 12.5 5.9-5.9a3.2 3.2 0 0 1 4.5 4.5l-7.1 7.1a4.6 4.6 0 0 1-6.5-6.5l7.3-7.3"/><path d="m9.5 15.1 7-7"/></svg>',
244
+ send:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M20 4 10.6 20l-1.8-7.2L4 10.5 20 4Z"/><path d="m8.8 12.8 5.4-3.5"/></svg>',
245
+ back:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M15 6 9 12l6 6"/></svg>',
246
+ close:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M7 7l10 10M17 7 7 17"/></svg>',
247
+ image:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="5" width="16" height="14" rx="3"/><path d="m7 16 3.5-3.5 2.7 2.7 1.8-1.8L18 16"/><circle cx="9" cy="9" r="1.2"/></svg>',
248
+ video:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="6" width="11" height="12" rx="3"/><path d="m15 10 5-3v10l-5-3"/></svg>',
249
+ mic:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="9" y="4" width="6" height="10" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg>',
250
+ audio:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 18V7l10-2v11"/><circle cx="6" cy="18" r="3"/><circle cx="16" cy="16" r="3"/></svg>',
251
+ file:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M7 3h6l4 4v14H7z"/><path d="M13 3v5h5M9.5 13h5M9.5 16h5"/></svg>',
252
+ sticker:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 4h12v9l-6 7H6z"/><path d="M12 20v-7h6M9 9h.01M14 9h.01"/></svg>',
253
+ callback:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="7" width="16" height="10" rx="4"/><path d="M8 12h8"/></svg>',
254
+ event:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 3v3M12 18v3M4.6 7.5l2.6 1.5M16.8 15l2.6 1.5M4.6 16.5 7.2 15M16.8 9l2.6-1.5"/><circle cx="12" cy="12" r="3.2"/></svg>',
255
+ keyboard:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="6" width="16" height="12" rx="3"/><path d="M7 10h.01M10.5 10h.01M14 10h.01M17.5 10h.01M7 14h7M16.5 14h.01"/></svg>',
256
+ search:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="11" cy="11" r="6"/><path d="m16 16 4 4"/></svg>',
257
+ link:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M10 13a4 4 0 0 0 5.7 0l2-2a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 11a4 4 0 0 0-5.7 0l-2 2A4 4 0 0 0 12 18.7l1-1"/></svg>',
258
+ user:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.5"/><path d="M5.5 20a6.5 6.5 0 0 1 13 0"/></svg>'
259
+ };
80
260
 
81
261
  const el = (t, c, x) => { const e = document.createElement(t); if (c) e.className = c; if (x != null) e.textContent = x; return e; };
262
+ const icon = (name, c) => { const s = el("span", "ico" + (c ? " " + c : "")); s.innerHTML = ICONS[name] || ICONS.file; return s; };
82
263
  const api = (p, opt = {}) => fetch(BASE + p, { ...opt, headers: { ...(opt.headers||{}), authorization: "Bearer " + token } });
83
264
 
84
- /* ---- auth ---- */
265
+ document.getElementById("logout").append(icon("logout"));
266
+ document.querySelector("#empty .empty-icon").append(icon("empty"));
267
+ document.querySelector(".viewer-close").append(icon("close"));
268
+
85
269
  const login = document.getElementById("login");
86
270
  login.onsubmit = async (e) => {
87
271
  e.preventDefault();
@@ -96,19 +280,13 @@ login.onsubmit = async (e) => {
96
280
  enter();
97
281
  };
98
282
  document.getElementById("logout").onclick = () => {
99
- sessionStorage.removeItem(KEY); token = ""; active = null;
283
+ sessionStorage.removeItem(KEY); token = ""; active = null; chatsCache = [];
100
284
  if (es) { es.close(); es = null; }
101
- document.body.classList.remove("authed");
285
+ document.body.classList.remove("authed", "chat-open");
102
286
  document.getElementById("token").value = "";
103
287
  };
104
288
 
105
- function enter() {
106
- document.body.classList.add("authed");
107
- loadChats();
108
- openStream();
109
- }
110
-
111
- /* ---- realtime: instant via SSE, with a slow polling safety net ---- */
289
+ function enter() { document.body.classList.add("authed"); loadChats(); openStream(); }
112
290
  function openStream() {
113
291
  if (es || !window.EventSource) return;
114
292
  es = new EventSource(BASE + "/api/stream?token=" + encodeURIComponent(token));
@@ -117,56 +295,162 @@ function openStream() {
117
295
  if (active && e.chatId === active) openChat(active, true);
118
296
  loadChats();
119
297
  });
120
- es.onerror = () => {}; // EventSource auto-reconnects
298
+ es.onerror = () => {};
121
299
  }
122
300
  setInterval(() => { if (token) (active ? openChat(active, true) : loadChats()); }, 8000);
123
301
 
124
- /* ---- chats ---- */
302
+ function displayName(c) {
303
+ const full = [c.firstName, c.lastName].filter(Boolean).join(" ").trim();
304
+ return full || (c.username ? "@" + c.username : c.name || "chat " + c.id);
305
+ }
306
+ function handleName(c) {
307
+ if (c.username) return "@" + c.username;
308
+ const name = displayName(c);
309
+ return c.name && c.name !== name ? c.name : "";
310
+ }
311
+ function avatar(c, small) {
312
+ const a = el("div", "avatar" + (small ? " small" : ""));
313
+ a.append(icon("user"));
314
+ return a;
315
+ }
316
+ function time(ts) {
317
+ if (!ts) return "";
318
+ const d = new Date(ts * 1000);
319
+ return d.toLocaleTimeString([], { hour:"2-digit", minute:"2-digit" });
320
+ }
321
+ const isPlaceholder = (t) => /^\\[[a-z_]+\\]$/.test(t || "");
322
+ const placeholderKind = (t) => { const m = /^\\[([a-z_]+)\\]$/.exec(t || ""); return m && m[1]; };
323
+ function mediaIcon(type) {
324
+ if (type === "photo") return "image";
325
+ if (type === "video" || type === "animation" || type === "video_note") return "video";
326
+ if (type === "voice") return "mic";
327
+ if (type === "audio") return "audio";
328
+ if (type === "sticker") return "sticker";
329
+ return "file";
330
+ }
331
+ function mediaLabel(type) { return String(type || "media").replace(/_/g, " "); }
332
+ function eventIcon(type) { return type === "callback" ? "callback" : "event"; }
333
+
125
334
  async function loadChats() {
126
335
  const res = await api("/api/chats"); if (!res.ok) return;
127
- const chats = await res.json();
128
- const box = document.getElementById("chats");
129
- box.querySelectorAll(".chat").forEach(n => n.remove());
130
- for (const c of chats) {
131
- const d = el("div", "chat" + (c.id === active ? " on" : ""));
132
- d.append(el("div", "n", c.name), el("div", "l", c.lastText));
133
- d.onclick = () => openChat(c.id);
134
- box.append(d);
336
+ chatsCache = await res.json();
337
+ const box = document.getElementById("chat-list");
338
+ box.innerHTML = "";
339
+ for (const c of chatsCache) box.append(chatRow(c));
340
+ }
341
+ function chatRow(c) {
342
+ const row = el("button", "chat" + (c.id === active ? " on" : "")); row.type = "button";
343
+ row.append(avatar(c));
344
+ const body = el("div", "chat-body");
345
+ const line = el("div", "chat-line");
346
+ line.append(el("div", "chat-name", displayName(c)), el("div", "chat-time", time(c.lastDate)));
347
+ body.append(line);
348
+ const handle = handleName(c); if (handle) body.append(el("div", "chat-handle", handle));
349
+ const preview = el("div", "preview");
350
+ const kind = c.lastAttachmentType || placeholderKind(c.lastText);
351
+ if (c.lastEventType) {
352
+ preview.append(icon(eventIcon(c.lastEventType)), el("span", "preview-label", "event"), el("span", "preview-text", c.lastText));
353
+ } else if (kind) {
354
+ const caption = isPlaceholder(c.lastText) ? "" : c.lastText;
355
+ preview.append(icon(mediaIcon(kind)), el("span", "preview-label", mediaLabel(kind)), el("span", "preview-text", caption || "media message"));
356
+ } else {
357
+ preview.append(el("span", "preview-text", c.lastText || "message"));
135
358
  }
359
+ body.append(preview);
360
+ row.append(body);
361
+ row.onclick = () => openChat(c.id);
362
+ return row;
136
363
  }
364
+ function chatById(id) { return chatsCache.find((c) => c.id === id) || { id, name:"chat " + id, lastText:"", lastDate:0 }; }
137
365
 
138
- /* ---- media rendering ---- */
139
366
  const fileSrc = (att) => BASE + "/api/file?id=" + encodeURIComponent(att.fileId) + "&token=" + encodeURIComponent(token);
140
- const isPlaceholder = (t) => /^\[[a-z_]+\]$/.test(t);
141
-
142
- function attEl(att) {
367
+ function attEl(att, caption) {
143
368
  const src = fileSrc(att);
144
- if (att.type === "photo" || att.type === "sticker") { const i = el("img", "media"); i.src = src; i.loading = "lazy"; return i; }
145
- if (att.type === "video" || att.type === "animation" || att.type === "video_note") { const v = el("video", "media"); v.src = src; v.controls = true; return v; }
146
- if (att.type === "voice" || att.type === "audio") { const a = el("audio", "media"); a.src = src; a.controls = true; return a; }
147
- const link = el("a", "media doc", "📎 " + (att.fileName || att.type)); link.href = src; link.target = "_blank"; return link;
369
+ if (att.type === "photo" || att.type === "sticker") {
370
+ const btn = el("button", "media-frame image"); btn.type = "button";
371
+ const img = el("img"); img.src = src; img.loading = "lazy"; img.alt = att.type;
372
+ btn.append(img, el("span", "expand")); btn.querySelector(".expand").append(icon("search"));
373
+ btn.onclick = () => openViewer(att, caption);
374
+ return btn;
375
+ }
376
+ if (att.type === "video" || att.type === "animation" || att.type === "video_note") {
377
+ const box = el("div", "video-card");
378
+ const v = el("video"); v.src = src; v.controls = true; v.preload = "metadata";
379
+ const badge = el("div", "video-badge"); badge.append(icon("video"), el("span", null, mediaLabel(att.type)));
380
+ const expand = el("button", "expand"); expand.type = "button"; expand.append(icon("search")); expand.onclick = () => openViewer(att, caption);
381
+ box.append(v, badge, expand);
382
+ return box;
383
+ }
384
+ if (att.type === "voice") {
385
+ const card = el("div", "voice-card");
386
+ const mark = el("div", "voice-mark"); mark.append(icon("mic"));
387
+ const wave = el("div", "wave"); for (let i = 0; i < 6; i++) wave.append(el("i"));
388
+ const a = el("audio"); a.src = src; a.controls = true; a.preload = "metadata";
389
+ card.append(mark, wave, a);
390
+ return card;
391
+ }
392
+ if (att.type === "audio") {
393
+ const card = el("div", "audio-card");
394
+ const mark = el("div", "voice-mark"); mark.append(icon("audio"));
395
+ const a = el("audio"); a.src = src; a.controls = true; a.preload = "metadata";
396
+ card.append(mark, a);
397
+ return card;
398
+ }
399
+ const link = el("a", "doc-card"); link.href = src; link.target = "_blank"; link.rel = "noreferrer";
400
+ const mark = el("div", "doc-mark"); mark.append(icon("file"));
401
+ const meta = el("div"); meta.append(el("div", "doc-title", att.fileName || mediaLabel(att.type)), el("div", "doc-sub", att.mimeType || "telegram file"));
402
+ link.append(mark, meta);
403
+ return link;
404
+ }
405
+ function keyboardEl(k) {
406
+ const box = el("div", "keyboard");
407
+ for (const row of k.rows || []) {
408
+ const r = el("div", "keyboard-row");
409
+ for (const b of row) {
410
+ const node = b.url ? el("a", "key") : el("div", "key");
411
+ if (b.url) { node.href = b.url; node.target = "_blank"; node.rel = "noreferrer"; }
412
+ node.append(icon(b.kind === "url" ? "link" : "keyboard"), el("span", null, b.text));
413
+ r.append(node);
414
+ }
415
+ box.append(r);
416
+ }
417
+ return box;
418
+ }
419
+ function eventBubble(m) {
420
+ const e = el("div", "eventline");
421
+ e.append(icon(eventIcon(m.event.type)), el("span", "event-title", m.event.title));
422
+ if (m.event.detail) e.append(el("span", null, m.event.detail));
423
+ return e;
148
424
  }
149
-
150
425
  function bubble(m) {
151
- const b = el("div", "msg " + m.direction);
426
+ if (m.event) return eventBubble(m);
427
+ const wrap = el("div", "msg " + m.direction);
428
+ const card = el("div", "bubble");
152
429
  const atts = m.attachments || [];
153
- for (const a of atts) b.append(attEl(a));
154
- if (m.text && !(atts.length && isPlaceholder(m.text))) b.append(el("div", atts.length ? "cap" : null, m.text));
155
- return b;
430
+ if (m.mediaGroupId && atts.length) {
431
+ const grid = el("div", "album-grid");
432
+ for (const a of atts) grid.append(attEl(a, m.text));
433
+ card.append(grid);
434
+ } else {
435
+ for (const a of atts) card.append(attEl(a, m.text));
436
+ }
437
+ if (m.text && !(atts.length && isPlaceholder(m.text))) card.append(el("div", atts.length ? "cap" : "cap", m.text));
438
+ if (m.keyboard) card.append(keyboardEl(m.keyboard));
439
+ wrap.append(card, el("div", "time", time(m.date)));
440
+ return wrap;
441
+ }
442
+ function addToAlbum(node, m) {
443
+ const grid = node.querySelector(".album-grid"); if (!grid) return;
444
+ for (const a of m.attachments || []) grid.append(attEl(a, m.text));
445
+ if (m.text && !isPlaceholder(m.text)) node.querySelector(".bubble").append(el("div", "cap", m.text));
156
446
  }
157
-
158
- /* merge consecutive messages sharing a media_group_id into one album bubble */
159
447
  function renderMsgs(msgs) {
160
448
  const out = [];
161
449
  let group = null, groupId = null;
162
450
  for (const m of msgs) {
163
- if (m.mediaGroupId && m.mediaGroupId === groupId && group) {
164
- for (const a of m.attachments || []) group.insertBefore(attEl(a), group.querySelector(".cap"));
165
- if (m.text && !isPlaceholder(m.text)) group.append(el("div", "cap", m.text));
166
- continue;
167
- }
451
+ if (!m.event && m.mediaGroupId && m.mediaGroupId === groupId && group) { addToAlbum(group, m); continue; }
168
452
  const b = bubble(m);
169
- if (m.mediaGroupId) { b.classList.add("album"); group = b; groupId = m.mediaGroupId; }
453
+ if (!m.event && m.mediaGroupId) { group = b; groupId = m.mediaGroupId; }
170
454
  else { group = null; groupId = null; }
171
455
  out.push(b);
172
456
  }
@@ -174,29 +458,28 @@ function renderMsgs(msgs) {
174
458
  }
175
459
 
176
460
  async function openChat(id, keepScroll) {
177
- active = id;
461
+ active = id; document.body.classList.add("chat-open");
178
462
  if (!keepScroll) loadChats();
179
463
  const res = await api("/api/chats/" + id + "?limit=200"); if (!res.ok) return;
180
- const msgs = await res.json();
181
- oldest = msgs.length ? msgs[0].date : null;
182
-
464
+ const msgs = await res.json(); oldest = msgs.length ? msgs[0].date : null;
465
+ const c = chatById(id);
183
466
  const main = document.getElementById("main");
184
467
  const prevTop = keepScroll ? (main.querySelector("#log")?.scrollTop ?? null) : null;
185
468
  main.innerHTML = "";
186
469
 
470
+ const head = el("div", "thread-head");
471
+ const back = el("button", "ghost back"); back.type = "button"; back.append(icon("back")); back.onclick = () => { document.body.classList.remove("chat-open"); };
472
+ const meta = el("div", "meta"); meta.append(el("div", "name", displayName(c)), el("div", "sub", handleName(c) || "private chat"));
473
+ head.append(back, avatar(c, true), meta);
187
474
  const log = el("div"); log.id = "log";
188
- if (msgs.length >= 200) {
189
- const more = el("button", "more", "load earlier"); more.onclick = () => loadEarlier(id, log);
190
- log.append(more);
191
- }
475
+ if (msgs.length >= 200) { const more = el("button", "more", "load earlier"); more.onclick = () => loadEarlier(id, log); log.append(more); }
192
476
  for (const b of renderMsgs(msgs)) log.append(b);
193
477
 
194
478
  const form = el("form"); form.id = "composer";
195
479
  const fileInput = el("input"); fileInput.type = "file"; fileInput.style.display = "none";
196
- const attach = el("button", "attach", "📎"); attach.type = "button"; attach.title = "send a file";
197
- attach.onclick = () => fileInput.click();
198
- const input = el("input", "text"); input.placeholder = "reply…"; input.autocomplete = "off";
199
- const btn = el("button", null, "send"); btn.type = "submit";
480
+ const attach = el("button", "attach"); attach.type = "button"; attach.title = "send a file"; attach.append(icon("attach")); attach.onclick = () => fileInput.click();
481
+ const input = el("input", "text"); input.placeholder = "reply..."; input.autocomplete = "off";
482
+ const btn = el("button", "send"); btn.type = "submit"; btn.append(icon("send"), el("span", null, "send"));
200
483
  form.append(attach, fileInput, input, btn);
201
484
 
202
485
  fileInput.onchange = async () => {
@@ -204,7 +487,7 @@ async function openChat(id, keepScroll) {
204
487
  const fd = new FormData(); fd.append("file", file);
205
488
  if (input.value.trim()) fd.append("caption", input.value.trim());
206
489
  input.value = ""; fileInput.value = "";
207
- await api("/api/chats/" + id + "/send", { method: "POST", body: fd }); // browser sets multipart boundary
490
+ await api("/api/chats/" + id + "/send", { method: "POST", body: fd });
208
491
  openChat(id);
209
492
  };
210
493
  form.onsubmit = async (e) => {
@@ -214,10 +497,9 @@ async function openChat(id, keepScroll) {
214
497
  await api("/api/chats/" + id + "/send", { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ text }) });
215
498
  openChat(id);
216
499
  };
217
- main.append(log, form);
500
+ main.append(head, log, form);
218
501
  log.scrollTop = prevTop != null ? prevTop : log.scrollHeight;
219
502
  }
220
-
221
503
  async function loadEarlier(id, log) {
222
504
  if (oldest == null) return;
223
505
  const res = await api("/api/chats/" + id + "?limit=200&before=" + oldest); if (!res.ok) return;
@@ -228,9 +510,24 @@ async function loadEarlier(id, log) {
228
510
  const frag = document.createDocumentFragment();
229
511
  if (older.length >= 200) { const more = el("button", "more", "load earlier"); more.onclick = () => loadEarlier(id, log); frag.append(more); }
230
512
  for (const b of renderMsgs(older)) frag.append(b);
231
- log.querySelector(".more")?.remove();
232
- log.insertBefore(frag, anchor);
513
+ log.querySelector(".more")?.remove(); log.insertBefore(frag, anchor);
514
+ }
515
+
516
+ function openViewer(att, caption) {
517
+ const viewer = document.getElementById("viewer"), frame = viewer.querySelector(".viewer-frame"), cap = viewer.querySelector(".viewer-caption");
518
+ frame.innerHTML = ""; cap.textContent = caption && !isPlaceholder(caption) ? caption : "";
519
+ const src = fileSrc(att);
520
+ if (att.type === "video" || att.type === "animation" || att.type === "video_note") {
521
+ const v = el("video"); v.src = src; v.controls = true; v.autoplay = true; frame.append(v);
522
+ } else {
523
+ const img = el("img"); img.src = src; img.alt = att.type; frame.append(img);
524
+ }
525
+ viewer.hidden = false;
233
526
  }
527
+ function closeViewer() { document.getElementById("viewer").hidden = true; }
528
+ document.querySelector(".viewer-close").onclick = closeViewer;
529
+ document.getElementById("viewer").onclick = (e) => { if (e.target.id === "viewer") closeViewer(); };
530
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeViewer(); });
234
531
 
235
532
  if (token) enter();
236
533
  </script>