@wendongfly/zihi 1.0.0

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.
Files changed (145) hide show
  1. package/bin/daemon.js +23 -0
  2. package/bin/zihi.js +603 -0
  3. package/dist/admin.html +297 -0
  4. package/dist/attach.js +2 -0
  5. package/dist/chat.html +2254 -0
  6. package/dist/client-dist/socket.io.esm.min.js +7 -0
  7. package/dist/client-dist/socket.io.js +4955 -0
  8. package/dist/client-dist/socket.io.min.js +7 -0
  9. package/dist/client-dist/socket.io.msgpack.min.js +7 -0
  10. package/dist/files.html +722 -0
  11. package/dist/icon.png +0 -0
  12. package/dist/icon.svg +4 -0
  13. package/dist/index.html +976 -0
  14. package/dist/index.js +485 -0
  15. package/dist/lib/ansi_up.js +431 -0
  16. package/dist/lib/xterm/LICENSE +21 -0
  17. package/dist/lib/xterm/README.md +230 -0
  18. package/dist/lib/xterm/css/xterm.css +209 -0
  19. package/dist/lib/xterm/lib/xterm.js +2 -0
  20. package/dist/lib/xterm/lib/xterm.js.map +1 -0
  21. package/dist/lib/xterm/package.json +100 -0
  22. package/dist/lib/xterm/src/browser/AccessibilityManager.ts +300 -0
  23. package/dist/lib/xterm/src/browser/Clipboard.ts +93 -0
  24. package/dist/lib/xterm/src/browser/ColorContrastCache.ts +34 -0
  25. package/dist/lib/xterm/src/browser/Lifecycle.ts +33 -0
  26. package/dist/lib/xterm/src/browser/Linkifier2.ts +416 -0
  27. package/dist/lib/xterm/src/browser/LocalizableStrings.ts +12 -0
  28. package/dist/lib/xterm/src/browser/OscLinkProvider.ts +128 -0
  29. package/dist/lib/xterm/src/browser/RenderDebouncer.ts +83 -0
  30. package/dist/lib/xterm/src/browser/ScreenDprMonitor.ts +72 -0
  31. package/dist/lib/xterm/src/browser/Terminal.ts +1305 -0
  32. package/dist/lib/xterm/src/browser/TimeBasedDebouncer.ts +86 -0
  33. package/dist/lib/xterm/src/browser/Types.d.ts +181 -0
  34. package/dist/lib/xterm/src/browser/Viewport.ts +401 -0
  35. package/dist/lib/xterm/src/browser/decorations/BufferDecorationRenderer.ts +134 -0
  36. package/dist/lib/xterm/src/browser/decorations/ColorZoneStore.ts +117 -0
  37. package/dist/lib/xterm/src/browser/decorations/OverviewRulerRenderer.ts +219 -0
  38. package/dist/lib/xterm/src/browser/input/CompositionHelper.ts +246 -0
  39. package/dist/lib/xterm/src/browser/input/Mouse.ts +54 -0
  40. package/dist/lib/xterm/src/browser/input/MoveToCell.ts +249 -0
  41. package/dist/lib/xterm/src/browser/public/Terminal.ts +260 -0
  42. package/dist/lib/xterm/src/browser/renderer/dom/DomRenderer.ts +506 -0
  43. package/dist/lib/xterm/src/browser/renderer/dom/DomRendererRowFactory.ts +522 -0
  44. package/dist/lib/xterm/src/browser/renderer/dom/WidthCache.ts +157 -0
  45. package/dist/lib/xterm/src/browser/renderer/shared/CellColorResolver.ts +137 -0
  46. package/dist/lib/xterm/src/browser/renderer/shared/CharAtlasCache.ts +96 -0
  47. package/dist/lib/xterm/src/browser/renderer/shared/CharAtlasUtils.ts +75 -0
  48. package/dist/lib/xterm/src/browser/renderer/shared/Constants.ts +14 -0
  49. package/dist/lib/xterm/src/browser/renderer/shared/CursorBlinkStateManager.ts +146 -0
  50. package/dist/lib/xterm/src/browser/renderer/shared/CustomGlyphs.ts +687 -0
  51. package/dist/lib/xterm/src/browser/renderer/shared/DevicePixelObserver.ts +41 -0
  52. package/dist/lib/xterm/src/browser/renderer/shared/README.md +1 -0
  53. package/dist/lib/xterm/src/browser/renderer/shared/RendererUtils.ts +58 -0
  54. package/dist/lib/xterm/src/browser/renderer/shared/SelectionRenderModel.ts +91 -0
  55. package/dist/lib/xterm/src/browser/renderer/shared/TextureAtlas.ts +1082 -0
  56. package/dist/lib/xterm/src/browser/renderer/shared/Types.d.ts +173 -0
  57. package/dist/lib/xterm/src/browser/selection/SelectionModel.ts +144 -0
  58. package/dist/lib/xterm/src/browser/selection/Types.d.ts +15 -0
  59. package/dist/lib/xterm/src/browser/services/CharSizeService.ts +102 -0
  60. package/dist/lib/xterm/src/browser/services/CharacterJoinerService.ts +339 -0
  61. package/dist/lib/xterm/src/browser/services/CoreBrowserService.ts +33 -0
  62. package/dist/lib/xterm/src/browser/services/MouseService.ts +46 -0
  63. package/dist/lib/xterm/src/browser/services/RenderService.ts +284 -0
  64. package/dist/lib/xterm/src/browser/services/SelectionService.ts +1029 -0
  65. package/dist/lib/xterm/src/browser/services/Services.ts +138 -0
  66. package/dist/lib/xterm/src/browser/services/ThemeService.ts +237 -0
  67. package/dist/lib/xterm/src/common/CircularList.ts +241 -0
  68. package/dist/lib/xterm/src/common/Clone.ts +23 -0
  69. package/dist/lib/xterm/src/common/Color.ts +356 -0
  70. package/dist/lib/xterm/src/common/CoreTerminal.ts +284 -0
  71. package/dist/lib/xterm/src/common/EventEmitter.ts +73 -0
  72. package/dist/lib/xterm/src/common/InputHandler.ts +3443 -0
  73. package/dist/lib/xterm/src/common/Lifecycle.ts +108 -0
  74. package/dist/lib/xterm/src/common/MultiKeyMap.ts +42 -0
  75. package/dist/lib/xterm/src/common/Platform.ts +43 -0
  76. package/dist/lib/xterm/src/common/SortedList.ts +118 -0
  77. package/dist/lib/xterm/src/common/TaskQueue.ts +166 -0
  78. package/dist/lib/xterm/src/common/TypedArrayUtils.ts +17 -0
  79. package/dist/lib/xterm/src/common/Types.d.ts +553 -0
  80. package/dist/lib/xterm/src/common/WindowsMode.ts +27 -0
  81. package/dist/lib/xterm/src/common/buffer/AttributeData.ts +196 -0
  82. package/dist/lib/xterm/src/common/buffer/Buffer.ts +654 -0
  83. package/dist/lib/xterm/src/common/buffer/BufferLine.ts +520 -0
  84. package/dist/lib/xterm/src/common/buffer/BufferRange.ts +13 -0
  85. package/dist/lib/xterm/src/common/buffer/BufferReflow.ts +223 -0
  86. package/dist/lib/xterm/src/common/buffer/BufferSet.ts +134 -0
  87. package/dist/lib/xterm/src/common/buffer/CellData.ts +94 -0
  88. package/dist/lib/xterm/src/common/buffer/Constants.ts +149 -0
  89. package/dist/lib/xterm/src/common/buffer/Marker.ts +43 -0
  90. package/dist/lib/xterm/src/common/buffer/Types.d.ts +52 -0
  91. package/dist/lib/xterm/src/common/data/Charsets.ts +256 -0
  92. package/dist/lib/xterm/src/common/data/EscapeSequences.ts +153 -0
  93. package/dist/lib/xterm/src/common/input/Keyboard.ts +398 -0
  94. package/dist/lib/xterm/src/common/input/TextDecoder.ts +346 -0
  95. package/dist/lib/xterm/src/common/input/UnicodeV6.ts +132 -0
  96. package/dist/lib/xterm/src/common/input/WriteBuffer.ts +246 -0
  97. package/dist/lib/xterm/src/common/input/XParseColor.ts +80 -0
  98. package/dist/lib/xterm/src/common/parser/Constants.ts +58 -0
  99. package/dist/lib/xterm/src/common/parser/DcsParser.ts +192 -0
  100. package/dist/lib/xterm/src/common/parser/EscapeSequenceParser.ts +792 -0
  101. package/dist/lib/xterm/src/common/parser/OscParser.ts +238 -0
  102. package/dist/lib/xterm/src/common/parser/Params.ts +229 -0
  103. package/dist/lib/xterm/src/common/parser/Types.d.ts +274 -0
  104. package/dist/lib/xterm/src/common/public/AddonManager.ts +53 -0
  105. package/dist/lib/xterm/src/common/public/BufferApiView.ts +35 -0
  106. package/dist/lib/xterm/src/common/public/BufferLineApiView.ts +29 -0
  107. package/dist/lib/xterm/src/common/public/BufferNamespaceApi.ts +36 -0
  108. package/dist/lib/xterm/src/common/public/ParserApi.ts +37 -0
  109. package/dist/lib/xterm/src/common/public/UnicodeApi.ts +27 -0
  110. package/dist/lib/xterm/src/common/services/BufferService.ts +151 -0
  111. package/dist/lib/xterm/src/common/services/CharsetService.ts +34 -0
  112. package/dist/lib/xterm/src/common/services/CoreMouseService.ts +318 -0
  113. package/dist/lib/xterm/src/common/services/CoreService.ts +87 -0
  114. package/dist/lib/xterm/src/common/services/DecorationService.ts +140 -0
  115. package/dist/lib/xterm/src/common/services/InstantiationService.ts +85 -0
  116. package/dist/lib/xterm/src/common/services/LogService.ts +124 -0
  117. package/dist/lib/xterm/src/common/services/OptionsService.ts +201 -0
  118. package/dist/lib/xterm/src/common/services/OscLinkService.ts +115 -0
  119. package/dist/lib/xterm/src/common/services/ServiceRegistry.ts +49 -0
  120. package/dist/lib/xterm/src/common/services/Services.ts +342 -0
  121. package/dist/lib/xterm/src/common/services/UnicodeService.ts +86 -0
  122. package/dist/lib/xterm/src/headless/Terminal.ts +136 -0
  123. package/dist/lib/xterm/src/headless/public/Terminal.ts +195 -0
  124. package/dist/lib/xterm/typings/xterm.d.ts +1844 -0
  125. package/dist/lib/xterm-fit/LICENSE +19 -0
  126. package/dist/lib/xterm-fit/README.md +24 -0
  127. package/dist/lib/xterm-fit/lib/xterm-addon-fit.js +2 -0
  128. package/dist/lib/xterm-fit/lib/xterm-addon-fit.js.map +1 -0
  129. package/dist/lib/xterm-fit/package.json +26 -0
  130. package/dist/lib/xterm-fit/src/FitAddon.ts +89 -0
  131. package/dist/lib/xterm-fit/typings/xterm-addon-fit.d.ts +55 -0
  132. package/dist/lib/xterm-links/LICENSE +19 -0
  133. package/dist/lib/xterm-links/README.md +21 -0
  134. package/dist/lib/xterm-links/lib/xterm-addon-web-links.js +2 -0
  135. package/dist/lib/xterm-links/lib/xterm-addon-web-links.js.map +1 -0
  136. package/dist/lib/xterm-links/package.json +26 -0
  137. package/dist/lib/xterm-links/src/WebLinkProvider.ts +198 -0
  138. package/dist/lib/xterm-links/src/WebLinksAddon.ts +57 -0
  139. package/dist/lib/xterm-links/typings/xterm-addon-web-links.d.ts +53 -0
  140. package/dist/login.html +163 -0
  141. package/dist/manifest.json +12 -0
  142. package/dist/package.json +1 -0
  143. package/dist/sw.js +127 -0
  144. package/dist/sync.html +816 -0
  145. package/package.json +47 -0
