forked from retoor/devplacepy
Update
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user