Files
Cristian Laporta de3910c018 2026.10.07.1: avvio degli homebrew, Installa PKG, ShadowMount integrato, nuove modalità del menu
- Avvio degli homebrew: Omega non si chiude più da sola mentre websrv la
  chiude (503), controlla websrv, sistema i permessi degli ELF, cartella di
  lavoro dell'homebrew, ripara FAKE00000; il demone non rilancia la UI per 30 s.
- Testo che diventava enorme: cache dei font da 48 a 256 posti, dimensioni
  quantizzate, nessun font aperto a ogni fotogramma.
- Installa PKG (pkgs.c): pkg da USB, dischi, /data/pkg e cartelle scelte;
  strutture di AppInstUtil corrette, authid di ShellCore, avanzamento vero,
  coda, tessera in stile PS4 nella home, pulizia dei file orfani.
- Cartelle di giochi e PKG, montaggio automatico come ShadowMount, home che si
  aggiorna da sola quando cambiano titoli e homebrew.
- Telecomando: installa subito i giochi caricati, guida al JSON con esempio.
- Sei modalità del menu (homestyles.c), Store con funzioni social
  (amici, tendenze, desideri, consiglia, creatori), Store/Browser/PKG come app,
  browser di sistema della PS5, Impostazioni in sezioni, Perché Omega,
  segnala un bug o chiedi una funzione, 15 icone nuove.
- Server: migrazione 014 (feedback, installazioni, desideri), rotte nuove,
  sezione «Bug e richieste» nel pannello.
- Plugin OnionHEN che si ricollega da solo; demone che riapre la porta dopo il riposo.
2026-10-07 02:54:04 +02:00

