dsh-remote-plugin 0.5.0 → 0.5.3
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/apk/dsh-remote.apk +0 -0
- package/client.js +0 -12
- package/gateway.cjs +41 -4
- package/package.json +1 -1
- package/public/admin.html +179 -29
- package/public/admin.js +102 -66
- package/public/app.js +427 -209
- package/public/i18n.js +52 -0
- package/public/index.html +321 -56
- package/public/jsqr.min.js +10104 -0
- package/public/styles.css +58 -6
- package/public/update.json +3 -3
- package/public/version.json +1 -1
package/public/admin.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
'use strict'
|
|
3
3
|
|
|
4
4
|
const $ = (id) => document.getElementById(id)
|
|
5
|
+
const I18N = window.I18N
|
|
6
|
+
const t = (k, v) => I18N.t(k, v)
|
|
7
|
+
I18N.init(window.ADMIN_STR)
|
|
5
8
|
// 插件内嵌(/remote/ 或 ?embedded=1)直接进管理面板, 不需要任何令牌门禁;
|
|
6
9
|
// 独立网关模式(/admin/)仍保留令牌输入。路径判断兼容无尾斜杠 /remote。
|
|
7
10
|
const pluginMode = location.pathname === '/remote'
|
|
@@ -31,10 +34,10 @@ function toast(text, kind = '') {
|
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
function fmtUptime(sec) {
|
|
34
|
-
if (sec < 60) return sec + '
|
|
35
|
-
if (sec < 3600) return Math.floor(sec / 60) + '
|
|
36
|
-
if (sec < 86400) return Math.floor(sec / 3600) + '
|
|
37
|
-
return Math.floor(sec / 86400) + '
|
|
37
|
+
if (sec < 60) return sec + t('unit.sec')
|
|
38
|
+
if (sec < 3600) return Math.floor(sec / 60) + t('unit.min')
|
|
39
|
+
if (sec < 86400) return Math.floor(sec / 3600) + t('unit.hour') + Math.floor(sec % 3600 / 60) + t('unit.minShort')
|
|
40
|
+
return Math.floor(sec / 86400) + t('unit.day') + Math.floor(sec % 86400 / 3600) + t('unit.hour')
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
function fmtTime(ts) {
|
|
@@ -54,10 +57,10 @@ async function loadState() {
|
|
|
54
57
|
render(st)
|
|
55
58
|
} catch (e) {
|
|
56
59
|
if (e.message === 'AUTH') {
|
|
57
|
-
toast('
|
|
60
|
+
toast(t('toast.tokenInvalid'), 'err')
|
|
58
61
|
logout()
|
|
59
62
|
} else {
|
|
60
|
-
$('conn-badge').textContent = '
|
|
63
|
+
$('conn-badge').textContent = t('toast.connFailed')
|
|
61
64
|
$('conn-badge').className = 'conn-badge off'
|
|
62
65
|
}
|
|
63
66
|
}
|
|
@@ -68,9 +71,10 @@ function render(st) {
|
|
|
68
71
|
const isPlugin = st.mode === 'plugin'
|
|
69
72
|
const isGateway = st.mode === 'gateway'
|
|
70
73
|
shownToken = st.token || token
|
|
71
|
-
$('conn-badge').textContent = isPlugin ? '
|
|
72
|
-
$('conn-badge').className = 'conn-badge on'
|
|
73
|
-
$('
|
|
74
|
+
$('conn-badge').textContent = t(isPlugin ? 'badge.embedded' : isGateway ? 'badge.gateway' : 'badge.connected')
|
|
75
|
+
$('conn-badge').className = 'conn-badge ' + (isPlugin || isGateway ? 'on' : 'off')
|
|
76
|
+
$('conn-badge').title = t(isGateway ? 'badge.gateway.title' : 'badge.gatewayDown')
|
|
77
|
+
$('token-full').textContent = shownToken || t(isPlugin ? 'token.pluginNoGateway' : 'token.unavailable')
|
|
74
78
|
// 主机端插件模式: 显示真实令牌(复制可用), 只隐藏退出按钮; 令牌门禁本身不存在
|
|
75
79
|
$('btn-copy').classList.toggle('hidden', !shownToken)
|
|
76
80
|
$('btn-logout').classList.toggle('hidden', pluginMode)
|
|
@@ -82,65 +86,70 @@ function render(st) {
|
|
|
82
86
|
gatewayRunning = isGateway
|
|
83
87
|
$('btn-gateway').classList.toggle('hidden', !pluginMode)
|
|
84
88
|
$('btn-gateway').textContent = gatewayBusy
|
|
85
|
-
? (gatewayRunning ? '
|
|
86
|
-
: (gatewayRunning ? '
|
|
89
|
+
? t(gatewayRunning ? 'stopping' : 'starting')
|
|
90
|
+
: t(gatewayRunning ? 'stopGateway' : 'startGateway')
|
|
87
91
|
$('btn-gateway').disabled = gatewayBusy
|
|
88
92
|
const upOk = st.upstream.reachable
|
|
89
|
-
const hostIPs = (st.lanIPs || []).join('
|
|
93
|
+
const hostIPs = (st.lanIPs || []).join(t('stat.ipSep')) || '127.0.0.1'
|
|
90
94
|
const latestHtml = st.latest?.newer
|
|
91
|
-
? `<div class="v"
|
|
92
|
-
: `<div class="v">v${st.version}</div><div class="k">${isPlugin ? '
|
|
95
|
+
? `<div class="v">${t('stat.updateAvailable', { version: st.latest.version })}</div><div class="k">${t('stat.currentV', { version: st.version })} · <a href="${st.latest.url || '#'}" target="_blank" rel="noopener" style="color:var(--orange)">${t('stat.download')}</a></div>`
|
|
96
|
+
: `<div class="v">v${st.version}</div><div class="k">${isPlugin ? t('stat.embedded') : st.latest?.error ? t('stat.updateCheck', { error: st.latest.error }) : st.latest?.version ? t('stat.latest') : t('stat.notChecked')}</div>`
|
|
93
97
|
$('stats').innerHTML = `
|
|
94
|
-
<div class="stat-card"><div class="v">v${st.version}</div><div class="k">${isPlugin ? '
|
|
98
|
+
<div class="stat-card"><div class="v">v${st.version}</div><div class="k">${t(isPlugin ? 'stat.pluginVersion' : 'stat.gatewayVersion')}</div></div>
|
|
95
99
|
<div class="stat-card ${st.latest?.newer ? 'warn' : 'ok'}">${latestHtml}</div>
|
|
96
|
-
<div class="stat-card ok"><div class="v" style="font-size:
|
|
97
|
-
<div class="stat-card ${upOk ? 'ok' : 'warn'}"><div class="v">${upOk ? '
|
|
98
|
-
<div class="stat-card"><div class="v">${st.onlineCount}/${st.deviceCount}</div><div class="k"
|
|
99
|
-
<div class="stat-card"><div class="v">${st.totalRequests}</div><div class="k"
|
|
100
|
-
<div class="stat-card"><div class="v">${st.authFailures}</div><div class="k"
|
|
101
|
-
<div class="stat-card"><div class="v">${fmtUptime(st.uptimeSec)}</div><div class="k"
|
|
100
|
+
<div class="stat-card ok"><div class="v" style="font-size:13px">${hostIPs}</div><div class="k">${t('stat.hostIP', { hostname: st.hostname })}${isPlugin ? t('stat.phoneGateway') : t('stat.phoneThis')}</div></div>
|
|
101
|
+
<div class="stat-card ${upOk ? 'ok' : 'warn'}"><div class="v">${t(upOk ? 'stat.reachable' : 'stat.unreachable')}</div><div class="k">${t('stat.dshUpstream', { url: st.upstream.url })}</div></div>
|
|
102
|
+
<div class="stat-card"><div class="v">${st.onlineCount}/${st.deviceCount}</div><div class="k">${t('stat.devicesOnline')}</div></div>
|
|
103
|
+
<div class="stat-card"><div class="v">${st.totalRequests}</div><div class="k">${t('stat.totalRequests')}</div></div>
|
|
104
|
+
<div class="stat-card"><div class="v">${st.authFailures}</div><div class="k">${t('stat.authFailures')}</div></div>
|
|
105
|
+
<div class="stat-card"><div class="v">${fmtUptime(st.uptimeSec)}</div><div class="k">${t('stat.uptime', { host: st.host, port: st.port })}</div></div>`
|
|
102
106
|
|
|
103
107
|
$('device-summary').textContent = isPlugin
|
|
104
|
-
? (st.gatewayInstalled ? '
|
|
105
|
-
:
|
|
108
|
+
? t(st.gatewayInstalled ? 'device.installedNotRunning' : 'device.noGatewayBinary')
|
|
109
|
+
: t('device.ipRefresh', { n: st.devices.length })
|
|
106
110
|
if (isPlugin && !st.devices.length) {
|
|
107
111
|
$('device-rows').innerHTML = ''
|
|
108
112
|
const rel = 'https://github.com/Blank-not-black/dsh-Remote/releases/latest/download/'
|
|
109
|
-
const apkBtn = `<a class="mini-btn" href="${rel}dsh-remote.apk" target="_blank" rel="noopener"
|
|
113
|
+
const apkBtn = `<a class="mini-btn" href="${rel}dsh-remote.apk" target="_blank" rel="noopener">${t('device.downloadApp')}</a>`
|
|
110
114
|
if (!st.gatewayInstalled) {
|
|
111
115
|
// 只有插件包真的没有内置网关程序时, 才引导下载网关
|
|
112
116
|
const isWin = /windows|win32/i.test(navigator.userAgent)
|
|
113
117
|
const gwAsset = isWin ? 'dsh-remote-win-x64.exe' : 'dsh-remote-linux-x64'
|
|
114
118
|
$('device-empty').innerHTML = `
|
|
115
|
-
<div
|
|
119
|
+
<div>${t('device.noBinaryGuide')}</div>
|
|
116
120
|
<div class="empty-actions">
|
|
117
|
-
<a class="mini-btn" href="${rel}${gwAsset}" target="_blank" rel="noopener"
|
|
121
|
+
<a class="mini-btn" href="${rel}${gwAsset}" target="_blank" rel="noopener">${t('device.downloadGateway', { os: isWin ? 'Windows x64' : 'Linux x64' })}</a>
|
|
118
122
|
${apkBtn}
|
|
119
123
|
</div>
|
|
120
|
-
<div class="muted" style="margin-top:10px"
|
|
124
|
+
<div class="muted" style="margin-top:10px">${t('device.afterRunGuide')}</div>`
|
|
121
125
|
} else {
|
|
122
126
|
$('device-empty').innerHTML = `
|
|
123
|
-
<div
|
|
127
|
+
<div>${t('device.installedGuide')}</div>
|
|
124
128
|
<div class="empty-actions">${apkBtn}</div>
|
|
125
|
-
<div class="muted" style="margin-top:10px"
|
|
129
|
+
<div class="muted" style="margin-top:10px">${t('device.afterStartGuide')}</div>`
|
|
126
130
|
}
|
|
127
131
|
$('device-empty').classList.remove('hidden')
|
|
128
132
|
} else {
|
|
129
133
|
// 网关模式: 清掉可能残留的引导文案, 设备为空时只显示中性提示
|
|
130
|
-
$('device-empty').textContent = '
|
|
134
|
+
$('device-empty').textContent = t('noDevices')
|
|
131
135
|
$('device-empty').classList.toggle('hidden', st.devices.length > 0)
|
|
132
|
-
$('device-rows').innerHTML = st.devices.map(d =>
|
|
136
|
+
$('device-rows').innerHTML = st.devices.map(d => {
|
|
137
|
+
const kindText = t(d.kind === 'app' ? 'device.kind.app' : d.kind === 'admin' ? 'device.kind.admin' : d.kind === 'web' ? 'device.kind.web' : 'device.kind.unknown')
|
|
138
|
+
const noteHtml = d.note ? `<b>${d.note.replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]))}</b>` : '<span class="muted">—</span>'
|
|
139
|
+
const ch = `${d.channels.mux ? 'mux' : ''}${d.channels.mux && d.channels.host ? ' · ' : ''}${d.channels.host ? 'host' : ''}${!d.channels.mux && !d.channels.host ? '—' : ''}`
|
|
140
|
+
return `
|
|
133
141
|
<tr>
|
|
134
|
-
<td><span class="dot ${d.online ? 'on' : 'off'}"></span>${d.online ? '
|
|
135
|
-
<td
|
|
136
|
-
<td><span class="badge ${d.kind}">${
|
|
137
|
-
<td class="mono">${d.ip}</td>
|
|
138
|
-
<td
|
|
139
|
-
<td>${d.requests}</td>
|
|
140
|
-
<td>${fmtTime(d.lastSeen)}</td>
|
|
141
|
-
<td class="ua" title="${d.ua.replace(/"/g, '"')}">${d.ua || '—'}</td>
|
|
142
|
-
<td>${d.online && d.kind !== 'admin' ? `<button class="mini-btn" data-kick="${d.ip}"
|
|
143
|
-
</tr>`
|
|
142
|
+
<td data-label="${t('th.status')}"><span class="dot ${d.online ? 'on' : 'off'}"></span>${t(d.online ? 'device.online' : 'device.offline')}</td>
|
|
143
|
+
<td data-label="${t('th.name')}">${noteHtml}<button class="mini-btn" data-note-ip="${d.ip}" data-note="${d.note.replace(/"/g, '"')}" style="margin-left:6px;padding:1px 7px">${t('device.note')}</button></td>
|
|
144
|
+
<td data-label="${t('th.type')}"><span class="badge ${d.kind}">${kindText}</span></td>
|
|
145
|
+
<td data-label="${t('th.ip')}" class="mono nowrap">${d.ip}</td>
|
|
146
|
+
<td data-label="${t('th.channels')}" class="mono nowrap">${ch}</td>
|
|
147
|
+
<td data-label="${t('th.requests')}">${d.requests}</td>
|
|
148
|
+
<td data-label="${t('th.lastSeen')}" class="nowrap">${fmtTime(d.lastSeen)}</td>
|
|
149
|
+
<td data-label="${t('th.ua')}" class="ua" title="${d.ua.replace(/"/g, '"')}">${d.ua || '—'}</td>
|
|
150
|
+
<td class="act">${d.online && d.kind !== 'admin' ? `<button class="mini-btn" data-kick="${d.ip}">${t('device.kick')}</button>` : ''}</td>
|
|
151
|
+
</tr>`
|
|
152
|
+
}).join('')
|
|
144
153
|
}
|
|
145
154
|
document.querySelectorAll('[data-kick]').forEach(btn =>
|
|
146
155
|
btn.addEventListener('click', () => kick(btn.dataset.kick)))
|
|
@@ -166,21 +175,21 @@ function renderQr(st) {
|
|
|
166
175
|
return
|
|
167
176
|
}
|
|
168
177
|
try {
|
|
169
|
-
const
|
|
178
|
+
const pt = pairTarget(st)
|
|
170
179
|
const qr = window.qrcode(0, 'M')
|
|
171
|
-
qr.addData(
|
|
180
|
+
qr.addData(pt.url)
|
|
172
181
|
qr.make()
|
|
173
182
|
$('pair-qr').innerHTML = qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true })
|
|
174
|
-
$('pair-hint').textContent =
|
|
183
|
+
$('pair-hint').textContent = t('pair.hint', { base: pt.base })
|
|
175
184
|
box.classList.remove('hidden')
|
|
176
185
|
} catch (e) {
|
|
177
|
-
$('pair-qr').textContent = '
|
|
186
|
+
$('pair-qr').textContent = t('pair.failed')
|
|
178
187
|
box.classList.remove('hidden')
|
|
179
188
|
}
|
|
180
189
|
}
|
|
181
190
|
|
|
182
191
|
async function setNote(ip, current) {
|
|
183
|
-
const name = prompt('
|
|
192
|
+
const name = prompt(t('prompt.note', { ip }), current || '')
|
|
184
193
|
if (name === null) return
|
|
185
194
|
const res = await fetch(`${API}/note`, {
|
|
186
195
|
method: 'POST',
|
|
@@ -188,33 +197,33 @@ async function setNote(ip, current) {
|
|
|
188
197
|
body: JSON.stringify({ ip, name })
|
|
189
198
|
})
|
|
190
199
|
if (res.ok) {
|
|
191
|
-
toast('
|
|
200
|
+
toast(t('toast.noteSaved'), 'ok')
|
|
192
201
|
setTimeout(loadState, 300)
|
|
193
202
|
} else {
|
|
194
|
-
toast('
|
|
203
|
+
toast(t('toast.noteFailed'), 'err')
|
|
195
204
|
}
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
async function kick(ip) {
|
|
199
|
-
if (!confirm('
|
|
208
|
+
if (!confirm(t('confirm.kick'))) return
|
|
200
209
|
const res = await fetch(`${API}/kick`, {
|
|
201
210
|
method: 'POST',
|
|
202
211
|
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
|
|
203
212
|
body: JSON.stringify({ ip })
|
|
204
213
|
})
|
|
205
214
|
if (res.ok) {
|
|
206
|
-
toast('
|
|
215
|
+
toast(t('toast.kicked', { ip }), 'ok')
|
|
207
216
|
setTimeout(loadState, 400)
|
|
208
217
|
} else {
|
|
209
|
-
toast('
|
|
218
|
+
toast(t('toast.opFailed'), 'err')
|
|
210
219
|
}
|
|
211
220
|
}
|
|
212
221
|
|
|
213
222
|
function enter() {
|
|
214
|
-
const
|
|
215
|
-
if (!
|
|
216
|
-
token =
|
|
217
|
-
store.set('dshAdminToken',
|
|
223
|
+
const val = $('token-input').value.trim()
|
|
224
|
+
if (!val) return
|
|
225
|
+
token = val
|
|
226
|
+
store.set('dshAdminToken', val)
|
|
218
227
|
history.replaceState(null, '', location.pathname)
|
|
219
228
|
showMain()
|
|
220
229
|
loadState()
|
|
@@ -232,7 +241,7 @@ function logout() {
|
|
|
232
241
|
clearInterval(timer)
|
|
233
242
|
$('main-view').classList.add('hidden')
|
|
234
243
|
$('login-view').classList.remove('hidden')
|
|
235
|
-
$('conn-badge').textContent = '
|
|
244
|
+
$('conn-badge').textContent = t('unauth')
|
|
236
245
|
$('conn-badge').className = 'conn-badge off'
|
|
237
246
|
$('token-input').value = ''
|
|
238
247
|
}
|
|
@@ -247,9 +256,9 @@ $('btn-close-drawer').addEventListener('click', () => {
|
|
|
247
256
|
$('btn-copy').addEventListener('click', async () => {
|
|
248
257
|
try {
|
|
249
258
|
await navigator.clipboard.writeText(shownToken || token)
|
|
250
|
-
toast('
|
|
259
|
+
toast(t('toast.tokenCopied'), 'ok')
|
|
251
260
|
} catch {
|
|
252
|
-
toast('
|
|
261
|
+
toast(t('toast.copyFailed'), 'err')
|
|
253
262
|
}
|
|
254
263
|
})
|
|
255
264
|
|
|
@@ -258,8 +267,22 @@ $('btn-qr').addEventListener('click', () => {
|
|
|
258
267
|
renderQr(lastState || { mode: '', token: shownToken })
|
|
259
268
|
})
|
|
260
269
|
|
|
270
|
+
/* 右上角「网关」徽章: 新标签页打开独立网关管理面板(带 token 免登录) */
|
|
271
|
+
$('conn-badge').addEventListener('click', () => {
|
|
272
|
+
const st = lastState
|
|
273
|
+
if (!st || st.mode !== 'gateway') { toast(t('toast.gatewayDown'), 'err'); return }
|
|
274
|
+
const host = location.hostname || '127.0.0.1'
|
|
275
|
+
const port = st.port || 8787
|
|
276
|
+
const url = `http://${host}:${port}/admin?token=${encodeURIComponent(shownToken || token)}`
|
|
277
|
+
try {
|
|
278
|
+
window.open(url, '_blank', 'noopener')
|
|
279
|
+
} catch {
|
|
280
|
+
toast(t('toast.popupBlocked'), 'err')
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
261
284
|
$('btn-rotate').addEventListener('click', async () => {
|
|
262
|
-
if (!confirm('
|
|
285
|
+
if (!confirm(t('confirm.rotate'))) return
|
|
263
286
|
try {
|
|
264
287
|
const res = await fetch(`${API}/token/rotate`, {
|
|
265
288
|
method: 'POST',
|
|
@@ -269,13 +292,13 @@ $('btn-rotate').addEventListener('click', async () => {
|
|
|
269
292
|
if (out.ok && out.token) {
|
|
270
293
|
token = out.token
|
|
271
294
|
store.set('dshAdminToken', out.token)
|
|
272
|
-
toast('
|
|
295
|
+
toast(t('toast.rotated'), 'ok')
|
|
273
296
|
setTimeout(loadState, 300)
|
|
274
297
|
} else {
|
|
275
|
-
toast(out.detail || out.error || '
|
|
298
|
+
toast(out.detail || out.error || t('toast.rotateFailed'), 'err')
|
|
276
299
|
}
|
|
277
300
|
} catch (e) {
|
|
278
|
-
toast('
|
|
301
|
+
toast(t('toast.rotateFailedMsg', { msg: e.message || e }), 'err')
|
|
279
302
|
}
|
|
280
303
|
})
|
|
281
304
|
|
|
@@ -284,7 +307,7 @@ $('btn-gateway').addEventListener('click', async () => {
|
|
|
284
307
|
gatewayBusy = true
|
|
285
308
|
const btn = $('btn-gateway')
|
|
286
309
|
btn.disabled = true
|
|
287
|
-
btn.textContent = gatewayRunning ? '
|
|
310
|
+
btn.textContent = t(gatewayRunning ? 'stopping' : 'starting')
|
|
288
311
|
try {
|
|
289
312
|
const res = await fetch(`${API}/gateway`, {
|
|
290
313
|
method: 'POST',
|
|
@@ -293,17 +316,29 @@ $('btn-gateway').addEventListener('click', async () => {
|
|
|
293
316
|
})
|
|
294
317
|
const out = await res.json().catch(() => ({}))
|
|
295
318
|
if (out.ok) {
|
|
296
|
-
toast(out.started ? '
|
|
319
|
+
toast(out.started ? t('toast.gatewayStarted') : out.running ? t('toast.gatewayAlready') : gatewayRunning ? t('toast.gatewayStopped') : (out.pending ? t('toast.gatewayPending') : t('toast.done')), 'ok')
|
|
297
320
|
} else {
|
|
298
|
-
toast(out.error || '
|
|
321
|
+
toast(out.error || t('toast.opFailed'), 'err')
|
|
299
322
|
}
|
|
300
323
|
} catch (e) {
|
|
301
|
-
toast('
|
|
324
|
+
toast(t('toast.opFailedMsg', { msg: e.message || e }), 'err')
|
|
302
325
|
}
|
|
303
326
|
gatewayBusy = false
|
|
304
327
|
setTimeout(loadState, 700)
|
|
305
328
|
})
|
|
306
329
|
|
|
330
|
+
function renderLangBtn() {
|
|
331
|
+
const btn = $('btn-lang')
|
|
332
|
+
if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
|
|
333
|
+
document.title = t('login.title')
|
|
334
|
+
}
|
|
335
|
+
$('btn-lang').addEventListener('click', () => {
|
|
336
|
+
I18N.setLang(I18N.lang === 'zh' ? 'en' : 'zh')
|
|
337
|
+
renderLangBtn()
|
|
338
|
+
if (lastState) render(lastState)
|
|
339
|
+
else if (!token && !pluginMode) $('conn-badge').textContent = t('unauth')
|
|
340
|
+
})
|
|
341
|
+
|
|
307
342
|
function start(showLogin) {
|
|
308
343
|
if (!showLogin) {
|
|
309
344
|
$('login-view').classList.add('hidden')
|
|
@@ -327,3 +362,4 @@ if (pluginMode) {
|
|
|
327
362
|
$('main-view').classList.add('hidden')
|
|
328
363
|
$('login-view').classList.remove('hidden')
|
|
329
364
|
}
|
|
365
|
+
renderLangBtn()
|