@@ -0,0 +1,722 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
6
+ <meta name="theme-color" content="#0d1117">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <meta name="apple-mobile-web-app-title" content="weHi Files">
10
+ <title>weHi - Files</title>
11
+ <style>
12
+ :root {
13
+ --bg: #0d1117;
14
+ --surface: #161b22;
15
+ --border: #21262d;
16
+ --accent: #f97316;
17
+ --accent2: #fb923c;
18
+ --text: #e6edf3;
19
+ --muted: #8b949e;
20
+ --green: #3fb950;
21
+ --red: #f85149;
22
+ --yellow: #d29922;
23
+ }
24
+ * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
25
+ html, body {
26
+ height: 100%;
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
30
+ overscroll-behavior: none;
31
+ }
32
+
33
+ #app {
34
+ display: flex;
35
+ flex-direction: column;
36
+ height: 100%;
37
+ max-width: 640px;
38
+ margin: 0 auto;
39
+ }
40
+
41
+ /* ── Header ── */
42
+ #header {
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: space-between;
46
+ padding: 0 1rem;
47
+ padding-top: max(1rem, env(safe-area-inset-top));
48
+ padding-bottom: 0.75rem;
49
+ background: var(--bg);
50
+ position: sticky; top: 0; z-index: 10;
51
+ border-bottom: 1px solid var(--border);
52
+ }
53
+ .header-left { display: flex; align-items: center; gap: 0.6rem; }
54
+ .back-btn {
55
+ background: none; border: 1px solid var(--border); color: var(--muted);
56
+ font-size: 0.85rem; padding: 0.3rem 0.6rem; border-radius: 6px; cursor: pointer;
57
+ text-decoration: none; display: flex; align-items: center; gap: 0.3rem;
58
+ }
59
+ .back-btn:active { border-color: var(--accent); color: var(--accent); }
60
+ .logo { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.5px; }
61
+ .logo span { color: var(--accent); }
62
+ .header-right { display: flex; align-items: center; gap: 0.6rem; }
63
+ .hdr-btn {
64
+ background: none; border: 1px solid var(--border); color: var(--muted);
65
+ font-size: 0.75rem; padding: 0.3rem 0.6rem; border-radius: 6px; cursor: pointer;
66
+ }
67
+ .hdr-btn:active { border-color: var(--accent); color: var(--accent); }
68
+
69
+ /* ── Breadcrumb ── */
70
+ #breadcrumb {
71
+ display: flex; align-items: center; gap: 0.3rem;
72
+ padding: 0.6rem 1rem; font-size: 0.8rem; color: var(--muted);
73
+ overflow-x: auto; white-space: nowrap; scrollbar-width: none;
74
+ border-bottom: 1px solid var(--border); flex-shrink: 0;
75
+ }
76
+ #breadcrumb::-webkit-scrollbar { display: none; }
77
+ .crumb { cursor: pointer; color: var(--accent); flex-shrink: 0; }
78
+ .crumb:hover { text-decoration: underline; }
79
+ .crumb-sep { color: var(--muted); flex-shrink: 0; }
80
+ .crumb-current { color: var(--text); flex-shrink: 0; }
81
+
82
+ /* ── Toolbar ── */
83
+ #toolbar {
84
+ display: flex; align-items: center; gap: 0.5rem;
85
+ padding: 0.5rem 1rem; border-bottom: 1px solid var(--border); flex-shrink: 0;
86
+ }
87
+ .tool-btn {
88
+ background: var(--surface); border: 1px solid var(--border); color: var(--text);
89
+ font-size: 0.8rem; padding: 0.4rem 0.75rem; border-radius: 8px; cursor: pointer;
90
+ display: flex; align-items: center; gap: 0.3rem; white-space: nowrap;
91
+ }
92
+ .tool-btn:active { border-color: var(--accent); color: var(--accent); }
93
+ .tool-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(249,115,22,0.1); }
94
+ #file-count { font-size: 0.75rem; color: var(--muted); margin-left: auto; }
95
+
96
+ /* ── File list ── */
97
+ #file-list {
98
+ flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch;
99
+ padding: 0.5rem 0.75rem 6rem;
100
+ }
101
+ .file-item {
102
+ display: flex; align-items: center; gap: 0.75rem;
103
+ padding: 0.65rem 0.75rem; margin-bottom: 0.35rem;
104
+ background: var(--surface); border-radius: 10px;
105
+ border: 1px solid var(--border); cursor: pointer;
106
+ transition: border-color 0.15s;
107
+ }
108
+ .file-item:active { border-color: var(--accent); }
109
+ .file-icon { font-size: 1.4rem; flex-shrink: 0; width: 2rem; text-align: center; }
110
+ .file-info { flex: 1; min-width: 0; }
111
+ .file-name {
112
+ font-size: 0.9rem; font-weight: 500; white-space: nowrap;
113
+ overflow: hidden; text-overflow: ellipsis;
114
+ }
115
+ .file-meta { font-size: 0.75rem; color: var(--muted); margin-top: 0.15rem; }
116
+ .file-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
117
+ .act-btn {
118
+ background: none; border: 1px solid var(--border); color: var(--muted);
119
+ width: 2rem; height: 2rem; border-radius: 6px; cursor: pointer;
120
+ display: flex; align-items: center; justify-content: center; font-size: 0.9rem;
121
+ }
122
+ .act-btn:active { border-color: var(--accent); color: var(--accent); }
123
+
124
+ /* ── Empty state ── */
125
+ .empty-state {
126
+ display: flex; flex-direction: column; align-items: center;
127
+ justify-content: center; gap: 0.75rem; height: 50%; color: var(--muted);
128
+ }
129
+ .empty-state svg { opacity: 0.3; }
130
+
131
+ /* ── Upload progress ── */
132
+ #upload-bar {
133
+ display: none; padding: 0.5rem 1rem; border-bottom: 1px solid var(--border); flex-shrink: 0;
134
+ }
135
+ #upload-bar.show { display: block; }
136
+ .progress-track {
137
+ width: 100%; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden;
138
+ }
139
+ .progress-fill {
140
+ height: 100%; background: var(--accent); border-radius: 3px;
141
+ transition: width 0.2s; width: 0%;
142
+ }
143
+ #upload-text { font-size: 0.75rem; color: var(--muted); margin-top: 0.3rem; }
144
+
145
+ /* ── Preview overlay ── */
146
+ #preview-overlay {
147
+ display: none; position: fixed; inset: 0; z-index: 100;
148
+ background: rgba(0,0,0,0.92); flex-direction: column;
149
+ }
150
+ #preview-overlay.show { display: flex; }
151
+ #preview-header {
152
+ display: flex; align-items: center; justify-content: space-between;
153
+ padding: 0.75rem 1rem; flex-shrink: 0;
154
+ padding-top: max(0.75rem, env(safe-area-inset-top));
155
+ }
156
+ #preview-name {
157
+ font-size: 0.9rem; font-weight: 500; white-space: nowrap;
158
+ overflow: hidden; text-overflow: ellipsis; flex: 1; margin: 0 0.75rem;
159
+ }
160
+ .pv-btn {
161
+ background: rgba(255,255,255,0.1); border: none; color: var(--text);
162
+ padding: 0.4rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem;
163
+ white-space: nowrap;
164
+ }
165
+ .pv-btn:active { background: rgba(255,255,255,0.2); }
166
+ #preview-body {
167
+ flex: 1; overflow: auto; display: flex; align-items: center;
168
+ justify-content: center; padding: 1rem;
169
+ }
170
+ #preview-body img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; }
171
+ #preview-body video, #preview-body audio { max-width: 100%; }
172
+ #preview-body pre {
173
+ width: 100%; max-height: 100%; overflow: auto; padding: 1rem;
174
+ background: var(--surface); border-radius: 8px; font-size: 0.8rem;
175
+ font-family: 'SF Mono', Consolas, monospace; white-space: pre-wrap;
176
+ word-break: break-all; line-height: 1.5; align-self: flex-start;
177
+ }
178
+ #preview-body iframe { width: 100%; height: 100%; border: none; border-radius: 4px; }
179
+
180
+ /* ── FAB ── */
181
+ #fab {
182
+ position: fixed; bottom: max(1.5rem, env(safe-area-inset-bottom, 0.5rem));
183
+ right: calc(50% - 300px); /* align to app max-width */
184
+ width: 52px; height: 52px; border-radius: 50%;
185
+ background: var(--accent); color: #fff; font-size: 1.6rem;
186
+ border: none; cursor: pointer; box-shadow: 0 4px 16px rgba(249,115,22,0.4);
187
+ display: flex; align-items: center; justify-content: center;
188
+ z-index: 20;
189
+ }
190
+ @media (max-width: 640px) { #fab { right: 1.5rem; } }
191
+ #fab:active { transform: scale(0.92); }
192
+
193
+ /* ── Upload sheet ── */
194
+ #upload-sheet-backdrop {
195
+ display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 30;
196
+ }
197
+ #upload-sheet-backdrop.show { display: block; }
198
+ #upload-sheet {
199
+ position: fixed; bottom: 0; left: 50%; transform: translateX(-50%) translateY(100%);
200
+ width: 100%; max-width: 640px; z-index: 31;
201
+ background: var(--surface); border-radius: 16px 16px 0 0;
202
+ padding: 1rem 1.25rem calc(1.25rem + env(safe-area-inset-bottom));
203
+ transition: transform 0.3s ease;
204
+ }
205
+ #upload-sheet.show { transform: translateX(-50%) translateY(0); }
206
+ .sheet-handle {
207
+ width: 36px; height: 4px; background: var(--border);
208
+ border-radius: 2px; margin: 0 auto 1rem;
209
+ }
210
+ .sheet-title { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; }
211
+ .upload-zone {
212
+ border: 2px dashed var(--border); border-radius: 12px;
213
+ padding: 2rem; text-align: center; color: var(--muted);
214
+ cursor: pointer; transition: border-color 0.2s;
215
+ }
216
+ .upload-zone:active, .upload-zone.dragover { border-color: var(--accent); color: var(--accent); }
217
+ .upload-zone p { font-size: 0.9rem; margin-top: 0.5rem; }
218
+ .upload-zone .sub { font-size: 0.75rem; margin-top: 0.3rem; }
219
+
220
+ /* ── Mkdir dialog ── */
221
+ #mkdir-dialog {
222
+ display: none; position: fixed; inset: 0; z-index: 40;
223
+ background: rgba(0,0,0,0.6); align-items: center; justify-content: center;
224
+ }
225
+ #mkdir-dialog.show { display: flex; }
226
+ .dialog-box {
227
+ background: var(--surface); border-radius: 12px; padding: 1.25rem;
228
+ width: 90%; max-width: 360px; border: 1px solid var(--border);
229
+ }
230
+ .dialog-box h3 { font-size: 1rem; margin-bottom: 0.75rem; }
231
+ .dialog-box input {
232
+ width: 100%; padding: 0.6rem 0.75rem; background: var(--bg);
233
+ border: 1px solid var(--border); border-radius: 8px; color: var(--text);
234
+ font-size: 0.9rem; outline: none;
235
+ }
236
+ .dialog-box input:focus { border-color: var(--accent); }
237
+ .dialog-btns { display: flex; gap: 0.5rem; margin-top: 0.75rem; justify-content: flex-end; }
238
+ .dialog-btns button {
239
+ padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.85rem;
240
+ cursor: pointer; border: none;
241
+ }
242
+ .btn-cancel { background: var(--border); color: var(--text); }
243
+ .btn-confirm { background: var(--accent); color: #fff; font-weight: 600; }
244
+
245
+ /* loading spinner */
246
+ .spinner { display: none; text-align: center; padding: 2rem; color: var(--muted); }
247
+ .spinner.show { display: block; }
248
+ </style>
249
+ <script src="/socket.io/socket.io.js"></script>
250
+ </head>
251
+ <body>
252
+ <div id="app">
253
+ <div id="header">
254
+ <div class="header-left">
255
+ <a id="back-link" href="/" class="back-btn">&larr;</a>
256
+ <div class="logo">we<span>Hi</span> Files</div>
257
+ </div>
258
+ <div class="header-right">
259
+ <span id="user-name" style="font-size:0.8rem;color:var(--muted)"></span>
260
+ <a id="sync-btn" class="hdr-btn" style="display:none;text-decoration:none">同步</a>
261
+ <button class="hdr-btn" id="hidden-toggle" onclick="toggleHidden()">.*</button>
262
+ <button class="hdr-btn" onclick="doLogout()">退出</button>
263
+ </div>
264
+ </div>
265
+
266
+ <div id="breadcrumb"></div>
267
+
268
+ <div id="toolbar">
269
+ <button class="tool-btn" onclick="openUploadSheet()">
270
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7-7 7 7"/></svg>
271
+ 上传
272
+ </button>
273
+ <button class="tool-btn" onclick="openMkdir()">
274
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
275
+ 新建文件夹
276
+ </button>
277
+ <span id="file-count"></span>
278
+ </div>
279
+
280
+ <div id="upload-bar">
281
+ <div class="progress-track"><div class="progress-fill" id="progress-fill"></div></div>
282
+ <div id="upload-text"></div>
283
+ </div>
284
+
285
+ <div id="file-list">
286
+ <div class="spinner" id="loading">加载中...</div>
287
+ </div>
288
+ </div>
289
+
290
+ <!-- FAB (upload shortcut) -->
291
+ <button id="fab" onclick="openUploadSheet()">
292
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12l7-7 7 7"/></svg>
293
+ </button>
294
+
295
+ <!-- Upload sheet -->
296
+ <div id="upload-sheet-backdrop" class="" onclick="closeUploadSheet()"></div>
297
+ <div id="upload-sheet" class="">
298
+ <div class="sheet-handle"></div>
299
+ <div class="sheet-title">上传文件</div>
300
+ <div class="upload-zone" id="upload-zone" onclick="document.getElementById('file-input').click()">
301
+ <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
302
+ <path d="M12 5v14M5 12l7-7 7 7"/>
303
+ </svg>
304
+ <p>点击选择文件 或 拖拽文件到此处</p>
305
+ <p class="sub">支持所有文件类型,单个文件最大 500MB</p>
306
+ </div>
307
+ <input type="file" id="file-input" multiple style="display:none" onchange="handleFiles(this.files)">
308
+ </div>
309
+
310
+ <!-- Mkdir dialog -->
311
+ <div id="mkdir-dialog">
312
+ <div class="dialog-box">
313
+ <h3>新建文件夹</h3>
314
+ <input id="mkdir-input" placeholder="文件夹名称" autocomplete="off" spellcheck="false">
315
+ <div class="dialog-btns">
316
+ <button class="btn-cancel" onclick="closeMkdir()">取消</button>
317
+ <button class="btn-confirm" onclick="doMkdir()">创建</button>
318
+ </div>
319
+ </div>
320
+ </div>
321
+
322
+ <!-- Preview overlay -->
323
+ <div id="preview-overlay">
324
+ <div id="preview-header">
325
+ <button class="pv-btn" onclick="closePreview()">&larr; 返回</button>
326
+ <div id="preview-name"></div>
327
+ <button class="pv-btn" id="preview-download">下载</button>
328
+ </div>
329
+ <div id="preview-body"></div>
330
+ </div>
331
+
332
+ <script>
333
+ // ── State ──
334
+ let currentPath = '';
335
+ let rootPath = '';
336
+ let showHidden = false;
337
+ let entries = [];
338
+
339
+ // 从 URL 参数获取 sessionId,限定文件范围到会话工作目录
340
+ const SESSION_ID = new URLSearchParams(location.search).get('session') || '';
341
+
342
+ // ── Init ──
343
+ (async function init() {
344
+ if ('showDirectoryPicker' in window) {
345
+ const syncBtn = document.getElementById('sync-btn');
346
+ if (syncBtn) {
347
+ syncBtn.style.display = '';
348
+ syncBtn.href = '/sync' + (SESSION_ID ? '?session=' + SESSION_ID : '');
349
+ }
350
+ }
351
+ try {
352
+ const me = await fetch('/api/me').then(r => r.json());
353
+ document.getElementById('user-name').textContent = me.name || '';
354
+ } catch {}
355
+ if (SESSION_ID) {
356
+ document.getElementById('back-link').href = '/terminal/' + SESSION_ID;
357
+ } else {
358
+ document.getElementById('file-list').innerHTML = '<div class="empty-state"><p>请从会话页面进入文件管理</p></div>';
359
+ return;
360
+ }
361
+ loadDir('');
362
+
363
+ // Socket.IO:监听文件变更自动刷新
364
+ if (SESSION_ID && typeof io !== 'undefined') {
365
+ const socket = io({ transports: ['polling', 'websocket'] });
366
+ socket.on('connect', () => socket.emit('join', SESSION_ID));
367
+ socket.on('files:changed', () => {
368
+ const relPath = currentPath.slice(rootPath.length).replace(/^[/\\]+/, '');
369
+ loadDir(relPath);
370
+ });
371
+ }
372
+ })();
373
+
374
+ // ── API helpers ──
375
+ async function api(url, opts) {
376
+ const res = await fetch(url, opts);
377
+ if (res.status === 401) { location.href = '/login'; return null; }
378
+ return res;
379
+ }
380
+
381
+ // ── Load directory ──
382
+ async function loadDir(path) {
383
+ const list = document.getElementById('file-list');
384
+ const loading = document.getElementById('loading');
385
+ loading.classList.add('show');
386
+
387
+ const params = new URLSearchParams();
388
+ if (path) params.set('path', path);
389
+ if (showHidden) params.set('showHidden', '1');
390
+ if (SESSION_ID) params.set('sessionId', SESSION_ID);
391
+
392
+ try {
393
+ const res = await api('/api/files/list?' + params);
394
+ if (!res) return;
395
+ const data = await res.json();
396
+ if (data.error) { list.innerHTML = `<div class="empty-state"><p>${data.error}</p></div>`; return; }
397
+
398
+ currentPath = data.current;
399
+ rootPath = data.root;
400
+ entries = data.entries;
401
+ renderBreadcrumb(data);
402
+ renderFiles(data.entries);
403
+ document.getElementById('file-count').textContent = `${data.entries.length} 项`;
404
+ } catch (e) {
405
+ list.innerHTML = `<div class="empty-state"><p>加载失败: ${e.message}</p></div>`;
406
+ } finally {
407
+ loading.classList.remove('show');
408
+ }
409
+ }
410
+
411
+ // ── Render breadcrumb ──
412
+ function renderBreadcrumb(data) {
413
+ const bc = document.getElementById('breadcrumb');
414
+ const rel = data.currentRel || '';
415
+ const parts = rel ? rel.split('/').filter(Boolean) : [];
416
+
417
+ let html = `<span class="crumb" onclick="loadDir('')">~</span>`;
418
+ let buildPath = '';
419
+ for (let i = 0; i < parts.length; i++) {
420
+ buildPath += (buildPath ? '/' : '') + parts[i];
421
+ html += `<span class="crumb-sep">/</span>`;
422
+ if (i === parts.length - 1) {
423
+ html += `<span class="crumb-current">${esc(parts[i])}</span>`;
424
+ } else {
425
+ const p = buildPath;
426
+ html += `<span class="crumb" onclick="loadDir('${esc(p)}')">${esc(parts[i])}</span>`;
427
+ }
428
+ }
429
+ bc.innerHTML = html;
430
+ bc.scrollLeft = bc.scrollWidth;
431
+ }
432
+
433
+ // ── Render file list ──
434
+ function renderFiles(items) {
435
+ const list = document.getElementById('file-list');
436
+ if (!items.length) {
437
+ list.innerHTML = `<div class="empty-state">
438
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
439
+ <path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
440
+ </svg>
441
+ <p>空文件夹</p>
442
+ </div>`;
443
+ return;
444
+ }
445
+
446
+ list.innerHTML = items.map(item => {
447
+ const icon = item.type === 'dir' ? getIcon('dir') : getIcon(item.name);
448
+ const size = item.type === 'dir' ? '' : formatSize(item.size);
449
+ const time = item.mtime ? formatTime(item.mtime) : '';
450
+ const meta = [size, time].filter(Boolean).join(' · ');
451
+ const relPath = currentPath.slice(rootPath.length).replace(/^[/\\]+/, '');
452
+ const itemPath = (relPath ? relPath + '/' : '') + item.name;
453
+
454
+ if (item.type === 'dir') {
455
+ return `<div class="file-item" onclick="loadDir('${escAttr(itemPath)}')">
456
+ <div class="file-icon">${icon}</div>
457
+ <div class="file-info">
458
+ <div class="file-name">${esc(item.name)}</div>
459
+ <div class="file-meta">${meta || '文件夹'}</div>
460
+ </div>
461
+ </div>`;
462
+ } else {
463
+ return `<div class="file-item" onclick="previewFile('${escAttr(itemPath)}', '${escAttr(item.name)}')">
464
+ <div class="file-icon">${icon}</div>
465
+ <div class="file-info">
466
+ <div class="file-name">${esc(item.name)}</div>
467
+ <div class="file-meta">${meta}</div>
468
+ </div>
469
+ <div class="file-actions">
470
+ <button class="act-btn" title="下载" onclick="event.stopPropagation();downloadFile('${escAttr(itemPath)}')">
471
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M19 12l-7 7-7-7"/><path d="M5 19h14"/></svg>
472
+ </button>
473
+ </div>
474
+ </div>`;
475
+ }
476
+ }).join('');
477
+ }
478
+
479
+ // ── File icons ──
480
+ function getIcon(nameOrType) {
481
+ if (nameOrType === 'dir') return '<svg width="22" height="22" viewBox="0 0 24 24" fill="#f97316" stroke="#f97316" stroke-width="1"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>';
482
+ const ext = nameOrType.includes('.') ? nameOrType.split('.').pop().toLowerCase() : '';
483
+ const icons = {
484
+ image: ['png','jpg','jpeg','gif','svg','webp','bmp','ico'],
485
+ audio: ['mp3','wav','ogg','flac','m4a','aac','wma'],
486
+ video: ['mp4','webm','mkv','avi','mov','flv'],
487
+ code: ['js','ts','jsx','tsx','py','sh','css','html','json','xml','yml','yaml','md','mjs','cjs'],
488
+ doc: ['pdf','doc','docx','xls','xlsx','ppt','pptx','txt','csv','log'],
489
+ zip: ['zip','gz','tar','7z','rar'],
490
+ };
491
+ for (const [type, exts] of Object.entries(icons)) {
492
+ if (exts.includes(ext)) {
493
+ const colors = { image: '#3fb950', audio: '#d29922', video: '#f85149', code: '#8b949e', doc: '#58a6ff', zip: '#f97316' };
494
+ const c = colors[type];
495
+ if (type === 'image') return `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8" cy="8" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>`;
496
+ if (type === 'audio') return `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`;
497
+ if (type === 'video') return `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="1.5"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M10 9l5 3-5 3z"/></svg>`;
498
+ if (type === 'code') return `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="1.5"><path d="M16 18l6-6-6-6M8 6l-6 6 6 6"/></svg>`;
499
+ if (type === 'doc') return `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="1.5"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8M8 9h2"/></svg>`;
500
+ if (type === 'zip') return `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="1.5"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/><path d="M10 12h1v2h-1zM12 14h1v2h-1zM10 16h1v2h-1z"/></svg>`;
501
+ }
502
+ }
503
+ return `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--muted)" stroke-width="1.5"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>`;
504
+ }
505
+
506
+ // ── Preview ──
507
+ function previewFile(path, name) {
508
+ const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
509
+ const overlay = document.getElementById('preview-overlay');
510
+ const body = document.getElementById('preview-body');
511
+ const nameEl = document.getElementById('preview-name');
512
+ const dlBtn = document.getElementById('preview-download');
513
+
514
+ nameEl.textContent = name;
515
+ dlBtn.onclick = () => downloadFile(path);
516
+
517
+ const previewUrl = '/api/files/preview?path=' + encodeURIComponent(path) + (SESSION_ID ? '&sessionId=' + SESSION_ID : '');
518
+
519
+ const imageExts = ['png','jpg','jpeg','gif','svg','webp','bmp','ico'];
520
+ const audioExts = ['mp3','wav','ogg','flac','m4a','aac','wma'];
521
+ const videoExts = ['mp4','webm','mov'];
522
+ const textExts = ['txt','md','js','ts','jsx','tsx','css','html','json','xml','yml','yaml','py','sh','log','csv','mjs','cjs','env','gitignore','conf','cfg','ini','toml'];
523
+
524
+ if (imageExts.includes(ext)) {
525
+ body.innerHTML = `<img src="${previewUrl}" alt="${esc(name)}">`;
526
+ } else if (audioExts.includes(ext)) {
527
+ body.innerHTML = `<audio controls autoplay src="${previewUrl}" style="width:90%;max-width:400px"></audio>`;
528
+ } else if (videoExts.includes(ext)) {
529
+ body.innerHTML = `<video controls autoplay src="${previewUrl}" style="max-width:100%;max-height:80vh"></video>`;
530
+ } else if (ext === 'pdf') {
531
+ body.innerHTML = `<iframe src="${previewUrl}" style="width:100%;height:100%"></iframe>`;
532
+ } else if (textExts.includes(ext)) {
533
+ body.innerHTML = '<pre>加载中...</pre>';
534
+ fetch(previewUrl).then(r => r.text()).then(text => {
535
+ const maxLen = 1024 * 1024; // 1MB
536
+ const truncated = text.length > maxLen ? text.slice(0, maxLen) + '\n\n... (文件过大,已截断)' : text;
537
+ body.querySelector('pre').textContent = truncated;
538
+ }).catch(e => { body.querySelector('pre').textContent = '加载失败: ' + e.message; });
539
+ } else {
540
+ body.innerHTML = `<div style="text-align:center;color:var(--muted)">
541
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
542
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/>
543
+ </svg>
544
+ <p style="margin-top:0.75rem">无法预览此文件类型</p>
545
+ <button class="pv-btn" style="margin-top:1rem" onclick="downloadFile('${escAttr(path)}')">下载文件</button>
546
+ </div>`;
547
+ }
548
+
549
+ overlay.classList.add('show');
550
+ }
551
+
552
+ function closePreview() {
553
+ const overlay = document.getElementById('preview-overlay');
554
+ const body = document.getElementById('preview-body');
555
+ overlay.classList.remove('show');
556
+ // Stop any playing media
557
+ body.querySelectorAll('audio, video').forEach(el => { el.pause(); el.src = ''; });
558
+ body.innerHTML = '';
559
+ }
560
+
561
+ // ── Download ──
562
+ function downloadFile(path) {
563
+ const a = document.createElement('a');
564
+ a.href = '/api/files/download?path=' + encodeURIComponent(path) + (SESSION_ID ? '&sessionId=' + SESSION_ID : '');
565
+ a.download = '';
566
+ document.body.appendChild(a);
567
+ a.click();
568
+ a.remove();
569
+ }
570
+
571
+ // ── Upload ──
572
+ function openUploadSheet() {
573
+ document.getElementById('upload-sheet-backdrop').classList.add('show');
574
+ document.getElementById('upload-sheet').classList.add('show');
575
+ }
576
+ function closeUploadSheet() {
577
+ document.getElementById('upload-sheet-backdrop').classList.remove('show');
578
+ document.getElementById('upload-sheet').classList.remove('show');
579
+ document.getElementById('file-input').value = '';
580
+ }
581
+
582
+ function handleFiles(files) {
583
+ if (!files.length) return;
584
+ closeUploadSheet();
585
+ uploadFiles(files);
586
+ }
587
+
588
+ async function uploadFiles(files) {
589
+ const bar = document.getElementById('upload-bar');
590
+ const fill = document.getElementById('progress-fill');
591
+ const text = document.getElementById('upload-text');
592
+ bar.classList.add('show');
593
+ fill.style.width = '0%';
594
+ text.textContent = `上传 ${files.length} 个文件...`;
595
+
596
+ const formData = new FormData();
597
+ for (const f of files) formData.append('files', f);
598
+
599
+ const relPath = currentPath.slice(rootPath.length).replace(/^[/\\]+/, '');
600
+
601
+ const xhr = new XMLHttpRequest();
602
+ const uploadParams = new URLSearchParams({ path: relPath });
603
+ if (SESSION_ID) uploadParams.set('sessionId', SESSION_ID);
604
+ xhr.open('POST', '/api/files/upload?' + uploadParams);
605
+
606
+ xhr.upload.onprogress = (e) => {
607
+ if (e.lengthComputable) {
608
+ const pct = Math.round(e.loaded / e.total * 100);
609
+ fill.style.width = pct + '%';
610
+ text.textContent = `上传中 ${pct}% (${formatSize(e.loaded)} / ${formatSize(e.total)})`;
611
+ }
612
+ };
613
+
614
+ xhr.onload = () => {
615
+ if (xhr.status === 200) {
616
+ text.textContent = '上传完成';
617
+ setTimeout(() => { bar.classList.remove('show'); }, 1500);
618
+ loadDir(relPath);
619
+ } else {
620
+ const err = JSON.parse(xhr.responseText || '{}').error || '上传失败';
621
+ text.textContent = '上传失败: ' + err;
622
+ fill.style.background = 'var(--red)';
623
+ setTimeout(() => { bar.classList.remove('show'); fill.style.background = ''; }, 3000);
624
+ }
625
+ };
626
+ xhr.onerror = () => {
627
+ text.textContent = '网络错误';
628
+ setTimeout(() => bar.classList.remove('show'), 3000);
629
+ };
630
+
631
+ xhr.send(formData);
632
+ }
633
+
634
+ // Drag and drop on upload zone
635
+ const zone = document.getElementById('upload-zone');
636
+ zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
637
+ zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
638
+ zone.addEventListener('drop', e => {
639
+ e.preventDefault();
640
+ zone.classList.remove('dragover');
641
+ if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
642
+ });
643
+
644
+ // ── Mkdir ──
645
+ function openMkdir() {
646
+ document.getElementById('mkdir-dialog').classList.add('show');
647
+ const inp = document.getElementById('mkdir-input');
648
+ inp.value = '';
649
+ setTimeout(() => inp.focus(), 100);
650
+ }
651
+ function closeMkdir() {
652
+ document.getElementById('mkdir-dialog').classList.remove('show');
653
+ }
654
+ async function doMkdir() {
655
+ const name = document.getElementById('mkdir-input').value.trim();
656
+ if (!name) return;
657
+ closeMkdir();
658
+ const relPath = currentPath.slice(rootPath.length).replace(/^[/\\]+/, '');
659
+ const newPath = (relPath ? relPath + '/' : '') + name;
660
+ try {
661
+ const res = await api('/api/files/mkdir', {
662
+ method: 'POST',
663
+ headers: { 'Content-Type': 'application/json' },
664
+ body: JSON.stringify({ path: newPath, sessionId: SESSION_ID }),
665
+ });
666
+ if (res) {
667
+ const data = await res.json();
668
+ if (data.ok) loadDir(relPath);
669
+ else alert(data.error || '创建失败');
670
+ }
671
+ } catch (e) { alert('创建失败: ' + e.message); }
672
+ }
673
+
674
+ // Enter key for mkdir
675
+ document.getElementById('mkdir-input').addEventListener('keydown', e => {
676
+ if (e.key === 'Enter') doMkdir();
677
+ if (e.key === 'Escape') closeMkdir();
678
+ });
679
+
680
+ // ── Toggle hidden ──
681
+ function toggleHidden() {
682
+ showHidden = !showHidden;
683
+ document.getElementById('hidden-toggle').classList.toggle('active', showHidden);
684
+ const relPath = currentPath.slice(rootPath.length).replace(/^[/\\]+/, '');
685
+ loadDir(relPath);
686
+ }
687
+
688
+ // ── Logout ──
689
+ async function doLogout() {
690
+ await fetch('/logout', { method: 'POST' });
691
+ location.href = '/login';
692
+ }
693
+
694
+ // ── Escape key closes preview ──
695
+ document.addEventListener('keydown', e => {
696
+ if (e.key === 'Escape') closePreview();
697
+ });
698
+
699
+ // ── Utilities ──
700
+ function formatSize(bytes) {
701
+ if (bytes === 0) return '0 B';
702
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
703
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
704
+ return (bytes / Math.pow(1024, i)).toFixed(i ? 1 : 0) + ' ' + units[i];
705
+ }
706
+
707
+ function formatTime(t) {
708
+ const d = new Date(t);
709
+ const now = new Date();
710
+ const diff = now - d;
711
+ if (diff < 60000) return '刚刚';
712
+ if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前';
713
+ if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前';
714
+ if (diff < 604800000) return Math.floor(diff / 86400000) + ' 天前';
715
+ return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
716
+ }
717
+
718
+ function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
719
+ function escAttr(s) { return s.replace(/'/g, "\\'").replace(/\\/g, '\\\\'); }
720
+ </script>
721
+ </body>
722
+ </html>