1668 lines
102 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjMTUxYzRhIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMDUwNjBkIi8+PC9saW5lYXJHcmFkaWVudD48cmFkaWFsR3JhZGllbnQgaWQ9Imx0IiBjeD0iLjUiIGN5PSIuMTgiIHI9Ii44NSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjNGE1ZmZmIiBzdG9wLW9wYWNpdHk9Ii40MiIvPjxzdG9wIG9mZnNldD0iLjYyIiBzdG9wLWNvbG9yPSIjNGE1ZmZmIiBzdG9wLW9wYWNpdHk9IjAiLz48L3JhZGlhbEdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iZyIgeDE9IjAiIHkxPSIxIiB4Mj0iMSIgeTI9IjAiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzJmZDZmZiIvPjxzdG9wIG9mZnNldD0iLjU1IiBzdG9wLWNvbG9yPSIjNmE3MmZmIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjYzQ1YmZmIi8+PC9saW5lYXJHcmFkaWVudD48cmFkaWFsR3JhZGllbnQgaWQ9ImRvdCIgY3g9Ii4zOCIgY3k9Ii4zMiIgcj0iLjc1Ij48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZmZmZmYiLz48c3RvcCBvZmZzZXQ9Ii43IiBzdG9wLWNvbG9yPSIjZTZmNmZmIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjYTlkY2ZmIi8+PC9yYWRpYWxHcmFkaWVudD48ZmlsdGVyIGlkPSJnbCIgeD0iLTgwJSIgeT0iLTgwJSIgd2lkdGg9IjI2MCUiIGhlaWdodD0iMjYwJSI+PGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMTIiIHJlc3VsdD0iYiIvPjxmZUNvbG9yTWF0cml4IGluPSJiIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAuNTUgIDAgMCAwIDAgLjg1ICAwIDAgMCAwIDEgIDAgMCAwIC45IDAiIHJlc3VsdD0iYyIvPjxmZU1lcmdlPjxmZU1lcmdlTm9kZSBpbj0iYyIvPjxmZU1lcmdlTm9kZSBpbj0iU291cmNlR3JhcGhpYyIvPjwvZmVNZXJnZT48L2ZpbHRlcj48L2RlZnM+PHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIHJ4PSIxMTQiIGZpbGw9InVybCgjYmcpIi8+PHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIHJ4PSIxMTQiIGZpbGw9InVybCgjbHQpIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wIDAuMCkiPjxwYXRoIGQ9Ik0zNjYuMyAyMzYuNiBBMTEyIDExMiAwIDEgMSAyNzUuNCAxNDUuNyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ1cmwoI2cpIiBzdHJva2Utd2lkdGg9IjU0IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48Y2lyY2xlIGN4PSIzNTMuNiIgY3k9IjE1OC40IiByPSIyOCIgZmlsbD0idXJsKCNkb3QpIiBmaWx0ZXI9InVybCgjZ2wpIi8+PC9nPjxyZWN0IHg9IjEuNSIgeT0iMS41IiB3aWR0aD0iNTA5IiBoZWlnaHQ9IjUwOSIgcng9IjExMi41IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS1vcGFjaXR5PSIuMDciIHN0cm9rZS13aWR0aD0iMyIvPjwvc3ZnPg==">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="dark">
<title>Omega · Amministrazione</title>
<style>
:root {
--bg: #0c0e16;
--panel: #161a26;
--panel-2: #1c2130;
--line: #262c3d;
--accent: #0070f3;
--accent-2: #38a0ff;
--text: #e8ecf5;
--dim: #8b93a7;
--danger: #e5484d;
--ok: #2fbf71;
--warn: #f5a524;
--radius: 12px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font: 14px/1.5 var(--font);
min-height: 100vh;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent-2); text-decoration: none; }
a:hover { text-decoration: underline; }
button, input, select, textarea { font: inherit; color: inherit; }
[hidden] { display: none !important; }
:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; }
/* ---------- Buttons & inputs ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
border: 1px solid var(--line); background: var(--panel-2); color: var(--text);
padding: 7px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500;
white-space: nowrap; transition: background .15s, border-color .15s, opacity .15s;
}
.btn:hover:not(:disabled) { border-color: #3a4258; background: #232a3c; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover:not(:disabled) { background: #1a80ff; border-color: #1a80ff; }
.btn.danger { background: transparent; border-color: rgba(229,72,77,.5); color: #ff7b7f; }
.btn.danger:hover:not(:disabled) { background: rgba(229,72,77,.12); border-color: var(--danger); }
.btn.danger.solid { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn.danger.solid:hover:not(:disabled) { background: #f05a5f; }
.btn.ok { background: transparent; border-color: rgba(47,191,113,.5); color: #5fd896; }
.btn.ok:hover:not(:disabled) { background: rgba(47,191,113,.12); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--dim); }
.btn.ghost:hover:not(:disabled) { color: var(--text); background: var(--panel-2); }
.btn.sm { padding: 4px 9px; font-size: 12px; border-radius: 7px; }
.input, .select, .textarea {
background: var(--bg); border: 1px solid var(--line); border-radius: 8px;
padding: 8px 11px; color: var(--text); width: 100%; min-width: 0;
}
.input:focus, .select:focus, .textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,112,243,.2); }
.textarea { resize: vertical; min-height: 72px; }
.select { appearance: none; padding-right: 30px;
background-image: linear-gradient(45deg, transparent 50%, var(--dim) 50%), linear-gradient(135deg, var(--dim) 50%, transparent 50%);
background-position: calc(100% - 15px) 50%, calc(100% - 10px) 50%; background-size: 5px 5px; background-repeat: no-repeat; }
label.field { display: block; margin-bottom: 14px; }
label.field > span { display: block; font-size: 12px; color: var(--dim); margin-bottom: 5px; font-weight: 500; }
/* ---------- Badges ---------- */
.badge {
display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px;
font-size: 11px; font-weight: 600; letter-spacing: .02em; white-space: nowrap;
background: var(--panel-2); color: var(--dim); border: 1px solid var(--line);
}
.badge.accent { background: rgba(0,112,243,.15); color: var(--accent-2); border-color: rgba(56,160,255,.35); }
.badge.danger { background: rgba(229,72,77,.14); color: #ff8a8d; border-color: rgba(229,72,77,.4); }
.badge.ok { background: rgba(47,191,113,.13); color: #5fd896; border-color: rgba(47,191,113,.35); }
.badge.warn { background: rgba(245,165,36,.13); color: #f7bb55; border-color: rgba(245,165,36,.35); }
/* ---------- Login ---------- */
#login {
min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px 16px;
background: radial-gradient(ellipse at 50% 0%, rgba(0,112,243,.18), transparent 60%), var(--bg);
}
.login-card {
width: 100%; max-width: 380px; background: var(--panel); border: 1px solid var(--line);
border-radius: 16px; padding: 30px 26px; box-shadow: 0 20px 60px rgba(0,0,0,.4);
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark { width: 36px; height: 36px; display: block; flex: 0 0 auto; }
.brand-name { font-weight: 700; font-size: 16px; letter-spacing: .01em; }
.brand-sub { color: var(--dim); font-size: 12px; }
.login-card h1 { font-size: 20px; margin: 22px 0 4px; }
.login-card p.hint { color: var(--dim); margin: 0 0 20px; font-size: 13px; }
.login-error {
background: rgba(229,72,77,.12); border: 1px solid rgba(229,72,77,.4); color: #ff9a9d;
padding: 9px 12px; border-radius: 8px; font-size: 13px; margin-bottom: 14px;
}
.login-card .btn.primary { width: 100%; padding: 10px; font-size: 14px; }
/* ---------- Lingua e crediti ---------- */
.lang-toggle { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; flex: 0 0 auto; }
.lang-toggle button { border: 0; background: transparent; color: var(--dim); padding: 4px 9px; font-size: 12px; font-weight: 600; cursor: pointer; letter-spacing: .03em; }
.lang-toggle button:hover { color: var(--text); }
.lang-toggle button[aria-pressed="true"] { background: var(--accent); color: #fff; }
.login-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.credit { color: var(--dim); font-size: 12px; text-align: center; }
.credit a { color: var(--dim); text-decoration: underline; text-underline-offset: 2px; }
.credit a:hover { color: var(--text); }
#login { flex-direction: column; gap: 18px; }
#app > .credit { grid-column: 1 / -1; padding: 16px 22px 24px; border-top: 1px solid var(--line); }
/* ---------- App layout ---------- */
#app { display: grid; grid-template-columns: 228px minmax(0, 1fr); grid-template-rows: auto 1fr auto; min-height: 100vh; }
.topbar {
grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 22px; border-bottom: 1px solid var(--line); background: rgba(12,14,22,.92);
position: sticky; top: 0; z-index: 20; backdrop-filter: blur(8px);
}
.topbar .who { display: flex; align-items: center; gap: 12px; min-width: 0; }
.topbar .who-text { color: var(--dim); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar .who-text strong { color: var(--text); font-weight: 600; }
.sidebar { border-right: 1px solid var(--line); padding: 16px 12px; background: var(--bg); }
.nav { display: flex; flex-direction: column; gap: 2px; position: sticky; top: 76px; }
.nav a {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 9px 12px; border-radius: 8px; color: var(--dim); font-weight: 500; text-decoration: none;
}
.nav a:hover { background: var(--panel); color: var(--text); }
.nav a.active { background: rgba(0,112,243,.14); color: var(--text); box-shadow: inset 3px 0 0 var(--accent); }
.nav .count {
min-width: 20px; padding: 0 6px; height: 20px; border-radius: 10px; font-size: 11px; font-weight: 700;
display: inline-grid; place-items: center; background: var(--danger); color: #fff;
}
main { padding: 24px 26px 60px; min-width: 0; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; }
.page-head h2 { margin: 0; font-size: 22px; letter-spacing: -.01em; }
.page-head p { margin: 4px 0 0; color: var(--dim); font-size: 13px; }
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; }
.toolbar .search { flex: 1 1 240px; max-width: 420px; }
.toolbar .select { width: auto; flex: 0 0 auto; }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
border: 1px solid var(--line); background: var(--panel); color: var(--dim); border-radius: 999px;
padding: 6px 13px; cursor: pointer; font-size: 13px; font-weight: 500;
}
.chip:hover { color: var(--text); }
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.filter-pill {
display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px 4px 11px; border-radius: 999px;
background: rgba(0,112,243,.15); border: 1px solid rgba(56,160,255,.35); color: var(--accent-2); font-size: 12px; max-width: 100%;
}
.filter-pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.filter-pill button { border: 0; background: transparent; color: inherit; cursor: pointer; padding: 0 4px; font-size: 15px; line-height: 1; }
/* ---------- Stats ---------- */
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
.stat {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px;
text-align: left; cursor: pointer; transition: border-color .15s, transform .15s; color: var(--text);
}
.stat:hover { border-color: #3a4258; transform: translateY(-1px); }
.stat .label { color: var(--dim); font-size: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: .05em; }
.stat .value { font-size: 28px; font-weight: 700; margin-top: 6px; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.stat.highlight { border-color: var(--accent); background: linear-gradient(160deg, rgba(0,112,243,.22), var(--panel) 70%); }
.stat.highlight .value { color: var(--accent-2); }
.stat.highlight.hot { border-color: var(--danger); background: linear-gradient(160deg, rgba(229,72,77,.2), var(--panel) 70%); }
.stat.highlight.hot .value { color: #ff8a8d; }
.callout {
margin-top: 20px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
padding: 18px 20px; display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap;
}
.callout h3 { margin: 0 0 3px; font-size: 15px; }
.callout p { margin: 0; color: var(--dim); font-size: 13px; }
/* ---------- Cards (reports) ---------- */
.cards { display: grid; gap: 12px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; min-width: 0; }
.card-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.card-head .spacer { flex: 1; }
.card-head .date { color: var(--dim); font-size: 12px; }
.target-title { font-weight: 600; font-size: 15px; word-break: break-word; }
.target-meta { color: var(--dim); font-size: 13px; margin-top: 2px; word-break: break-word; }
.quote {
margin: 10px 0 0; padding: 10px 12px; background: var(--bg); border-left: 3px solid var(--accent);
border-radius: 0 8px 8px 0; white-space: pre-wrap; word-break: break-word; font-size: 13px;
}
.quote.muted { border-left-color: var(--line); color: var(--dim); }
.card-info { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 12px; color: var(--dim); font-size: 12px; }
.card-info strong { color: var(--text); font-weight: 500; }
.card-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
/* ---------- Tables ---------- */
.table-wrap { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow-x: auto; }
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
table.data th {
text-align: left; color: var(--dim); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
padding: 11px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; background: var(--panel-2);
}
table.data td { padding: 11px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
table.data tr:last-child td { border-bottom: 0; }
table.data tr:hover td { background: rgba(255,255,255,.015); }
table.data td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
table.data th.num { text-align: right; }
table.data td.nowrap { white-space: nowrap; }
.cell-title { font-weight: 600; overflow-wrap: anywhere; word-break: normal; min-width: 9ch; }
.cell-sub { color: var(--dim); font-size: 12px; margin-top: 2px; word-break: break-word; }
.cell-body { max-width: 440px; white-space: pre-wrap; word-break: break-word; }
.cell-badges { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 5px; align-items: center; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.muted { color: var(--dim); }
.danger-text { color: #ff8a8d; }
.empty, .loading {
padding: 40px 16px; text-align: center; color: var(--dim); background: var(--panel);
border: 1px dashed var(--line); border-radius: var(--radius);
}
.loading::before {
content: ""; display: block; width: 22px; height: 22px; margin: 0 auto 10px; border-radius: 50%;
border: 2px solid var(--line); border-top-color: var(--accent-2); animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.pager { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-top: 14px; color: var(--dim); font-size: 13px; }
.pager .btns { display: flex; gap: 8px; }
/* ---------- Modal ---------- */
.overlay {
position: fixed; inset: 0; background: rgba(5,6,10,.7); z-index: 100; display: flex;
align-items: center; justify-content: center; padding: 16px; backdrop-filter: blur(3px);
animation: fade .12s ease-out;
}
.modal {
width: 100%; max-width: 460px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 22px; box-shadow: 0 30px 80px rgba(0,0,0,.5); max-height: calc(100vh - 32px); overflow-y: auto;
}
.modal h3 { margin: 0 0 8px; font-size: 17px; }
.modal p { margin: 0 0 12px; color: var(--dim); word-break: break-word; }
.modal .subject { color: var(--text); font-weight: 600; }
.modal .warning {
background: rgba(229,72,77,.1); border: 1px solid rgba(229,72,77,.45); color: #ffb0b2;
padding: 11px 13px; border-radius: 9px; margin-bottom: 14px; font-size: 13px;
}
.modal .warning strong { display: block; color: #ff8a8d; margin-bottom: 3px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
@keyframes fade { from { opacity: 0; } }
/* ---------- Toasts ---------- */
#toasts { position: fixed; right: 16px; bottom: 16px; z-index: 200; display: flex; flex-direction: column; gap: 8px; max-width: min(380px, calc(100vw - 32px)); }
.toast {
background: var(--panel-2); border: 1px solid var(--line); border-left: 4px solid var(--accent);
border-radius: 10px; padding: 11px 14px; box-shadow: 0 12px 30px rgba(0,0,0,.45);
animation: slide .18s ease-out; cursor: pointer; word-break: break-word;
}
.toast.error { border-left-color: var(--danger); }
.toast.ok { border-left-color: var(--ok); }
.toast .t-title { font-weight: 600; font-size: 13px; }
.toast .t-detail { color: var(--dim); font-size: 12px; margin-top: 2px; }
@keyframes slide { from { transform: translateY(10px); opacity: 0; } }
/* ---------- Responsive ---------- */
@media (max-width: 860px) {
#app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto 1fr auto; }
.topbar { padding: 10px 16px; position: static; }
.brand-sub { display: none; }
.sidebar {
border-right: 0; border-bottom: 1px solid var(--line); padding: 0 8px;
position: sticky; top: 0; z-index: 19; background: rgba(12,14,22,.96); backdrop-filter: blur(8px);
}
.nav { flex-direction: row; overflow-x: auto; position: static; gap: 0; scrollbar-width: none; }
.nav::-webkit-scrollbar { display: none; }
.nav a { flex: 0 0 auto; border-radius: 0; padding: 12px 12px; box-shadow: none; }
.nav a.active { background: transparent; box-shadow: inset 0 -2px 0 var(--accent); }
main { padding: 18px 16px 50px; }
.page-head h2 { font-size: 19px; }
.toolbar .search { max-width: none; flex-basis: 100%; }
.toolbar .select { flex: 1 1 auto; }
/* table -> cards */
.table-wrap { background: transparent; border: 0; overflow: visible; }
table.data, table.data tbody, table.data tr, table.data td { display: block; width: 100%; }
table.data thead { display: none; }
table.data tr {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
padding: 10px 14px; margin-bottom: 10px;
}
table.data tr:hover td { background: transparent; }
table.data td {
border: 0; padding: 5px 0; display: flex; justify-content: space-between; align-items: flex-start; gap: 14px;
text-align: right;
}
table.data td.num { text-align: right; }
table.data td::before {
content: attr(data-label); color: var(--dim); font-size: 12px; font-weight: 500; flex: 0 0 auto; text-align: left; padding-top: 1px;
}
table.data td.cell-main { display: block; text-align: left; padding-bottom: 8px; border-bottom: 1px solid var(--line); margin-bottom: 4px; }
table.data td.cell-main::before { display: none; }
table.data td.cell-main .cell-badges { justify-content: flex-start; }
table.data td .cell-body { max-width: none; text-align: left; }
table.data td.cell-actions { display: block; padding-top: 10px; margin-top: 4px; border-top: 1px solid var(--line); }
table.data td.cell-actions::before { display: none; }
table.data td.cell-actions .actions { justify-content: flex-start; }
table.data td > div, table.data td > span { min-width: 0; }
.cell-badges { justify-content: flex-end; }
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stat .value { font-size: 24px; }
}
@media (max-width: 420px) {
.topbar .who-text .lbl { display: none; }
.stat { padding: 13px 14px; }
}
</style>
</head>
<body>
<section id="login" hidden>
<form class="login-card" id="login-form" autocomplete="on" novalidate>
<div class="login-top">
<div class="brand">
<img class="brand-mark" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ijk2IDk2IDMyMCAzMjAiPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0iYmciIHgxPSIwIiB5MT0iMCIgeDI9IjAiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMxNTFjNGEiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMwNTA2MGQiLz48L2xpbmVhckdyYWRpZW50PjxyYWRpYWxHcmFkaWVudCBpZD0ibHQiIGN4PSIuNSIgY3k9Ii4xOCIgcj0iLjg1Ij48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiM0YTVmZmYiIHN0b3Atb3BhY2l0eT0iLjQyIi8+PHN0b3Agb2Zmc2V0PSIuNjIiIHN0b3AtY29sb3I9IiM0YTVmZmYiIHN0b3Atb3BhY2l0eT0iMCIvPjwvcmFkaWFsR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjEiIHgyPSIxIiB5Mj0iMCI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjMmZkNmZmIi8+PHN0b3Agb2Zmc2V0PSIuNTUiIHN0b3AtY29sb3I9IiM2YTcyZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNjNDViZmYiLz48L2xpbmVhckdyYWRpZW50PjxyYWRpYWxHcmFkaWVudCBpZD0iZG90IiBjeD0iLjM4IiBjeT0iLjMyIiByPSIuNzUiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iI2ZmZmZmZiIvPjxzdG9wIG9mZnNldD0iLjciIHN0b3AtY29sb3I9IiNlNmY2ZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNhOWRjZmYiLz48L3JhZGlhbEdyYWRpZW50PjxmaWx0ZXIgaWQ9ImdsIiB4PSItODAlIiB5PSItODAlIiB3aWR0aD0iMjYwJSIgaGVpZ2h0PSIyNjAlIj48ZmVHYXVzc2lhbkJsdXIgc3RkRGV2aWF0aW9uPSIxMiIgcmVzdWx0PSJiIi8+PGZlQ29sb3JNYXRyaXggaW49ImIiIHR5cGU9Im1hdHJpeCIgdmFsdWVzPSIwIDAgMCAwIC41NSAgMCAwIDAgMCAuODUgIDAgMCAwIDAgMSAgMCAwIDAgLjkgMCIgcmVzdWx0PSJjIi8+PGZlTWVyZ2U+PGZlTWVyZ2VOb2RlIGluPSJjIi8+PGZlTWVyZ2VOb2RlIGluPSJTb3VyY2VHcmFwaGljIi8+PC9mZU1lcmdlPjwvZmlsdGVyPjwvZGVmcz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLjAgMC4wKSI+PHBhdGggZD0iTTM2Ni4zIDIzNi42IEExMTIgMTEyIDAgMSAxIDI3NS40IDE0NS43IiBmaWxsPSJub25lIiBzdHJva2U9InVybCgjZykiIHN0cm9rZS13aWR0aD0iNTQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjxjaXJjbGUgY3g9IjM1My42IiBjeT0iMTU4LjQiIHI9IjI4IiBmaWxsPSJ1cmwoI2RvdCkiIGZpbHRlcj0idXJsKCNnbCkiLz48L2c+PC9zdmc+" alt="" width="36" height="36">
<div>
<div class="brand-name">Omega</div>
<div class="brand-sub" data-t="Amministrazione">Amministrazione</div>
</div>
</div>
<div class="lang-toggle" role="group" data-t-label="Lingua" aria-label="Lingua"><button type="button" data-lang="it" lang="it" title="Italiano">IT</button><button type="button" data-lang="en" lang="en" title="English">EN</button></div>
</div>
<h1 data-t="Accesso amministratori">Accesso amministratori</h1>
<p class="hint" data-t="Entra con il tuo ID online e la password di un account amministratore.">Entra con il tuo ID online e la password di un account amministratore.</p>
<div class="login-error" id="login-error" role="alert" hidden></div>
<label class="field"><span data-t="ID online">ID online</span>
<input class="input" id="login-id" name="online_id" autocomplete="username" required autocapitalize="off" spellcheck="false">
</label>
<label class="field"><span data-t="Password">Password</span>
<input class="input" id="login-pw" name="password" type="password" autocomplete="current-password" required>
</label>
<button class="btn primary" type="submit" id="login-btn" data-t="Accedi">Accedi</button>
</form>
<p class="credit"><span data-t="Sviluppato da">Sviluppato da</span> <a href="https://outlinedigital.it" target="_blank" rel="noopener noreferrer">TheCriicom</a></p>
</section>
<div id="app" hidden>
<header class="topbar">
<div class="brand">
<img class="brand-mark" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ijk2IDk2IDMyMCAzMjAiPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0iYmciIHgxPSIwIiB5MT0iMCIgeDI9IjAiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiMxNTFjNGEiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMwNTA2MGQiLz48L2xpbmVhckdyYWRpZW50PjxyYWRpYWxHcmFkaWVudCBpZD0ibHQiIGN4PSIuNSIgY3k9Ii4xOCIgcj0iLjg1Ij48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiM0YTVmZmYiIHN0b3Atb3BhY2l0eT0iLjQyIi8+PHN0b3Agb2Zmc2V0PSIuNjIiIHN0b3AtY29sb3I9IiM0YTVmZmYiIHN0b3Atb3BhY2l0eT0iMCIvPjwvcmFkaWFsR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjEiIHgyPSIxIiB5Mj0iMCI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjMmZkNmZmIi8+PHN0b3Agb2Zmc2V0PSIuNTUiIHN0b3AtY29sb3I9IiM2YTcyZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNjNDViZmYiLz48L2xpbmVhckdyYWRpZW50PjxyYWRpYWxHcmFkaWVudCBpZD0iZG90IiBjeD0iLjM4IiBjeT0iLjMyIiByPSIuNzUiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iI2ZmZmZmZiIvPjxzdG9wIG9mZnNldD0iLjciIHN0b3AtY29sb3I9IiNlNmY2ZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNhOWRjZmYiLz48L3JhZGlhbEdyYWRpZW50PjxmaWx0ZXIgaWQ9ImdsIiB4PSItODAlIiB5PSItODAlIiB3aWR0aD0iMjYwJSIgaGVpZ2h0PSIyNjAlIj48ZmVHYXVzc2lhbkJsdXIgc3RkRGV2aWF0aW9uPSIxMiIgcmVzdWx0PSJiIi8+PGZlQ29sb3JNYXRyaXggaW49ImIiIHR5cGU9Im1hdHJpeCIgdmFsdWVzPSIwIDAgMCAwIC41NSAgMCAwIDAgMCAuODUgIDAgMCAwIDAgMSAgMCAwIDAgLjkgMCIgcmVzdWx0PSJjIi8+PGZlTWVyZ2U+PGZlTWVyZ2VOb2RlIGluPSJjIi8+PGZlTWVyZ2VOb2RlIGluPSJTb3VyY2VHcmFwaGljIi8+PC9mZU1lcmdlPjwvZmlsdGVyPjwvZGVmcz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLjAgMC4wKSI+PHBhdGggZD0iTTM2Ni4zIDIzNi42IEExMTIgMTEyIDAgMSAxIDI3NS40IDE0NS43IiBmaWxsPSJub25lIiBzdHJva2U9InVybCgjZykiIHN0cm9rZS13aWR0aD0iNTQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjxjaXJjbGUgY3g9IjM1My42IiBjeT0iMTU4LjQiIHI9IjI4IiBmaWxsPSJ1cmwoI2RvdCkiIGZpbHRlcj0idXJsKCNnbCkiLz48L2c+PC9zdmc+" alt="" width="36" height="36">
<div>
<div class="brand-name">Omega</div>
<div class="brand-sub" data-t="Amministrazione">Amministrazione</div>
</div>
</div>
<div class="who">
<span class="who-text"><span class="lbl" data-t="Connesso come ">Connesso come </span><strong id="me-name"></strong></span>
<div class="lang-toggle" role="group" data-t-label="Lingua" aria-label="Lingua"><button type="button" data-lang="it" lang="it" title="Italiano">IT</button><button type="button" data-lang="en" lang="en" title="English">EN</button></div>
<button class="btn sm" id="logout-btn" type="button" data-t="Esci">Esci</button>
</div>
</header>
<aside class="sidebar">
<nav class="nav" id="nav" aria-label="Sezioni" data-t-label="Sezioni"></nav>
</aside>
<main id="view" tabindex="-1"></main>
<p class="credit"><span data-t="Sviluppato da">Sviluppato da</span> <a href="https://outlinedigital.it" target="_blank" rel="noopener noreferrer">TheCriicom</a></p>
</div>
<div id="toasts" aria-live="polite"></div>
<script>
(function () {
'use strict';
/* --- Lingua: italiano (testi del codice) o inglese, scelta salvata in localStorage --- */
const LANG_KEY = "omega-admin-lang";
const EN = {
" (tu)":" (you)",
" · per il ban usa la sezione Utenti":" · to ban, use the Users section",
"(account eliminato)":"(deleted account)",
"(senza titolo)":"(untitled)",
"Accedi":"Sign in",
"Accesso":"Sign-in",
"Accesso in corso…":"Signing in…",
"Accesso amministratori":"Administrator sign-in",
"Entra con il tuo ID online e la password di un account amministratore.":"Sign in with the online ID and password of an administrator account.",
"ID online":"Online ID",
"Password":"Password",
"Connesso come ":"Signed in as ",
"Esci":"Sign out",
"Sezioni":"Sections",
"Lingua":"Language",
"Sviluppato da":"Developed by",
"Account eliminato":"Account deleted",
"Account registrati al servizio.":"Accounts registered with the service.",
"Admin":"Admin",
"Admin rimosso":"Admin removed",
"Aggiorna":"Refresh",
"Altro":"Other",
"Amministratore":"Administrator",
"Amministratori":"Administrators",
"Amministrazione":"Administration",
"Annotazione interna sulla decisione":"Internal note about the decision",
"Annulla":"Cancel",
"Aperta":"Open",
"Aperte":"Open",
"Applicazioni pubblicate dagli utenti e voci di catalogo.":"Apps published by users and catalog entries.",
"Apri in Utenti":"Open in Users",
"Archivia":"Dismiss",
"Archiviare la segnalazione?":"Dismiss the report?",
"Archiviata":"Dismissed",
"Archiviate":"Dismissed",
"Attenzione: operazione irreversibile":"Warning: this cannot be undone",
"Attivo":"Active",
"Autore":"Author",
"Avrà accesso completo a questo pannello: moderazione, ban ed eliminazione di account.":"They will have full access to this panel: moderation, bans and account deletion.",
"Azione":"Action",
"Azione non valida":"Invalid action",
"Azioni":"Actions",
"Azioni eseguite dagli amministratori.":"Actions taken by administrators.",
"Bacheca":"Wall",
"Banna":"Ban",
"Bannare questo utente?":"Ban this user?",
"Bannati":"Banned",
"Bannato":"Banned",
"Caricamento…":"Loading…",
"Catalogo":"Catalog",
"Cerca nel testo o per autore…":"Search text or author…",
"Cerca per ID online o email…":"Search by online ID or email…",
"Cerca per titolo o autore…":"Search by title or author…",
"Ci sono {n} segnalazioni da esaminare":"There are {n} reports to review",
"Commenti":"Comments",
"Commenti degli utenti sugli homebrew.":"User comments on homebrew.",
"Commenti oscurati":"Hidden comments",
"Commento":"Comment",
"Commento a un post":"Comment on a post",
"Commento di {user}":"Comment by {user}",
"Commento eliminato":"Comment deleted",
"Commento oscurato":"Comment hidden",
"Commento ripristinato":"Comment restored",
"Conferma":"Confirm",
"Contenuto eliminato":"Content deleted",
"Contenuto offensivo":"Offensive content",
"Contenuto oscurato":"Content hidden",
"Controlla i contenuti segnalati dagli utenti e decidi se archiviarli, oscurarli o eliminarli.":"Review the content reported by users and decide whether to dismiss the report, hide the content or delete it.",
"Credenziali non valide":"Invalid credentials",
"C’è 1 segnalazione da esaminare":"There is 1 report to review",
"Data":"Date",
"Destinazione":"Target",
"Dettaglio":"Details",
"Disconnesso ovunque":"Signed out everywhere",
"Disconnettere ovunque?":"Sign out everywhere?",
"Disconnetti":"Sign out",
"Disconnetti ovunque":"Sign out everywhere",
"Download":"Downloads",
"Download ↗":"Download ↗",
"Elementi in libreria":"Library items",
"Elemento non trovato":"Item not found",
"Elimina":"Delete",
"Elimina account":"Delete account",
"Elimina contenuto":"Delete content",
"Eliminare definitivamente il contenuto?":"Permanently delete the content?",
"Eliminare definitivamente l’account?":"Permanently delete the account?",
"Eliminare questo commento?":"Delete this comment?",
"Eliminare questo homebrew?":"Delete this homebrew?",
"Eliminare questo post?":"Delete this post?",
"Eliminato":"Deleted",
"Errore del server":"Server error",
"Errore di rete":"Network error",
"Errore imprevisto":"Unexpected error",
"Es. pubblicazione ripetuta di contenuti pirata":"E.g. repeatedly publishing pirated content",
"Facoltativo":"Optional",
"Gestita da ":"Handled by ",
"Gruppi":"Groups",
"Homebrew":"Homebrew",
"Homebrew e commenti dello Store segnalati dagli utenti.":"Store homebrew and comments reported by users.",
"Homebrew eliminato":"Homebrew deleted",
"Homebrew oscurati":"Hidden homebrew",
"Homebrew oscurato":"Homebrew hidden",
"Homebrew ripristinato":"Homebrew restored",
"Homebrew «{title}»":"Homebrew «{title}»",
"Homebrew: {title}":"Homebrew: {title}",
"ID online o password non corretti.":"Incorrect online ID or password.",
"Il commento non sarà più visibile agli utenti, ma potrà essere ripristinato.":"The comment will no longer be visible to users, but it can be restored.",
"Il commento sarà cancellato definitivamente.":"The comment will be permanently deleted.",
"Il contenuto non esiste più.":"The content no longer exists.",
"Il contenuto sarà cancellato e non potrà essere recuperato.":"The content will be deleted and cannot be recovered.",
"Il post non sarà più visibile agli utenti, ma potrà essere ripristinato.":"The post will no longer be visible to users, but it can be restored.",
"Il registro è vuoto.":"The log is empty.",
"Impersonificazione":"Impersonation",
"Impossibile caricare gli homebrew.":"Could not load the homebrew.",
"Impossibile caricare gli utenti.":"Could not load the users.",
"Impossibile caricare i commenti.":"Could not load the comments.",
"Impossibile caricare i post.":"Could not load the posts.",
"Impossibile caricare il registro.":"Could not load the log.",
"Impossibile caricare le segnalazioni.":"Could not load the reports.",
"Impossibile caricare le statistiche.":"Could not load the statistics.",
"Impossibile contattare il server":"Could not reach the server",
"Impossibile verificare la sessione: {error}.":"Could not verify the session: {error}.",
"Inserisci ID online e password.":"Enter your online ID and password.",
"Iscritto":"Joined",
"La segnalazione verrà chiusa senza intervenire sul contenuto.":"The report will be closed without acting on the content.",
"La segnalazione verrà chiusa senza intervenire.":"The report will be closed without taking action.",
"La sessione è scaduta: accedi di nuovo.":"Your session has expired: please sign in again.",
"Link non funzionante":"Broken link",
"L’utente non potrà più accedere a questo pannello.":"The user will no longer be able to access this panel.",
"L’utente non potrà più accedere e tutte le sue sessioni verranno chiuse.":"The user will no longer be able to sign in and all of their sessions will be closed.",
"L’utente potrà di nuovo accedere e pubblicare contenuti.":"The user will be able to sign in and publish content again.",
"Mai":"Never",
"Malware/pericoloso":"Malware/dangerous",
"Mi piace":"Likes",
"Molestie":"Harassment",
"Mostra solo i commenti di questo homebrew":"Show only the comments on this homebrew",
"Motivo (facoltativo)":"Reason (optional)",
"Motivo del ban (facoltativo)":"Reason for the ban (optional)",
"Motivo: {reason}":"Reason: {reason}",
"Nessun commento trovato.":"No comments found.",
"Nessun homebrew trovato.":"No homebrew found.",
"Nessun post trovato.":"No posts found.",
"Nessun risultato":"No results",
"Nessun utente trovato.":"No users found.",
"Nessuna segnalazione aperta.":"No open reports.",
"Nessuna segnalazione in attesa":"No pending reports",
"Nessuna segnalazione.":"No reports.",
"Non puoi applicare questa azione al tuo account":"You cannot apply this action to your own account",
"Non sarà più visibile nello Store finché non verrà ripristinato.":"It will not be visible in the Store until it is restored.",
"Nota (facoltativa)":"Note (optional)",
"Nota del segnalante: ":"Reporter's note: ",
"Operazione irreversibile":"This cannot be undone",
"Operazione non consentita":"Operation not allowed",
"Oscura":"Hide",
"Oscurare il contenuto?":"Hide the content?",
"Oscurare questo commento?":"Hide this comment?",
"Oscurare questo homebrew?":"Hide this homebrew?",
"Oscurare questo post?":"Hide this post?",
"Oscurati":"Hidden",
"Oscurato":"Hidden",
"Ottimo lavoro: la coda di moderazione è vuota.":"Great job: the moderation queue is empty.",
"Panoramica":"Overview",
"Per confermare digita ":"To confirm, type ",
"Permessi di amministratore rimossi":"Administrator permissions removed",
"Piattaforma":"Platform",
"Pirateria":"Piracy",
"Post":"Post",
"Post di {user}":"Post by {user}",
"Post eliminato":"Post deleted",
"Post in bacheca":"Wall posts",
"Post oscurati":"Hidden posts",
"Post oscurato":"Post hidden",
"Post pubblicati dagli utenti nella bacheca social.":"Posts published by users on the social wall.",
"Post ripristinato":"Post restored",
"Profilo al momento della segnalazione: ":"Profile at the time of the report: ",
"Pubblicati":"Published",
"Pubblicato":"Published",
"Questo account non ha i permessi di amministratore.":"This account does not have administrator permissions.",
"Questo account non è un amministratore":"This account is not an administrator",
"Registro":"Log",
"Rendere amministratore questo utente?":"Make this user an administrator?",
"Rendi admin":"Make admin",
"Reso admin":"Made admin",
"Resta nel registro":"Kept in the log",
"Riabilita":"Unban",
"Riabilitare questo utente?":"Unban this user?",
"Riabilitato":"Unbanned",
"Richiesta bloccata (controllo CSRF)":"Request blocked (CSRF check)",
"Richiesta non valida":"Invalid request",
"Rimuovi filtro":"Remove filter",
"Ripristina":"Restore",
"Ripristinato":"Restored",
"Risolta":"Resolved",
"Risolte":"Resolved",
"Ruolo":"Role",
"Segna come risolta":"Mark as resolved",
"Segnal.":"Reports",
"Segnal. ricevute":"Reports received",
"Segnalato da ":"Reported by ",
"Segnalazione":"Report",
"Segnalazione archiviata":"Report dismissed",
"Segnalazione risolta":"Report resolved",
"Segnalazione social archiviata":"Social report dismissed",
"Segnalazione social risolta":"Social report resolved",
"Segnalazione social: eliminato":"Social report: deleted",
"Segnalazione social: oscurato":"Social report: hidden",
"Segnalazione: eliminato":"Report: deleted",
"Segnalazione: oscurato":"Report: hidden",
"Segnalazioni":"Reports",
"Segnalazioni Store":"Store reports",
"Segnalazioni aperte":"Open reports",
"Segnalazioni aperte su questo contenuto: ":"Open reports on this content: ",
"Segnalazioni ricevute":"Reports received",
"Segnalazioni social":"Social reports",
"Segnare come risolta?":"Mark as resolved?",
"Sei uscito.":"You have signed out.",
"Sessione scaduta":"Session expired",
"Sessioni attive":"Active sessions",
"Sessioni chiuse":"Sessions closed",
"Social":"Social",
"Spam":"Spam",
"Stato":"Status",
"Stato generale del servizio Omega: Store e social.":"General status of the Omega service: Store and social.",
"Store":"Store",
"Successiva ›":"Next ›",
"Tipo file":"File type",
"Tipo: ":"Type: ",
"Titolo":"Title",
"Togli admin":"Remove admin",
"Togliere i permessi di amministratore?":"Remove administrator permissions?",
"Troppe richieste: riprova tra poco":"Too many requests: try again shortly",
"Troppi tentativi di accesso. Riprova tra qualche minuto.":"Too many sign-in attempts. Try again in a few minutes.",
"Troppi tentativi: riprova tra qualche minuto":"Too many attempts: try again in a few minutes",
"Tutte":"All",
"Tutte le sessioni attive di questo utente (console e web) verranno chiuse.":"All active sessions of this user (console and web) will be closed.",
"Tutti":"All",
"Ultimo accesso":"Last sign-in",
"Usa questa azione dopo aver preso provvedimenti (es. ban dalla sezione Utenti).":"Use this action after taking measures (e.g. a ban from the Users section).",
"Uscita":"Sign-out",
"Utente":"User",
"Utente bannato":"User banned",
"Utente disconnesso ovunque":"User signed out everywhere",
"Utente reso admin":"User made admin",
"Utente reso amministratore":"User made administrator",
"Utente riabilitato":"User unbanned",
"Utente {user}":"User {user}",
"Utenti":"Users",
"Utenti bannati":"Banned users",
"Utenti, post e commenti della bacheca segnalati dagli utenti.":"Users, wall posts and comments reported by users.",
"Vai alle segnalazioni":"Go to reports",
"Verranno cancellati l’account e TUTTI i suoi contenuti: {apps} homebrew, {comments} commenti, voti, libreria e sessioni. Non sarà possibile recuperarli.":"The account and ALL of its content will be deleted: {apps} homebrew, {comments} comments, votes, library and sessions. They cannot be recovered.",
"Verranno eliminati il post, i suoi commenti e i mi piace.":"The post, its comments and its likes will be deleted.",
"Verranno eliminati l’homebrew, i suoi commenti, voti e segnalazioni.":"The homebrew and its comments, votes and reports will be deleted.",
"Verrà eliminato {what} segnalato.":"The reported {what} will be deleted.",
"Verrà oscurato {what} segnalato: non sarà più visibile agli utenti, ma potrà essere ripristinato.":"The reported {what} will be hidden: it will no longer be visible to users, but it can be restored.",
"Visibile":"Visible",
"Visibile all’autore e nel registro":"Visible to the author and in the log",
"Visibili":"Visible",
"Voce del catalogo curato":"Curated catalog entry",
"dal {date}":"since {date}",
"di {user}":"by {user}",
"homebrew sconosciuto":"unknown homebrew",
"il commento":"comment",
"il post":"post",
"il {date}":"on {date}",
"l’homebrew":"homebrew",
"sconosciuto":"unknown",
"su «{title}»":"on «{title}»",
"sul post #{id}":"on post #{id}",
"utente sconosciuto":"unknown user",
"{from}–{to} di {total}":"{from}–{to} of {total}",
"{likes} mi piace, {dislikes} non mi piace":"{likes} likes, {dislikes} dislikes",
"{n} commenti":"{n} comments",
"«{text}» di {user}":"«{text}» by {user}",
"‹ Precedente":"‹ Previous",
"Bug e richieste":"Bugs & requests",
"Messaggi mandati dagli utenti dall'app: bug con il registro allegato e funzioni richieste.":"Messages sent by users from the app: bugs with the attached log and feature requests.",
"Da leggere":"To read",
"Fatti":"Done",
"Archiviati":"Archived",
"Tutti":"All",
"Impossibile caricare i messaggi.":"Couldn't load the messages.",
"Nessun messaggio qui.":"No messages here.",
"Tipo":"Type",
"Bug":"Bug",
"Funzione":"Feature",
"Utente":"User",
"Messaggio":"Message",
"Registro allegato":"Attached log",
"Fatto":"Done",
"Archivia":"Archive",
"Riapri":"Reopen"
};
function initialLang() {
try { const v = localStorage.getItem(LANG_KEY); if (v === "it" || v === "en") return v; } catch (_) {}
return /^it\b/i.test(navigator.language || "") ? "it" : "en";
}
let LANG = initialLang();
// T("testo italiano", { var }) → testo nella lingua scelta, con i segnaposto {var} sostituiti
function T(s, vars) {
let out = LANG === "en" && Object.prototype.hasOwnProperty.call(EN, s) ? EN[s] : s;
if (vars) out = out.replace(/\{(\w+)\}/g, (m, k) => (vars[k] != null ? String(vars[k]) : m));
return out;
}
/* --- Utilities --- */
const PAGE = 50;
function el(tag, props) {
const n = document.createElement(tag);
if (props) {
for (const k of Object.keys(props)) {
const v = props[k];
if (v == null || v === false) continue;
if (k === 'class') n.className = v;
else if (k === 'text') n.textContent = String(v);
else if (k.slice(0, 2) === 'on' && typeof v === 'function') n.addEventListener(k.slice(2), v);
else if (k === 'value') n.value = v;
else n.setAttribute(k, v === true ? '' : String(v));
}
}
for (let i = 2; i < arguments.length; i++) append(n, arguments[i]);
return n;
}
function append(n, c) {
if (c == null || c === false) return;
if (Array.isArray(c)) { c.forEach(x => append(n, x)); return; }
n.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
}
function parseDate(v) {
if (v == null || v === '') return null;
let d;
if (typeof v === 'number') d = new Date(v < 1e12 ? v * 1000 : v);
else {
const s = String(v);
if (/^\d+$/.test(s)) { const n = Number(s); d = new Date(n < 1e12 ? n * 1000 : n); }
else if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(s)) d = new Date(s.replace(' ', 'T') + 'Z');
else d = new Date(s);
}
return isNaN(d.getTime()) ? null : d;
}
const locale = () => (LANG === 'en' ? 'en-GB' : 'it-IT');
let dtFmt, dFmt;
function makeFormats() {
dtFmt = new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
dFmt = new Intl.DateTimeFormat(locale(), { day: '2-digit', month: 'short', year: 'numeric' });
}
makeFormats();
function fmtDate(v) { const d = parseDate(v); return d ? dtFmt.format(d) : (v ? String(v) : '—'); }
function fmtDay(v) { const d = parseDate(v); return d ? dFmt.format(d) : (v ? String(v) : '—'); }
function fmtNum(v) { const n = Number(v); return Number.isFinite(n) ? n.toLocaleString(locale()) : '—'; }
function safeUrl(u) {
if (!u || typeof u !== 'string') return null;
try {
const url = new URL(u, location.href);
return (url.protocol === 'http:' || url.protocol === 'https:') ? url.href : null;
} catch (_) { return null; }
}
function truncate(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
function qs(obj) {
const p = new URLSearchParams();
for (const k of Object.keys(obj)) if (obj[k] !== '' && obj[k] != null) p.set(k, obj[k]);
const s = p.toString();
return s ? '?' + s : '';
}
function debounce(fn, ms) { let t; return function () { const a = arguments; clearTimeout(t); t = setTimeout(() => fn.apply(null, a), ms); }; }
/* --- Toasts --- */
const toasts = document.getElementById('toasts');
function toast(title, detail, kind) {
const t = el('div', { class: 'toast ' + (kind || ''), role: kind === 'error' ? 'alert' : 'status' },
el('div', { class: 't-title', text: title }),
detail ? el('div', { class: 't-detail', text: detail }) : null);
const remove = () => { if (t.parentNode) t.parentNode.removeChild(t); };
t.addEventListener('click', remove);
toasts.appendChild(t);
setTimeout(remove, kind === 'error' ? 7000 : 4000);
while (toasts.children.length > 4) toasts.removeChild(toasts.firstChild);
}
const ERRORS = {
invalid_credentials: 'Credenziali non valide',
not_admin: 'Questo account non è un amministratore',
too_many_attempts: 'Troppi tentativi: riprova tra qualche minuto',
rate_limited: 'Troppe richieste: riprova tra poco',
unauthorized: 'Sessione scaduta',
forbidden: 'Operazione non consentita',
not_found: 'Elemento non trovato',
invalid_action: 'Azione non valida',
bad_request: 'Richiesta non valida',
csrf: 'Richiesta bloccata (controllo CSRF)',
network: 'Errore di rete',
server_error: 'Errore del server',
internal_error: 'Errore del server'
};
function errorTitle(err) {
const code = err && err.body && err.body.error;
if (code && ERRORS[code]) return T(ERRORS[code]);
if (err && err.status === 429) return T(ERRORS.too_many_attempts);
if (err && err.status >= 500) return T(ERRORS.server_error);
return code ? String(code) : T('Errore imprevisto');
}
function errorDetail(err) {
const b = err && err.body;
if (!b || b.detail == null) return (b && b.error && ERRORS[b.error]) ? String(b.error) : '';
return typeof b.detail === 'string' ? b.detail : JSON.stringify(b.detail);
}
function toastError(err) { toast(errorTitle(err), errorDetail(err), 'error'); }
/* --- API --- */
class ApiError extends Error {
constructor(status, body) { super((body && body.error) || ('HTTP ' + status)); this.status = status; this.body = body || {}; }
}
async function api(method, path, body, opts) {
opts = opts || {};
const init = { method: method, credentials: 'same-origin', headers: { 'Accept': 'application/json' }, cache: 'no-store' };
if (method !== 'GET') {
init.headers['X-Omega-Admin'] = '1';
init.headers['Content-Type'] = 'application/json';
if (body !== undefined) init.body = JSON.stringify(body);
}
let res;
try { res = await fetch(path, init); }
catch (e) {
const err = new ApiError(0, { error: 'network', detail: T('Impossibile contattare il server') });
if (!opts.silent) toastError(err);
throw err;
}
let data = null;
if (res.status !== 204) {
const txt = await res.text();
if (txt) { try { data = JSON.parse(txt); } catch (_) { data = res.ok ? null : { error: 'HTTP ' + res.status, detail: truncate(txt, 200) }; } }
}
if (res.status === 401 && !opts.isLogin) {
showLogin(state.me ? T('La sessione è scaduta: accedi di nuovo.') : '');
throw new ApiError(401, data);
}
if (!res.ok) {
const err = new ApiError(res.status, data || { error: 'HTTP ' + res.status });
if (!opts.silent) toastError(err);
throw err;
}
return data;
}
/* --- Modal --- */
// opts: {title, message (string|Node), subject, warning:{title,text}, confirmLabel, danger,
// reason: {label, placeholder}, requireText}
function confirmModal(opts) {
return new Promise(resolve => {
const prevFocus = document.activeElement;
let reasonInput = null, typeInput = null;
const confirmBtn = el('button', { class: 'btn ' + (opts.danger ? 'danger solid' : 'primary'), type: 'submit', text: opts.confirmLabel || T('Conferma') });
const cancelBtn = el('button', { class: 'btn', type: 'button', text: T('Annulla') });
const body = [];
if (opts.message) body.push(el('p', null, opts.message));
if (opts.subject) body.push(el('p', null, el('span', { class: 'subject', text: opts.subject })));
if (opts.warning) body.push(el('div', { class: 'warning' }, el('strong', { text: opts.warning.title }), opts.warning.text));
if (opts.reason) {
reasonInput = el('textarea', { class: 'textarea', maxlength: '500', placeholder: opts.reason.placeholder || T('Facoltativo') });
body.push(el('label', { class: 'field' }, el('span', { text: opts.reason.label || T('Motivo (facoltativo)') }), reasonInput));
}
if (opts.requireText) {
typeInput = el('input', { class: 'input', autocomplete: 'off', spellcheck: 'false', autocapitalize: 'off' });
confirmBtn.disabled = true;
typeInput.addEventListener('input', () => { confirmBtn.disabled = typeInput.value.trim() !== opts.requireText; });
body.push(el('label', { class: 'field' },
el('span', null, T('Per confermare digita '), el('strong', { style: 'color:var(--text)', text: opts.requireText })),
typeInput));
}
const form = el('form', { class: 'modal', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'modal-title' },
el('h3', { id: 'modal-title', text: opts.title }),
body,
el('div', { class: 'modal-actions' }, cancelBtn, confirmBtn));
const overlay = el('div', { class: 'overlay' }, form);
function close(result) {
document.removeEventListener('keydown', onKey, true);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
if (prevFocus && prevFocus.focus) try { prevFocus.focus(); } catch (_) {}
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); close(null); }
else if (e.key === 'Tab') {
const f = form.querySelectorAll('button:not(:disabled), input, textarea');
if (!f.length) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
form.addEventListener('submit', e => {
e.preventDefault();
if (confirmBtn.disabled) return;
close({ reason: reasonInput ? reasonInput.value.trim() : '' });
});
cancelBtn.addEventListener('click', () => close(null));
overlay.addEventListener('mousedown', e => { if (e.target === overlay) close(null); });
document.addEventListener('keydown', onKey, true);
document.body.appendChild(overlay);
(typeInput || reasonInput || cancelBtn).focus();
});
}
/* --- State & routing --- */
const state = {
me: null,
route: 'panoramica',
seq: 0,
reports: { status: 'open', kind: 'store' },
posts: { q: '', status: 'all', offset: 0 },
apps: { q: '', status: 'all', offset: 0 },
comments: { q: '', status: 'all', app_id: '', app_title: '', offset: 0 },
users: { q: '', status: 'all', offset: 0 },
log: { offset: 0 },
feedback: { status: 'open', offset: 0 }
};
const ROUTES = [
{ id: 'panoramica', label: 'Panoramica', render: renderOverview },
{ id: 'segnalazioni', label: 'Segnalazioni', render: renderReports },
{ id: 'homebrew', label: 'Homebrew', render: renderApps },
{ id: 'commenti', label: 'Commenti', render: renderComments },
{ id: 'bacheca', label: 'Bacheca', render: renderPosts },
{ id: 'utenti', label: 'Utenti', render: renderUsers },
{ id: 'messaggi', label: 'Bug e richieste', render: renderFeedback },
{ id: 'registro', label: 'Registro', render: renderLog }
];
const loginSec = document.getElementById('login');
const appSec = document.getElementById('app');
const view = document.getElementById('view');
const nav = document.getElementById('nav');
const navLinks = {};
let reportsBadge = null;
ROUTES.forEach(r => {
const a = el('a', { href: '#' + r.id }, el('span', { class: 'nav-label', 'data-route': r.id, text: T(r.label) }));
if (r.id === 'segnalazioni') { reportsBadge = el('span', { class: 'count', hidden: true }); a.appendChild(reportsBadge); }
navLinks[r.id] = a;
nav.appendChild(a);
});
function currentRoute() {
const h = (location.hash || '').replace(/^#/, '');
return ROUTES.find(r => r.id === h) || ROUTES[0];
}
function go(id) { if (location.hash === '#' + id) rerender(); else location.hash = id; }
window.addEventListener('hashchange', () => { if (state.me) rerender(true); });
function rerender(scrollTop) {
const r = currentRoute();
state.route = r.id;
Object.keys(navLinks).forEach(k => {
navLinks[k].classList.toggle('active', k === r.id);
if (k === r.id) navLinks[k].setAttribute('aria-current', 'page'); else navLinks[k].removeAttribute('aria-current');
});
document.title = T(r.label) + ' · Omega · ' + T('Amministrazione');
if (scrollTop) window.scrollTo(0, 0);
const seq = ++state.seq;
r.render(seq);
}
function stale(seq) { return seq !== state.seq; }
function setView(nodes) { view.replaceChildren(); append(view, nodes); }
function pageHead(title, sub, right) {
return el('div', { class: 'page-head' },
el('div', null, el('h2', { text: title }), sub ? el('p', { text: sub }) : null),
right || null);
}
function loading() { return el('div', { class: 'loading', text: T('Caricamento…') }); }
function empty(text) { return el('div', { class: 'empty', text: text }); }
function chips(options, current, onPick) {
return el('div', { class: 'chips', role: 'group' }, options.map(o =>
el('button', { type: 'button', class: 'chip' + (o.value === current ? ' active' : ''), 'aria-pressed': o.value === current ? 'true' : 'false',
text: o.label, onclick: () => { if (o.value !== current) onPick(o.value); } })));
}
function searchBox(value, placeholder, onSearch) {
const input = el('input', { class: 'input search', type: 'search', placeholder: placeholder, value: value, 'aria-label': placeholder });
const fire = debounce(() => onSearch(input.value.trim()), 350);
input.addEventListener('input', fire);
input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); onSearch(input.value.trim()); } });
return input;
}
function pager(total, offset, onChange) {
total = Number(total) || 0;
const from = total ? offset + 1 : 0;
const to = Math.min(offset + PAGE, total);
return el('div', { class: 'pager' },
el('span', { text: total ? T('{from}–{to} di {total}', { from: fmtNum(from), to: fmtNum(to), total: fmtNum(total) }) : T('Nessun risultato') }),
el('div', { class: 'btns' },
el('button', { class: 'btn sm', type: 'button', text: T('‹ Precedente'), disabled: offset <= 0,
onclick: () => onChange(Math.max(0, offset - PAGE)) }),
el('button', { class: 'btn sm', type: 'button', text: T('Successiva ›'), disabled: offset + PAGE >= total,
onclick: () => onChange(offset + PAGE) })));
}
function td(label, content, cls) { return el('td', { 'data-label': label, class: cls || null }, content); }
function table(headers, rows) {
return el('div', { class: 'table-wrap' },
el('table', { class: 'data' },
el('thead', null, el('tr', null, headers.map(h => el('th', { class: h.num ? 'num' : null, scope: 'col', text: h.label })))),
el('tbody', null, rows)));
}
// Run an action button: disables, calls fn, toasts ok, refreshes.
async function runAction(btn, fn, okMsg) {
const btns = btn && btn.closest ? btn.closest('.actions, .card-actions') : null;
const all = btns ? Array.from(btns.querySelectorAll('button')) : (btn ? [btn] : []);
all.forEach(b => { b.disabled = true; });
try {
await fn();
toast(okMsg, '', 'ok');
refreshBadge();
rerender();
} catch (e) {
all.forEach(b => { b.disabled = false; });
}
}
/* --- Cambio lingua --- */
function applyStatic() {
document.documentElement.lang = LANG;
document.querySelectorAll('[data-t]').forEach(n => { n.textContent = T(n.getAttribute('data-t')); });
document.querySelectorAll('[data-t-label]').forEach(n => { n.setAttribute('aria-label', T(n.getAttribute('data-t-label'))); });
document.querySelectorAll('.lang-toggle button').forEach(b => { b.setAttribute('aria-pressed', b.getAttribute('data-lang') === LANG ? 'true' : 'false'); });
document.querySelectorAll('.nav-label').forEach(n => {
const r = ROUTES.find(x => x.id === n.getAttribute('data-route'));
if (r) n.textContent = T(r.label);
});
}
function setLang(l) {
if ((l !== 'it' && l !== 'en') || l === LANG) return;
LANG = l;
try { localStorage.setItem(LANG_KEY, l); } catch (_) {}
makeFormats();
applyStatic();
if (state.me) rerender();
else document.title = T('Accesso') + ' · Omega · ' + T('Amministrazione');
}
document.querySelectorAll('.lang-toggle button').forEach(b => b.addEventListener('click', () => setLang(b.getAttribute('data-lang'))));
applyStatic();
/* --- Auth --- */
const loginForm = document.getElementById('login-form');
const loginErr = document.getElementById('login-error');
const loginBtn = document.getElementById('login-btn');
const loginId = document.getElementById('login-id');
const loginPw = document.getElementById('login-pw');
function showLogin(msg) {
state.me = null;
state.seq++;
document.querySelectorAll('.overlay').forEach(o => o.remove());
appSec.hidden = true;
loginSec.hidden = false;
document.title = T('Accesso') + ' · Omega · ' + T('Amministrazione');
loginPw.value = '';
if (msg) { loginErr.textContent = msg; loginErr.hidden = false; } else loginErr.hidden = true;
setTimeout(() => (loginId.value ? loginPw : loginId).focus(), 0);
}
function showApp(me) {
state.me = me;
document.getElementById('me-name').textContent = me.online_id || '';
loginSec.hidden = true;
appSec.hidden = false;
refreshBadge();
rerender(true);
}
loginForm.addEventListener('submit', async e => {
e.preventDefault();
loginErr.hidden = true;
const id = loginId.value.trim(), pw = loginPw.value;
if (!id || !pw) { loginErr.textContent = T('Inserisci ID online e password.'); loginErr.hidden = false; return; }
loginBtn.disabled = true; loginBtn.textContent = T('Accesso in corso…');
try {
const me = await api('POST', '/admin/api/login', { online_id: id, password: pw }, { isLogin: true, silent: true });
loginPw.value = '';
showApp(me || { online_id: id });
} catch (err) {
let msg = errorTitle(err);
if (err.status === 401) msg = T('ID online o password non corretti.');
else if (err.status === 403) msg = T('Questo account non ha i permessi di amministratore.');
else if (err.status === 429) msg = T('Troppi tentativi di accesso. Riprova tra qualche minuto.');
const d = err.body && typeof err.body.detail === 'string' ? err.body.detail : '';
loginErr.textContent = d ? msg + ' (' + d + ')' : msg;
loginErr.hidden = false;
} finally {
loginBtn.disabled = false; loginBtn.textContent = T('Accedi');
}
});
document.getElementById('logout-btn').addEventListener('click', async () => {
try { await api('POST', '/admin/api/logout', {}, { silent: true }); } catch (_) {}
showLogin(T('Sei uscito.'));
});
async function refreshBadge() {
try {
const s = await api('GET', '/admin/api/stats', undefined, { silent: true });
const n = openTotal(s);
reportsBadge.textContent = n > 99 ? '99+' : String(n);
reportsBadge.hidden = n === 0;
return s;
} catch (_) { return null; }
}
// segnalazioni aperte in tutto: Store + social
function openTotal(s) { return (Number(s && s.open_reports) || 0) + (Number(s && s.open_social_reports) || 0); }
/* --- Labels --- */
const REASONS = {
pirateria: 'Pirateria',
malware: 'Malware/pericoloso',
contenuto_offensivo: 'Contenuto offensivo',
spam: 'Spam',
link_rotto: 'Link non funzionante',
molestie: 'Molestie',
impersonificazione: 'Impersonificazione',
altro: 'Altro'
};
const REPORT_STATUS = { open: ['Aperta', 'warn'], resolved: ['Risolta', 'ok'], dismissed: ['Archiviata', ''] };
const LOG_ACTIONS = {
login: 'Accesso', logout: 'Uscita',
hide: 'Oscurato', show: 'Ripristinato', delete: 'Eliminato', remove: 'Eliminato',
dismiss: 'Segnalazione archiviata',
ban: 'Bannato', unban: 'Riabilitato', make_admin: 'Reso admin', remove_admin: 'Admin rimosso',
logout_all: 'Disconnesso ovunque',
report_dismiss: 'Segnalazione archiviata', report_hide: 'Segnalazione: oscurato', report_remove: 'Segnalazione: eliminato',
app_hide: 'Homebrew oscurato', app_show: 'Homebrew ripristinato', app_delete: 'Homebrew eliminato',
comment_hide: 'Commento oscurato', comment_show: 'Commento ripristinato', comment_delete: 'Commento eliminato',
user_ban: 'Utente bannato', user_unban: 'Utente riabilitato', user_make_admin: 'Utente reso admin',
user_remove_admin: 'Admin rimosso', user_logout_all: 'Utente disconnesso ovunque', user_delete: 'Account eliminato',
social_report_dismiss: 'Segnalazione social archiviata', social_report_resolve: 'Segnalazione social risolta',
social_report_hide: 'Segnalazione social: oscurato', social_report_remove: 'Segnalazione social: eliminato',
post_hide: 'Post oscurato', post_show: 'Post ripristinato', post_delete: 'Post eliminato'
};
const TARGET_TYPES = { app: 'Homebrew', comment: 'Commento', user: 'Utente', report: 'Segnalazione', post: 'Post', post_comment: 'Commento a un post' };
function reasonBadge(r) {
const cls = (r === 'malware' || r === 'pirateria' || r === 'molestie') ? 'danger' : (r === 'contenuto_offensivo' || r === 'impersonificazione' ? 'warn' : 'accent');
return el('span', { class: 'badge ' + cls, text: REASONS[r] ? T(REASONS[r]) : (r || T('Altro')) });
}
function isAdminRole(role) { return role === 'admin' || role === 'superadmin' || role === 'owner'; }
/* --- Panoramica --- */
async function renderOverview(seq) {
setView([pageHead(T('Panoramica'), T('Stato generale del servizio Omega: Store e social.')), loading()]);
let s;
try { s = await api('GET', '/admin/api/stats'); } catch (_) { if (!stale(seq)) setView([pageHead(T('Panoramica')), empty(T('Impossibile caricare le statistiche.'))]); return; }
if (stale(seq)) return;
s = s || {};
const open = openTotal(s);
reportsBadge.textContent = open > 99 ? '99+' : String(open);
reportsBadge.hidden = open === 0;
const cards = [
{ k: 'open_reports', label: T('Segnalazioni Store'), go: () => { state.reports.status = 'open'; state.reports.kind = 'store'; go('segnalazioni'); }, hl: true, hot: Number(s.open_reports) > 0 },
{ k: 'open_social_reports', label: T('Segnalazioni social'), go: () => { state.reports.status = 'open'; state.reports.kind = 'social'; go('segnalazioni'); }, hl: true, hot: Number(s.open_social_reports) > 0 },
{ k: 'users', label: T('Utenti'), go: () => { state.users.status = 'all'; state.users.offset = 0; go('utenti'); } },
{ k: 'banned', label: T('Utenti bannati'), go: () => { state.users.status = 'banned'; state.users.offset = 0; go('utenti'); } },
{ k: 'admins', label: T('Amministratori'), go: () => { state.users.status = 'admins'; state.users.offset = 0; go('utenti'); } },
{ k: 'apps', label: T('Homebrew'), go: () => { state.apps.status = 'all'; state.apps.offset = 0; go('homebrew'); } },
{ k: 'hidden_apps', label: T('Homebrew oscurati'), go: () => { state.apps.status = 'hidden'; state.apps.offset = 0; go('homebrew'); } },
{ k: 'comments', label: T('Commenti'), go: () => { state.comments.status = 'all'; state.comments.offset = 0; go('commenti'); } },
{ k: 'hidden_comments', label: T('Commenti oscurati'), go: () => { state.comments.status = 'hidden'; state.comments.offset = 0; go('commenti'); } },
{ k: 'posts', label: T('Post in bacheca'), go: () => { state.posts.status = 'all'; state.posts.offset = 0; go('bacheca'); } },
{ k: 'hidden_posts', label: T('Post oscurati'), go: () => { state.posts.status = 'hidden'; state.posts.offset = 0; go('bacheca'); } },
{ k: 'groups', label: T('Gruppi'), go: null },
{ k: 'library_items', label: T('Elementi in libreria'), go: null },
{ k: 'active_sessions', label: T('Sessioni attive'), go: null }
];
const grid = el('div', { class: 'stats' }, cards.map(c => {
const cls = 'stat' + (c.hl ? ' highlight' + (c.hot ? ' hot' : '') : '');
const inner = [el('div', { class: 'label', text: c.label }), el('div', { class: 'value', text: fmtNum(s[c.k]) })];
return c.go ? el('button', { type: 'button', class: cls, onclick: c.go }, inner) : el('div', { class: cls, style: 'cursor:default' }, inner);
}));
const callout = el('div', { class: 'callout' },
el('div', null,
el('h3', { text: open > 0 ? (open === 1 ? T('C’è 1 segnalazione da esaminare') : T('Ci sono {n} segnalazioni da esaminare', { n: fmtNum(open) })) : T('Nessuna segnalazione in attesa') }),
el('p', { text: open > 0 ? T('Controlla i contenuti segnalati dagli utenti e decidi se archiviarli, oscurarli o eliminarli.') : T('Ottimo lavoro: la coda di moderazione è vuota.') })),
el('button', { class: 'btn primary', type: 'button', text: T('Vai alle segnalazioni'), onclick: () => {
state.reports.status = 'open'; state.reports.kind = (Number(s.open_reports) || 0) === 0 && Number(s.open_social_reports) > 0 ? 'social' : 'store'; go('segnalazioni');
} }));
setView([pageHead(T('Panoramica'), T('Stato generale del servizio Omega: Store e social.'),
el('button', { class: 'btn sm', type: 'button', text: T('Aggiorna'), onclick: () => rerender() })), grid, callout]);
}
/* --- Segnalazioni --- */
async function renderReports(seq) {
const f = state.reports;
const social = f.kind === 'social';
const head = pageHead(T('Segnalazioni'), social ? T('Utenti, post e commenti della bacheca segnalati dagli utenti.') : T('Homebrew e commenti dello Store segnalati dagli utenti.'),
el('button', { class: 'btn sm', type: 'button', text: T('Aggiorna'), onclick: () => rerender() }));
const bar = el('div', { class: 'toolbar' },
chips([{ value: 'store', label: T('Store') }, { value: 'social', label: T('Social') }], f.kind, v => { f.kind = v; rerender(); }),
chips([
{ value: 'open', label: T('Aperte') }, { value: 'resolved', label: T('Risolte') },
{ value: 'dismissed', label: T('Archiviate') }, { value: 'all', label: T('Tutte') }
], f.status, v => { f.status = v; rerender(); }));
setView([head, bar, loading()]);
let data;
try { data = await api('GET', (social ? '/admin/api/social-reports' : '/admin/api/reports') + qs({ status: f.status })); } catch (_) { if (!stale(seq)) setView([head, bar, empty(T('Impossibile caricare le segnalazioni.'))]); return; }
if (stale(seq)) return;
const list = (data && data.reports) || [];
if (!list.length) { setView([head, bar, empty(f.status === 'open' ? T('Nessuna segnalazione aperta.') : T('Nessuna segnalazione.'))]); return; }
setView([head, bar, el('div', { class: 'cards' }, list.map(social ? socialReportCard : reportCard))]);
}
// Segnalazione social: utente, post della bacheca o commento a un post.
function socialReportCard(r) {
const st = REPORT_STATUS[r.status] || [r.status || '—', ''];
const type = r.target_type;
const kindLabel = type === 'user' ? T('Utente') : type === 'post' ? T('Post') : T('Commento a un post');
const target = [];
if (type === 'user') {
target.push(el('div', { class: 'target-title' }, T('Utente {user}', { user: r.owner || T('(account eliminato)') })));
target.push(el('div', { class: 'target-meta' }, r.banned ? el('span', { class: 'badge danger', text: T('Bannato') }) : el('span', { class: 'badge ok', text: T('Attivo') }),
T(' · per il ban usa la sezione Utenti')));
if (r.text) target.push(el('div', { class: 'quote muted' }, el('strong', { text: T('Profilo al momento della segnalazione: ') }), r.text));
} else {
target.push(el('div', { class: 'target-title' }, (type === 'post' ? T('Post di {user}', { user: r.owner || T('utente sconosciuto') }) : T('Commento di {user}', { user: r.owner || T('utente sconosciuto') }))));
target.push(el('div', { class: 'target-meta' },
type === 'post_comment' && r.post_id ? T('sul post #{id}', { id: r.post_id }) + ' · ' : null,
r.game_name ? r.game_name + ' · ' : null,
!r.exists ? el('span', { class: 'badge', text: T('Eliminato') }) : r.hidden ? el('span', { class: 'badge warn', text: T('Oscurato') }) : el('span', { class: 'badge ok', text: T('Visibile') })));
target.push(el('div', { class: 'quote' + (r.exists ? '' : ' muted'), text: r.text || '' }));
}
if (r.note) target.push(el('div', { class: 'quote muted' }, el('strong', { text: T('Nota del segnalante: ') }), r.note));
const info = el('div', { class: 'card-info' },
el('span', null, T('Segnalato da '), el('strong', { text: r.reporter || '—' })),
el('span', null, T('Tipo: '), el('strong', { text: kindLabel })),
Number(r.open_for_target) > 0 ? el('span', null, T('Segnalazioni aperte su questo contenuto: '),
el('strong', { class: Number(r.open_for_target) > 1 ? 'danger-text' : null, text: fmtNum(r.open_for_target) })) : null,
r.resolved_by ? el('span', null, T('Gestita da '), el('strong', { text: r.resolved_by }), r.resolved_at ? ' ' + T('il {date}', { date: fmtDate(r.resolved_at) }) : '') : null);
let actions = null;
if (r.status === 'open') {
const subj = type === 'user' ? (r.owner || '') : '«' + truncate(r.text, 120) + '»';
const path = '/admin/api/social-reports/' + encodeURIComponent(r.report_id);
const act = (btn, action, cfg, ok) => async () => {
const res = await confirmModal(Object.assign({ subject: subj, reason: { label: T('Nota (facoltativa)'), placeholder: T('Annotazione interna sulla decisione') } }, cfg));
if (!res) return;
const body = { action: action };
if (res.reason) body.note = res.reason;
runAction(btn, () => api('POST', path, body), ok);
};
const bD = el('button', { class: 'btn sm', type: 'button', text: T('Archivia') });
bD.addEventListener('click', act(bD, 'dismiss', { title: T('Archiviare la segnalazione?'), message: T('La segnalazione verrà chiusa senza intervenire.'), confirmLabel: T('Archivia') }, T('Segnalazione archiviata')));
const btns = [bD];
if (type === 'user') {
const bOk = el('button', { class: 'btn sm ok', type: 'button', text: T('Segna come risolta') });
bOk.addEventListener('click', act(bOk, 'resolve', { title: T('Segnare come risolta?'), message: T('Usa questa azione dopo aver preso provvedimenti (es. ban dalla sezione Utenti).'), confirmLabel: T('Risolta') }, T('Segnalazione risolta')));
const bU = el('button', { class: 'btn sm', type: 'button', text: T('Apri in Utenti') });
bU.addEventListener('click', () => { Object.assign(state.users, { q: r.owner || '', status: 'all', offset: 0 }); go('utenti'); });
btns.push(bOk, bU);
} else if (r.exists) {
const what = type === 'post' ? T('il post') : T('il commento');
const bH = el('button', { class: 'btn sm', type: 'button', text: T('Oscura') });
const bR = el('button', { class: 'btn sm danger', type: 'button', text: T('Elimina contenuto') });
bH.addEventListener('click', act(bH, 'hide', { title: T('Oscurare il contenuto?'), message: T('Verrà oscurato {what} segnalato: non sarà più visibile agli utenti, ma potrà essere ripristinato.', { what: what }), confirmLabel: T('Oscura'), danger: true }, T('Contenuto oscurato')));
bR.addEventListener('click', act(bR, 'remove', { title: T('Eliminare definitivamente il contenuto?'), message: T('Verrà eliminato {what} segnalato.', { what: what }), warning: { title: T('Operazione irreversibile'), text: T('Il contenuto sarà cancellato e non potrà essere recuperato.') }, confirmLabel: T('Elimina contenuto'), danger: true }, T('Contenuto eliminato')));
btns.push(bH, bR);
} else {
const bOk = el('button', { class: 'btn sm ok', type: 'button', text: T('Segna come risolta') });
bOk.addEventListener('click', act(bOk, 'resolve', { title: T('Segnare come risolta?'), message: T('Il contenuto non esiste più.'), confirmLabel: T('Risolta') }, T('Segnalazione risolta')));
btns.push(bOk);
}
actions = el('div', { class: 'card-actions' }, btns);
}
return el('article', { class: 'card' },
el('div', { class: 'card-head' }, el('span', { class: 'badge', text: kindLabel }), reasonBadge(r.reason), el('span', { class: 'badge ' + st[1], text: T(st[0]) }),
el('span', { class: 'spacer' }), el('span', { class: 'date', text: fmtDate(r.created_at) })),
target, info, actions);
}
function reportCard(r) {
const st = REPORT_STATUS[r.status] || [r.status || '—', ''];
const app = r.app || {};
const isComment = r.target_type === 'comment' && r.comment;
const target = [];
if (isComment) {
const c = r.comment;
target.push(el('div', { class: 'target-title' }, T('Commento di {user}', { user: c.author || T('utente sconosciuto') })));
target.push(el('div', { class: 'target-meta' }, T('su «{title}»', { title: app.title || T('homebrew sconosciuto') }),
c.hidden ? ' · ' : null, c.hidden ? el('span', { class: 'badge warn', text: T('Oscurato') }) : null));
target.push(el('div', { class: 'quote', text: c.body || '' }));
} else if (r.target_type === 'comment') {
target.push(el('div', { class: 'target-title', text: T('Commento eliminato') }));
target.push(el('div', { class: 'target-meta' }, T('su «{title}»', { title: app.title || T('homebrew sconosciuto') })));
} else {
target.push(el('div', { class: 'target-title' }, T('Homebrew «{title}»', { title: app.title || T('sconosciuto') })));
target.push(el('div', { class: 'target-meta' }, T('di {user}', { user: app.author || '—' }), ' · ',
app.published === false ? el('span', { class: 'badge warn', text: T('Oscurato') }) : el('span', { class: 'badge ok', text: T('Pubblicato') })));
}
if (r.note) target.push(el('div', { class: 'quote muted' }, el('strong', { text: T('Nota del segnalante: ') }), r.note));
const info = el('div', { class: 'card-info' },
el('span', null, T('Segnalato da '), el('strong', { text: r.reporter || '—' })),
el('span', null, T('Tipo: '), el('strong', { text: isComment || r.target_type === 'comment' ? T('Commento') : T('Homebrew') })),
Number(r.open_for_target) > 0 ? el('span', null, T('Segnalazioni aperte su questo contenuto: '),
el('strong', { class: Number(r.open_for_target) > 1 ? 'danger-text' : null, text: fmtNum(r.open_for_target) })) : null,
r.resolved_by ? el('span', null, T('Gestita da '), el('strong', { text: r.resolved_by }), r.resolved_at ? ' ' + T('il {date}', { date: fmtDate(r.resolved_at) }) : '') : null);
let actions = null;
if (r.status === 'open') {
const subj = isComment ? '«' + truncate(r.comment.body, 120) + '»' : '«' + (app.title || '') + '»';
const act = (btn, action, cfg, ok) => async () => {
const res = await confirmModal(Object.assign({ subject: subj, reason: { label: T('Nota (facoltativa)'), placeholder: T('Annotazione interna sulla decisione') } }, cfg));
if (!res) return;
const body = { action: action };
if (res.reason) body.note = res.reason;
runAction(btn(), () => api('POST', '/admin/api/reports/' + encodeURIComponent(r.report_id), body), ok);
};
const bD = el('button', { class: 'btn sm', type: 'button', text: T('Archivia') });
const bH = el('button', { class: 'btn sm', type: 'button', text: T('Oscura') });
const bR = el('button', { class: 'btn sm danger', type: 'button', text: T('Elimina contenuto') });
const what = isComment ? T('il commento') : T('l’homebrew');
bD.addEventListener('click', act(() => bD, 'dismiss', { title: T('Archiviare la segnalazione?'), message: T('La segnalazione verrà chiusa senza intervenire sul contenuto.'), confirmLabel: T('Archivia') }, T('Segnalazione archiviata')));
bH.addEventListener('click', act(() => bH, 'hide', { title: T('Oscurare il contenuto?'), message: T('Verrà oscurato {what} segnalato: non sarà più visibile agli utenti, ma potrà essere ripristinato.', { what: what }), confirmLabel: T('Oscura'), danger: true }, T('Contenuto oscurato')));
bR.addEventListener('click', act(() => bR, 'remove', { title: T('Eliminare definitivamente il contenuto?'), message: T('Verrà eliminato {what} segnalato.', { what: what }), warning: { title: T('Operazione irreversibile'), text: T('Il contenuto sarà cancellato e non potrà essere recuperato.') }, confirmLabel: T('Elimina contenuto'), danger: true }, T('Contenuto eliminato')));
actions = el('div', { class: 'card-actions' }, bD, bH, bR);
}
return el('article', { class: 'card' },
el('div', { class: 'card-head' }, reasonBadge(r.reason), el('span', { class: 'badge ' + st[1], text: T(st[0]) }),
el('span', { class: 'spacer' }), el('span', { class: 'date', text: fmtDate(r.created_at) })),
target, info, actions);
}
/* --- Homebrew --- */
async function renderApps(seq) {
const f = state.apps;
const head = pageHead(T('Homebrew'), T('Applicazioni pubblicate dagli utenti e voci di catalogo.'));
const bar = el('div', { class: 'toolbar' },
searchBox(f.q, T('Cerca per titolo o autore…'), q => { f.q = q; f.offset = 0; rerender(); }),
chips([{ value: 'all', label: T('Tutti') }, { value: 'published', label: T('Pubblicati') }, { value: 'hidden', label: T('Oscurati') }],
f.status, v => { f.status = v; f.offset = 0; rerender(); }));
setView([head, bar, loading()]);
let data;
try { data = await api('GET', '/admin/api/apps' + qs({ q: f.q, status: f.status, offset: f.offset, limit: PAGE })); }
catch (_) { if (!stale(seq)) setView([head, bar, empty(T('Impossibile caricare gli homebrew.'))]); return; }
if (stale(seq)) return;
const list = (data && data.apps) || [];
const total = data && data.total != null ? data.total : list.length;
const pg = pager(total, f.offset, o => { f.offset = o; rerender(true); });
if (!list.length) { setView([head, bar, empty(T('Nessun homebrew trovato.')), total ? pg : null]); return; }
const rows = list.map(appRow);
setView([head, bar, table([
{ label: T('Titolo') }, { label: T('Autore') }, { label: T('Piattaforma') }, { label: T('Tipo file') }, { label: T('Stato') },
{ label: T('Download'), num: true }, { label: T('Mi piace'), num: true }, { label: T('Segnal.'), num: true }, { label: T('Data') }, { label: T('Azioni') }
], rows), pg]);
}
function appRow(a) {
const hidden = a.published === false || a.published === 0;
const href = safeUrl(a.download_url);
const badges = el('div', { class: 'cell-badges' },
a.catalog_key != null ? el('span', { class: 'badge accent', title: T('Voce del catalogo curato'), text: T('Catalogo') }) : null,
a.category ? el('span', { class: 'badge', text: a.category }) : null,
href ? el('a', { href: href, target: '_blank', rel: 'noopener noreferrer', text: T('Download ↗') }) : null,
Number(a.comments) > 0 ? el('a', { href: '#commenti', text: T('{n} commenti', { n: fmtNum(a.comments) }), onclick: e => {
e.preventDefault(); Object.assign(state.comments, { app_id: a.app_id, app_title: a.title || '', offset: 0, q: '', status: 'all' }); go('commenti');
} }) : null);
const stato = hidden
? el('div', null, el('span', { class: 'badge warn', text: T('Oscurato') }), a.hidden_reason ? el('div', { class: 'cell-sub', text: a.hidden_reason }) : null)
: el('span', { class: 'badge ok', text: T('Pubblicato') });
const bToggle = el('button', { class: 'btn sm' + (hidden ? ' ok' : ''), type: 'button', text: hidden ? T('Ripristina') : T('Oscura') });
const bDel = el('button', { class: 'btn sm danger', type: 'button', text: T('Elimina') });
const path = '/admin/api/apps/' + encodeURIComponent(a.app_id);
bToggle.addEventListener('click', async () => {
if (hidden) { runAction(bToggle, () => api('POST', path, { action: 'show' }), T('Homebrew ripristinato')); return; }
const res = await confirmModal({ title: T('Oscurare questo homebrew?'), subject: '«' + (a.title || '') + '»',
message: T('Non sarà più visibile nello Store finché non verrà ripristinato.'), danger: true, confirmLabel: T('Oscura'),
reason: { label: T('Motivo (facoltativo)'), placeholder: T('Visibile all’autore e nel registro') } });
if (!res) return;
const body = { action: 'hide' }; if (res.reason) body.reason = res.reason;
runAction(bToggle, () => api('POST', path, body), T('Homebrew oscurato'));
});
bDel.addEventListener('click', async () => {
const res = await confirmModal({ title: T('Eliminare questo homebrew?'), subject: '«' + (a.title || '') + '»',
warning: { title: T('Operazione irreversibile'), text: T('Verranno eliminati l’homebrew, i suoi commenti, voti e segnalazioni.') },
danger: true, confirmLabel: T('Elimina') });
if (!res) return;
runAction(bDel, () => api('DELETE', path), T('Homebrew eliminato'));
});
const likes = el('span', { title: T('{likes} mi piace, {dislikes} non mi piace', { likes: fmtNum(a.likes), dislikes: fmtNum(a.dislikes) }) },
fmtNum(a.likes), el('span', { class: 'muted', text: ' / ' + fmtNum(a.dislikes) }));
const rep = Number(a.reports_open) || 0;
return el('tr', null,
td(T('Titolo'), [el('div', { class: 'cell-title', text: a.title || T('(senza titolo)') }),
a.tagline ? el('div', { class: 'cell-sub', text: a.tagline }) : null, badges], 'cell-main'),
td(T('Autore'), el('span', { text: a.author || '—' })),
td(T('Piattaforma'), el('span', { text: a.platform || '—' })),
td(T('Tipo file'), el('span', { text: a.file_kind || '—' })),
td(T('Stato'), stato),
td(T('Download'), fmtNum(a.downloads), 'num'),
td(T('Mi piace'), likes, 'num'),
td(T('Segnalazioni aperte'), rep ? el('span', { class: 'badge danger', text: fmtNum(rep) }) : el('span', { class: 'muted', text: '0' }), 'num'),
td(T('Data'), el('span', { text: fmtDay(a.created_at), title: fmtDate(a.created_at) }), 'nowrap'),
td(T('Azioni'), el('div', { class: 'actions' }, bToggle, bDel), 'cell-actions'));
}
/* --- Commenti --- */
async function renderComments(seq) {
const f = state.comments;
const head = pageHead(T('Commenti'), T('Commenti degli utenti sugli homebrew.'));
const bar = el('div', { class: 'toolbar' },
searchBox(f.q, T('Cerca nel testo o per autore…'), q => { f.q = q; f.offset = 0; rerender(); }),
chips([{ value: 'all', label: T('Tutti') }, { value: 'visible', label: T('Visibili') }, { value: 'hidden', label: T('Oscurati') }],
f.status, v => { f.status = v; f.offset = 0; rerender(); }),
f.app_id ? el('span', { class: 'filter-pill' }, el('span', { text: T('Homebrew: {title}', { title: f.app_title || f.app_id }) }),
el('button', { type: 'button', 'aria-label': T('Rimuovi filtro'), text: '×', onclick: () => { f.app_id = ''; f.app_title = ''; f.offset = 0; rerender(); } })) : null);
setView([head, bar, loading()]);
let data;
try { data = await api('GET', '/admin/api/comments' + qs({ q: f.q, app_id: f.app_id, status: f.status, offset: f.offset, limit: PAGE })); }
catch (_) { if (!stale(seq)) setView([head, bar, empty(T('Impossibile caricare i commenti.'))]); return; }
if (stale(seq)) return;
const list = (data && data.comments) || [];
const total = data && data.total != null ? data.total : list.length;
const pg = pager(total, f.offset, o => { f.offset = o; rerender(true); });
if (!list.length) { setView([head, bar, empty(T('Nessun commento trovato.')), total ? pg : null]); return; }
setView([head, bar, table([
{ label: T('Commento') }, { label: T('Homebrew') }, { label: T('Autore') }, { label: T('Stato') }, { label: T('Segnal.'), num: true }, { label: T('Data') }, { label: T('Azioni') }
], list.map(commentRow)), pg]);
}
function commentRow(c) {
const hidden = !!c.hidden;
const path = '/admin/api/comments/' + encodeURIComponent(c.comment_id);
const bToggle = el('button', { class: 'btn sm' + (hidden ? ' ok' : ''), type: 'button', text: hidden ? T('Ripristina') : T('Oscura') });
const bDel = el('button', { class: 'btn sm danger', type: 'button', text: T('Elimina') });
const subj = T('«{text}» di {user}', { text: truncate(c.body, 140), user: c.author || '—' });
bToggle.addEventListener('click', async () => {
if (hidden) { runAction(bToggle, () => api('POST', path, { action: 'show' }), T('Commento ripristinato')); return; }
const res = await confirmModal({ title: T('Oscurare questo commento?'), subject: subj,
message: T('Il commento non sarà più visibile agli utenti, ma potrà essere ripristinato.'), danger: true, confirmLabel: T('Oscura') });
if (!res) return;
runAction(bToggle, () => api('POST', path, { action: 'hide' }), T('Commento oscurato'));
});
bDel.addEventListener('click', async () => {
const res = await confirmModal({ title: T('Eliminare questo commento?'), subject: subj,
warning: { title: T('Operazione irreversibile'), text: T('Il commento sarà cancellato definitivamente.') }, danger: true, confirmLabel: T('Elimina') });
if (!res) return;
runAction(bDel, () => api('DELETE', path), T('Commento eliminato'));
});
const rep = Number(c.reports_open) || 0;
return el('tr', null,
td(T('Commento'), el('div', { class: 'cell-body', text: c.body || '' }), 'cell-main'),
td(T('Homebrew'), el('a', { href: '#commenti', text: c.app_title || ('#' + c.app_id), title: T('Mostra solo i commenti di questo homebrew'),
onclick: e => { e.preventDefault(); Object.assign(state.comments, { app_id: c.app_id, app_title: c.app_title || '', offset: 0 }); rerender(); } })),
td(T('Autore'), el('span', { text: c.author || '—' })),
td(T('Stato'), hidden ? el('span', { class: 'badge warn', text: T('Oscurato') }) : el('span', { class: 'badge ok', text: T('Visibile') })),
td(T('Segnalazioni aperte'), rep ? el('span', { class: 'badge danger', text: fmtNum(rep) }) : el('span', { class: 'muted', text: '0' }), 'num'),
td(T('Data'), el('span', { text: fmtDate(c.created_at) }), 'nowrap'),
td(T('Azioni'), el('div', { class: 'actions' }, bToggle, bDel), 'cell-actions'));
}
/* --- Bacheca --- */
async function renderPosts(seq) {
const f = state.posts;
const head = pageHead(T('Bacheca'), T('Post pubblicati dagli utenti nella bacheca social.'));
const bar = el('div', { class: 'toolbar' },
searchBox(f.q, T('Cerca nel testo o per autore…'), q => { f.q = q; f.offset = 0; rerender(); }),
chips([{ value: 'all', label: T('Tutti') }, { value: 'visible', label: T('Visibili') }, { value: 'hidden', label: T('Oscurati') }],
f.status, v => { f.status = v; f.offset = 0; rerender(); }));
setView([head, bar, loading()]);
let data;
try { data = await api('GET', '/admin/api/posts' + qs({ q: f.q, status: f.status, offset: f.offset, limit: PAGE })); }
catch (_) { if (!stale(seq)) setView([head, bar, empty(T('Impossibile caricare i post.'))]); return; }
if (stale(seq)) return;
const list = (data && data.posts) || [];
const total = data && data.total != null ? data.total : list.length;
const pg = pager(total, f.offset, o => { f.offset = o; rerender(true); });
if (!list.length) { setView([head, bar, empty(T('Nessun post trovato.')), total ? pg : null]); return; }
setView([head, bar, table([
{ label: T('Post') }, { label: T('Autore') }, { label: T('Stato') }, { label: T('Mi piace'), num: true }, { label: T('Commenti'), num: true },
{ label: T('Segnal.'), num: true }, { label: T('Data') }, { label: T('Azioni') }
], list.map(postRow)), pg]);
}
function postRow(p) {
const hidden = !!p.hidden;
const path = '/admin/api/posts/' + encodeURIComponent(p.post_id);
const bToggle = el('button', { class: 'btn sm' + (hidden ? ' ok' : ''), type: 'button', text: hidden ? T('Ripristina') : T('Oscura') });
const bDel = el('button', { class: 'btn sm danger', type: 'button', text: T('Elimina') });
const subj = T('«{text}» di {user}', { text: truncate(p.text, 140), user: p.author || '—' });
bToggle.addEventListener('click', async () => {
if (hidden) { runAction(bToggle, () => api('POST', path, { action: 'show' }), T('Post ripristinato')); return; }
const res = await confirmModal({ title: T('Oscurare questo post?'), subject: subj,
message: T('Il post non sarà più visibile agli utenti, ma potrà essere ripristinato.'), danger: true, confirmLabel: T('Oscura'),
reason: { label: T('Motivo (facoltativo)'), placeholder: T('Resta nel registro') } });
if (!res) return;
const body = { action: 'hide' }; if (res.reason) body.reason = res.reason;
runAction(bToggle, () => api('POST', path, body), T('Post oscurato'));
});
bDel.addEventListener('click', async () => {
const res = await confirmModal({ title: T('Eliminare questo post?'), subject: subj,
warning: { title: T('Operazione irreversibile'), text: T('Verranno eliminati il post, i suoi commenti e i mi piace.') }, danger: true, confirmLabel: T('Elimina') });
if (!res) return;
runAction(bDel, () => api('DELETE', path), T('Post eliminato'));
});
const rep = Number(p.reports_open) || 0;
const stato = hidden
? el('div', null, el('span', { class: 'badge warn', text: T('Oscurato') }), p.hidden_reason ? el('div', { class: 'cell-sub', text: p.hidden_reason }) : null)
: el('span', { class: 'badge ok', text: T('Visibile') });
return el('tr', null,
td(T('Post'), [el('div', { class: 'cell-body', text: p.text || '' }),
p.game_name ? el('div', { class: 'cell-badges' }, el('span', { class: 'badge accent', text: p.game_name })) : null], 'cell-main'),
td(T('Autore'), el('span', { text: p.author || '—' })),
td(T('Stato'), stato),
td(T('Mi piace'), fmtNum(p.likes), 'num'),
td(T('Commenti'), fmtNum(p.comments), 'num'),
td(T('Segnalazioni aperte'), rep ? el('span', { class: 'badge danger', text: fmtNum(rep) }) : el('span', { class: 'muted', text: '0' }), 'num'),
td(T('Data'), el('span', { text: fmtDate(p.created_at) }), 'nowrap'),
td(T('Azioni'), el('div', { class: 'actions' }, bToggle, bDel), 'cell-actions'));
}
/* --- Utenti --- */
async function renderUsers(seq) {
const f = state.users;
const head = pageHead(T('Utenti'), T('Account registrati al servizio.'));
const bar = el('div', { class: 'toolbar' },
searchBox(f.q, T('Cerca per ID online o email…'), q => { f.q = q; f.offset = 0; rerender(); }),
chips([{ value: 'all', label: T('Tutti') }, { value: 'banned', label: T('Bannati') }, { value: 'admins', label: T('Admin') }],
f.status, v => { f.status = v; f.offset = 0; rerender(); }));
setView([head, bar, loading()]);
let data;
try { data = await api('GET', '/admin/api/users' + qs({ q: f.q, status: f.status, offset: f.offset, limit: PAGE })); }
catch (_) { if (!stale(seq)) setView([head, bar, empty(T('Impossibile caricare gli utenti.'))]); return; }
if (stale(seq)) return;
const list = (data && data.users) || [];
const total = data && data.total != null ? data.total : list.length;
const pg = pager(total, f.offset, o => { f.offset = o; rerender(true); });
if (!list.length) { setView([head, bar, empty(T('Nessun utente trovato.')), total ? pg : null]); return; }
setView([head, bar, table([
{ label: T('Utente') }, { label: T('Ruolo') }, { label: T('Iscritto') }, { label: T('Ultimo accesso') }, { label: T('Stato') },
{ label: T('Homebrew'), num: true }, { label: T('Commenti'), num: true }, { label: T('Segnal. ricevute'), num: true }, { label: T('Azioni') }
], list.map(userRow)), pg]);
}
function userRow(u) {
const self = state.me && u.online_id === state.me.online_id;
const banned = !!u.banned_at;
const admin = isAdminRole(u.role);
const path = '/admin/api/users/' + encodeURIComponent(u.account_id);
const name = u.online_id || ('#' + u.account_id);
const bBan = el('button', { class: 'btn sm ' + (banned ? 'ok' : 'danger'), type: 'button', text: banned ? T('Riabilita') : T('Banna'), disabled: self && !banned });
const bAdm = el('button', { class: 'btn sm', type: 'button', text: admin ? T('Togli admin') : T('Rendi admin'), disabled: self && admin });
const bOut = el('button', { class: 'btn sm', type: 'button', text: T('Disconnetti ovunque') });
const bDel = el('button', { class: 'btn sm danger', type: 'button', text: T('Elimina account'), disabled: self });
if (self) [bBan, bAdm, bDel].forEach(b => { if (b.disabled) b.title = T('Non puoi applicare questa azione al tuo account'); });
bBan.addEventListener('click', async () => {
if (banned) {
const res = await confirmModal({ title: T('Riabilitare questo utente?'), subject: name, message: T('L’utente potrà di nuovo accedere e pubblicare contenuti.'), confirmLabel: T('Riabilita') });
if (!res) return;
runAction(bBan, () => api('POST', path, { action: 'unban' }), T('Utente riabilitato'));
return;
}
const res = await confirmModal({ title: T('Bannare questo utente?'), subject: name,
message: T('L’utente non potrà più accedere e tutte le sue sessioni verranno chiuse.'), danger: true, confirmLabel: T('Banna'),
reason: { label: T('Motivo del ban (facoltativo)'), placeholder: T('Es. pubblicazione ripetuta di contenuti pirata') } });
if (!res) return;
const body = { action: 'ban' }; if (res.reason) body.reason = res.reason;
runAction(bBan, () => api('POST', path, body), T('Utente bannato'));
});
bAdm.addEventListener('click', async () => {
const res = await confirmModal(admin
? { title: T('Togliere i permessi di amministratore?'), subject: name, message: T('L’utente non potrà più accedere a questo pannello.'), danger: true, confirmLabel: T('Togli admin'), reason: { label: T('Motivo (facoltativo)') } }
: { title: T('Rendere amministratore questo utente?'), subject: name, message: T('Avrà accesso completo a questo pannello: moderazione, ban ed eliminazione di account.'), confirmLabel: T('Rendi admin'), reason: { label: T('Motivo (facoltativo)') } });
if (!res) return;
const body = { action: admin ? 'remove_admin' : 'make_admin' }; if (res.reason) body.reason = res.reason;
runAction(bAdm, () => api('POST', path, body), admin ? T('Permessi di amministratore rimossi') : T('Utente reso amministratore'));
});
bOut.addEventListener('click', async () => {
const res = await confirmModal({ title: T('Disconnettere ovunque?'), subject: name,
message: T('Tutte le sessioni attive di questo utente (console e web) verranno chiuse.'), danger: true, confirmLabel: T('Disconnetti'),
reason: { label: T('Motivo (facoltativo)') } });
if (!res) return;
const body = { action: 'logout_all' }; if (res.reason) body.reason = res.reason;
runAction(bOut, () => api('POST', path, body), T('Sessioni chiuse'));
});
bDel.addEventListener('click', async () => {
const res = await confirmModal({ title: T('Eliminare definitivamente l’account?'), subject: name + (u.email ? ' · ' + u.email : ''),
warning: { title: T('Attenzione: operazione irreversibile'),
text: T('Verranno cancellati l’account e TUTTI i suoi contenuti: {apps} homebrew, {comments} commenti, voti, libreria e sessioni. Non sarà possibile recuperarli.',
{ apps: fmtNum(u.apps), comments: fmtNum(u.comments) }) },
requireText: u.online_id || String(u.account_id), danger: true, confirmLabel: T('Elimina account') });
if (!res) return;
runAction(bDel, () => api('DELETE', path), T('Account eliminato'));
});
const stato = banned
? el('div', null, el('span', { class: 'badge danger', text: T('Bannato') }),
el('div', { class: 'cell-sub', text: T('dal {date}', { date: fmtDay(u.banned_at) }) }),
u.ban_reason ? el('div', { class: 'cell-sub', text: T('Motivo: {reason}', { reason: u.ban_reason }) }) : null)
: el('span', { class: 'badge ok', text: T('Attivo') });
const rep = Number(u.reports_against) || 0;
return el('tr', null,
td(T('Utente'), [el('div', { class: 'cell-title' }, name, self ? el('span', { class: 'muted', text: T(' (tu)') }) : null),
el('div', { class: 'cell-sub', text: u.email || '—' })], 'cell-main'),
td(T('Ruolo'), admin ? el('span', { class: 'badge accent', text: T('Admin') }) : el('span', { class: 'muted', text: T('Utente') })),
td(T('Iscritto'), el('span', { text: fmtDay(u.created_at), title: fmtDate(u.created_at) }), 'nowrap'),
td(T('Ultimo accesso'), el('span', { text: u.last_login_at ? fmtDate(u.last_login_at) : T('Mai') }), 'nowrap'),
td(T('Stato'), stato),
td(T('Homebrew'), fmtNum(u.apps), 'num'),
td(T('Commenti'), fmtNum(u.comments), 'num'),
td(T('Segnalazioni ricevute'), rep ? el('span', { class: 'badge danger', text: fmtNum(rep) }) : el('span', { class: 'muted', text: '0' }), 'num'),
td(T('Azioni'), el('div', { class: 'actions' }, bBan, bAdm, bOut, bDel), 'cell-actions'));
}
/* --- Registro --- */
async function renderLog(seq) {
const f = state.log;
const head = pageHead(T('Registro'), T('Azioni eseguite dagli amministratori.'),
el('button', { class: 'btn sm', type: 'button', text: T('Aggiorna'), onclick: () => rerender() }));
setView([head, loading()]);
let data;
try { data = await api('GET', '/admin/api/log' + qs({ offset: f.offset, limit: PAGE })); }
catch (_) { if (!stale(seq)) setView([head, empty(T('Impossibile caricare il registro.'))]); return; }
if (stale(seq)) return;
const list = (data && data.log) || [];
const total = data && data.total != null ? data.total : list.length;
const pg = pager(total, f.offset, o => { f.offset = o; rerender(true); });
if (!list.length) { setView([head, empty(T('Il registro è vuoto.')), total ? pg : null]); return; }
setView([head, table([{ label: T('Data') }, { label: T('Amministratore') }, { label: T('Azione') }, { label: T('Destinazione') }, { label: T('Dettaglio') }],
list.map(e => {
let detail = e.detail;
if (detail != null && typeof detail === 'object') detail = JSON.stringify(detail);
const target = e.target_type ? (TARGET_TYPES[e.target_type] ? T(TARGET_TYPES[e.target_type]) : e.target_type) + (e.target_id != null && e.target_id !== '' ? ' #' + e.target_id : '') : '—';
return el('tr', null,
td(T('Data'), el('span', { text: fmtDate(e.created_at) }), 'cell-main nowrap'),
td(T('Amministratore'), el('span', { text: e.admin || '—' })),
td(T('Azione'), el('span', { class: 'badge', title: e.action || '', text: LOG_ACTIONS[e.action] ? T(LOG_ACTIONS[e.action]) : (e.action || '—') })),
td(T('Destinazione'), el('span', { text: target })),
td(T('Dettaglio'), el('div', { class: 'cell-body muted', text: detail ? String(detail) : '—' })));
})), pg]);
}
// Bug segnalati e funzioni chieste dall'app (Impostazioni › Aiuto)
async function renderFeedback(seq) {
const f = state.feedback;
const sel = el('select', { onchange: (e) => { f.status = e.target.value; f.offset = 0; rerender(); } },
...[['open', T('Da leggere')], ['done', T('Fatti')], ['dismissed', T('Archiviati')], ['all', T('Tutti')]].map(([v, l]) => el('option', { value: v, text: l, selected: f.status === v })));
const head = pageHead(T('Bug e richieste'), T('Messaggi mandati dagli utenti dall\'app: bug con il registro allegato e funzioni richieste.'), sel);
setView([head, loading()]);
let data;
try { data = await api('GET', '/admin/api/feedback' + qs({ status: f.status, offset: f.offset, limit: PAGE })); }
catch (_) { if (!stale(seq)) setView([head, empty(T('Impossibile caricare i messaggi.'))]); return; }
if (stale(seq)) return;
const list = (data && data.feedback) || [];
const pg = pager(data.total || 0, f.offset, o => { f.offset = o; rerender(true); });
if (!list.length) { setView([head, empty(T('Nessun messaggio qui.'))]); return; }
const setStatus = async (id, st) => { try { await api('POST', '/admin/api/feedback/' + encodeURIComponent(id), { status: st }); rerender(); } catch (_) { } };
setView([head, table([{ label: T('Data') }, { label: T('Tipo') }, { label: T('Utente') }, { label: T('Messaggio') }, { label: '' }],
list.map(x => el('tr', null,
td(T('Data'), el('span', { text: fmtDate(x.created_at) }), 'cell-main nowrap'),
td(T('Tipo'), el('span', { class: 'badge', text: x.kind === 'bug' ? T('Bug') : T('Funzione') })),
td(T('Utente'), el('span', { text: (x.online_id || '—') + (x.app_version ? ' · v' + x.app_version : '') })),
td(T('Messaggio'), el('div', { class: 'cell-body' }, el('div', { text: x.body }),
x.has_log ? el('details', null, el('summary', { text: T('Registro allegato') }), el('pre', { style: 'white-space:pre-wrap;max-height:320px;overflow:auto;font-size:12px', text: x.log || '' })) : null)),
td('', el('div', { class: 'actions' },
x.status !== 'done' ? el('button', { class: 'btn sm', type: 'button', text: T('Fatto'), onclick: () => setStatus(x.feedback_id, 'done') }) : null,
x.status !== 'dismissed' ? el('button', { class: 'btn sm', type: 'button', text: T('Archivia'), onclick: () => setStatus(x.feedback_id, 'dismissed') }) : null,
x.status !== 'open' ? el('button', { class: 'btn sm', type: 'button', text: T('Riapri'), onclick: () => setStatus(x.feedback_id, 'open') }) : null))))), pg]);
}
/* --- Boot --- */
(async function boot() {
try {
const me = await api('GET', '/admin/api/me', undefined, { isLogin: true, silent: true });
if (me && me.online_id) showApp(me); else showLogin('');
} catch (err) {
if (err.status === 401 || err.status === 403) showLogin('');
else showLogin(T('Impossibile verificare la sessione: {error}.', { error: errorTitle(err) }));
}
})();
})();
</script>
</body>
</html>