@orangeworks/orangetree 0.4.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.
- package/CHANGELOG.md +44 -0
- package/LICENSE +8 -0
- package/README.md +55 -0
- package/dist/bin/orangetree.js +89 -0
- package/dist/public/api.js +33 -0
- package/dist/public/app.js +650 -0
- package/dist/public/canvas.js +362 -0
- package/dist/public/chat.js +890 -0
- package/dist/public/connection.js +102 -0
- package/dist/public/dirpicker.js +93 -0
- package/dist/public/explorer.js +299 -0
- package/dist/public/guidelines.js +35 -0
- package/dist/public/i18n.js +959 -0
- package/dist/public/index.html +75 -0
- package/dist/public/insight.js +40 -0
- package/dist/public/logo.svg +5 -0
- package/dist/public/md.js +103 -0
- package/dist/public/pairing.js +114 -0
- package/dist/public/projectSettings.js +150 -0
- package/dist/public/slash.js +95 -0
- package/dist/public/style.css +649 -0
- package/dist/public/ui.js +372 -0
- package/dist/public/viewer.html +156 -0
- package/dist/server.js +18573 -0
- package/package.json +45 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { t } from "./i18n.js";
|
|
2
|
+
import { toast } from "./api.js";
|
|
3
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
4
|
+
function overlay() {
|
|
5
|
+
const o = document.createElement("div");
|
|
6
|
+
o.className = "ui-overlay";
|
|
7
|
+
document.body.append(o);
|
|
8
|
+
return o;
|
|
9
|
+
}
|
|
10
|
+
function formModal({ title, fields = [], submitLabel = t("common.confirm"), cancelLabel = t("common.cancel"), width = 460 }) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const o = overlay();
|
|
13
|
+
const box = document.createElement("div");
|
|
14
|
+
box.className = "ui-modal";
|
|
15
|
+
box.style.width = `min(${width}px, 94vw)`;
|
|
16
|
+
box.innerHTML = `<div class="ui-head"><b>${esc(title)}</b><button class="ui-x" type="button" aria-label="${esc(t("common.close"))}">\u2715</button></div>
|
|
17
|
+
<form class="ui-body"></form>
|
|
18
|
+
<div class="ui-foot"><button type="button" class="ui-cancel">${esc(cancelLabel)}</button>
|
|
19
|
+
<button type="button" class="ui-ok">${esc(submitLabel)}</button></div>`;
|
|
20
|
+
const form = box.querySelector(".ui-body");
|
|
21
|
+
for (const f of fields) {
|
|
22
|
+
const row = document.createElement("div");
|
|
23
|
+
row.className = "ui-field";
|
|
24
|
+
const id = `f_${f.name}`;
|
|
25
|
+
let control = "";
|
|
26
|
+
if (f.type === "textarea") {
|
|
27
|
+
control = `<textarea id="${id}" rows="${f.rows ?? 4}" placeholder="${esc(f.placeholder)}">${esc(f.value)}</textarea>`;
|
|
28
|
+
} else if (f.type === "select") {
|
|
29
|
+
control = `<select id="${id}">${(f.options ?? []).map((op) => `<option value="${esc(op.value)}"${op.value === f.value ? " selected" : ""}>${esc(op.label)}</option>`).join("")}</select>`;
|
|
30
|
+
} else if (f.type === "checkbox") {
|
|
31
|
+
control = `<label class="ui-check"><input type="checkbox" id="${id}"${f.value ? " checked" : ""}> ${esc(f.checkLabel ?? "")}</label>`;
|
|
32
|
+
} else if (f.type === "radio") {
|
|
33
|
+
const def = f.value ?? f.options[0]?.value;
|
|
34
|
+
control = `<div class="ui-radios">${f.options.map(
|
|
35
|
+
(op) => `<label class="ui-radio"><input type="radio" name="${id}" value="${esc(op.value)}"${op.value === def ? " checked" : ""}> <span><b>${esc(op.label)}</b>${op.hint ? `<em>${esc(op.hint)}</em>` : ""}</span></label>`
|
|
36
|
+
).join("")}</div>`;
|
|
37
|
+
} else {
|
|
38
|
+
control = `<input type="text" id="${id}" value="${esc(f.value)}" placeholder="${esc(f.placeholder)}">`;
|
|
39
|
+
}
|
|
40
|
+
row.innerHTML = (f.label ? `<label class="ui-lbl" for="${id}">${esc(f.label)}</label>` : "") + control + (f.hint ? `<div class="ui-hint">${esc(f.hint)}</div>` : "");
|
|
41
|
+
form.append(row);
|
|
42
|
+
}
|
|
43
|
+
const close = (val) => {
|
|
44
|
+
o.remove();
|
|
45
|
+
document.removeEventListener("keydown", onKey);
|
|
46
|
+
resolve(val);
|
|
47
|
+
};
|
|
48
|
+
const collect = () => {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const f of fields) {
|
|
51
|
+
if (f.type === "checkbox") {
|
|
52
|
+
out[f.name] = box.querySelector(`#f_${f.name}`).checked;
|
|
53
|
+
} else if (f.type === "radio") {
|
|
54
|
+
out[f.name] = box.querySelector(`input[name="f_${f.name}"]:checked`)?.value ?? null;
|
|
55
|
+
} else {
|
|
56
|
+
out[f.name] = box.querySelector(`#f_${f.name}`).value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
};
|
|
61
|
+
const submit = () => close(collect());
|
|
62
|
+
const onKey = (e) => {
|
|
63
|
+
if (e.key === "Escape") close(null);
|
|
64
|
+
if (e.key === "Enter" && e.target.tagName !== "TEXTAREA") {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
submit();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
document.addEventListener("keydown", onKey);
|
|
70
|
+
box.querySelector(".ui-x").onclick = () => close(null);
|
|
71
|
+
box.querySelector(".ui-cancel").onclick = () => close(null);
|
|
72
|
+
box.querySelector(".ui-ok").onclick = submit;
|
|
73
|
+
o.onclick = (e) => {
|
|
74
|
+
if (e.target === o) close(null);
|
|
75
|
+
};
|
|
76
|
+
o.append(box);
|
|
77
|
+
box.querySelector("input,textarea,select")?.focus();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function confirmModal({ title = t("common.confirm"), message = "", okLabel = t("common.confirm"), cancelLabel = t("common.cancel"), danger = false }) {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
const o = overlay();
|
|
83
|
+
const box = document.createElement("div");
|
|
84
|
+
box.className = "ui-modal";
|
|
85
|
+
box.style.width = "min(420px, 92vw)";
|
|
86
|
+
box.innerHTML = `<div class="ui-head"><b>${esc(title)}</b><button class="ui-x" type="button">\u2715</button></div>
|
|
87
|
+
<div class="ui-body"><p class="ui-msg">${esc(message).replace(/\n/g, "<br>")}</p></div>
|
|
88
|
+
<div class="ui-foot"><button type="button" class="ui-cancel">${esc(cancelLabel)}</button>
|
|
89
|
+
<button type="button" class="ui-ok${danger ? " danger" : ""}">${esc(okLabel)}</button></div>`;
|
|
90
|
+
const close = (v) => {
|
|
91
|
+
o.remove();
|
|
92
|
+
document.removeEventListener("keydown", onKey);
|
|
93
|
+
resolve(v);
|
|
94
|
+
};
|
|
95
|
+
const onKey = (e) => {
|
|
96
|
+
if (e.key === "Escape") close(false);
|
|
97
|
+
if (e.key === "Enter") close(true);
|
|
98
|
+
};
|
|
99
|
+
document.addEventListener("keydown", onKey);
|
|
100
|
+
box.querySelector(".ui-x").onclick = () => close(false);
|
|
101
|
+
box.querySelector(".ui-cancel").onclick = () => close(false);
|
|
102
|
+
box.querySelector(".ui-ok").onclick = () => close(true);
|
|
103
|
+
o.onclick = (e) => {
|
|
104
|
+
if (e.target === o) close(false);
|
|
105
|
+
};
|
|
106
|
+
o.append(box);
|
|
107
|
+
box.querySelector(".ui-ok").focus();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const REPORT_IMG = {
|
|
111
|
+
maxCount: 6,
|
|
112
|
+
maxBytesEach: 3 * 1024 * 1024,
|
|
113
|
+
maxBytesTotal: 8 * 1024 * 1024,
|
|
114
|
+
mimes: ["image/png", "image/jpeg", "image/gif", "image/webp"],
|
|
115
|
+
accept: "image/png,image/jpeg,image/gif,image/webp"
|
|
116
|
+
};
|
|
117
|
+
function reportModal({ body = "", title = "" } = {}) {
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
const o = overlay();
|
|
120
|
+
const box = document.createElement("div");
|
|
121
|
+
box.className = "ui-modal";
|
|
122
|
+
box.style.width = "min(560px, 94vw)";
|
|
123
|
+
box.innerHTML = `<div class="ui-head"><b>${esc(t("report.title"))}</b><button class="ui-x" type="button" aria-label="${esc(t("common.close"))}">\u2715</button></div>
|
|
124
|
+
<form class="ui-body">
|
|
125
|
+
<div class="ui-field">
|
|
126
|
+
<label class="ui-lbl" for="report-title">${esc(t("report.field.title"))}</label>
|
|
127
|
+
<input type="text" id="report-title" value="${esc(title)}" placeholder="${esc(t("report.field.titlePh"))}">
|
|
128
|
+
</div>
|
|
129
|
+
<div class="ui-field">
|
|
130
|
+
<label class="ui-lbl" for="report-body">${esc(t("report.field.body"))}
|
|
131
|
+
<button type="button" class="report-copy" title="${esc(t("report.copy"))}">\u{1F4CB} ${esc(t("report.copy"))}</button>
|
|
132
|
+
</label>
|
|
133
|
+
<textarea id="report-body" rows="10" placeholder="${esc(t("report.field.bodyPh"))}"></textarea>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="ui-field">
|
|
136
|
+
<label class="ui-lbl">${esc(t("report.field.images"))}
|
|
137
|
+
<button type="button" class="report-attach" title="${esc(t("report.attach.title"))}">\u{1F5BC} ${esc(t("report.attach"))}</button>
|
|
138
|
+
</label>
|
|
139
|
+
<div class="report-imgs" hidden></div>
|
|
140
|
+
<div class="ui-hint">${esc(t("report.images.hint"))}</div>
|
|
141
|
+
<input type="file" class="report-file" accept="${REPORT_IMG.accept}" multiple hidden>
|
|
142
|
+
</div>
|
|
143
|
+
</form>
|
|
144
|
+
<div class="ui-foot"><button type="button" class="ui-cancel">${esc(t("common.cancel"))}</button>
|
|
145
|
+
<button type="button" class="ui-ok">${esc(t("report.submit"))}</button></div>`;
|
|
146
|
+
const titleEl = box.querySelector("#report-title");
|
|
147
|
+
const bodyEl = box.querySelector("#report-body");
|
|
148
|
+
bodyEl.value = body;
|
|
149
|
+
const fileEl = box.querySelector(".report-file");
|
|
150
|
+
const imgsEl = box.querySelector(".report-imgs");
|
|
151
|
+
const images = [];
|
|
152
|
+
const totalBytes = () => images.reduce((sum, im) => sum + base64Bytes(im.dataBase64), 0);
|
|
153
|
+
const renderImgs = () => {
|
|
154
|
+
imgsEl.replaceChildren();
|
|
155
|
+
imgsEl.hidden = !images.length;
|
|
156
|
+
images.forEach((im, i) => {
|
|
157
|
+
const chip = document.createElement("div");
|
|
158
|
+
chip.className = "att-chip";
|
|
159
|
+
const thumb = document.createElement("img");
|
|
160
|
+
thumb.src = im.url;
|
|
161
|
+
chip.append(thumb);
|
|
162
|
+
const name = document.createElement("span");
|
|
163
|
+
name.className = "att-name";
|
|
164
|
+
name.textContent = im.name;
|
|
165
|
+
chip.append(name);
|
|
166
|
+
const x = document.createElement("button");
|
|
167
|
+
x.type = "button";
|
|
168
|
+
x.className = "att-x";
|
|
169
|
+
x.textContent = "\u2715";
|
|
170
|
+
x.title = t("report.image.remove");
|
|
171
|
+
x.onclick = () => {
|
|
172
|
+
URL.revokeObjectURL(im.url);
|
|
173
|
+
images.splice(i, 1);
|
|
174
|
+
renderImgs();
|
|
175
|
+
};
|
|
176
|
+
chip.append(x);
|
|
177
|
+
imgsEl.append(chip);
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
const addFile = async (file) => {
|
|
181
|
+
if (!REPORT_IMG.mimes.includes(file.type)) {
|
|
182
|
+
toast(t("report.toast.badType"), "error");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (images.length >= REPORT_IMG.maxCount) {
|
|
186
|
+
toast(t("report.toast.tooMany", { n: REPORT_IMG.maxCount }), "error");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (file.size > REPORT_IMG.maxBytesEach) {
|
|
190
|
+
toast(t("report.toast.tooLarge", { mb: 3 }), "error");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
let dataUrl;
|
|
194
|
+
try {
|
|
195
|
+
dataUrl = await readAsDataURL(file);
|
|
196
|
+
} catch {
|
|
197
|
+
toast(t("report.toast.readFail"), "error");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const comma = dataUrl.indexOf(",");
|
|
201
|
+
const dataBase64 = comma >= 0 ? dataUrl.slice(comma + 1) : "";
|
|
202
|
+
if (!dataBase64) {
|
|
203
|
+
toast(t("report.toast.readFail"), "error");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (totalBytes() + base64Bytes(dataBase64) > REPORT_IMG.maxBytesTotal) {
|
|
207
|
+
toast(t("report.toast.tooLargeTotal", { mb: 8 }), "error");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
images.push({ name: file.name || "image.png", mime: file.type, dataBase64, url: URL.createObjectURL(file) });
|
|
211
|
+
renderImgs();
|
|
212
|
+
};
|
|
213
|
+
const addFiles = (files) => {
|
|
214
|
+
for (const f of files) void addFile(f);
|
|
215
|
+
};
|
|
216
|
+
const close = (val) => {
|
|
217
|
+
for (const im of images) URL.revokeObjectURL(im.url);
|
|
218
|
+
o.remove();
|
|
219
|
+
document.removeEventListener("keydown", onKey);
|
|
220
|
+
resolve(val);
|
|
221
|
+
};
|
|
222
|
+
const submit = () => {
|
|
223
|
+
const ti = titleEl.value.trim();
|
|
224
|
+
const bo = bodyEl.value.trim();
|
|
225
|
+
if (!ti || !bo) {
|
|
226
|
+
toast(t("report.toast.required"), "error");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
close({ title: ti, body: bo, images: images.map(({ name, mime, dataBase64 }) => ({ name, mime, dataBase64 })) });
|
|
230
|
+
};
|
|
231
|
+
const onKey = (e) => {
|
|
232
|
+
if (e.key === "Escape") close(null);
|
|
233
|
+
if (e.key === "Enter" && e.target.tagName !== "TEXTAREA") {
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
submit();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
document.addEventListener("keydown", onKey);
|
|
239
|
+
box.querySelector(".report-copy").onclick = () => {
|
|
240
|
+
void copyText(bodyEl.value);
|
|
241
|
+
};
|
|
242
|
+
box.querySelector(".report-attach").onclick = () => fileEl.click();
|
|
243
|
+
fileEl.onchange = () => {
|
|
244
|
+
addFiles(Array.from(fileEl.files ?? []));
|
|
245
|
+
fileEl.value = "";
|
|
246
|
+
};
|
|
247
|
+
box.addEventListener("paste", (e) => {
|
|
248
|
+
const files = [...e.clipboardData?.items ?? []].filter((it) => it.kind === "file").map((it) => it.getAsFile()).filter((f) => !!f && /^image\//.test(f.type));
|
|
249
|
+
if (!files.length) return;
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
addFiles(files);
|
|
252
|
+
});
|
|
253
|
+
box.querySelector(".ui-x").onclick = () => close(null);
|
|
254
|
+
box.querySelector(".ui-cancel").onclick = () => close(null);
|
|
255
|
+
box.querySelector(".ui-ok").onclick = submit;
|
|
256
|
+
o.onclick = (e) => {
|
|
257
|
+
if (e.target === o) close(null);
|
|
258
|
+
};
|
|
259
|
+
o.append(box);
|
|
260
|
+
(title ? bodyEl : titleEl).focus();
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
function readAsDataURL(file) {
|
|
264
|
+
return new Promise((resolve, reject) => {
|
|
265
|
+
const fr = new FileReader();
|
|
266
|
+
fr.onload = () => resolve(String(fr.result ?? ""));
|
|
267
|
+
fr.onerror = () => reject(fr.error ?? new Error("read failed"));
|
|
268
|
+
fr.readAsDataURL(file);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function base64Bytes(b64) {
|
|
272
|
+
const len = b64.length;
|
|
273
|
+
if (!len) return 0;
|
|
274
|
+
let pad = 0;
|
|
275
|
+
if (b64[len - 1] === "=") pad++;
|
|
276
|
+
if (b64[len - 2] === "=") pad++;
|
|
277
|
+
return Math.floor(len * 3 / 4) - pad;
|
|
278
|
+
}
|
|
279
|
+
async function copyText(text) {
|
|
280
|
+
try {
|
|
281
|
+
if (navigator.clipboard?.writeText) {
|
|
282
|
+
await navigator.clipboard.writeText(text);
|
|
283
|
+
} else {
|
|
284
|
+
const ta = document.createElement("textarea");
|
|
285
|
+
ta.value = text;
|
|
286
|
+
ta.style.position = "fixed";
|
|
287
|
+
ta.style.opacity = "0";
|
|
288
|
+
document.body.append(ta);
|
|
289
|
+
ta.select();
|
|
290
|
+
document.execCommand("copy");
|
|
291
|
+
ta.remove();
|
|
292
|
+
}
|
|
293
|
+
toast(t("report.toast.copied"), "success");
|
|
294
|
+
return true;
|
|
295
|
+
} catch {
|
|
296
|
+
toast(t("report.toast.copyFail"), "error");
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
let closeOpenMenu = null;
|
|
301
|
+
function popover(entries, anchor) {
|
|
302
|
+
closeOpenMenu?.();
|
|
303
|
+
const menu = document.createElement("div");
|
|
304
|
+
menu.className = "ui-menu";
|
|
305
|
+
const close = () => {
|
|
306
|
+
if (closeOpenMenu !== close) return;
|
|
307
|
+
closeOpenMenu = null;
|
|
308
|
+
menu.remove();
|
|
309
|
+
document.removeEventListener("keydown", onKey, true);
|
|
310
|
+
document.removeEventListener("pointerdown", onOutside, true);
|
|
311
|
+
window.removeEventListener("blur", close);
|
|
312
|
+
window.removeEventListener("resize", close);
|
|
313
|
+
window.removeEventListener("scroll", close, true);
|
|
314
|
+
};
|
|
315
|
+
const onKey = (e) => {
|
|
316
|
+
if (e.key === "Escape") {
|
|
317
|
+
e.stopPropagation();
|
|
318
|
+
close();
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
const onOutside = (e) => {
|
|
322
|
+
if (!menu.contains(e.target)) close();
|
|
323
|
+
};
|
|
324
|
+
for (const it of entries) {
|
|
325
|
+
if (it === "separator") {
|
|
326
|
+
const s = document.createElement("div");
|
|
327
|
+
s.className = "ui-menu-sep";
|
|
328
|
+
menu.append(s);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (it.hidden) continue;
|
|
332
|
+
const b = document.createElement("button");
|
|
333
|
+
b.type = "button";
|
|
334
|
+
b.className = "ui-menu-item" + (it.danger ? " danger" : "");
|
|
335
|
+
b.textContent = it.label;
|
|
336
|
+
if (it.title) b.title = it.title;
|
|
337
|
+
b.disabled = !!it.disabled;
|
|
338
|
+
b.addEventListener("click", () => {
|
|
339
|
+
close();
|
|
340
|
+
it.onClick();
|
|
341
|
+
});
|
|
342
|
+
menu.append(b);
|
|
343
|
+
}
|
|
344
|
+
document.body.append(menu);
|
|
345
|
+
const isRect = "bottom" in anchor && "left" in anchor;
|
|
346
|
+
const ax = isRect ? anchor.left : anchor.x;
|
|
347
|
+
const ay = isRect ? anchor.bottom : anchor.y;
|
|
348
|
+
const r = menu.getBoundingClientRect();
|
|
349
|
+
const margin = 8;
|
|
350
|
+
let left = ax;
|
|
351
|
+
let top = ay;
|
|
352
|
+
if (left + r.width > window.innerWidth - margin) left = Math.max(margin, window.innerWidth - r.width - margin);
|
|
353
|
+
if (top + r.height > window.innerHeight - margin) top = Math.max(margin, ay - r.height);
|
|
354
|
+
menu.style.left = `${left}px`;
|
|
355
|
+
menu.style.top = `${top}px`;
|
|
356
|
+
closeOpenMenu = close;
|
|
357
|
+
setTimeout(() => {
|
|
358
|
+
document.addEventListener("keydown", onKey, true);
|
|
359
|
+
document.addEventListener("pointerdown", onOutside, true);
|
|
360
|
+
window.addEventListener("blur", close);
|
|
361
|
+
window.addEventListener("resize", close);
|
|
362
|
+
window.addEventListener("scroll", close, true);
|
|
363
|
+
}, 0);
|
|
364
|
+
return close;
|
|
365
|
+
}
|
|
366
|
+
export {
|
|
367
|
+
confirmModal,
|
|
368
|
+
copyText,
|
|
369
|
+
formModal,
|
|
370
|
+
popover,
|
|
371
|
+
reportModal
|
|
372
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Orange Tree</title>
|
|
6
|
+
<script>
|
|
7
|
+
/* Set UI locale before render (shared localStorage with the main window) — no flash. */
|
|
8
|
+
(function () { try { var lo = localStorage.getItem('otree-locale'); if (lo === 'ko' || lo === 'en' || lo === 'ja') document.documentElement.lang = lo; } catch (e) {} })();
|
|
9
|
+
</script>
|
|
10
|
+
<style>
|
|
11
|
+
* { box-sizing: border-box; margin: 0; }
|
|
12
|
+
html, body { height: 100%; font-family: system-ui, sans-serif; background: #1a1816; color: #d4d0c8; }
|
|
13
|
+
body { display: flex; flex-direction: column; }
|
|
14
|
+
#tabs { display: flex; gap: 2px; background: #232020; padding: 6px 8px 0; overflow-x: auto; flex-shrink: 0; }
|
|
15
|
+
.tab { display: flex; align-items: center; gap: 6px; padding: 6px 10px; background: #2c2825; border-radius: 8px 8px 0 0;
|
|
16
|
+
font-size: 12px; cursor: pointer; white-space: nowrap; color: #9a9088; max-width: 220px; }
|
|
17
|
+
.tab.active { background: #14110f; color: #f0ece4; }
|
|
18
|
+
.tab span { overflow: hidden; text-overflow: ellipsis; }
|
|
19
|
+
.tab .x { font-size: 11px; color: #8a8078; }
|
|
20
|
+
.tab .x:hover { color: #f87171; }
|
|
21
|
+
#body { flex: 1; overflow: auto; background: #14110f; }
|
|
22
|
+
.pane { display: none; padding: 0; }
|
|
23
|
+
.pane.active { display: block; }
|
|
24
|
+
.doc { max-width: 860px; margin: 0 auto; padding: 28px 32px; font-size: 14.5px; line-height: 1.7; color: #d4d0c8; }
|
|
25
|
+
.doc h1 { font-size: 22px; margin: 16px 0 10px; color: #f0934a; }
|
|
26
|
+
.doc h2 { font-size: 18px; margin: 14px 0 8px; color: #ece8e0; }
|
|
27
|
+
.doc h3 { font-size: 15px; margin: 12px 0 6px; color: #ece8e0; }
|
|
28
|
+
.doc p { margin: 8px 0; white-space: pre-wrap; }
|
|
29
|
+
.doc ul, .doc ol { margin: 8px 0; padding-left: 26px; }
|
|
30
|
+
.doc li { margin: 3px 0; }
|
|
31
|
+
.doc blockquote { border-left: 3px solid #7a5c2e; padding-left: 10px; color: #9a9088; margin: 8px 0; }
|
|
32
|
+
.doc pre { background: #0d0b0a; color: #d8d4cc; padding: 12px; border-radius: 8px; overflow-x: auto; font-size: 12.5px; }
|
|
33
|
+
.doc code { background: #2c2825; color: #e8b88a; padding: 1px 4px; border-radius: 4px; font-size: 12.5px; }
|
|
34
|
+
.doc hr { border: none; border-top: 1px solid #3a352f; margin: 14px 0; }
|
|
35
|
+
.doc table { border-collapse: collapse; margin: 10px 0; font-size: 13px; }
|
|
36
|
+
.doc th, .doc td { border: 1px solid #3a352f; padding: 5px 10px; text-align: left; }
|
|
37
|
+
.doc th { background: #232020; font-weight: 600; }
|
|
38
|
+
.doc a { color: #6ab0ef; }
|
|
39
|
+
.media { display: flex; align-items: center; justify-content: center; min-height: 100%; padding: 20px; background: #0a0908; }
|
|
40
|
+
.media img, .media video { max-width: 100%; max-height: calc(100vh - 80px); border-radius: 6px; }
|
|
41
|
+
.plain { padding: 20px 26px; font-family: ui-monospace, Consolas, monospace; font-size: 13px; white-space: pre-wrap; word-break: break-all; color: #d4d0c8; }
|
|
42
|
+
.code { font-family: ui-monospace, Consolas, monospace; font-size: 13px; line-height: 1.55; padding: 14px 0; color: #d4d0c8; }
|
|
43
|
+
.code .ln { display: flex; }
|
|
44
|
+
.code .no { width: 52px; flex-shrink: 0; text-align: right; padding-right: 14px; color: #5a554d; user-select: none; }
|
|
45
|
+
.code .ct { white-space: pre-wrap; word-break: break-all; flex: 1; padding-right: 16px; }
|
|
46
|
+
.code .ln:hover { background: #1f1b18; }
|
|
47
|
+
.tok-str { color: #7ec699; } .tok-com { color: #6f675c; font-style: italic; }
|
|
48
|
+
.tok-kw { color: #f08a7a; } .tok-num { color: #79b8ff; }
|
|
49
|
+
#empty { padding: 40px; text-align: center; color: #6f675c; font-size: 14px; }
|
|
50
|
+
</style>
|
|
51
|
+
</head>
|
|
52
|
+
<body>
|
|
53
|
+
<div id="tabs"></div>
|
|
54
|
+
<div id="body"><div id="empty"></div></div>
|
|
55
|
+
<script type="module">
|
|
56
|
+
import { mdDoc, esc } from '/md.js';
|
|
57
|
+
import { t } from '/i18n.js';
|
|
58
|
+
|
|
59
|
+
document.title = t('viewer.title');
|
|
60
|
+
const emptyHtml = () => `<div id="empty">${esc(t('viewer.empty'))}</div>`;
|
|
61
|
+
|
|
62
|
+
const tabs = []; // { path, $tab, $pane }
|
|
63
|
+
let active = null;
|
|
64
|
+
const $tabs = document.getElementById('tabs');
|
|
65
|
+
const $body = document.getElementById('body');
|
|
66
|
+
|
|
67
|
+
const IMG = /\.(png|jpe?g|gif|webp|svg|ico|bmp)$/i;
|
|
68
|
+
const VID = /\.(mp4|webm|mov|ogg)$/i;
|
|
69
|
+
const AUD = /\.(mp3|wav|m4a)$/i;
|
|
70
|
+
const CODE = /\.(mjs|js|ts|jsx|tsx|css|html|json|py|sh|sql|yml|yaml)$/i;
|
|
71
|
+
|
|
72
|
+
const KW = /\b(const|let|var|function|class|return|if|else|for|while|of|in|import|export|from|await|async|new|try|catch|finally|throw|switch|case|break|continue|default|typeof|instanceof|this|null|undefined|true|false|def|elif|lambda|print|select|insert|update|delete|where)\b/;
|
|
73
|
+
function highlight(src) {
|
|
74
|
+
const re = /(\/\/[^\n]*|\/\*[\s\S]*?\*\/|#[^\n]*|"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`|\b\d[\d_.]*\b|[A-Za-z_$][\w$]*)/g;
|
|
75
|
+
let out = '', last = 0, m;
|
|
76
|
+
while ((m = re.exec(src))) {
|
|
77
|
+
out += esc(src.slice(last, m.index));
|
|
78
|
+
const t = m[0];
|
|
79
|
+
if (/^(\/\/|\/\*|#)/.test(t)) out += `<span class="tok-com">${esc(t)}</span>`;
|
|
80
|
+
else if (/^["'\`]/.test(t)) out += `<span class="tok-str">${esc(t)}</span>`;
|
|
81
|
+
else if (/^\d/.test(t)) out += `<span class="tok-num">${esc(t)}</span>`;
|
|
82
|
+
else if (KW.test(t)) out += `<span class="tok-kw">${esc(t)}</span>`;
|
|
83
|
+
else out += esc(t);
|
|
84
|
+
last = m.index + t.length;
|
|
85
|
+
}
|
|
86
|
+
return out + esc(src.slice(last));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function codeView(text) {
|
|
90
|
+
const lines = highlight(text).split('\n');
|
|
91
|
+
return `<div class="code">${lines.map((l, i) =>
|
|
92
|
+
`<div class="ln"><span class="no">${i + 1}</span><span class="ct">${l || ' '}</span></div>`).join('')}</div>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function renderPane(path, $pane) {
|
|
96
|
+
try {
|
|
97
|
+
if (IMG.test(path)) { $pane.innerHTML = `<div class="media"><img src="/api/raw?path=${encodeURIComponent(path)}"></div>`; return; }
|
|
98
|
+
if (VID.test(path)) { $pane.innerHTML = `<div class="media"><video controls src="/api/raw?path=${encodeURIComponent(path)}"></video></div>`; return; }
|
|
99
|
+
if (AUD.test(path)) { $pane.innerHTML = `<div class="media"><audio controls src="/api/raw?path=${encodeURIComponent(path)}"></audio></div>`; return; }
|
|
100
|
+
const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
|
|
101
|
+
const data = await res.json();
|
|
102
|
+
if (!res.ok) throw new Error(data.error);
|
|
103
|
+
if (/\.md$/i.test(path)) $pane.innerHTML = `<div class="doc">${mdDoc(data.content)}</div>`;
|
|
104
|
+
else if (CODE.test(path)) $pane.innerHTML = codeView(data.content);
|
|
105
|
+
else $pane.innerHTML = `<div class="plain">${esc(data.content)}</div>`;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
$pane.innerHTML = `<div id="empty">${esc(t('viewer.openFail', { msg: String(err.message ?? err) }))}</div>`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function activate(path) {
|
|
112
|
+
active = path;
|
|
113
|
+
for (const t of tabs) {
|
|
114
|
+
t.$tab.classList.toggle('active', t.path === path);
|
|
115
|
+
t.$pane.classList.toggle('active', t.path === path);
|
|
116
|
+
}
|
|
117
|
+
document.title = `${path.split('/').pop()} — ${t('viewer.title')}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function closeTab(path) {
|
|
121
|
+
const i = tabs.findIndex((t) => t.path === path);
|
|
122
|
+
if (i < 0) return;
|
|
123
|
+
tabs[i].$tab.remove(); tabs[i].$pane.remove();
|
|
124
|
+
tabs.splice(i, 1);
|
|
125
|
+
if (active === path) tabs.length ? activate(tabs[Math.max(0, i - 1)].path) : (active = null);
|
|
126
|
+
if (!tabs.length) $body.innerHTML = emptyHtml();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function openTab(path) {
|
|
130
|
+
const found = tabs.find((t) => t.path === path);
|
|
131
|
+
if (found) { renderPane(path, found.$pane); activate(path); return; }
|
|
132
|
+
document.getElementById('empty')?.remove();
|
|
133
|
+
const $tab = document.createElement('div');
|
|
134
|
+
$tab.className = 'tab';
|
|
135
|
+
$tab.innerHTML = `<span>${esc(path.split('/').pop())}</span><b class="x">✕</b>`;
|
|
136
|
+
$tab.title = path;
|
|
137
|
+
$tab.onclick = () => activate(path);
|
|
138
|
+
$tab.querySelector('.x').onclick = (e) => { e.stopPropagation(); closeTab(path); };
|
|
139
|
+
const $pane = document.createElement('div');
|
|
140
|
+
$pane.className = 'pane';
|
|
141
|
+
$tabs.append($tab); $body.append($pane);
|
|
142
|
+
tabs.push({ path, $tab, $pane });
|
|
143
|
+
renderPane(path, $pane);
|
|
144
|
+
activate(path);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
new BroadcastChannel('otree-viewer').onmessage = (e) => {
|
|
148
|
+
if (e.data?.path) { openTab(e.data.path); window.focus(); }
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const first = new URLSearchParams(location.search).get('path');
|
|
152
|
+
if (first) openTab(first);
|
|
153
|
+
else $body.innerHTML = emptyHtml(); // localize the empty-state placeholder
|
|
154
|
+
</script>
|
|
155
|
+
</body>
|
|
156
|
+
</html>
|