This commit is contained in:
2026-07-09 02:52:54 +02:00
parent 818568c609
commit 48bb6c2ec2
95 changed files with 6115 additions and 267 deletions
+24
View File
@@ -40,6 +40,30 @@
margin-bottom: 1.25rem;
}
.admin-tabs[data-overflow-tabs] {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 0.25rem;
}
.admin-tabs[data-overflow-tabs] .overflow-tabs-more {
background: none;
border: none;
box-shadow: none;
cursor: pointer;
padding: 0.4rem 0.85rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
}
.admin-tabs[data-overflow-tabs] .overflow-tabs-more:hover {
color: var(--text-primary);
border-color: transparent;
background: none;
}
.admin-tab {
display: inline-flex;
align-items: center;
+107
View File
@@ -0,0 +1,107 @@
.award-prominent {
margin-bottom: 1.25rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
text-align: center;
}
.award-prominent-image {
width: min(256px, 100%);
height: auto;
object-fit: contain;
margin: 0 auto 0.75rem;
}
.award-prominent-desc {
font-size: 1.05rem;
margin: 0 0 0.5rem;
}
.award-prominent-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
justify-content: center;
align-items: center;
color: var(--text-muted);
font-size: 0.9rem;
}
.award-prominent-giver {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.awards-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
@media (max-width: 720px) {
.awards-grid {
grid-template-columns: 1fr;
}
}
.award-tile {
position: relative;
padding: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
}
.award-tile-image {
width: 100%;
max-width: 256px;
height: auto;
object-fit: contain;
display: block;
margin: 0 auto 0.75rem;
}
.award-tile-desc {
margin: 0 0 0.75rem;
word-break: break-word;
}
.award-tile-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
align-items: center;
justify-content: space-between;
color: var(--text-muted);
font-size: 0.9rem;
}
.award-tile-giver {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.award-revoke-form {
margin-top: 0.75rem;
}
.award-revoke-btn {
width: 100%;
}
#award-give-modal textarea {
width: 100%;
min-height: 5rem;
resize: vertical;
}
.award-char-count {
display: block;
text-align: right;
color: var(--text-muted);
font-size: 0.85rem;
}
+44 -14
View File
@@ -30,6 +30,34 @@
overflow: hidden;
}
[data-overflow-tabs] {
display: flex;
align-items: center;
gap: 0.25rem;
width: 100%;
max-width: 100%;
min-width: 0;
}
.overflow-tabs-strip {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
flex-wrap: nowrap;
gap: inherit;
overflow: hidden;
}
.overflow-tabs-strip > * {
flex-shrink: 0;
white-space: nowrap;
}
[data-overflow-tabs] .overflow-tabs-more {
flex-shrink: 0;
}
.modal-card {
width: 100%;
max-width: 560px;
@@ -394,22 +422,14 @@ img {
display: block;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
* {
scrollbar-width: none;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-light);
*::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.btn {
@@ -559,6 +579,16 @@ img {
.presence-dot.online { background: var(--success, #2f9e44); }
.award-badge {
position: absolute;
left: 0;
bottom: 0;
width: 25%;
height: 25%;
object-fit: contain;
pointer-events: none;
}
.topnav {
position: fixed;
top: 0;
+23
View File
@@ -57,6 +57,18 @@
color: var(--text-muted);
}
.gw-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: var(--bg-card-hover);
border: 1px solid var(--border);
font-size: 0.75rem;
color: var(--text-secondary);
margin-right: 0.25rem;
white-space: nowrap;
}
.gw-actions {
text-align: right;
white-space: nowrap;
@@ -82,6 +94,17 @@
color: var(--text-primary);
}
.gw-form-subtitle {
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px dashed var(--border);
}
.gw-form .gw-section-hint {
grid-column: 1 / -1;
margin: 0;
}
.gw-field {
display: flex;
flex-direction: column;
+1 -12
View File
@@ -205,31 +205,20 @@ a.profile-stat-value:hover {
}
.profile-tabs {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem;
margin-bottom: 1rem;
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 0.25rem;
border: 1px solid var(--border);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scroll-margin-top: calc(var(--nav-height) + 1rem);
}
.profile-tabs.tabs-managed {
overflow: hidden;
}
.profile-tab {
flex-shrink: 0;
white-space: nowrap;
}
.profile-tabs-more {
margin-left: auto;
.profile-tabs .overflow-tabs-more {
background: none;
border: none;
cursor: pointer;
+229
View File
@@ -0,0 +1,229 @@
/* retoor <retoor@molodetz.nl> */
.statistics-toolbar {
flex-wrap: wrap;
align-items: flex-start;
gap: var(--space-md);
}
.statistics-toolbar h2 {
flex: 1 1 auto;
min-width: 8rem;
}
.statistics-controls {
flex: 1 1 16rem;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--space-sm) var(--space-lg);
}
.statistics-controls-primary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm) var(--space-lg);
}
.statistics-controls label,
.statistics-compare-label {
font-size: 0.8125rem;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.statistics-compare-label span {
white-space: normal;
line-height: 1.35;
}
.statistics-generated {
flex: 1 1 12rem;
margin-left: 0;
font-size: 0.75rem;
color: var(--text-muted);
text-align: right;
white-space: normal;
word-break: break-word;
line-height: 1.35;
}
@media (max-width: 720px) {
.statistics-toolbar {
flex-direction: column;
align-items: stretch;
}
.statistics-controls {
justify-content: flex-start;
}
.statistics-generated {
flex-basis: 100%;
text-align: left;
}
}
.statistics-section {
margin: var(--space-xl) 0;
}
.statistics-section h3 {
margin: 0 0 var(--space-base);
font-size: 0.9375rem;
color: var(--text-primary);
}
.statistics-delta {
font-size: 0.6875rem;
font-weight: 600;
margin-top: 0.125rem;
}
.statistics-delta.up {
color: var(--success);
}
.statistics-delta.down {
color: var(--danger);
}
.statistics-delta.flat {
color: var(--text-muted);
}
.statistics-charts {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
@media (min-width: 768px) {
.statistics-charts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.statistics-chart-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-md);
min-height: 16rem;
display: flex;
flex-direction: column;
}
.statistics-chart-panel h4 {
margin: 0 0 var(--space-sm);
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.statistics-chart-wrap {
position: relative;
flex: 1;
min-height: 12rem;
}
.statistics-breakdowns-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-md);
}
.statistics-table-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.statistics-table-panel.is-wide {
grid-column: 1 / -1;
}
.statistics-table-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-md) var(--space-md) var(--space-sm);
border-bottom: 1px solid var(--border);
}
.statistics-table-panel-head h4 {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.statistics-table-panel-count {
font-size: 0.6875rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.statistics-table-panel .admin-table-wrap {
overflow: visible;
max-height: none;
}
.statistics-table-panel .admin-table td.statistics-col-name {
font-weight: 600;
color: var(--text-primary);
}
.statistics-table-panel .admin-table td.statistics-col-key {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-primary);
}
.statistics-table-panel .admin-table th.statistics-col-num,
.statistics-table-panel .admin-table td.statistics-col-num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.statistics-table-panel .admin-table td.statistics-col-num {
font-weight: 600;
color: var(--text-primary);
}
.statistics-table-panel .admin-table th.statistics-col-text,
.statistics-table-panel .admin-table td.statistics-col-text {
color: var(--text-secondary);
}
.statistics-note {
font-size: 0.75rem;
color: var(--text-muted);
margin: var(--space-sm) 0 var(--space-lg);
}
.statistics-link {
font-size: 0.8125rem;
color: var(--accent);
}
.statistics-link:hover {
color: var(--accent-hover);
}
+2
View File
@@ -44,6 +44,7 @@ import { LocalTime } from "./LocalTime.js";
import { ScrollMemory } from "./ScrollMemory.js";
import { GameFarm } from "./GameFarm.js";
import { Accessibility } from "./Accessibility.js";
import { OverflowTabs } from "./OverflowTabs.js";
class Application {
constructor() {
@@ -95,6 +96,7 @@ class Application {
this.localTime = new LocalTime();
this.scrollMemory = new ScrollMemory();
this.gameFarm = new GameFarm();
this.overflowTabs = new OverflowTabs();
}
}
+66
View File
@@ -0,0 +1,66 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
class AwardGiver {
constructor() {
this.btn = document.querySelector("[data-award-give]");
this.modal = document.getElementById("award-give-modal");
if (!this.btn || !this.modal) return;
this.form = this.modal.querySelector("form");
this.textarea = this.modal.querySelector('textarea[name="description"]');
this.counter = this.modal.querySelector("[data-award-char-count]");
this.btn.addEventListener("click", () => this.open());
if (this.form) {
this.form.addEventListener("submit", (event) => this.submit(event));
}
if (this.textarea) {
this.textarea.addEventListener("input", () => this.updateCounter());
this.updateCounter();
}
}
open() {
if (this.btn.disabled) return;
this.modal.classList.add("visible");
if (this.textarea) {
this.textarea.focus();
}
}
updateCounter() {
if (!this.counter || !this.textarea) return;
const max = Number(this.textarea.getAttribute("maxlength") || 125);
const length = this.textarea.value.length;
this.counter.textContent = `${length}/${max}`;
}
async submit(event) {
event.preventDefault();
const username = this.btn.dataset.username;
const description = (this.textarea?.value || "").trim();
if (!description) return;
const submitBtn = this.form.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
try {
await Http.postJson(`/profile/${username}/award`, { description });
this.modal.classList.remove("visible");
if (this.form) this.form.reset();
this.updateCounter();
await window.app.dialog.alert({
title: "Award submitted",
message: `Your award for @${username} is being created. They will be notified when it is ready.`,
});
} catch {
await window.app.dialog.alert({
title: "Award failed",
message: "Could not submit the award. Try again later.",
});
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
}
window.AwardGiver = AwardGiver;
export { AwardGiver };
+74 -2
View File
@@ -15,9 +15,25 @@ export class GatewayAdmin {
async start() {
this.bind();
this.syncKindFields();
await this.reload();
}
syncKindFields() {
const kind = this.modelForm.kind.value || "chat";
const priceInputLabel = this.root.querySelector("#gw-model-price-input-label");
if (priceInputLabel) {
priceInputLabel.textContent =
kind === "image"
? "Price per image ($)"
: "Price input / 1M ($) (embed/vision)";
}
this.root.querySelectorAll("[data-kind-field]").forEach((field) => {
const kinds = (field.dataset.kindField || "").split(/\s+/).filter(Boolean);
field.style.display = kinds.includes(kind) ? "" : "none";
});
}
bind() {
this.providerForm.addEventListener("submit", (event) => {
event.preventDefault();
@@ -27,6 +43,7 @@ export class GatewayAdmin {
event.preventDefault();
this.saveModel();
});
this.modelForm.kind.addEventListener("change", () => this.syncKindFields());
this.providersBody.addEventListener("click", (event) => this.onProviderClick(event));
this.modelsBody.addEventListener("click", (event) => this.onModelClick(event));
}
@@ -71,7 +88,9 @@ export class GatewayAdmin {
el.innerHTML = `
<strong>default</strong> (from <a href="/admin/services">Services config</a>):
chat <code class="gw-code">${this.escape(def.model)}</code> at <code class="gw-code">${this.escape(def.base_url)}</code>,
embed <code class="gw-code">${this.escape(def.embed_model)}</code>, vision <code class="gw-code">${this.escape(def.vision_model)}</code>`;
embed <code class="gw-code">${this.escape(def.embed_model)}</code>,
image <code class="gw-code">${this.escape(def.image_model)}</code>,
vision <code class="gw-code">${this.escape(def.vision_model)}</code>`;
}
renderProviders() {
@@ -109,7 +128,7 @@ export class GatewayAdmin {
const data = await Http.getJson("/admin/gateway/models");
const models = data.models || [];
if (!models.length) {
this.modelsBody.innerHTML = `<tr><td colspan="6" class="admin-empty">No model routes. Requests fall through to the default upstream.</td></tr>`;
this.modelsBody.innerHTML = `<tr><td colspan="7" class="admin-empty">No model routes. Requests fall through to the default upstream.</td></tr>`;
return 0;
}
this.modelsBody.innerHTML = models
@@ -118,12 +137,26 @@ export class GatewayAdmin {
? `<code class="gw-code">${this.escape(m.vision_provider || m.provider || "default")}/${this.escape(m.vision_model)}</code>`
: `<span class="gw-muted">-</span>`;
const provider = m.provider || "default";
const badges = [];
if (m.context_tier_threshold_tokens) {
badges.push(`<span class="gw-badge" title="Tier-2 rates above ${m.context_tier_threshold_tokens} input tokens">tiered</span>`);
}
if (m.off_peak_start_minute !== null && m.off_peak_start_minute !== undefined) {
const start = this.minutesToTime(m.off_peak_start_minute);
const end = this.minutesToTime(m.off_peak_end_minute);
badges.push(`<span class="gw-badge" title="${m.off_peak_discount_pct}% off ${start}-${end} UTC">off-peak</span>`);
}
if (m.kind === "image" && m.price_input_per_m) {
badges.push(`<span class="gw-badge" title="Flat price per generated image">$${m.price_input_per_m}/img</span>`);
}
const economy = badges.length ? badges.join(" ") : `<span class="gw-muted">-</span>`;
return `<tr>
<td>${this.escape(m.source_model)}</td>
<td>${this.escape(provider)}</td>
<td><code class="gw-code">${this.escape(m.target_model)}</code></td>
<td>${this.escape(m.kind)}</td>
<td>${vision}</td>
<td>${economy}</td>
<td class="gw-actions">
<button class="admin-btn admin-btn-sm" data-edit-model='${this.attr(JSON.stringify(m))}'>Edit</button>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-del-model="${this.attr(m.source_model)}">Delete</button>
@@ -142,6 +175,28 @@ export class GatewayAdmin {
return values;
}
timeToMinutes(value) {
if (!value) return null;
const [hours, minutes] = value.split(":").map((part) => parseInt(part, 10));
if (Number.isNaN(hours) || Number.isNaN(minutes)) return null;
return hours * 60 + minutes;
}
minutesToTime(minutes) {
if (minutes === null || minutes === undefined || minutes === "") return "";
const total = parseInt(minutes, 10);
if (Number.isNaN(total)) return "";
const hours = Math.floor(total / 60) % 24;
const mins = total % 60;
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
}
optionalFloat(value) {
if (value === "" || value === null || value === undefined) return null;
const parsed = parseFloat(value);
return Number.isNaN(parsed) ? null : parsed;
}
async saveProvider() {
const values = this.formValues(this.providerForm);
const payload = {
@@ -174,6 +229,14 @@ export class GatewayAdmin {
price_cache_miss_per_m: parseFloat(values.price_cache_miss_per_m) || 0,
price_output_per_m: parseFloat(values.price_output_per_m) || 0,
price_input_per_m: parseFloat(values.price_input_per_m) || 0,
context_tier_threshold_tokens: parseInt(values.context_tier_threshold_tokens, 10) || 0,
price_cache_hit_per_m_tier2: this.optionalFloat(values.price_cache_hit_per_m_tier2),
price_cache_miss_per_m_tier2: this.optionalFloat(values.price_cache_miss_per_m_tier2),
price_output_per_m_tier2: this.optionalFloat(values.price_output_per_m_tier2),
price_input_per_m_tier2: this.optionalFloat(values.price_input_per_m_tier2),
off_peak_start_minute: this.timeToMinutes(values.off_peak_start),
off_peak_end_minute: this.timeToMinutes(values.off_peak_end),
off_peak_discount_pct: parseFloat(values.off_peak_discount_pct) || 0,
is_active: values.is_active === "1",
};
try {
@@ -243,6 +306,7 @@ export class GatewayAdmin {
form.provider.value = model.provider || "";
form.target_model.value = model.target_model || "";
form.kind.value = model.kind || "chat";
this.syncKindFields();
form.vision_provider.value = model.vision_provider || "";
form.vision_model.value = model.vision_model || "";
form.context_window.value = model.context_window || 0;
@@ -250,6 +314,14 @@ export class GatewayAdmin {
form.price_cache_miss_per_m.value = model.price_cache_miss_per_m || 0;
form.price_output_per_m.value = model.price_output_per_m || 0;
form.price_input_per_m.value = model.price_input_per_m || 0;
form.context_tier_threshold_tokens.value = model.context_tier_threshold_tokens || 0;
form.price_cache_hit_per_m_tier2.value = model.price_cache_hit_per_m_tier2 ?? "";
form.price_cache_miss_per_m_tier2.value = model.price_cache_miss_per_m_tier2 ?? "";
form.price_output_per_m_tier2.value = model.price_output_per_m_tier2 ?? "";
form.price_input_per_m_tier2.value = model.price_input_per_m_tier2 ?? "";
form.off_peak_start.value = this.minutesToTime(model.off_peak_start_minute);
form.off_peak_end.value = this.minutesToTime(model.off_peak_end_minute);
form.off_peak_discount_pct.value = model.off_peak_discount_pct || 0;
form.is_active.value = model.is_active ? "1" : "0";
form.source_model.scrollIntoView({ block: "center" });
}
+7
View File
@@ -22,6 +22,13 @@ export class LocalTime {
const date = new Date(iso);
if (isNaN(date.getTime())) return null;
if (mode === "ago") return this.relative(date);
if (mode === "award") {
const weekday = date.toLocaleDateString([], { weekday: "long" });
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear();
return `${weekday}, ${day}-${month}-${year}`;
}
if (mode === "date") return date.toLocaleDateString();
if (mode === "time") {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+8
View File
@@ -49,6 +49,14 @@ export class OnlineUsers {
dot.setAttribute("aria-hidden", "true");
badge.appendChild(img);
badge.appendChild(dot);
if (user.award_prominent && user.last_award_slug) {
const award = document.createElement("img");
award.className = "award-badge";
award.src = `/awards/${encodeURIComponent(user.last_award_slug)}/64`;
award.alt = "Latest award";
award.loading = "lazy";
badge.appendChild(award);
}
link.appendChild(badge);
return link;
}
+115
View File
@@ -0,0 +1,115 @@
// retoor <retoor@molodetz.nl>
export class OverflowTabs {
constructor() {
this.instances = [];
document.querySelectorAll("[data-overflow-tabs]").forEach((root) => {
const instance = this.create(root);
if (instance) {
this.instances.push(instance);
}
});
}
create(root) {
const more = root.querySelector(".overflow-tabs-more");
const strip = root.querySelector(".overflow-tabs-strip");
if (!more || !strip) {
return null;
}
const tabs = Array.from(strip.children).filter(
(el) => !el.classList.contains("overflow-tabs-more")
);
if (!tabs.length) {
return null;
}
const state = {
root,
strip,
more,
tabs,
overflow: [],
};
state.sync = () => {
void strip.offsetWidth;
};
state.stripOverflows = () => strip.scrollWidth > strip.clientWidth + 1;
state.layout = () => {
tabs.forEach((tab) => {
tab.hidden = false;
});
more.hidden = true;
state.sync();
if (!state.stripOverflows()) {
state.overflow = [];
return;
}
more.hidden = false;
state.sync();
while (state.stripOverflows()) {
const victim = tabs
.filter((tab) => !tab.hidden && !tab.classList.contains("active"))
.pop();
if (!victim) {
break;
}
victim.hidden = true;
state.sync();
}
state.overflow = tabs.filter((tab) => tab.hidden);
if (state.overflow.length > 0 || state.stripOverflows()) {
more.hidden = false;
}
};
state.open = (event) => {
event.preventDefault();
event.stopPropagation();
const items = state.overflow.map((tab) => ({
icon: tab.dataset.menuIcon || "",
label: tab.dataset.menuLabel || tab.textContent.trim(),
onSelect: () => tab.click(),
}));
if (!items.length) {
return;
}
const rect = more.getBoundingClientRect();
window.app.contextMenu.open(rect.left, rect.bottom + 4, items);
};
root.classList.add("tabs-managed");
more.addEventListener("click", (event) => state.open(event));
for (const tab of tabs) {
tab.addEventListener("click", () => {
requestAnimationFrame(() => state.layout());
});
}
const scheduleLayout = () => {
requestAnimationFrame(() => state.layout());
};
window.addEventListener("resize", scheduleLayout);
if (typeof ResizeObserver !== "undefined") {
state.resizeObserver = new ResizeObserver(scheduleLayout);
state.resizeObserver.observe(root);
state.resizeObserver.observe(strip);
}
scheduleLayout();
if (document.fonts?.ready) {
document.fonts.ready.then(scheduleLayout);
}
window.addEventListener("load", scheduleLayout, { once: true });
return state;
}
}
-64
View File
@@ -1,64 +0,0 @@
// retoor <retoor@molodetz.nl>
export class ProfileTabs {
constructor(root) {
this.root = root;
this.more = root.querySelector(".profile-tabs-more");
this.tabs = Array.from(root.querySelectorAll(".profile-tab")).filter(
(tab) => tab !== this.more
);
if (!this.more || !this.tabs.length) {
return;
}
this.overflow = [];
this.root.classList.add("tabs-managed");
this.more.addEventListener("click", (event) => this.open(event));
this._onResize = () => this.layout();
window.addEventListener("resize", this._onResize);
this.layout();
}
layout() {
this.tabs.forEach((tab) => {
tab.hidden = false;
});
this.more.hidden = false;
if (this._fits()) {
this.more.hidden = true;
this.overflow = [];
return;
}
while (!this._fits()) {
const victim = this.tabs
.filter((tab) => !tab.hidden && !tab.classList.contains("active"))
.pop();
if (!victim) {
break;
}
victim.hidden = true;
}
this.overflow = this.tabs.filter((tab) => tab.hidden);
}
_fits() {
return this.root.scrollWidth <= this.root.clientWidth;
}
open(event) {
event.preventDefault();
event.stopPropagation();
const items = this.overflow.map((tab) => ({
icon: tab.dataset.menuIcon || "",
label: tab.dataset.menuLabel || tab.textContent.trim(),
onSelect: () => tab.click(),
}));
if (!items.length) {
return;
}
const rect = this.more.getBoundingClientRect();
window.app.contextMenu.open(rect.left, rect.bottom + 4, items);
}
}
+111
View File
@@ -0,0 +1,111 @@
// retoor <retoor@molodetz.nl>
const CHART_COLORS = [
"#ff6b35",
"#7c4dff",
"#00bfa5",
"#448aff",
"#ffab00",
"#ff5252",
"#42a5f5",
"#ff4f8b",
];
export class StatisticsCharts {
constructor() {
this.instances = [];
}
destroy() {
for (const chart of this.instances) {
try {
chart.destroy();
} catch {
// ignore destroy errors
}
}
this.instances = [];
}
render(container, seriesList) {
this.destroy();
if (!container || !window.Chart || !Array.isArray(seriesList)) return;
const panels = [];
for (const series of seriesList) {
const panel = document.createElement("div");
panel.className = "statistics-chart-panel";
const title = document.createElement("h4");
title.textContent = series.label || series.key || "Series";
panel.appendChild(title);
const wrap = document.createElement("div");
wrap.className = "statistics-chart-wrap";
const canvas = document.createElement("canvas");
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", `${series.label || "Chart"} over time`);
wrap.appendChild(canvas);
panel.appendChild(wrap);
container.appendChild(panel);
panels.push({ canvas, series });
}
for (let index = 0; index < panels.length; index += 1) {
const { canvas, series } = panels[index];
const labels = (series.points || []).map((point) => point.t);
const values = (series.points || []).map((point) => Number(point.v || 0));
const color = CHART_COLORS[index % CHART_COLORS.length];
const chart = new window.Chart(canvas, {
type: "line",
data: {
labels,
datasets: [
{
label: series.label || series.key,
data: values,
borderColor: color,
backgroundColor: `${color}22`,
fill: true,
tension: 0.25,
pointRadius: labels.length > 40 ? 0 : 2,
pointHoverRadius: 4,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context) {
const value = context.parsed.y;
return `${series.label}: ${Number(value).toLocaleString("en-GB")}`;
},
},
},
},
scales: {
x: {
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 8,
},
grid: { color: "rgba(255, 255, 255, 0.08)" },
ticks: { color: "#7a6a90" },
},
y: {
beginAtZero: true,
ticks: {
precision: 0,
color: "#7a6a90",
},
grid: { color: "rgba(255, 255, 255, 0.08)" },
},
},
},
});
this.instances.push(chart);
}
}
}
+327
View File
@@ -0,0 +1,327 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { StatisticsCharts } from "./StatisticsCharts.js";
class StatisticsDashboard {
constructor(options = {}) {
this.root = document.getElementById(options.rootId || "statistics-root");
this.initialNode = document.getElementById(options.initialId || "statistics-initial");
this.windowSelect = document.getElementById("statistics-window");
this.compareToggle = document.getElementById("statistics-compare");
this.generated = document.querySelector("[data-meta='generated']");
this.tabLinks = Array.from(document.querySelectorAll(".admin-tabs .admin-tab[data-tab]"));
this.activeTab = options.activeTab || "overview";
this.windowHours = Number(options.windowHours || 168);
this.charts = new StatisticsCharts();
this.cache = new Map();
this.loading = false;
}
start() {
if (!this.root) return;
if (this.windowSelect) {
this.windowSelect.addEventListener("change", () => {
const parsed = parseInt(this.windowSelect.value, 10);
this.windowHours = Number.isNaN(parsed) ? 168 : parsed;
this.cache.clear();
this.loadTab(this.activeTab, true);
});
}
if (this.compareToggle) {
this.compareToggle.addEventListener("change", () => {
this.cache.clear();
this.loadTab(this.activeTab, true);
});
}
for (const link of this.tabLinks) {
link.addEventListener("click", (event) => {
event.preventDefault();
const tab = link.dataset.tab;
if (!tab || tab === this.activeTab) return;
for (const node of this.tabLinks) {
node.classList.toggle("active", node.dataset.tab === tab);
}
this.activeTab = tab;
const url = new URL(window.location.href);
url.searchParams.set("tab", tab);
url.searchParams.set("hours", String(this.windowHours));
window.history.replaceState({}, "", url);
this.loadTab(tab, false);
});
}
const initial = this.readInitial();
if (initial) {
this.cache.set(this.cacheKey(this.activeTab), initial);
this.render(initial);
} else {
this.loadTab(this.activeTab, true);
}
}
readInitial() {
if (!this.initialNode) return null;
try {
return JSON.parse(this.initialNode.textContent || "null");
} catch {
return null;
}
}
cacheKey(tab) {
const compare = this.compareToggle && this.compareToggle.checked ? 1 : 0;
return `${tab}:${this.windowHours}:${compare}`;
}
async loadTab(tab, force) {
const key = this.cacheKey(tab);
if (!force && this.cache.has(key)) {
this.render(this.cache.get(key));
return;
}
if (this.loading) return;
this.loading = true;
this.root.innerHTML = '<p class="admin-empty">Loading statistics...</p>';
try {
const compare = this.compareToggle && this.compareToggle.checked ? 1 : 0;
const data = await Http.getJson(
`/admin/statistics/data?tab=${encodeURIComponent(tab)}&hours=${this.windowHours}&compare=${compare}`
);
this.cache.set(key, data);
this.render(data);
} catch {
this.root.innerHTML = '<p class="admin-empty">Could not load statistics.</p>';
} finally {
this.loading = false;
}
}
formatValue(card) {
const value = card.value;
const kind = card.format || "int";
if (kind === "money") {
const number = Number(value || 0);
return number < 1 ? `$${number.toFixed(4)}` : `$${number.toFixed(2)}`;
}
if (kind === "decimal") {
return Number(value || 0).toLocaleString("en-GB", { maximumFractionDigits: 1 });
}
return Number(value || 0).toLocaleString("en-GB");
}
deltaMarkup(card) {
if (card.delta === undefined || card.delta === null) return null;
const direction = card.direction || "flat";
const arrow = direction === "up" ? "\u2191" : direction === "down" ? "\u2193" : "\u2192";
const node = document.createElement("span");
node.className = `statistics-delta ${direction}`;
node.textContent = `${arrow} ${Math.abs(Number(card.delta || 0)).toLocaleString("en-GB")}%`;
return node;
}
isNumericValue(value) {
if (typeof value === "number" && Number.isFinite(value)) return true;
const text = String(value ?? "").trim().replace(/,/g, "");
if (!text) return false;
return /^-?\d+(\.\d+)?$/.test(text);
}
columnIsNumeric(columnIndex, columns, rows) {
const header = (columns[columnIndex] || "").toLowerCase();
const numericHeaders = [
"count", "events", "views", "gists", "posts", "votes", "actions", "stars",
"turns", "requests", "cost", "coins", "level", "restarts", "followers",
"upvotes", "downvotes", "members", "guests", "tokens", "errors",
];
if (numericHeaders.some((token) => header.includes(token))) return true;
const sample = rows.slice(0, 12);
if (!sample.length) return columnIndex > 0;
return sample.every((row) => this.isNumericValue(row[columnIndex]));
}
formatTableCell(value) {
if (typeof value === "number" && Number.isFinite(value)) {
if (Number.isInteger(value)) return value.toLocaleString("en-GB");
return value.toLocaleString("en-GB", { maximumFractionDigits: 2 });
}
const text = String(value ?? "");
const plain = text.trim().replace(/,/g, "");
if (/^-?\d+(\.\d+)?$/.test(plain)) {
const number = Number(plain);
if (Number.isInteger(number)) return number.toLocaleString("en-GB");
return number.toLocaleString("en-GB", { maximumFractionDigits: 2 });
}
return text;
}
columnClass(columnIndex, columns, rows) {
const header = (columns[columnIndex] || "").toLowerCase();
if (columnIndex === 0) {
if (
header.includes("username")
|| header === "user"
|| header.includes("instance")
|| header === "name"
) {
return "statistics-col-name";
}
return "statistics-col-key";
}
if (this.columnIsNumeric(columnIndex, columns, rows)) return "statistics-col-num";
return "statistics-col-text";
}
renderBreakdownTable(table) {
const columns = table.columns || [];
const rows = table.rows || [];
const panel = document.createElement("article");
panel.className = "statistics-table-panel";
if (columns.length > 2) panel.classList.add("is-wide");
const head = document.createElement("div");
head.className = "statistics-table-panel-head";
const title = document.createElement("h4");
title.textContent = table.title || "";
head.appendChild(title);
const count = document.createElement("span");
count.className = "statistics-table-panel-count";
count.textContent = `${rows.length.toLocaleString("en-GB")} rows`;
head.appendChild(count);
panel.appendChild(head);
const wrap = document.createElement("div");
wrap.className = "admin-table-wrap";
const node = document.createElement("table");
node.className = "admin-table";
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
for (let index = 0; index < columns.length; index += 1) {
const th = document.createElement("th");
th.className = this.columnClass(index, columns, rows);
th.textContent = columns[index];
headRow.appendChild(th);
}
thead.appendChild(headRow);
node.appendChild(thead);
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
for (let index = 0; index < columns.length; index += 1) {
const td = document.createElement("td");
const cellClass = this.columnClass(index, columns, rows);
td.className = cellClass;
const cell = row[index];
td.textContent = cellClass === "statistics-col-num"
? this.formatTableCell(cell)
: String(cell ?? "");
tr.appendChild(td);
}
tbody.appendChild(tr);
}
node.appendChild(tbody);
wrap.appendChild(node);
panel.appendChild(wrap);
return panel;
}
render(data) {
if (!data) return;
if (this.generated && data.generated_at) {
this.generated.textContent = `Generated ${data.generated_at.replace("T", " ").slice(0, 19)} UTC`;
}
this.root.innerHTML = "";
if (Array.isArray(data.highlights) && data.highlights.length) {
const wrap = document.createElement("div");
wrap.className = "metric-cards";
for (const item of data.highlights) {
const card = document.createElement("div");
card.className = "metric-card";
const value = document.createElement("span");
value.className = "metric-value";
value.textContent = String(item.value ?? "");
const label = document.createElement("span");
label.className = "metric-label";
label.textContent = item.label || "";
card.appendChild(value);
card.appendChild(label);
wrap.appendChild(card);
}
this.root.appendChild(wrap);
}
if (Array.isArray(data.cards) && data.cards.length) {
const grid = document.createElement("div");
grid.className = "metric-cards";
for (const card of data.cards) {
const node = document.createElement("div");
node.className = "metric-card";
const value = document.createElement("span");
value.className = "metric-value";
value.textContent = this.formatValue(card);
const label = document.createElement("span");
label.className = "metric-label";
label.textContent = card.label || "";
node.appendChild(value);
node.appendChild(label);
const delta = this.deltaMarkup(card);
if (delta) node.appendChild(delta);
grid.appendChild(node);
}
this.root.appendChild(grid);
}
if (Array.isArray(data.series) && data.series.length) {
const section = document.createElement("div");
section.className = "statistics-section";
const heading = document.createElement("h3");
heading.textContent = "Trends";
section.appendChild(heading);
const charts = document.createElement("div");
charts.className = "statistics-charts";
section.appendChild(charts);
this.root.appendChild(section);
const renderCharts = () => this.charts.render(charts, data.series);
if (window.Chart) {
renderCharts();
} else {
window.addEventListener("load", renderCharts, { once: true });
}
} else {
this.charts.destroy();
}
if (Array.isArray(data.tables) && data.tables.length) {
const section = document.createElement("div");
section.className = "statistics-section statistics-breakdowns";
const heading = document.createElement("h3");
heading.textContent = "Breakdowns";
section.appendChild(heading);
const grid = document.createElement("div");
grid.className = "statistics-breakdowns-grid";
for (const table of data.tables) {
grid.appendChild(this.renderBreakdownTable(table));
}
section.appendChild(grid);
this.root.appendChild(section);
}
if (data.notes && data.notes.full_page) {
const note = document.createElement("p");
note.className = "statistics-note";
const link = document.createElement("a");
link.className = "statistics-link";
link.href = data.notes.full_page;
link.textContent = "Open full detail page";
note.appendChild(link);
this.root.appendChild(note);
}
if (data.notes && data.notes.retention_days) {
const note = document.createElement("p");
note.className = "statistics-note";
note.textContent = `Visitor data retention: ${data.notes.retention_days} days.`;
this.root.appendChild(note);
}
}
}
window.StatisticsDashboard = StatisticsDashboard;
export { StatisticsDashboard };
File diff suppressed because one or more lines are too long