forked from retoor/devplacepy
feat: add TTLCache for get_cache_version and TEMPLATE_AUTO_RELOAD config with Makefile worker count variables
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.bots-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.bot-card {
|
||||
margin: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card-hover);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.bot-card.bot-active {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.bot-card.bot-idle {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.bot-shot {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 10;
|
||||
background: var(--bg-input);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bot-shot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bot-shot-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.bot-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bot-persona {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.bot-action {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.bots-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
@@ -132,3 +132,140 @@
|
||||
.cm-body { grid-template-columns: 1fr; }
|
||||
.ci-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.admin-btn-primary {
|
||||
background: var(--accent, var(--info));
|
||||
color: var(--white);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.admin-btn-danger {
|
||||
background: var(--danger);
|
||||
color: var(--white);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.cm-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cm-form.cm-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.cm-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cm-form input[type="text"],
|
||||
.cm-form input[type="number"],
|
||||
.cm-form select,
|
||||
.cm-form textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--bg-input, var(--bg-card));
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.cm-form textarea {
|
||||
font-family: var(--font-mono, monospace);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.cm-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cm-check {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cm-suggest {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cm-suggest:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cm-suggest li {
|
||||
padding: 0.4rem 0.65rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cm-suggest li:hover,
|
||||
.cm-suggest li.cm-suggest-active {
|
||||
background: var(--bg-hover, var(--border));
|
||||
}
|
||||
|
||||
.cm-suggest li.cm-suggest-active {
|
||||
outline: 2px solid var(--accent, var(--border));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.cm-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cm-form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.ci-events {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ci-event {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ci-event-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ci-event-time {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,109 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.messages-header-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages-presence {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.messages-presence.online {
|
||||
color: var(--success, var(--accent));
|
||||
}
|
||||
|
||||
.messages-presence.online::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success, var(--accent));
|
||||
margin-right: 0.3125rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.conversation-unread-dot {
|
||||
flex-shrink: 0;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.conversation-unread-dot[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-receipt {
|
||||
font-size: 0.6875rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-left: 0.375rem;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.message-receipt[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-bubble.pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.attachment-pending {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-card-hover);
|
||||
border-radius: var(--radius-lg);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.typing-indicator[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
animation: typing-bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing-bounce {
|
||||
0%, 60%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.messages-thread {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -233,6 +336,39 @@
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.messages-send-btn .send-spinner {
|
||||
display: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--white);
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: messages-send-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.messages-send-btn.is-sending {
|
||||
cursor: default;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.messages-send-btn.is-sending .send-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messages-send-btn.is-sending .send-spinner {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.messages-send-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@keyframes messages-send-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.messages-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
import { Poller } from "./Poller.js";
|
||||
|
||||
export class BotMonitor {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.endpoint = root.dataset.endpoint;
|
||||
this.count = document.getElementById("bots-count");
|
||||
this.pollMs = 2000;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.poller = new Poller(() => this.refresh(), this.pollMs, {
|
||||
immediate: false,
|
||||
pauseHidden: true,
|
||||
});
|
||||
}
|
||||
|
||||
escape(value) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value == null ? "" : String(value);
|
||||
return span.innerHTML;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const payload = await Http.getJson(this.endpoint);
|
||||
const frames = payload.frames || [];
|
||||
if (this.count) {
|
||||
this.count.textContent = `${frames.length} bots`;
|
||||
}
|
||||
if (!frames.length) {
|
||||
this.root.innerHTML = `<p class="admin-empty">No bots running. Launch the fleet on <a href="/admin/services">Services</a>.</p>`;
|
||||
return;
|
||||
}
|
||||
this.reconcile(frames);
|
||||
}
|
||||
|
||||
reconcile(frames) {
|
||||
const seen = new Set();
|
||||
let previous = null;
|
||||
for (const frame of frames) {
|
||||
const slot = String(frame.slot);
|
||||
seen.add(slot);
|
||||
let card = this.root.querySelector(`.bot-card[data-slot="${this.escape(slot)}"]`);
|
||||
if (card) {
|
||||
this.update(card, frame);
|
||||
} else {
|
||||
card = document.createElement("div");
|
||||
card.innerHTML = this.card(frame);
|
||||
card = card.firstElementChild;
|
||||
}
|
||||
const anchor = previous ? previous.nextElementSibling : this.root.firstElementChild;
|
||||
if (anchor !== card) {
|
||||
this.root.insertBefore(card, anchor);
|
||||
}
|
||||
previous = card;
|
||||
}
|
||||
for (const card of [...this.root.querySelectorAll(".bot-card")]) {
|
||||
if (!seen.has(card.dataset.slot)) {
|
||||
card.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(card, frame) {
|
||||
const active = frame.active ? "bot-active" : "bot-idle";
|
||||
card.className = `bot-card ${active}`;
|
||||
const shot = card.querySelector(".bot-shot");
|
||||
const image = shot.querySelector("img");
|
||||
if (frame.has_image) {
|
||||
if (image) {
|
||||
if (image.getAttribute("src") !== frame.frame_url) {
|
||||
image.src = frame.frame_url;
|
||||
}
|
||||
} else {
|
||||
shot.innerHTML = `<img src="${this.escape(frame.frame_url)}" alt="${this.escape(frame.label || `bot${frame.slot}`)} screenshot" loading="lazy">`;
|
||||
}
|
||||
} else if (!shot.querySelector(".bot-shot-empty")) {
|
||||
shot.innerHTML = `<div class="bot-shot-empty">no frame yet</div>`;
|
||||
}
|
||||
card.querySelector(".bot-name").textContent = frame.label || `bot${frame.slot}`;
|
||||
card.querySelector(".bot-persona").textContent = frame.persona || "-";
|
||||
card.querySelector(".bot-action").textContent = frame.action || frame.status || "idle";
|
||||
}
|
||||
|
||||
card(frame) {
|
||||
const slot = this.escape(frame.slot);
|
||||
const label = this.escape(frame.label || `bot${frame.slot}`);
|
||||
const persona = this.escape(frame.persona || "-");
|
||||
const action = this.escape(frame.action || frame.status || "idle");
|
||||
const active = frame.active ? "bot-active" : "bot-idle";
|
||||
const shot = frame.has_image
|
||||
? `<img src="${this.escape(frame.frame_url)}" alt="${label} screenshot" loading="lazy">`
|
||||
: `<div class="bot-shot-empty">no frame yet</div>`;
|
||||
return `<figure class="bot-card ${active}" data-slot="${slot}">
|
||||
<div class="bot-shot">${shot}</div>
|
||||
<figcaption class="bot-meta">
|
||||
<span class="bot-name">${label}</span>
|
||||
<span class="bot-persona">${persona}</span>
|
||||
<span class="bot-action">${action}</span>
|
||||
</figcaption>
|
||||
</figure>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
import { wireSearch, wireBootToggle } from "./ContainerFormControls.js";
|
||||
|
||||
export class ContainerEdit {
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
this.uid = form ? form.dataset.uid : "";
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.form) return;
|
||||
wireSearch(this.form, "user", "/admin/containers/users/search", "run_as_uid", (r) => r.uid, (r) => r.username);
|
||||
wireBootToggle(this.form);
|
||||
this.form.addEventListener("submit", (e) => { e.preventDefault(); this.save(); });
|
||||
}
|
||||
|
||||
toast(message, type) {
|
||||
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
|
||||
}
|
||||
|
||||
async save() {
|
||||
const f = this.form;
|
||||
const params = {
|
||||
run_as_uid: f.elements.run_as_uid.value.trim(),
|
||||
boot_language: f.elements.boot_language.value,
|
||||
boot_script: f.elements.boot_script.value,
|
||||
boot_command: f.elements.boot_command.value.trim(),
|
||||
restart_policy: f.elements.restart_policy.value,
|
||||
cpu_limit: f.elements.cpu_limit.value.trim(),
|
||||
mem_limit: f.elements.mem_limit.value.trim(),
|
||||
start_on_boot: f.elements.start_on_boot.checked ? "true" : "false",
|
||||
};
|
||||
try {
|
||||
await Http.send(`/admin/containers/${this.uid}/edit`, params);
|
||||
this.toast("Saved", "success");
|
||||
window.location.assign(`/admin/containers/${this.uid}`);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
export function escapeHtml(value) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value == null ? "" : String(value);
|
||||
return span.innerHTML;
|
||||
}
|
||||
|
||||
export function wireBootToggle(form) {
|
||||
const select = form.elements.boot_language;
|
||||
const update = () => {
|
||||
const script = form.querySelector('[data-boot="script"]');
|
||||
if (script) script.style.display = select.value === "none" ? "none" : "";
|
||||
};
|
||||
select.addEventListener("change", update);
|
||||
update();
|
||||
}
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 200;
|
||||
|
||||
export function wireSearch(form, kind, url, hiddenName, valueOf, labelOf) {
|
||||
const input = form.querySelector(`[data-search="${kind}"]`);
|
||||
const list = form.querySelector(`[data-suggest="${kind}"]`);
|
||||
const hidden = form.elements[hiddenName];
|
||||
if (!input || !list || !hidden) return;
|
||||
|
||||
if (!list.id) list.id = `cm-suggest-${kind}`;
|
||||
list.setAttribute("role", "listbox");
|
||||
input.setAttribute("role", "combobox");
|
||||
input.setAttribute("aria-autocomplete", "list");
|
||||
input.setAttribute("aria-controls", list.id);
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
|
||||
let timer = null;
|
||||
let active = -1;
|
||||
|
||||
const options = () => Array.from(list.querySelectorAll("li[data-value]"));
|
||||
|
||||
const close = () => {
|
||||
list.innerHTML = "";
|
||||
active = -1;
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
input.removeAttribute("aria-activedescendant");
|
||||
};
|
||||
|
||||
const setActive = (index) => {
|
||||
const opts = options();
|
||||
active = index;
|
||||
opts.forEach((li, i) => {
|
||||
const on = i === index;
|
||||
li.classList.toggle("cm-suggest-active", on);
|
||||
li.setAttribute("aria-selected", on ? "true" : "false");
|
||||
if (on) {
|
||||
input.setAttribute("aria-activedescendant", li.id);
|
||||
li.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
});
|
||||
if (index < 0) input.removeAttribute("aria-activedescendant");
|
||||
};
|
||||
|
||||
const choose = (item) => {
|
||||
if (!item) return;
|
||||
hidden.value = item.dataset.value;
|
||||
input.value = item.dataset.label;
|
||||
close();
|
||||
};
|
||||
|
||||
const render = (results) => {
|
||||
list.innerHTML = results.map((r, i) =>
|
||||
`<li id="${list.id}-opt-${i}" role="option" aria-selected="false" `
|
||||
+ `data-value="${escapeHtml(valueOf(r))}" data-label="${escapeHtml(labelOf(r))}">`
|
||||
+ `${escapeHtml(labelOf(r))}</li>`
|
||||
).join("");
|
||||
active = -1;
|
||||
input.setAttribute("aria-expanded", results.length ? "true" : "false");
|
||||
input.removeAttribute("aria-activedescendant");
|
||||
};
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
hidden.value = "";
|
||||
clearTimeout(timer);
|
||||
const q = input.value.trim();
|
||||
if (!q) { close(); return; }
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const data = await Http.getJson(`${url}?q=${encodeURIComponent(q)}`);
|
||||
render(data.results || []);
|
||||
} catch (err) {
|
||||
close();
|
||||
}
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
const opts = options();
|
||||
const count = opts.length;
|
||||
if (!count) return;
|
||||
const last = count - 1;
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setActive(active < last ? active + 1 : 0);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setActive(active > 0 ? active - 1 : last);
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
setActive(0);
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
setActive(last);
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
choose(active >= 0 ? opts[active] : opts[0]);
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
close();
|
||||
break;
|
||||
case "Tab":
|
||||
if (e.shiftKey) {
|
||||
if (active > 0) { e.preventDefault(); setActive(active - 1); }
|
||||
else close();
|
||||
} else if (active < last) {
|
||||
e.preventDefault();
|
||||
setActive(active + 1);
|
||||
} else {
|
||||
choose(opts[active]);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
list.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
list.addEventListener("click", (e) => {
|
||||
const item = e.target.closest("li[data-value]");
|
||||
choose(item);
|
||||
});
|
||||
input.addEventListener("blur", () => {
|
||||
window.setTimeout(close, 150);
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
import { Poller } from "./Poller.js";
|
||||
import { escapeHtml, wireSearch, wireBootToggle } from "./ContainerFormControls.js";
|
||||
|
||||
export class ContainerList {
|
||||
constructor(root) {
|
||||
@@ -12,20 +13,103 @@ export class ContainerList {
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindActions();
|
||||
this.bindCreate();
|
||||
this.poller = new Poller(() => this.refresh(), this.pollMs, { immediate: false });
|
||||
}
|
||||
|
||||
toast(message, type) {
|
||||
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
|
||||
}
|
||||
|
||||
escape(value) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value == null ? "" : String(value);
|
||||
return span.innerHTML;
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
bindActions() {
|
||||
if (!this.body) return;
|
||||
this.body.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-cm-action]");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
const row = btn.closest(".cm-row");
|
||||
if (!row) return;
|
||||
this.runAction(btn.dataset.cmAction, row.dataset.uid, row.dataset.slug, row.dataset.name);
|
||||
});
|
||||
}
|
||||
|
||||
async runAction(action, uid, slug, name) {
|
||||
if (action === "terminal") {
|
||||
if (window.app && window.app.containerTerminals && slug) {
|
||||
window.app.containerTerminals.open(slug, uid, name);
|
||||
} else {
|
||||
this.toast("Terminal needs a project slug", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "delete") {
|
||||
const ok = window.app && window.app.dialog
|
||||
? await window.app.dialog.confirm(`Delete container instance "${name}"? This cannot be undone.`)
|
||||
: window.confirm(`Delete container instance "${name}"?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
try {
|
||||
await Http.send(`/admin/containers/${uid}/${action}`, {});
|
||||
this.toast(`Instance ${action} requested`, "success");
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bindCreate() {
|
||||
this.form = document.getElementById("cm-admin-create-form");
|
||||
if (!this.form) return;
|
||||
wireSearch(this.form, "project", "/admin/containers/projects/search", "project_slug", (r) => r.slug, (r) => r.title);
|
||||
wireSearch(this.form, "user", "/admin/containers/users/search", "run_as_uid", (r) => r.uid, (r) => r.username);
|
||||
wireBootToggle(this.form);
|
||||
this.form.addEventListener("submit", (e) => { e.preventDefault(); this.create(); });
|
||||
}
|
||||
|
||||
async create() {
|
||||
const f = this.form;
|
||||
const slug = f.elements.project_slug.value.trim();
|
||||
if (!slug) { this.toast("Pick a project first", "error"); return; }
|
||||
const params = {
|
||||
project_slug: slug,
|
||||
name: f.elements.name.value.trim(),
|
||||
run_as_uid: f.elements.run_as_uid.value.trim(),
|
||||
boot_language: f.elements.boot_language.value,
|
||||
boot_script: f.elements.boot_script.value,
|
||||
boot_command: f.elements.boot_command.value.trim(),
|
||||
restart_policy: f.elements.restart_policy.value,
|
||||
ports: f.elements.ports.value.trim(),
|
||||
env: f.elements.env.value,
|
||||
cpu_limit: f.elements.cpu_limit.value.trim(),
|
||||
mem_limit: f.elements.mem_limit.value.trim(),
|
||||
ingress_slug: f.elements.ingress_slug.value.trim(),
|
||||
ingress_port: f.elements.ingress_port.value.trim(),
|
||||
start_on_boot: f.elements.start_on_boot.checked ? "true" : "false",
|
||||
autostart: f.elements.autostart.checked ? "true" : "false",
|
||||
};
|
||||
try {
|
||||
await Http.send("/admin/containers/create", params);
|
||||
const modal = document.getElementById("cm-admin-create-modal");
|
||||
if (modal) modal.classList.remove("visible");
|
||||
f.reset();
|
||||
wireBootToggle(this.form);
|
||||
this.toast("Instance created", "success");
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const instances = (await Http.getJson(this.endpoint)).instances || [];
|
||||
if (!this.body) return;
|
||||
if (!instances.length) {
|
||||
this.body.innerHTML = `<tr><td colspan="6" class="admin-empty">No container instances yet. Open a project's container manager to launch one.</td></tr>`;
|
||||
this.body.innerHTML = `<tr><td colspan="8" class="admin-empty">No container instances yet. Create one above or open a project's container manager.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
this.body.innerHTML = instances.map((inst) => this.row(inst)).join("");
|
||||
@@ -33,19 +117,33 @@ export class ContainerList {
|
||||
|
||||
row(inst) {
|
||||
const uid = this.escape(inst.uid);
|
||||
const slug = this.escape(inst.project_slug || "");
|
||||
const name = this.escape(inst.name);
|
||||
const project = inst.project_slug
|
||||
? `<a class="cm-row-project" href="/projects/${this.escape(inst.project_slug)}/containers">${this.escape(inst.project_title || inst.project_slug)}</a>`
|
||||
? `<a class="cm-row-project" href="/projects/${slug}/containers">${this.escape(inst.project_title || inst.project_slug)}</a>`
|
||||
: `<span class="cm-muted">-</span>`;
|
||||
const ingress = inst.ingress_slug
|
||||
? `<a href="/p/${this.escape(inst.ingress_slug)}" target="_blank" rel="noopener">/p/${this.escape(inst.ingress_slug)}</a>`
|
||||
: `<span class="cm-muted">-</span>`;
|
||||
return `<tr class="cm-row" data-uid="${uid}">
|
||||
<td><a class="cm-row-name" href="/admin/containers/${uid}">${this.escape(inst.name)}</a></td>
|
||||
const boot = inst.start_on_boot
|
||||
? `<span class="cm-badge cm-running">on</span>`
|
||||
: `<span class="cm-muted">off</span>`;
|
||||
return `<tr class="cm-row" data-uid="${uid}" data-slug="${slug}" data-name="${name}">
|
||||
<td><a class="cm-row-name" href="/admin/containers/${uid}">${name}</a></td>
|
||||
<td>${project}</td>
|
||||
<td><span class="cm-badge cm-${this.escape(inst.status)}">${this.escape(inst.status)}</span></td>
|
||||
<td>${this.escape(inst.boot_language || "none")}</td>
|
||||
<td>${ingress}</td>
|
||||
<td>${this.escape(inst.restart_policy || "never")}</td>
|
||||
<td><a class="admin-btn admin-btn-sm" href="/admin/containers/${uid}">Manage →</a></td>
|
||||
<td>${boot}</td>
|
||||
<td class="cm-actions">
|
||||
<button class="admin-btn admin-btn-sm" data-cm-action="start">Start</button>
|
||||
<button class="admin-btn admin-btn-sm" data-cm-action="stop">Stop</button>
|
||||
<button class="admin-btn admin-btn-sm" data-cm-action="restart">Restart</button>
|
||||
<button class="admin-btn admin-btn-sm" data-cm-action="terminal">Terminal</button>
|
||||
<a class="admin-btn admin-btn-sm" href="/admin/containers/${uid}/edit">Edit</a>
|
||||
<button class="admin-btn admin-btn-sm admin-btn-danger" data-cm-action="delete">Delete</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class CustomizationToggle {
|
||||
Toast.flash(button, "Error", 1500);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.classList.remove("is-loading");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ export class FormManager {
|
||||
|
||||
initFormDisable() {
|
||||
document.querySelectorAll("form").forEach((form) => {
|
||||
if (form.hasAttribute("data-live-form")) {
|
||||
return;
|
||||
}
|
||||
form.addEventListener("submit", () => {
|
||||
const btn = form.querySelector("button[type='submit']");
|
||||
if (btn) {
|
||||
|
||||
@@ -6,6 +6,26 @@ export class Http {
|
||||
window.location.href = `/auth/login?next=${next}`;
|
||||
}
|
||||
|
||||
static notifyError(message) {
|
||||
const text = (message && String(message).trim()) || "Something went wrong. Please try again.";
|
||||
const app = window.app;
|
||||
if (app && app.dialog && typeof app.dialog.alert === "function") {
|
||||
app.dialog.alert({ title: "Error", message: text });
|
||||
return;
|
||||
}
|
||||
if (app && app.toast && typeof app.toast.show === "function") {
|
||||
app.toast.show(text, { type: "error", ms: 6000 });
|
||||
return;
|
||||
}
|
||||
console.error(text);
|
||||
}
|
||||
|
||||
static async _messageFrom(response) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const message = (data && data.error && data.error.message) || `request failed with status ${response.status}`;
|
||||
return { data, message };
|
||||
}
|
||||
|
||||
static async getJson(url) {
|
||||
const response = await fetch(url, { headers: { "Accept": "application/json" } });
|
||||
if (!response.ok) {
|
||||
@@ -14,10 +34,11 @@ export class Http {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
static async sendForm(url, params = {}) {
|
||||
static async sendForm(url, params = {}, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"X-Requested-With": "fetch",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
@@ -28,12 +49,14 @@ export class Http {
|
||||
return new Promise(() => {});
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`request failed with status ${response.status}`);
|
||||
const { message } = await Http._messageFrom(response);
|
||||
if (!options.silent) Http.notifyError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
return response.json();
|
||||
return response.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
static async send(url, params = {}) {
|
||||
static async send(url, params = {}, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -42,21 +65,33 @@ export class Http {
|
||||
},
|
||||
body: new URLSearchParams(params),
|
||||
});
|
||||
if (response.redirected && response.url.includes("/auth/login")) {
|
||||
Http.toLogin();
|
||||
return new Promise(() => {});
|
||||
}
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.ok === false) {
|
||||
throw new Error((data.error && data.error.message) || `request failed: ${response.status}`);
|
||||
const message = (data.error && data.error.message) || `request failed: ${response.status}`;
|
||||
if (!options.silent) Http.notifyError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
static async postJson(url, body) {
|
||||
static async postJson(url, body, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Accept": "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (response.redirected && response.url.includes("/auth/login")) {
|
||||
Http.toLogin();
|
||||
return new Promise(() => {});
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`request failed with status ${response.status}`);
|
||||
const { message } = await Http._messageFrom(response);
|
||||
if (!options.silent) Http.notifyError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { MessagesSocket } from "./MessagesSocket.js";
|
||||
import { contentRenderer } from "./ContentRenderer.js";
|
||||
|
||||
const TYPING_THROTTLE_MS = 1500;
|
||||
const TYPING_HIDE_MS = 4000;
|
||||
|
||||
export class MessagesLayout {
|
||||
constructor() {
|
||||
this.layout = document.querySelector(".messages-layout");
|
||||
@@ -7,12 +13,359 @@ export class MessagesLayout {
|
||||
return;
|
||||
}
|
||||
this.thread = document.querySelector(".messages-thread");
|
||||
this.input = document.querySelector('.messages-input-area input[name="content"]');
|
||||
this.form = document.querySelector(".messages-input-area");
|
||||
this.input = this.form ? this.form.querySelector('input[name="content"]') : null;
|
||||
this.upload = this.form ? this.form.querySelector("dp-upload") : null;
|
||||
this.sendBtn = this.form ? this.form.querySelector(".messages-send-btn") : null;
|
||||
this._uploading = false;
|
||||
this._pendingSends = new Set();
|
||||
this.presenceEl = document.getElementById("messages-presence");
|
||||
this.typingEl = document.getElementById("typing-indicator");
|
||||
|
||||
this.selfUid = this.layout.dataset.selfUid || "";
|
||||
this.otherUid = this.layout.dataset.otherUid || "";
|
||||
|
||||
this._lastTypingSent = 0;
|
||||
this._typingHideTimer = null;
|
||||
this._socketReady = false;
|
||||
|
||||
this.scrollThreadToEnd();
|
||||
if (this.input) {
|
||||
this.input.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
this.initPresence();
|
||||
this.connect();
|
||||
this.bindForm();
|
||||
this.bindTyping();
|
||||
this.markRead();
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.socket = new MessagesSocket({
|
||||
onReady: () => this.onReady(),
|
||||
onMessage: (frame) => this.onFrame(frame),
|
||||
onClose: () => { this._socketReady = false; },
|
||||
});
|
||||
this.socket.connect();
|
||||
}
|
||||
|
||||
onReady() {
|
||||
this._socketReady = true;
|
||||
if (this.otherUid) {
|
||||
this.socket.send({ type: "presence", with_uid: this.otherUid });
|
||||
this.markRead();
|
||||
}
|
||||
}
|
||||
|
||||
onFrame(frame) {
|
||||
switch (frame.type) {
|
||||
case "message":
|
||||
this.handleIncoming(frame);
|
||||
break;
|
||||
case "typing":
|
||||
if (frame.from_uid === this.otherUid) this.showTyping();
|
||||
break;
|
||||
case "read":
|
||||
if (frame.by_uid === this.otherUid) this.markReceipts();
|
||||
break;
|
||||
case "presence":
|
||||
if (frame.user_uid === this.otherUid) {
|
||||
this.renderPresence(frame.online, frame.last_seen);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bindForm() {
|
||||
if (!this.form || !this.input) return;
|
||||
this.form.addEventListener("submit", (event) => {
|
||||
if (!this._socketReady || !this.socket.isOpen()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.sendViaSocket();
|
||||
});
|
||||
if (this.upload) {
|
||||
this.upload.addEventListener("dp-upload:busy", (event) => {
|
||||
this._uploading = !!(event.detail && event.detail.busy);
|
||||
this.refreshSendButton();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
refreshSendButton() {
|
||||
if (!this.sendBtn) return;
|
||||
const busy = this._uploading || this._pendingSends.size > 0;
|
||||
this.sendBtn.disabled = busy;
|
||||
this.sendBtn.classList.toggle("is-sending", busy);
|
||||
}
|
||||
|
||||
sendViaSocket() {
|
||||
const content = (this.input.value || "").trim();
|
||||
const attachmentUids = this.collectAttachments();
|
||||
if (!content && attachmentUids.length === 0) return;
|
||||
const clientId = "c" + Date.now() + Math.random().toString(36).slice(2, 8);
|
||||
this.appendOptimistic(content, clientId, attachmentUids.length);
|
||||
const ok = this.socket.send({
|
||||
type: "send",
|
||||
receiver_uid: this.otherUid,
|
||||
content,
|
||||
attachment_uids: attachmentUids,
|
||||
client_id: clientId,
|
||||
});
|
||||
if (!ok) {
|
||||
this.form.submit();
|
||||
return;
|
||||
}
|
||||
this._pendingSends.add(clientId);
|
||||
this.refreshSendButton();
|
||||
window.setTimeout(() => this.clearPendingSend(clientId), 8000);
|
||||
this.input.value = "";
|
||||
if (this.upload && typeof this.upload.clear === "function") {
|
||||
this.upload.clear();
|
||||
}
|
||||
this.input.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
clearPendingSend(clientId) {
|
||||
if (this._pendingSends.delete(clientId)) {
|
||||
this.refreshSendButton();
|
||||
}
|
||||
}
|
||||
|
||||
collectAttachments() {
|
||||
const hidden = this.form.querySelector('input[name="attachment_uids"]');
|
||||
if (!hidden || !hidden.value) return [];
|
||||
return hidden.value.split(",").map((v) => v.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
bindTyping() {
|
||||
if (!this.input) return;
|
||||
this.input.addEventListener("input", () => {
|
||||
if (!this._socketReady || !this.otherUid) return;
|
||||
const now = Date.now();
|
||||
if (now - this._lastTypingSent < TYPING_THROTTLE_MS) return;
|
||||
this._lastTypingSent = now;
|
||||
this.socket.send({ type: "typing", receiver_uid: this.otherUid });
|
||||
});
|
||||
}
|
||||
|
||||
appendOptimistic(content, clientId, attachmentCount) {
|
||||
const bubble = this.buildBubble({
|
||||
content,
|
||||
mine: true,
|
||||
clientId,
|
||||
time: "now",
|
||||
attachmentCount,
|
||||
});
|
||||
bubble.classList.add("pending");
|
||||
this.insertBubble(bubble);
|
||||
}
|
||||
|
||||
handleIncoming(frame) {
|
||||
if (frame.sender_uid === this.selfUid && frame.client_id) {
|
||||
this.clearPendingSend(frame.client_id);
|
||||
const pending = this.thread.querySelector(`.message-bubble[data-client-id="${frame.client_id}"]`);
|
||||
if (pending) {
|
||||
pending.classList.remove("pending");
|
||||
pending.dataset.msgUid = frame.uid;
|
||||
const time = pending.querySelector(".message-time");
|
||||
if (time) time.textContent = frame.time_ago;
|
||||
const placeholder = pending.querySelector(".attachment-pending");
|
||||
if (placeholder) placeholder.remove();
|
||||
const gallery = this.renderAttachments(frame.attachments);
|
||||
if (gallery) pending.insertBefore(gallery, time || null);
|
||||
this.bumpConversation(frame, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const partnerUid = frame.sender_uid === this.selfUid ? frame.receiver_uid : frame.sender_uid;
|
||||
const inThisThread = this.otherUid && partnerUid === this.otherUid;
|
||||
|
||||
if (inThisThread) {
|
||||
const mine = frame.sender_uid === this.selfUid;
|
||||
const bubble = this.buildBubble({
|
||||
content: frame.content,
|
||||
mine,
|
||||
time: frame.time_ago,
|
||||
uid: frame.uid,
|
||||
attachments: frame.attachments,
|
||||
});
|
||||
this.insertBubble(bubble);
|
||||
if (!mine && this._socketReady) {
|
||||
this.socket.send({ type: "read", with_uid: this.otherUid });
|
||||
}
|
||||
}
|
||||
this.bumpConversation(frame, frame.sender_uid === this.selfUid);
|
||||
}
|
||||
|
||||
buildBubble({ content, mine, time, uid, clientId, attachments, attachmentCount }) {
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "message-bubble " + (mine ? "mine" : "theirs");
|
||||
if (uid) bubble.dataset.msgUid = uid;
|
||||
if (clientId) bubble.dataset.clientId = clientId;
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "rendered-content";
|
||||
body.setAttribute("data-render", "");
|
||||
body.textContent = content || "";
|
||||
bubble.appendChild(body);
|
||||
|
||||
const gallery = this.renderAttachments(attachments);
|
||||
if (gallery) {
|
||||
bubble.appendChild(gallery);
|
||||
} else if (attachmentCount > 0) {
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "attachment-pending";
|
||||
placeholder.textContent = attachmentCount === 1
|
||||
? "Uploading attachment..."
|
||||
: `Uploading ${attachmentCount} attachments...`;
|
||||
bubble.appendChild(placeholder);
|
||||
}
|
||||
|
||||
const timeEl = document.createElement("span");
|
||||
timeEl.className = "message-time";
|
||||
timeEl.textContent = time || "";
|
||||
bubble.appendChild(timeEl);
|
||||
|
||||
if (mine) {
|
||||
const receipt = document.createElement("span");
|
||||
receipt.className = "message-receipt";
|
||||
receipt.hidden = true;
|
||||
receipt.innerHTML = "✓✓";
|
||||
bubble.appendChild(receipt);
|
||||
}
|
||||
|
||||
this.renderBody(body);
|
||||
return bubble;
|
||||
}
|
||||
|
||||
renderBody(el) {
|
||||
contentRenderer.applyTo(el);
|
||||
el.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
|
||||
img.dataset.lightbox = "";
|
||||
});
|
||||
contentRenderer.highlightAll();
|
||||
}
|
||||
|
||||
renderAttachments(attachments) {
|
||||
if (!attachments || !attachments.length) return null;
|
||||
const gallery = document.createElement("div");
|
||||
gallery.className = "attachment-gallery";
|
||||
for (const att of attachments) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "attachment-gallery-item";
|
||||
if (att.is_image) {
|
||||
const img = document.createElement("img");
|
||||
img.src = att.thumbnail_url || att.url;
|
||||
img.alt = att.original_filename || "";
|
||||
img.loading = "lazy";
|
||||
img.className = "gallery-thumb";
|
||||
img.dataset.lightbox = "";
|
||||
img.dataset.full = att.url;
|
||||
if (att.mime_type) img.dataset.mime = att.mime_type;
|
||||
item.appendChild(img);
|
||||
} else if (att.is_video) {
|
||||
const video = document.createElement("video");
|
||||
video.src = att.url;
|
||||
video.controls = true;
|
||||
video.preload = "metadata";
|
||||
video.className = "gallery-video";
|
||||
item.appendChild(video);
|
||||
} else {
|
||||
const link = document.createElement("a");
|
||||
link.href = att.url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener";
|
||||
link.className = "non-image";
|
||||
link.download = att.original_filename || "file";
|
||||
link.textContent = att.original_filename || "file";
|
||||
item.appendChild(link);
|
||||
}
|
||||
gallery.appendChild(item);
|
||||
}
|
||||
return gallery;
|
||||
}
|
||||
|
||||
insertBubble(bubble) {
|
||||
if (!this.thread) return;
|
||||
if (this.typingEl && this.typingEl.parentElement === this.thread) {
|
||||
this.thread.insertBefore(bubble, this.typingEl);
|
||||
} else {
|
||||
this.thread.appendChild(bubble);
|
||||
}
|
||||
this.scrollThreadToEnd();
|
||||
}
|
||||
|
||||
markReceipts() {
|
||||
this.thread.querySelectorAll(".message-bubble.mine .message-receipt").forEach((el) => {
|
||||
el.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
markRead() {
|
||||
if (this._socketReady && this.otherUid) {
|
||||
this.socket.send({ type: "read", with_uid: this.otherUid });
|
||||
}
|
||||
}
|
||||
|
||||
showTyping() {
|
||||
if (!this.typingEl) return;
|
||||
this.typingEl.hidden = false;
|
||||
this.scrollThreadToEnd();
|
||||
clearTimeout(this._typingHideTimer);
|
||||
this._typingHideTimer = setTimeout(() => {
|
||||
this.typingEl.hidden = true;
|
||||
}, TYPING_HIDE_MS);
|
||||
}
|
||||
|
||||
initPresence() {
|
||||
if (!this.presenceEl) return;
|
||||
const online = this.layout.dataset.otherOnline === "1";
|
||||
const lastSeen = this.layout.dataset.otherLastSeen || null;
|
||||
this.renderPresence(online, lastSeen);
|
||||
}
|
||||
|
||||
renderPresence(online, lastSeen) {
|
||||
if (!this.presenceEl) return;
|
||||
this.presenceEl.classList.toggle("online", !!online);
|
||||
if (online) {
|
||||
this.presenceEl.textContent = "online";
|
||||
} else if (lastSeen) {
|
||||
this.presenceEl.textContent = "last seen " + this.formatLastSeen(lastSeen);
|
||||
} else {
|
||||
this.presenceEl.textContent = "offline";
|
||||
}
|
||||
}
|
||||
|
||||
formatLastSeen(iso) {
|
||||
const then = new Date(iso);
|
||||
if (Number.isNaN(then.getTime())) return "recently";
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - then.getTime()) / 1000));
|
||||
if (seconds < 60) return "just now";
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
|
||||
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
|
||||
return then.toLocaleDateString("en-GB");
|
||||
}
|
||||
|
||||
bumpConversation(frame, mine) {
|
||||
const partnerUid = mine ? frame.receiver_uid : frame.sender_uid;
|
||||
const item = document.querySelector(`.conversation-item[data-conv-uid="${partnerUid}"]`);
|
||||
if (!item) return;
|
||||
const preview = item.querySelector(".conversation-preview");
|
||||
if (preview) preview.textContent = frame.content.slice(0, 60);
|
||||
const dot = item.querySelector(".conversation-unread-dot");
|
||||
if (dot) {
|
||||
dot.hidden = mine || partnerUid === this.otherUid;
|
||||
}
|
||||
const list = item.parentElement;
|
||||
if (list && list.firstElementChild !== item) {
|
||||
list.insertBefore(item, list.firstElementChild);
|
||||
}
|
||||
}
|
||||
|
||||
scrollThreadToEnd() {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const RECONNECT_DELAY_MS = 1500;
|
||||
const WRONG_WORKER_RETRY_MS = 200;
|
||||
const WRONG_WORKER_CODE = 4013;
|
||||
|
||||
export class MessagesSocket {
|
||||
constructor(handlers) {
|
||||
this.handlers = handlers || {};
|
||||
this.ws = null;
|
||||
this.url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/messages/ws";
|
||||
this._shouldRun = false;
|
||||
this._settled = false;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this._shouldRun = true;
|
||||
this._open();
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
_open() {
|
||||
this._settled = false;
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.ws.addEventListener("open", () => this._emit("onOpen"));
|
||||
this.ws.addEventListener("close", (event) => {
|
||||
const transient = !this._settled && event.code === WRONG_WORKER_CODE;
|
||||
if (!transient) this._emit("onClose");
|
||||
if (this._shouldRun) {
|
||||
const delay = transient ? WRONG_WORKER_RETRY_MS : RECONNECT_DELAY_MS;
|
||||
window.setTimeout(() => this._open(), delay);
|
||||
}
|
||||
});
|
||||
this.ws.addEventListener("error", () => this._emit("onError"));
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!this._settled) {
|
||||
this._settled = true;
|
||||
this._emit("onReady", payload);
|
||||
}
|
||||
this._emit("onMessage", payload);
|
||||
});
|
||||
}
|
||||
|
||||
send(message) {
|
||||
if (this.isOpen()) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
close() {
|
||||
this._shouldRun = false;
|
||||
if (this.ws) this.ws.close();
|
||||
}
|
||||
|
||||
_emit(name, payload) {
|
||||
const handler = this.handlers[name];
|
||||
if (typeof handler === "function") handler(payload);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Toast } from "./Toast.js";
|
||||
export class OptimisticAction {
|
||||
async submit(url, params, errorTarget, render) {
|
||||
try {
|
||||
const result = await Http.sendForm(url, params);
|
||||
const result = await Http.sendForm(url, params, { silent: true });
|
||||
if (render) render(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -19,7 +19,7 @@ export class OptimisticAction {
|
||||
async submitOptimistic(url, params, errorTarget, apply, revert, reconcile) {
|
||||
apply();
|
||||
try {
|
||||
const result = await Http.sendForm(url, params);
|
||||
const result = await Http.sendForm(url, params, { silent: true });
|
||||
if (reconcile) reconcile(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
@@ -16,8 +16,10 @@ export class AppUpload extends Component {
|
||||
this.maxFiles = this.intAttr("max-files", 10);
|
||||
this.allowedTypes = this.attr("allowed-types", "")
|
||||
.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
||||
this.showChips = !this.boolAttr("hide-chips");
|
||||
this.extraFields = {};
|
||||
this.items = [];
|
||||
this.busyCount = 0;
|
||||
this.build();
|
||||
}
|
||||
|
||||
@@ -145,6 +147,7 @@ export class AppUpload extends Component {
|
||||
|
||||
async upload(file) {
|
||||
this.button.classList.add("uploading");
|
||||
this.setBusy(true);
|
||||
const body = new FormData();
|
||||
body.append(this.fieldName, file);
|
||||
for (const [key, value] of Object.entries(this.extraFields)) {
|
||||
@@ -172,10 +175,21 @@ export class AppUpload extends Component {
|
||||
this.emit("error", { file, message: error.message });
|
||||
} finally {
|
||||
this.button.classList.remove("uploading");
|
||||
this.setBusy(false);
|
||||
this.updateCount();
|
||||
}
|
||||
}
|
||||
|
||||
setBusy(busy) {
|
||||
this.busyCount = Math.max(0, this.busyCount + (busy ? 1 : -1));
|
||||
const active = this.busyCount > 0;
|
||||
if (active === this._lastBusy) {
|
||||
return;
|
||||
}
|
||||
this._lastBusy = active;
|
||||
this.emit("busy", { busy: active });
|
||||
}
|
||||
|
||||
setFieldFiles(files) {
|
||||
if (this.input.multiple) {
|
||||
this.items.push(...files.map((file) => ({ file, name: file.name })));
|
||||
@@ -209,6 +223,9 @@ export class AppUpload extends Component {
|
||||
|
||||
renderChips() {
|
||||
this.chips.textContent = "";
|
||||
if (!this.showChips) {
|
||||
return;
|
||||
}
|
||||
this.items.forEach((item, index) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "dp-upload-chip";
|
||||
|
||||
Reference in New Issue
Block a user