This commit is contained in:
mochaisvibecoded
2026-08-24 17:00:26 -04:00
parent cc1148050c
commit 42f7f57b72
401 changed files with 1108081 additions and 0 deletions
@@ -0,0 +1 @@
{"sessionId":"6940fe99-1bd7-4635-85d5-557e3850deee","pid":47388,"procStart":"134319905964952279","acquiredAt":1787517356747}
+2284
View File
@@ -0,0 +1,2284 @@
.admin-migration-banner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 16px 20px;
text-align: center;
margin-bottom: 20px;
border-radius: 8px;
}
.admin-migration-link {
color: white;
font-weight: 700;
text-decoration: underline;
font-size: 16px;
}
.admin-migration-link:hover {
color: #f0f0f0;
}
.admin-layout {
display: grid;
grid-template-columns: minmax(320px,.85fr) minmax(420px,1.15fr);
gap: 20px;
align-items: start;
}
.admin-panel {
padding: 22px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow);
}
.admin-panel h2 {
margin: 0 0 5px;
font: 900 21px var(--futura-extra);
}
.admin-panel>p {
margin: 0 0 18px;
color: var(--muted);
}
.admin-check {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 13px;
border: 1px solid #5c4669;
border-radius: var(--radius-sm);
background: #251c2d;
color: #ded4e5;
}
.admin-check input {
width: 18px;
height: 18px;
accent-color: var(--purple);
}
.admin-check small {
display: block;
color: var(--muted);
}
.admin-account-list {
display: grid;
gap: 9px;
max-height: 680px;
overflow: auto;
}
.admin-account {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #151119;
}
.admin-account .avatar {
width: 43px;
height: 43px;
}
.admin-account-main {
min-width: 0;
flex: 1;
}
.admin-account-name {
font-weight: 800;
}
.admin-platform {
font-size: 12px;
color: var(--muted);
overflow-wrap: anywhere;
}
.admin-roles {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-end;
}
.admin-role {
padding: 3px 6px;
border-radius: var(--radius-pill);
background: var(--purple-soft);
color: #d5bdff;
font-size: 10px;
}
.admin-result {
margin-top: 13px;
padding: 10px;
border-radius: var(--radius-sm);
background: #18271f;
color: #8ee4af;
}
.admin-error {
background: #321b22;
color: #ff9baa;
}
@media (max-width:900px) {
.admin-layout {
grid-template-columns: 1fr;
}
}
@media (max-width:620px) {
#adminNav[hidden] {
display: none !important;
}
}
#adminNav[hidden] {
display: none !important;
}
.admin-balance-summary {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 8px;
border: 1px solid #7047a0;
border-radius: var(--radius-pill);
background: #2b1c3a;
color: #eadcff;
font: 900 11px var(--futura-extra);
white-space: nowrap;
}
.admin-balance-summary i {
color: #c68cff;
}
.admin-balance-editor {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.admin-token-balance {
min-width: 120px;
color: #d5bdff;
font: 900 15px var(--futura-extra);
}
.admin-balance-amount {
width: 145px;
}
.admin-balance-editor button {
white-space: nowrap;
}
.admin-users-panel {
min-width: 0;
}
.admin-account {
display: block;
padding: 0;
overflow: hidden;
}
.admin-account-summary {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
}
.admin-manage-button {
padding: 8px 11px;
border: 1px solid #7047a0;
border-radius: var(--radius-sm);
background: #2b1c3a;
color: #e5d4ff;
font-weight: 800;
cursor: pointer;
}
.admin-manage-button:hover {
background: #3a2450;
}
.admin-tools {
padding: 14px;
border-top: 1px solid var(--line);
background: #100d13;
}
.admin-tools[hidden] {
display: none;
}
.admin-tool-row,.admin-tool-actions,.admin-role-editor {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.admin-role-editor {
margin: 11px 0;
}
.admin-role-select,.admin-panel .field select {
min-width: 150px;
padding: 10px 12px;
border: 1px solid #453a50;
border-radius: var(--radius-sm);
background: #120e16;
color: #fff;
font: inherit;
}
.admin-role.removable {
border: 1px solid #604579;
cursor: pointer;
}
.admin-role.removable:hover {
background: #4a285e;
color: #fff;
}
.small-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
font-size: 12px;
text-decoration: none;
}
.secondary-button {
border: 1px solid #574b62;
border-radius: var(--radius-sm);
background: #211a27;
color: #eee6f5;
font-weight: 800;
cursor: pointer;
}
.secondary-button:hover {
background: #302638;
}
.admin-tool-actions .danger-button {
margin-left: auto;
}
.admin-result {
margin-top: 13px;
padding: 10px;
border-radius: var(--radius-sm);
background: #18271f;
color: #8ee4af;
}
.admin-error {
background: #321b22;
color: #ff9baa;
}
@media (max-width:620px) {
.admin-account-summary {
align-items: flex-start;
flex-wrap: wrap;
}
.admin-account-summary .admin-roles {
order: 4;
width: 100%;
justify-content: flex-start;
}
.admin-manage-button {
margin-left: auto;
}
.admin-tool-actions .danger-button {
margin-left: 0;
}
}
.admin-overview {
display: grid;
grid-template-columns: repeat(8,minmax(90px,1fr));
gap: 9px;
margin: 20px 0;
}
.admin-stat {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 2px 8px;
padding: 13px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
}
.admin-stat i {
grid-row: 1/3;
color: #a65cff;
font-size: 18px;
}
.admin-stat strong {
font: 900 19px var(--futura-extra);
}
.admin-stat span {
color: var(--muted);
font-size: 10px;
}
.admin-tool-section {
padding: 12px 0;
border-bottom: 1px solid var(--line);
}
.admin-tool-section:first-child {
padding-top: 0;
}
.admin-tool-section h3 {
margin: 0 0 9px;
font-size: 13px;
color: #d9c6e7;
text-transform: uppercase;
letter-spacing: .05em;
}
.admin-profile-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 9px;
}
.admin-profile-grid .wide {
grid-column: 1/-1;
}
.admin-profile-grid label {
display: grid;
gap: 4px;
color: var(--muted);
font-size: 10px;
}
.admin-profile-grid input,.admin-profile-grid textarea,.admin-tool-row input {
min-width: 0;
padding: 9px 10px;
border: 1px solid #453a50;
border-radius: var(--radius-sm);
background: #120e16;
color: #fff;
font: 12px system-ui;
}
.admin-profile-grid textarea {
min-height: 55px;
resize: vertical;
}
.admin-platform-select {
min-width: 115px !important;
}
.admin-platform-id {
width: 145px;
}
.admin-platform-editor {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 9px;
}
.admin-platform-chip {
padding: 5px 8px;
border: 1px solid #554268;
border-radius: var(--radius-pill);
background: #21172b;
color: #dbc5ef;
font-size: 10px;
cursor: pointer;
}
.admin-platform-chip:hover {
border-color: #c64f65;
color: #ff9baa;
}
.admin-mini-check {
display: flex;
align-items: center;
gap: 5px;
color: var(--muted);
font-size: 10px;
}
.admin-mini-check input {
accent-color: var(--purple);
}
@media (max-width:1100px) {
.admin-overview {
grid-template-columns: repeat(4,1fr);
}
}
@media (max-width:620px) {
.admin-overview {
grid-template-columns: repeat(2,1fr);
}
.admin-profile-grid {
grid-template-columns: 1fr;
}
.admin-profile-grid .wide {
grid-column: auto;
}
}
.admin-account.is-locked {
border-color: #8e3344;
box-shadow: inset 3px 0 #d34d64;
}
.moderation-lock-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 7px;
padding: 3px 7px;
border-radius: var(--radius-pill);
background: #8d2638;
color: #fff;
font-size: 9px;
vertical-align: middle;
}
.moderation-lock-section {
padding: 14px;
border: 1px solid #682d3a;
border-radius: var(--radius-sm);
background: #211319;
}
.moderation-lock-section h3 {
color: #ff9aab;
}
.admin-lock-reason-label {
display: grid;
gap: 6px;
color: #dcbcc3;
font-size: 11px;
}
.admin-lock-reason {
min-height: 70px;
padding: 10px;
border: 1px solid #653744;
border-radius: var(--radius-sm);
background: #130c0f;
color: #fff;
resize: vertical;
}
.link-ban-check {
margin: 9px 0;
border-color: #743b47;
background: #2c171d;
}
.moderation-lock-button {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 12px;
border: 1px solid #ed687d;
border-radius: var(--radius-sm);
background: #a3293d;
color: #fff;
font-weight: 900;
cursor: pointer;
}
.moderation-lock-button:hover {
background: #c3334b;
}
.active-lock {
display: grid;
gap: 8px;
}
.active-lock strong {
color: #ff8fa1;
}
.active-lock pre {
margin: 0;
padding: 10px;
border-radius: var(--radius-sm);
background: #130c0f;
color: #f4dce1;
white-space: pre-wrap;
font: 12px/1.45 system-ui;
}
.active-lock span {
color: var(--muted);
font-size: 10px;
}
.admin-account.is-banned {
border-color: #73552a;
box-shadow: inset 3px 0 #d29a3d;
}
.ban-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 7px;
padding: 3px 7px;
border-radius: var(--radius-pill);
background: #8c5d16;
color: #fff;
font-size: 9px;
vertical-align: middle;
}
.normal-ban-section {
border-color: #684d2d;
background: #211a13;
}
.normal-ban-section h3 {
color: #f3bd6e;
}
.admin-ban-reason {
min-height: 70px;
padding: 10px;
border: 1px solid #6e5330;
border-radius: var(--radius-sm);
background: #130f0b;
color: #fff;
resize: vertical;
}
.ban-button {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 12px;
border: 1px solid #e6a64a;
border-radius: var(--radius-sm);
background: #8c5c18;
color: #fff;
font-weight: 900;
cursor: pointer;
}
.ban-button:hover {
background: #ad7420;
}
.moderation-lock-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #69404a;
}
.moderation-lock-option>div {
display: grid;
gap: 3px;
}
.moderation-lock-option small {
color: var(--muted);
font-size: 10px;
}
.admin-ban-duration {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 9px;
margin: 10px 0;
}
.admin-ban-duration label {
display: grid;
gap: 5px;
color: #dcbf91;
font-size: 11px;
}
.admin-ban-duration input,.admin-ban-duration select {
width: 100%;
padding: 10px;
border: 1px solid #6e5330;
border-radius: var(--radius-sm);
background: #130f0b;
color: #fff;
font: 12px system-ui;
}
.admin-ban-duration input:focus,.admin-ban-duration select:focus {
outline: 2px solid #a8732e;
}
.admin-account-summary {
display: grid;
grid-template-columns: 43px minmax(0,1fr) auto;
grid-template-areas: "avatar identity manage" "avatar balance manage" "roles roles roles";
align-items: center;
gap: 7px 12px;
}
.admin-account-summary>.avatar {
grid-area: avatar;
}
.admin-account-summary>.admin-account-main {
grid-area: identity;
width: 100%;
min-width: 0;
}
.admin-account-summary>.admin-balance-summary {
grid-area: balance;
justify-self: start;
}
.admin-account-summary>.admin-roles {
grid-area: roles;
justify-content: flex-start;
}
.admin-account-summary>.admin-manage-button {
grid-area: manage;
max-width: 170px;
}
.admin-account-summary .admin-platform {
overflow-wrap: anywhere;
word-break: normal;
line-height: 1.35;
}
.admin-token-section {
border: 1px solid #5f3b86;
border-radius: var(--radius-md);
background: #1a1222;
padding: 14px;
margin-bottom: 12px;
}
.admin-token-section h3 {
color: #d9b8ff;
}
.admin-quick-balance {
display: grid;
grid-template-columns: minmax(120px,1fr) minmax(110px,150px) auto auto;
align-items: center;
gap: 8px;
padding: 11px 12px;
border-top: 1px solid #5f3b86;
background: #1a1222;
}
.admin-quick-balance-title {
display: flex;
align-items: center;
gap: 7px;
color: #e2caff;
font: 900 14px var(--futura-extra);
}
.admin-quick-balance-title i {
color: #bd7dff;
}
.admin-quick-balance .admin-balance-amount {
width: 100%;
padding: 9px 10px;
border: 1px solid #65468a;
border-radius: var(--radius-sm);
background: #100b15;
color: #fff;
font: inherit;
}
.admin-quick-balance button {
white-space: nowrap;
}
@media (max-width:620px) {
.admin-account-summary {
grid-template-columns: 43px minmax(0,1fr);
grid-template-areas: "avatar identity" "avatar balance" "manage manage" "roles roles";
}
.admin-account-summary>.admin-manage-button {
width: 100%;
max-width: none;
}
.admin-balance-editor {
align-items: stretch;
}
.admin-balance-amount {
width: 100%;
}
}
@media (max-width:720px) {
.admin-quick-balance {
grid-template-columns: 1fr 1fr;
}
.admin-quick-balance-title {
grid-column: 1/-1;
}
.admin-quick-balance .admin-balance-amount {
grid-column: 1/-1;
}
.admin-quick-balance button {
width: 100%;
}
}
.admin-shop-panel {
margin: 0 0 20px;
}
.admin-shop-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.admin-shop-heading h2 {
display: flex;
align-items: center;
gap: 9px;
}
.admin-shop-heading h2 i {
color: #bd7dff;
}
.admin-shop-heading p {
margin: 0;
color: var(--muted);
}
.admin-shop-heading .primary-button {
flex: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.admin-shop-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 18px;
}
.admin-shop-grid>div {
min-width: 0;
}
.admin-shop-grid h3 {
margin: 0 0 9px;
color: #dfc9f2;
font-size: 13px;
text-transform: uppercase;
letter-spacing: .05em;
}
.admin-custom-shop-items,.admin-shop-search-results {
display: grid;
gap: 7px;
max-height: 320px;
overflow: auto;
padding-right: 4px;
}
.admin-shop-item,.admin-shop-result {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 11px;
border: 1px solid #493657;
border-radius: var(--radius-sm);
background: #151019;
color: #f8f3fa;
text-align: left;
}
.admin-shop-item>div,.admin-shop-result>span {
display: grid;
min-width: 0;
}
.admin-shop-item strong,.admin-shop-result strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-shop-item span,.admin-shop-result small {
color: var(--muted);
font-size: 10px;
}
.admin-shop-result {
width: 100%;
cursor: pointer;
font: inherit;
}
.admin-shop-result:hover {
border-color: #a65cff;
background: #21162b;
}
.admin-shop-result>i {
color: #bd7dff;
}
.admin-shop-empty {
padding: 14px;
font-size: 12px;
}
.admin-shop-panel>.form-status {
display: block;
min-height: 20px;
margin-top: 12px;
}
.admin-shop-panel>.admin-error {
padding: 9px 11px;
}
.admin-shop-search-results {
margin-top: 9px;
}
@media (max-width:760px) {
.admin-shop-heading {
display: grid;
}
.admin-shop-heading .primary-button {
width: 100%;
justify-content: center;
}
.admin-shop-grid {
grid-template-columns: 1fr;
}
.admin-custom-shop-items,.admin-shop-search-results {
max-height: 260px;
}
}
.admin-rooms-panel {
margin: 0 0 20px;
padding: 0;
overflow: hidden;
}
.admin-room-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 22px;
border-bottom: 1px solid var(--line);
}
.admin-room-heading h2 {
display: flex;
align-items: center;
gap: 9px;
}
.admin-room-heading h2 i {
color: #bd7dff;
}
.admin-room-heading p {
margin: 0;
color: var(--muted);
}
.admin-room-browser {
display: grid;
grid-template-columns: minmax(280px,360px) minmax(0,1fr);
min-height: 560px;
}
.admin-room-sidebar {
padding: 14px;
border-right: 1px solid var(--line);
background: #100d13;
}
.admin-room-search-row {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.admin-room-dorm-toggle {
display: flex;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 11px;
}
.admin-room-dorm-toggle input {
accent-color: var(--purple);
}
.admin-room-list {
display: grid;
gap: 7px;
max-height: 760px;
overflow: auto;
padding-right: 4px;
}
.admin-room-list-item {
display: grid;
grid-template-columns: 56px minmax(0,1fr) auto;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px;
border: 1px solid #403448;
border-radius: var(--radius-md);
background: #151119;
color: #f7f0fa;
text-align: left;
cursor: pointer;
}
.admin-room-list-item:hover,.admin-room-list-item.selected {
border-color: #9656cc;
background: #21162b;
}
.admin-room-list-item img {
width: 56px;
height: 42px;
border-radius: var(--radius-sm);
object-fit: cover;
background: #0e0b10;
}
.admin-room-list-main {
display: grid;
min-width: 0;
gap: 2px;
}
.admin-room-list-main strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-room-list-main small,.admin-room-list-main span {
color: var(--muted);
font-size: 10px;
line-height: 1.3;
}
.admin-room-list-main b {
color: #bc8cff;
}
.admin-room-list-item>i {
color: #755a84;
}
.admin-room-editor {
min-width: 0;
padding: 18px;
max-height: 980px;
overflow: auto;
background: #0d0a10;
}
.admin-room-editor>.empty {
min-height: 450px;
display: grid;
place-content: center;
gap: 10px;
text-align: center;
}
.admin-room-editor-head {
display: grid;
grid-template-columns: 120px minmax(0,1fr) auto;
align-items: center;
gap: 14px;
margin-bottom: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #151119;
}
.admin-room-editor-head>img {
width: 120px;
height: 82px;
border-radius: var(--radius-sm);
object-fit: cover;
}
.admin-room-editor-head h3 {
margin: 2px 0;
font: 900 24px var(--futura-extra);
}
.admin-room-editor-head p {
margin: 0;
color: var(--muted);
font-size: 11px;
}
.admin-room-editor>#adminRoomStatus {
display: block;
min-height: 20px;
margin: 8px 0;
}
.admin-room-editor>#adminRoomStatus.admin-error {
padding: 9px 11px;
border-radius: var(--radius-sm);
}
.admin-room-section {
display: block;
margin: 12px 0;
padding: 14px;
border: 1px solid #403448;
border-radius: var(--radius-md);
background: #151119;
}
.admin-room-section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.admin-room-section-head h3 {
margin: 0;
font: 900 17px var(--futura-extra);
}
.admin-room-section-head p {
margin: 3px 0 0;
color: var(--muted);
font-size: 11px;
}
.admin-room-count {
padding: 4px 8px;
border-radius: var(--radius-pill);
background: #2b1c3a;
color: #d9bfff;
font-size: 10px;
white-space: nowrap;
}
.admin-room-form-grid {
display: grid;
grid-template-columns: repeat(2,minmax(0,1fr));
gap: 9px;
}
.admin-room-form-grid label,.admin-room-stats-grid label,.admin-room-inline-form label,.admin-subroom-grid label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 10px;
}
.admin-room-form-grid .wide {
grid-column: 1/-1;
}
.admin-room-form-grid input,.admin-room-form-grid select,.admin-room-form-grid textarea,.admin-room-stats-grid input,.admin-room-inline-form input,.admin-room-inline-form select,.admin-subroom-grid input,.admin-subroom-grid select {
width: 100%;
min-width: 0;
padding: 9px 10px;
border: 1px solid #453a50;
border-radius: var(--radius-sm);
background: #100c13;
color: #fff;
font: 12px system-ui;
}
.admin-room-form-grid textarea {
min-height: 80px;
resize: vertical;
}
.admin-room-form-grid small {
color: #715f7b;
}
.admin-room-flags {
display: grid;
grid-template-columns: repeat(3,minmax(0,1fr));
gap: 7px;
margin-top: 12px;
}
.admin-room-flags .admin-check {
min-height: 46px;
padding: 9px;
}
.admin-room-flags .admin-check strong {
font-size: 11px;
}
.admin-room-stats-grid {
display: grid;
grid-template-columns: repeat(4,minmax(0,1fr));
gap: 9px;
}
.admin-room-inline-form {
display: grid;
grid-template-columns: repeat(3,minmax(120px,1fr)) auto auto;
align-items: end;
gap: 8px;
margin-bottom: 12px;
}
.admin-room-inline-form .grow {
grid-column: span 2;
}
.admin-room-members {
display: grid;
gap: 7px;
}
.admin-room-member-row {
display: grid;
grid-template-columns: 38px minmax(0,1fr) auto auto auto;
align-items: center;
gap: 9px;
padding: 9px;
border: 1px solid #3f3446;
border-radius: var(--radius-sm);
background: #100d13;
}
.admin-room-member-row>img {
width: 38px;
height: 38px;
border-radius: 50%;
object-fit: cover;
}
.admin-room-member-row>div:nth-child(2) {
display: grid;
min-width: 0;
}
.admin-room-member-row strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-room-member-row small {
color: var(--muted);
font-size: 10px;
}
.admin-room-member-row p {
grid-column: 1/-1;
margin: 3px 0 0;
color: #d9cddd;
font-size: 11px;
}
.admin-room-role-badge,.admin-room-invite-badge {
padding: 4px 7px;
border-radius: var(--radius-pill);
background: #38214a;
color: #dbbaff;
font-size: 10px;
white-space: nowrap;
}
.admin-room-invite-badge {
background: #1d3541;
color: #9edcf4;
}
.admin-room-row-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.admin-subroom-list {
display: grid;
gap: 9px;
}
.admin-subroom-card {
padding: 11px;
border: 1px solid #403448;
border-radius: var(--radius-md);
background: #100d13;
}
.admin-subroom-title {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 9px;
}
.admin-subroom-title>div {
display: grid;
}
.admin-subroom-title small {
color: var(--muted);
font-size: 10px;
}
.admin-subroom-title>span {
color: #c9a9df;
font-size: 10px;
}
.admin-subroom-grid {
display: grid;
grid-template-columns: 1.2fr .6fr .8fr 1.4fr;
gap: 8px;
margin-bottom: 9px;
}
.admin-subroom-grid .admin-mini-check {
align-content: end;
padding-bottom: 9px;
}
.admin-room-section .danger-button {
border: 1px solid #8b3c4b;
border-radius: var(--radius-sm);
background: #6f2635;
color: #fff;
font-weight: 800;
cursor: pointer;
}
.admin-room-section .danger-button:hover {
background: #913145;
}
@media (max-width:1100px) {
.admin-room-browser {
grid-template-columns: 300px minmax(0,1fr);
}
.admin-room-flags {
grid-template-columns: repeat(2,minmax(0,1fr));
}
.admin-subroom-grid {
grid-template-columns: repeat(2,minmax(0,1fr));
}
.admin-room-inline-form {
grid-template-columns: repeat(2,minmax(0,1fr));
}
.admin-room-inline-form button {
width: 100%;
}
}
@media (max-width:820px) {
.admin-room-browser {
grid-template-columns: 1fr;
}
.admin-room-sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.admin-room-list {
max-height: 300px;
}
.admin-room-editor {
max-height: none;
}
.admin-room-editor-head {
grid-template-columns: 90px minmax(0,1fr);
}
.admin-room-editor-head>img {
width: 90px;
height: 62px;
}
.admin-room-editor-head>a {
grid-column: 1/-1;
}
.admin-room-member-row {
grid-template-columns: 38px minmax(0,1fr);
}
.admin-room-member-row>.admin-room-role-badge,.admin-room-member-row>.admin-room-invite-badge,.admin-room-member-row>.admin-room-row-actions {
grid-column: 2;
justify-self: start;
}
}
@media (max-width:580px) {
.admin-room-heading {
display: grid;
}
.admin-room-heading button {
width: 100%;
}
.admin-room-form-grid,.admin-room-stats-grid,.admin-room-flags,.admin-room-inline-form,.admin-subroom-grid {
grid-template-columns: 1fr;
}
.admin-room-form-grid .wide,.admin-room-inline-form .grow {
grid-column: auto;
}
.admin-room-section-head {
display: grid;
}
.admin-room-editor {
padding: 10px;
}
.admin-room-editor-head {
grid-template-columns: 1fr;
}
.admin-room-editor-head>img {
width: 100%;
height: 150px;
}
}
#moderatorNav[hidden] {
display: none !important;
}
.moderator-nav .nav-icon {
color: #ffd17b;
}
.moderator-layout {
display: grid;
grid-template-columns: minmax(400px,1fr) minmax(480px,1.15fr);
gap: 20px;
align-items: start;
}
.moderator-panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 15px;
}
.moderator-panel-heading h2 {
display: flex;
align-items: center;
gap: 9px;
}
.moderator-panel-heading h2 i {
color: #f0b75c;
}
.moderator-panel-heading p {
margin: 0;
color: var(--muted);
}
.moderator-account-list {
max-height: 860px;
margin-top: 10px;
}
.moderator-protected {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid #725b2d;
border-radius: var(--radius-sm);
background: #241d10;
color: #ffe0a4;
}
.moderator-protected i {
font-size: 22px;
}
.moderator-protected div {
display: grid;
gap: 3px;
}
.moderator-protected small {
color: #c6ae7e;
}
.moderator-room-browser {
display: grid;
grid-template-columns: minmax(230px,.8fr) minmax(300px,1.2fr);
gap: 12px;
margin-top: 10px;
}
.moderator-room-browser>.admin-room-list {
max-height: 720px;
}
.moderator-room-editor {
min-width: 0;
max-height: 820px;
overflow: auto;
}
.moderator-room-editor>.empty {
min-height: 360px;
display: grid;
place-content: center;
gap: 10px;
text-align: center;
}
.moderator-room-ban-form {
grid-template-columns: minmax(110px,.55fr) minmax(220px,1.45fr) auto;
}
.moderator-room-ban-form .grow {
grid-column: auto;
}
.moderator-users-panel,.moderator-rooms-panel {
min-width: 0;
}
@media (max-width:1200px) {
.moderator-layout {
grid-template-columns: 1fr;
}
.moderator-room-browser {
grid-template-columns: 320px minmax(0,1fr);
}
}
@media (max-width:760px) {
.moderator-room-browser {
grid-template-columns: 1fr;
}
.moderator-room-browser>.admin-room-list {
max-height: 280px;
}
.moderator-room-ban-form {
grid-template-columns: 1fr;
}
.moderator-layout {
display: block;
}
.moderator-users-panel {
margin-bottom: 20px;
}
}
.admin-room-heading-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.admin-room-import-status {
display: block;
min-height: 0;
margin: 0;
padding: 0 22px;
transition: .15s ease;
}
.admin-room-import-status:not(:empty) {
min-height: 20px;
padding: 10px 22px;
border-bottom: 1px solid var(--line);
background: #121016;
color: #d9cddd;
}
.admin-room-import-status.admin-error {
margin: 0;
border-radius: 0;
border-left: 0;
border-right: 0;
}
.admin-room-heading-actions .primary-button,.admin-room-heading-actions .secondary-button {
white-space: nowrap;
}
@media (max-width:580px) {
.admin-room-heading-actions {
display: grid;
grid-template-columns: 1fr;
width: 100%;
}
.admin-room-import-status:not(:empty) {
padding: 10px 14px;
}
}
.admin-announcements-panel {
margin: 0 0 20px;
}
.admin-announcement-layout {
display: grid;
grid-template-columns: minmax(320px,.9fr) minmax(360px,1.1fr);
gap: 18px;
align-items: start;
}
.admin-announcement-layout>div>h3,.admin-announcement-form>div>h3 {
margin: 0 0 9px;
color: #d9c6e7;
font-size: 12px;
text-transform: uppercase;
letter-spacing: .06em;
}
.admin-announcement-form {
display: grid;
gap: 13px;
}
.admin-announcement-form .settings-grid {
margin: 0;
}
.admin-announcement-form .field {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 11px;
}
.admin-announcement-form .field.wide {
grid-column: 1/-1;
}
.admin-announcement-form input,.admin-announcement-form select,.admin-announcement-form textarea {
width: 100%;
min-width: 0;
padding: 10px 11px;
border: 1px solid #453a50;
border-radius: var(--radius-sm);
background: #120e16;
color: #fff;
font: 12px system-ui;
}
.admin-announcement-form textarea {
min-height: 230px;
resize: vertical;
font-family: ui-monospace,SFMono-Regular,Consolas,monospace;
line-height: 1.45;
}
.admin-announcement-options {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.admin-announcement-options .admin-mini-check {
padding: 8px 10px;
border: 1px solid #453a50;
border-radius: var(--radius-sm);
background: #151119;
color: #d9cddd;
font-size: 11px;
}
.admin-announcement-preview {
min-height: 120px;
}
.admin-announcement-list {
display: grid;
gap: 10px;
max-height: 880px;
overflow: auto;
padding-right: 4px;
}
.admin-announcement-list .announcement-card {
box-shadow: none;
background: #151119;
}
.admin-announcement-list .announcement-card>h3 {
font-size: 17px;
}
.admin-announcement-list .markdown-body {
max-height: 230px;
overflow: auto;
font-size: 12px;
}
.admin-announcement-list .empty {
padding: 35px 18px;
}
@media (max-width:980px) {
.admin-announcement-layout {
grid-template-columns: 1fr;
}
.admin-announcement-list {
max-height: none;
}
}
@media (max-width:620px) {
.admin-announcement-form .settings-grid {
grid-template-columns: 1fr;
}
.admin-announcement-form .field.wide {
grid-column: auto;
}
}
.admin-blob-editor {
margin-top: 12px;
padding: 12px;
border: 1px solid #513a62;
border-radius: var(--radius-md);
background: #17101d;
}
.admin-blob-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.admin-blob-heading>div {
display: grid;
gap: 2px;
}
.admin-blob-heading strong {
font: 900 14px var(--futura-extra);
}
.admin-blob-heading small {
color: var(--muted);
font-size: 10px;
}
.admin-blob-heading code {
padding: 1px 4px;
border-radius: 4px;
background: #0f0b12;
color: #d9bfff;
}
.admin-blob-health,.admin-blob-file-status span {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 7px;
border-radius: var(--radius-pill);
font-size: 10px;
white-space: nowrap;
}
.admin-blob-health.ok,.admin-blob-file-status .ok {
background: #173529;
color: #8fe3b5;
}
.admin-blob-health.missing,.admin-blob-file-status .missing {
background: #43222c;
color: #ffadb9;
}
.admin-blob-grid {
display: grid;
grid-template-columns: repeat(2,minmax(0,1fr));
gap: 9px;
}
.admin-blob-grid label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 10px;
}
.admin-blob-grid label small {
color: #846f8d;
}
.admin-blob-input {
width: 100%;
min-width: 0;
padding: 9px 10px;
border: 1px solid #5a4866;
border-radius: var(--radius-sm);
background: #0d0910;
color: #fff;
font: 11px ui-monospace,SFMono-Regular,Consolas,monospace;
}
.admin-blob-input:focus {
border-color: #ac6ddd;
outline: none;
box-shadow: 0 0 0 2px #8f50c326;
}
.admin-blob-file-status {
display: flex;
gap: 7px;
flex-wrap: wrap;
margin: 9px 0;
}
.admin-blob-editor>.admin-room-row-actions {
border-top: 1px solid #3c2c45;
padding-top: 9px;
}
@media (max-width:760px) {
.admin-blob-grid {
grid-template-columns: 1fr;
}
.admin-blob-heading {
display: grid;
}
.admin-blob-health {
justify-self: start;
}
}
.admin-security-panel {
position: relative;
overflow: hidden;
margin: 0 0 20px;
background: linear-gradient(145deg,#1b1224,#120e17 62%,#20102c);
border-color: #60417c;
}
.admin-security-panel:before {
content: "";
position: absolute;
inset: -90px -120px auto auto;
width: 270px;
height: 270px;
border-radius: 50%;
background: radial-gradient(circle,#9b5cff2c,transparent 68%);
pointer-events: none;
}
.admin-security-panel .admin-shop-heading {
position: relative;
}
.security-state {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: 1px solid #68507a;
border-radius: var(--radius-pill);
background: #18111e;
color: #cbbcd5;
font: 900 10px var(--futura-extra);
text-transform: uppercase;
letter-spacing: .06em;
}
.security-state:before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: #a99ab2;
box-shadow: 0 0 10px currentColor;
}
.security-state.enabled {
border-color: #2f7557;
background: #11251c;
color: #83e5ae;
}
.security-state.disabled {
border-color: #8d4154;
background: #2d151d;
color: #ff9daf;
}
.admin-security-toggles {
position: relative;
display: grid;
grid-template-columns: repeat(3,minmax(0,1fr));
gap: 10px;
margin: 15px 0;
}
.admin-security-toggles .admin-check {
min-height: 105px;
}
.admin-security-toggles .admin-check strong {
display: block;
margin-bottom: 3px;
color: #f4ebfa;
font: 900 13px var(--futura-extra);
}
.admin-ip-ban-layout {
position: relative;
display: grid;
grid-template-columns: minmax(320px,.9fr) minmax(360px,1.1fr);
gap: 16px;
margin-top: 17px;
padding-top: 17px;
border-top: 1px solid #4c365c;
}
.admin-ip-ban-layout .settings-grid {
align-content: start;
margin: 0;
}
.admin-ip-ban-layout .field>strong {
color: #eadcf4;
font: 900 15px var(--futura-extra);
}
.admin-ip-ban-layout .field>small {
display: block;
margin-top: 3px;
color: var(--muted);
font-size: 10px;
}
.admin-ip-ban-list {
display: grid;
gap: 8px;
max-height: 330px;
overflow: auto;
}
.admin-ip-ban {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 12px;
border: 1px solid #51375f;
border-radius: var(--radius-md);
background: #100c14;
}
.admin-ip-ban>div {
display: grid;
min-width: 0;
gap: 2px;
}
.admin-ip-ban strong {
color: #f1dff9;
font: 900 13px var(--futura-extra);
overflow-wrap: anywhere;
}
.admin-ip-ban strong i {
margin-right: 5px;
color: #d85f78;
}
.admin-ip-ban span {
color: #cbbdce;
font-size: 11px;
overflow-wrap: anywhere;
}
.admin-ip-ban small {
color: var(--muted);
font-size: 9px;
}
@media (max-width:980px) {
.admin-security-toggles {
grid-template-columns: 1fr;
}
.admin-security-toggles .admin-check {
min-height: 0;
}
.admin-ip-ban-layout {
grid-template-columns: 1fr;
}
}
@media (max-width:580px) {
.admin-security-panel .admin-shop-heading {
display: grid;
}
.security-state {
justify-self: start;
}
.admin-ip-ban {
align-items: flex-start;
flex-direction: column;
}
.admin-ip-ban button {
width: 100%;
}
}
.admin-inventory-section {
display: grid;
gap: 12px;
}
.admin-inventory-search-row {
grid-template-columns: minmax(180px, 1fr) auto;
}
.admin-inventory-results {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.admin-inventory-group {
min-width: 0;
border: 1px solid #4f3a5b;
border-radius: var(--radius-md);
background: #100c14;
overflow: hidden;
}
.admin-inventory-group-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid #44314f;
background: #18101d;
}
.admin-inventory-group-title strong {
color: #f0e1f7;
font: 900 13px var(--futura-extra);
}
.admin-inventory-group-title span {
color: var(--muted);
font-size: 10px;
}
.admin-inventory-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 10px;
border-bottom: 1px solid #2f2335;
}
.admin-inventory-row:last-child {
border-bottom: 0;
}
.admin-inventory-item {
display: grid;
min-width: 0;
gap: 2px;
}
.admin-inventory-item strong {
color: #eadcf4;
font-size: 12px;
overflow-wrap: anywhere;
}
.admin-inventory-item small {
color: var(--muted);
font-size: 9px;
overflow-wrap: anywhere;
}
.admin-consumable-editor {
display: flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
}
.admin-consumable-quantity {
width: 82px;
}
.compact-empty {
min-height: 0;
padding: 14px;
}
@media (max-width: 900px) {
.admin-inventory-results {
grid-template-columns: 1fr;
}
}
@media (max-width: 580px) {
.admin-inventory-search-row {
grid-template-columns: 1fr;
}
.admin-inventory-row {
align-items: flex-start;
flex-direction: column;
}
.admin-consumable-editor {
width: 100%;
}
.admin-consumable-quantity {
flex: 1 1 auto;
width: auto;
}
}
.admin-gift-panel {
border-color: #745286;
background: radial-gradient(circle at top right, rgba(133, 78, 160, .18), transparent 38%), #17101c;
}
.admin-gift-from {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 13px;
border: 1px solid #79548c;
border-radius: var(--radius-pill);
color: #f2dcff;
background: #281731;
font: 900 12px var(--futura-extra);
}
.admin-gift-layout {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.admin-gift-column {
display: flex;
min-width: 0;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid #483552;
border-radius: var(--radius-md);
background: rgba(14, 10, 17, .78);
}
.admin-gift-column h3 {
margin: 0;
color: #f2e2fa;
}
.admin-gift-all {
margin: 0;
}
.admin-gift-player-results,
.admin-gift-catalog-results {
display: grid;
max-height: 310px;
overflow: auto;
border-radius: var(--radius-md);
}
.admin-gift-player,
.admin-gift-catalog-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
width: 100%;
padding: 9px;
border: 0;
border-bottom: 1px solid #34263b;
color: #eaddf1;
text-align: left;
background: #120d16;
cursor: pointer;
}
.admin-gift-player:hover,
.admin-gift-catalog-item:hover,
.admin-gift-catalog-item.selected {
background: #291a31;
}
.admin-gift-player img {
width: 34px;
height: 34px;
border-radius: 50%;
object-fit: cover;
}
.admin-gift-player span,
.admin-gift-catalog-item > span:not(.admin-gift-item-icon) {
display: grid;
min-width: 0;
}
.admin-gift-player small,
.admin-gift-catalog-item small,
.admin-gift-selected small,
.admin-gift-summary small {
color: var(--muted);
font-size: 10px;
}
.admin-gift-item-icon {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: var(--radius-md);
color: #e5bbf8;
background: #3b2347;
}
.admin-gift-catalog-item > .fa-check {
color: transparent;
}
.admin-gift-catalog-item.selected > .fa-check {
color: #70e59b;
}
.admin-gift-selected,
.admin-gift-summary {
display: flex;
align-items: center;
gap: 9px;
padding: 11px;
border: 1px solid #5f426d;
border-radius: var(--radius-md);
color: #ebdaf3;
background: #211628;
}
.admin-gift-selected > span,
.admin-gift-summary {
flex-direction: column;
align-items: flex-start;
}
.admin-gift-selected.empty {
color: var(--muted);
border-style: dashed;
background: transparent;
}
.admin-gift-send {
width: 100%;
margin-top: auto;
}
.admin-gift-online-only {
margin: 0 0 4px;
}
.admin-gift-clear-outgoing {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 14px;
padding: 14px;
border: 1px dashed #6b4a5a;
border-radius: var(--radius-md);
background: rgba(157, 38, 56, .08);
}
.admin-gift-clear-outgoing > div:first-child {
flex: 1 1 260px;
display: grid;
gap: 3px;
color: #f2e2fa;
}
.admin-gift-clear-outgoing small {
color: var(--muted);
font-size: 11px;
}
.admin-gift-clear-outgoing .form-status {
flex-basis: 100%;
}
@media (max-width: 1050px) {
.admin-gift-layout {
grid-template-columns: 1fr;
}
}
File diff suppressed because one or more lines are too long
+133
View File
@@ -0,0 +1,133 @@
.avatar-wrap {
--avatar-size: 40px;
position: relative;
display: inline-block;
width: var(--avatar-size);
height: var(--avatar-size);
flex: 0 0 var(--avatar-size);
overflow: visible;
}
.avatar-wrap>.avatar {
display: block;
width: 100%;
height: 100%;
}
.verified-badge {
position: absolute;
right: -2px;
bottom: -2px;
z-index: 4;
display: grid;
place-items: center;
width: 18px;
height: 18px;
border: 2px solid var(--card);
border-radius: 50%;
background: var(--card);
color: #2f8cff;
font-size: 16px;
line-height: 1;
filter: drop-shadow(0 1px 3px rgba(0,0,0,.55));
pointer-events: none;
}
.verified-badge i {
display: block;
line-height: 1;
}
.rrplus-inline {
display: inline-flex;
align-items: center;
justify-content: center;
width: 27px;
height: 27px;
margin-left: 6px;
flex: 0 0 27px;
overflow: hidden;
vertical-align: middle;
filter: drop-shadow(0 2px 3px rgba(0,0,0,.5));
}
.rrplus-inline img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
transform: scale(.92);
}
.owner-row,.name-row {
display: flex;
align-items: center;
min-width: 0;
}
.owner-row .rrplus-inline {
width: 21px;
height: 21px;
margin-left: 4px;
flex-basis: 21px;
}
.user-card .avatar-wrap {
--avatar-size: 62px;
}
.user-card .verified-badge {
right: -1px;
bottom: -1px;
width: 21px;
height: 21px;
font-size: 19px;
}
.profile-avatar-row .avatar-wrap {
--avatar-size: 88px;
}
.profile-avatar-row .avatar-wrap>.avatar {
border: 4px solid var(--card);
box-shadow: 0 2px 7px rgba(0,0,0,.4);
}
.profile-avatar-row .verified-badge {
right: 4px;
bottom: 4px;
width: 26px;
height: 26px;
border-width: 3px;
font-size: 23px;
}
.profile-side-body h1 {
display: flex;
align-items: center;
gap: 2px;
min-height: 34px;
color: #fff;
-webkit-text-stroke: 2px rgba(0,0,0,.95);
paint-order: stroke fill;
text-shadow: 0 2px 3px #000,1px 1px 0 #000,-1px 1px 0 #000,1px -1px 0 #000,-1px -1px 0 #000;
}
@media (max-width:620px) {
.profile-avatar-row .avatar-wrap {
--avatar-size: 76px;
}
.profile-avatar-row .verified-badge {
right: 2px;
bottom: 2px;
width: 25px;
height: 25px;
font-size: 22px;
}
.rrplus-inline {
width: 24px;
height: 24px;
flex-basis: 24px;
}
}
+835
View File
@@ -0,0 +1,835 @@
.community-board-admin {
margin: 0 0 20px;
padding: 0;
overflow: hidden;
border-color: #4b3561;
background: linear-gradient(145deg,#1b1522,#120e17);
}
.community-board-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 22px 24px;
border-bottom: 1px solid #493558;
background: linear-gradient(110deg,rgba(111,53,210,.24),rgba(28,24,34,.2));
}
.community-board-heading h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 3px 0 2px;
}
.community-board-heading h2 i {
color: #c28aff;
}
.community-board-heading p {
margin: 0;
color: var(--muted);
}
.community-board-save {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 9px;
flex-wrap: wrap;
}
.community-board-save .primary-button {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 13px;
}
.community-board-status {
color: #a9dfbd;
font-size: 11px;
}
.community-board-status.error {
color: #ff9baa;
}
.community-board-canvas {
display: grid;
grid-template-columns: minmax(190px,.82fr) minmax(280px,1.25fr) minmax(210px,.9fr);
gap: 12px;
padding: 16px;
background: radial-gradient(circle at 50% 10%,rgba(137,73,205,.13),transparent 46%),#0e0b12;
}
.community-board-left {
display: grid;
grid-template-rows: 1fr 1fr;
gap: 12px;
min-width: 0;
}
.community-board-card {
min-width: 0;
padding: 13px;
border: 1px solid #503b5e;
border-radius: var(--radius-lg);
background: linear-gradient(145deg,#2a2132,#211929);
box-shadow: inset 0 1px rgba(255,255,255,.04);
}
.community-card-label {
display: block;
color: #f1e3ff;
font: 900 15px var(--futura-extra);
letter-spacing: .01em;
}
.community-card-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.community-card-heading small {
color: #a99caf;
font-size: 9px;
}
.community-featured-card {
padding: 0;
overflow: hidden;
}
.community-featured-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 190px;
padding: 15px;
border: 0;
background: transparent;
color: #fff;
font: inherit;
cursor: pointer;
}
.community-featured-button:hover {
background: rgba(174,103,255,.09);
}
.community-featured-button .community-card-label {
margin-bottom: 10px;
}
.community-featured-button img {
width: 68px;
height: 68px;
border: 3px solid #aa72e7;
border-radius: 50%;
object-fit: cover;
background: #18111e;
}
.community-featured-button img.is-broken {
opacity: .15;
}
.community-featured-button strong {
max-width: 100%;
margin-top: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: 900 17px var(--futura-extra);
}
.community-featured-button small {
color: #bcb0c5;
}
.community-add-orb {
display: grid;
place-items: center;
width: 65px;
height: 65px;
border: 1px dashed #a873dc;
border-radius: 50%;
background: #392748;
color: #d8b0ff;
font-size: 27px;
}
.community-edit-hint {
margin-top: 10px;
color: #b990df;
font-size: 10px;
}
.community-rooms-card {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 10px;
}
.community-rooms-card strong {
display: block;
margin-top: 2px;
color: #c899f7;
font-size: 11px;
}
.community-rooms-card p {
margin: 6px 0 0;
color: #aaa0b1;
font-size: 11px;
line-height: 1.35;
}
.community-pinned-list {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.community-pinned-list span {
max-width: 100%;
padding: 4px 7px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: var(--radius-pill);
background: #3c2850;
color: #dbbbfa;
font-size: 9px;
}
.community-pinned-list .empty-slot {
background: #241c2a;
color: #8e8295;
}
.community-inline-button {
width: 100%;
padding: 9px;
border: 1px solid #724c94;
border-radius: var(--radius-sm);
background: #31203f;
color: #ead8fb;
font: 800 11px system-ui;
cursor: pointer;
}
.community-inline-button:hover {
background: #442b59;
}
.community-photo-grid {
display: grid;
grid-template-columns: repeat(3,minmax(0,1fr));
gap: 8px;
}
.community-photo-tile,.community-video-tile {
position: relative;
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid #5e466e;
border-radius: var(--radius-md);
background: linear-gradient(145deg,#3c3044,#2b2232);
color: #caa4e8;
cursor: pointer;
}
.community-photo-tile {
aspect-ratio: 1;
}
.community-photo-tile>i,.community-video-tile>i {
font-size: 25px;
}
.community-photo-tile:hover,.community-video-tile:hover {
border-color: #b678ef;
box-shadow: 0 0 0 2px rgba(171,103,235,.12);
}
.community-photo-tile img,.community-video-tile img {
width: 100%;
height: 100%;
object-fit: cover;
}
.community-photo-tile img.is-broken,.community-video-tile img.is-broken {
opacity: .15;
}
.community-photo-tile span {
position: absolute;
right: 5px;
bottom: 5px;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(20,12,25,.82);
color: #fff;
font-size: 10px;
opacity: 0;
transition: .15s opacity;
}
.community-photo-tile:hover span {
opacity: 1;
}
.community-videos-card {
display: flex;
flex-direction: column;
}
.community-video-grid {
display: grid;
grid-template-rows: repeat(3,minmax(80px,1fr));
gap: 10px;
min-height: 0;
flex: 1;
}
.community-video-tile {
min-height: 90px;
}
.community-video-tile span {
position: absolute;
inset: auto 0 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 10px 7px;
background: linear-gradient(transparent,rgba(12,8,15,.92));
color: #fff;
text-align: left;
}
.community-video-tile span strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
}
.community-board-bottom {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 0 16px 16px;
background: #0e0b12;
}
.community-action-card {
display: grid;
grid-template-columns: 38px minmax(0,1fr) auto;
align-items: center;
gap: 10px;
padding: 14px;
border: 1px solid #543e63;
border-radius: var(--radius-md);
background: linear-gradient(135deg,#30243a,#251b2d);
color: #f8edff;
text-align: left;
cursor: pointer;
}
.community-action-card:hover {
border-color: #a56ed6;
background: linear-gradient(135deg,#3b2949,#2d2036);
}
.community-action-card>i:first-child {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: var(--radius-md);
background: #4a2d61;
color: #d7a9ff;
font-size: 16px;
}
.community-action-card>span {
display: grid;
min-width: 0;
}
.community-action-card strong {
font: 900 14px var(--futura-extra);
}
.community-action-card small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #a99eaf;
font-size: 9px;
}
.community-action-card>i:last-child {
color: #806d89;
font-size: 11px;
}
.community-board-dialog {
width: min(650px,calc(100% - 28px));
max-height: min(86vh,820px);
padding: 0;
overflow: hidden;
border: 1px solid #5b426b;
border-radius: var(--radius-lg);
background: #1a1420;
color: #f6f0f9;
box-shadow: 0 30px 100px rgba(0,0,0,.72);
}
.community-board-dialog::backdrop {
background: rgba(6,3,8,.8);
backdrop-filter: blur(5px);
}
.community-board-dialog form {
display: grid;
grid-template-rows: auto minmax(0,1fr) auto;
max-height: min(86vh,820px);
}
.community-dialog-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
border-bottom: 1px solid #493653;
background: #211827;
}
.community-dialog-heading h3 {
margin: 2px 0 0;
font: 900 22px var(--futura-extra);
}
.community-dialog-heading .dialog-close {
position: static;
}
.community-dialog-body {
display: grid;
gap: 13px;
padding: 18px 20px;
overflow: auto;
}
.community-dialog-body>label,.community-room-fields label {
display: grid;
gap: 5px;
color: #b7acbe;
font-size: 11px;
font-weight: 700;
}
.community-dialog-body input,.community-dialog-body textarea {
width: 100%;
min-width: 0;
padding: 10px 11px;
border: 1px solid #54425e;
border-radius: var(--radius-sm);
background: #100c13;
color: #fff;
font: 12px system-ui;
outline: 0;
}
.community-dialog-body textarea {
resize: vertical;
}
.community-dialog-body input:focus,.community-dialog-body textarea:focus {
border-color: #a774ff;
box-shadow: 0 0 0 3px rgba(167,116,255,.13);
}
.community-dialog-help {
margin: 0;
padding: 10px;
border-radius: var(--radius-sm);
background: #241a2c;
color: #b9aebe;
font-size: 11px;
}
.community-dialog-preview {
width: 100%;
max-height: 260px;
border-radius: var(--radius-md);
object-fit: cover;
background: #0d0910;
}
.community-room-fields {
display: grid;
gap: 9px;
}
.community-room-fields fieldset {
display: grid;
grid-template-columns: 100px minmax(130px,.8fr) minmax(180px,1.2fr);
gap: 8px;
padding: 10px;
border: 1px solid #45344e;
border-radius: var(--radius-md);
}
.community-room-fields legend {
padding: 0 5px;
color: #cfa8f2;
font-size: 10px;
}
.community-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid #493653;
background: #151019;
}
.community-dialog-actions .danger-button {
margin-right: auto;
padding: 10px 12px;
border: 1px solid #8b3c4b;
border-radius: var(--radius-sm);
background: #6f2635;
color: #fff;
font-weight: 800;
cursor: pointer;
}
.community-dialog-actions .secondary-button,.community-dialog-actions .primary-button {
padding: 10px 13px;
}
.community-board-unavailable .community-board-canvas,.community-board-unavailable .community-board-bottom {
opacity: .45;
pointer-events: none;
}
@media (max-width:1050px) {
.community-board-canvas {
grid-template-columns: minmax(180px,.7fr) minmax(260px,1.3fr);
}
.community-videos-card {
grid-column: 1/-1;
}
.community-video-grid {
grid-template-columns: repeat(3,minmax(0,1fr));
grid-template-rows: 1fr;
}
.community-video-tile {
aspect-ratio: 16/8;
}
}
@media (max-width:760px) {
.community-board-heading {
display: grid;
}
.community-board-save {
justify-content: flex-start;
}
.community-board-canvas {
grid-template-columns: 1fr;
}
.community-board-left {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
}
.community-videos-card {
grid-column: auto;
}
.community-board-bottom {
grid-template-columns: 1fr;
}
.community-room-fields fieldset {
grid-template-columns: 1fr 1fr;
}
.community-room-fields fieldset label:last-child {
grid-column: 1/-1;
}
}
@media (max-width:520px) {
.community-board-canvas {
padding: 10px;
}
.community-board-left {
grid-template-columns: 1fr;
}
.community-board-bottom {
padding: 0 10px 10px;
}
.community-photo-grid {
gap: 5px;
}
.community-video-grid {
grid-template-columns: 1fr;
grid-template-rows: repeat(3,110px);
}
.community-video-tile {
aspect-ratio: auto;
}
.community-dialog-actions {
flex-wrap: wrap;
}
.community-dialog-actions button {
flex: 1;
}
.community-dialog-actions .danger-button {
flex-basis: 100%;
margin-right: 0;
}
.community-room-fields fieldset {
grid-template-columns: 1fr;
}
.community-room-fields fieldset label:last-child {
grid-column: auto;
}
}
.community-video-tile video {
width: 100%;
height: 100%;
object-fit: cover;
background: #0d0910;
}
.community-video-tile video.is-broken {
opacity: .15;
}
.community-video-tile span.community-video-fallback {
display: grid;
place-items: center;
position: static;
inset: auto;
width: 100%;
height: 100%;
padding: 0;
background: transparent;
color: #b789d8;
font-size: 24px;
}
.community-video-tile>span:last-child>i {
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(255,255,255,.15);
}
.community-upload-field {
position: relative;
display: grid;
grid-template-columns: minmax(0,1fr) auto;
align-items: center;
gap: 12px;
padding: var(--space-4);
border: 1px dashed #8058a1;
border-radius: var(--radius-md);
background: linear-gradient(135deg,#291d34,#201728);
color: #eee1f7;
cursor: pointer;
}
.community-upload-field:hover {
border-color: #b97df0;
background: linear-gradient(135deg,#33213f,#281b31);
}
.community-upload-field>span {
display: grid;
grid-template-columns: 30px minmax(0,1fr);
column-gap: 10px;
align-items: center;
}
.community-upload-field>span>i {
grid-row: 1/3;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
background: #4a2d61;
color: #d9b2ff;
}
.community-upload-field strong {
font-size: 12px;
}
.community-upload-field small {
color: #9f94a7;
font-size: 9px;
font-weight: 500;
}
.community-upload-field input[type=file] {
width: min(220px,100%);
padding: 7px;
border: 1px solid #564061;
background: #140f18;
font-size: 10px;
cursor: pointer;
}
.community-upload-field input[type=file]::file-selector-button {
margin-right: 7px;
padding: 6px 8px;
border: 0;
border-radius: var(--radius-sm);
background: #6d3ca0;
color: #fff;
font: 700 10px system-ui;
cursor: pointer;
}
.community-upload-field.compact {
padding: 10px 14px;
}
.community-upload-divider {
display: flex;
align-items: center;
gap: 10px;
color: #776c80;
font-size: 9px;
text-transform: uppercase;
letter-spacing: .1em;
}
.community-upload-divider:before,.community-upload-divider:after {
content: "";
height: 1px;
flex: 1;
background: #44354c;
}
.community-media-preview {
overflow: hidden;
border: 1px solid #4f3a5c;
border-radius: var(--radius-md);
background: #0d0910;
}
.community-dialog-preview,.community-dialog-video {
display: block;
width: 100%;
height: min(310px,36vh);
max-height: none;
object-fit: contain;
background: #09060b;
border-radius: 0;
}
.community-dialog-embed {
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: #09060b;
}
.community-dialog-embed iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.community-dialog-video-poster {
position: relative;
display: grid;
place-items: center;
min-height: 220px;
background: #09060b;
}
.community-dialog-video-poster img {
width: 100%;
height: min(310px,36vh);
object-fit: contain;
}
.community-dialog-video-poster>i {
position: absolute;
display: grid;
place-items: center;
width: 52px;
height: 52px;
padding-left: 3px;
border-radius: 50%;
background: rgba(92,46,128,.9);
color: #fff;
font-size: 18px;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
}
.community-media-empty {
display: grid;
place-items: center;
gap: 8px;
min-height: 190px;
color: #776a80;
}
.community-media-empty i {
font-size: 30px;
color: #684b79;
}
.community-media-empty span {
font-size: 10px;
}
.community-upload-status {
min-height: 18px;
}
.community-upload-status.admin-error {
padding: 9px 11px;
border-radius: var(--radius-sm);
}
@media (max-width:620px) {
.community-upload-field {
grid-template-columns: 1fr;
}
.community-upload-field input[type=file] {
width: 100%;
}
.community-dialog-preview,.community-dialog-video,.community-dialog-video-poster img {
height: 230px;
}
}
+220
View File
@@ -0,0 +1,220 @@
.global-search input {
background: rgba(255,255,255,.06);
color: var(--ink);
border: 1px solid var(--glass-border);
}
.global-search input::placeholder {
color: #958aa2;
}
.nav-link {
color: var(--muted);
border-radius: var(--radius-sm);
}
.nav-link:hover {
background: var(--surface-hover);
color: var(--ink);
}
.nav-link.active {
position: relative;
color: var(--pink);
background: var(--purple-soft);
border-left-color: transparent;
box-shadow: none;
}
.photo-card,.search-panel,.user-card,.empty,.loading {
background: var(--surface);
border-color: var(--glass-border);
}
.photo-image {
background: rgba(255,255,255,.03);
}
.avatar {
background: rgba(255,255,255,.05);
border-color: var(--glass-border);
}
.search {
background: rgba(255,255,255,.04);
border-color: var(--glass-border);
color: var(--ink);
}
.photo-card:hover,.user-card:hover,.room-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--purple-soft);
}
.profile-bio {
color: var(--muted);
}
.chip {
color: var(--pink);
}
.login-button {
padding: 9px 17px;
border: 0;
border-radius: var(--radius-sm);
background: var(--surface-hover);
border: 1px solid var(--glass-border);
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: .15s background,.15s border-color;
}
.login-button:hover {
background: var(--purple-soft);
border-color: var(--pink);
}
.login-dialog {
width: min(420px,calc(100% - 28px));
padding: 34px;
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
background: rgba(20,10,25,.72);
color: var(--ink);
box-shadow: var(--shadow-lg);
}
.login-dialog::backdrop {
background: rgba(0,0,0,.82);
backdrop-filter: blur(6px);
}
.dialog-close {
position: absolute;
right: 17px;
top: 12px;
border: 0;
background: none;
color: var(--muted);
font-size: 30px;
cursor: pointer;
transition: .15s color;
}
.dialog-close:hover {
color: var(--ink);
}
.dialog-logo {
display: grid;
place-items: center;
width: 54px;
height: 54px;
margin: 0 auto;
border-radius: var(--radius-md);
background: var(--grad-brand);
color: #fff;
font-size: 28px;
font-weight: 900;
font-family: var(--font-display);
transform: rotate(-4deg);
box-shadow: var(--glow-brand);
}
.login-dialog h2 {
text-align: center;
margin: 16px 0 4px;
font-size: 25px;
}
.login-dialog>p {
text-align: center;
color: var(--muted);
margin: 0 0 23px;
}
.login-dialog form {
display: grid;
gap: 15px;
}
.login-dialog label {
display: grid;
gap: 6px;
font-size: 13px;
font-weight: 750;
}
.login-dialog input {
width: 100%;
padding: 12px;
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
background: rgba(0,0,0,.35);
color: #fff;
font: inherit;
outline: 0;
}
.login-dialog input:focus {
border-color: var(--purple);
box-shadow: 0 0 0 3px var(--purple-soft);
}
.primary-button {
padding: 12px;
border: 0;
border-radius: var(--radius-sm);
background: var(--grad-brand);
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
box-shadow: var(--glow-brand);
transition: .15s filter,.15s transform;
}
.primary-button:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-1px);
}
.primary-button:active:not(:disabled) {
transform: translateY(0);
}
.primary-button:disabled {
opacity: .6;
box-shadow: none;
}
.login-error {
min-height: 18px;
color: var(--red);
font-size: 13px;
text-align: center;
}
.mini-avatar.has-user {
font-size: 0;
background-size: cover;
background-position: center;
border-color: #fff;
}
.signed-welcome {
display: flex;
align-items: center;
gap: 9px;
color: var(--ink);
font-size: 13px;
}
@media (max-width:620px) {
.login-button {
padding: 7px 11px;
}
}
+663
View File
@@ -0,0 +1,663 @@
:root {
color-scheme: dark;
--bg: #0d0a11;
--surface: #17131c;
--surface-2: #1e1925;
--line: #322a3b;
--ink: #f7f4fa;
--muted: #a69dad;
--purple: #9c63f5;
--blue: #4da3ff;
--green: #42d6a4;
--orange: #ffab5c;
--red: #ff687f;
--pink: #f373d1;
--shadow: 0 18px 50px rgba(0,0,0,.28);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: radial-gradient(circle at 75% -20%,#2a193c 0,transparent 34%),var(--bg);
color: var(--ink);
font: 15px/1.45 Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
}
a {
color: inherit;
}
.ops-header {
height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 max(24px,calc((100vw - 1500px)/2));
border-bottom: 1px solid var(--line);
background: rgba(13,10,17,.86);
backdrop-filter: blur(14px);
position: sticky;
top: 0;
z-index: 10;
}
.ops-brand {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.brand-mark {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: linear-gradient(135deg,#7133d3,#ae6cff);
font: 900 23px FuturaPT,Inter,sans-serif;
transform: rotate(-3deg);
box-shadow: 0 7px 22px rgba(122,53,217,.42);
}
.ops-brand strong,.ops-brand small {
display: block;
}
.ops-brand strong {
font-size: 17px;
letter-spacing: .01em;
}
.ops-brand small {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: .12em;
}
.header-actions {
display: flex;
align-items: center;
gap: 15px;
}
.stream-state {
display: flex;
align-items: center;
gap: 8px;
color: var(--green);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .08em;
}
.stream-state i {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 5px rgba(66,214,164,.1);
}
.stream-state.connecting {
color: var(--orange);
}
.stream-state.offline {
color: var(--red);
}
.back-button {
padding: 9px 13px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
text-decoration: none;
font-size: 13px;
font-weight: 750;
}
.back-button:hover {
border-color: #6f5585;
}
main {
width: min(1500px,calc(100% - 48px));
margin: 0 auto;
padding: 42px 0 70px;
}
.page-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
margin-bottom: 26px;
}
.eyebrow,.panel-kicker {
display: block;
color: var(--purple);
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .15em;
}
.page-heading h1 {
margin: 5px 0 5px;
font: 900 38px/1.05 FuturaPT,Inter,sans-serif;
letter-spacing: -.02em;
}
.page-heading p {
margin: 0;
color: var(--muted);
}
.updated {
text-align: right;
}
.updated span,.updated strong {
display: block;
}
.updated span {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: .1em;
}
.updated strong {
margin-top: 3px;
font-size: 13px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(6,minmax(0,1fr));
gap: 13px;
margin-bottom: 18px;
}
.metric-card {
position: relative;
overflow: hidden;
padding: 19px 18px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: linear-gradient(145deg,var(--surface-2),var(--surface));
box-shadow: var(--shadow);
}
.metric-card:before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 3px;
background: var(--accent);
}
.metric-card span,.metric-card small {
display: block;
color: var(--muted);
}
.metric-card span {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .08em;
}
.metric-card strong {
display: block;
margin: 8px 0 2px;
font-size: 25px;
letter-spacing: -.03em;
}
.metric-card small {
font-size: 11px;
}
.accent-purple {
--accent: var(--purple);
}
.accent-blue {
--accent: var(--blue);
}
.accent-green {
--accent: var(--green);
}
.accent-orange {
--accent: var(--orange);
}
.accent-red {
--accent: var(--red);
}
.accent-pink {
--accent: var(--pink);
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: linear-gradient(145deg,var(--surface-2),var(--surface));
box-shadow: var(--shadow);
overflow: hidden;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 18px 20px;
border-bottom: 1px solid var(--line);
}
.panel-heading h2 {
margin: 2px 0 0;
font-size: 17px;
}
.legend {
display: flex;
gap: 14px;
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
font-weight: 800;
}
.legend span:before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
margin-right: 6px;
border-radius: 50%;
background: currentColor;
}
.legend .purple {
color: var(--purple);
}
.legend .orange {
color: var(--orange);
}
.legend .blue {
color: var(--blue);
}
.legend .green {
color: var(--green);
}
.legend .pink {
color: var(--pink);
}
.chart-grid {
display: grid;
grid-template-columns: repeat(2,minmax(0,1fr));
gap: 18px;
margin-bottom: 18px;
}
.chart-panel canvas {
display: block;
width: 100%;
height: 210px;
}
.chart-panel.wide {
grid-column: 1/-1;
}
.chart-panel.wide canvas {
height: 180px;
}
.count-badge {
padding: 6px 10px;
border-radius: var(--radius-pill);
background: rgba(66,214,164,.1);
color: var(--green);
font-size: 11px;
font-weight: 850;
}
.online-list {
display: grid;
grid-template-columns: repeat(4,minmax(0,1fr));
gap: 0;
}
.online-player {
display: grid;
grid-template-columns: 42px minmax(0,1fr) auto;
align-items: center;
gap: 11px;
padding: 16px 18px;
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.online-player:nth-child(4n) {
border-right: 0;
}
.online-player img {
width: 42px;
height: 42px;
border-radius: 50%;
object-fit: cover;
background: #292231;
}
.online-player strong,.online-player small {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.online-player small {
color: var(--muted);
font-size: 11px;
}
.socket-pill {
padding: 4px 7px;
border-radius: var(--radius-sm);
background: rgba(77,163,255,.1);
color: var(--blue);
font-size: 10px;
font-weight: 800;
}
.empty {
padding: 35px;
color: var(--muted);
text-align: center;
grid-column: 1/-1;
}
.split-grid {
display: grid;
grid-template-columns: minmax(0,1.15fr) minmax(380px,.85fr);
gap: 18px;
margin-top: 18px;
}
.activity-panel {
min-width: 0;
}
.controls-heading {
align-items: flex-end;
}
.panel-controls {
display: flex;
gap: 8px;
}
.panel-controls input,.controls-heading>input {
width: 190px;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #100c14;
color: var(--ink);
font: inherit;
font-size: 12px;
outline: 0;
}
.panel-controls input:focus,.controls-heading>input:focus {
border-color: var(--purple);
}
.panel-controls button {
padding: 8px 11px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--ink);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.panel-controls button.active {
border-color: var(--orange);
color: var(--orange);
}
.table-wrap {
max-height: 615px;
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
th {
position: sticky;
top: 0;
z-index: 1;
padding: 10px 12px;
background: #17121c;
color: var(--muted);
font-size: 9px;
text-align: left;
text-transform: uppercase;
letter-spacing: .09em;
}
td {
padding: 9px 12px;
border-top: 1px solid rgba(50,42,59,.72);
vertical-align: top;
}
.request-path {
max-width: 330px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: "Cascadia Code",Consolas,monospace;
color: #ddd5e4;
}
.method {
font-weight: 850;
color: var(--blue);
}
.status {
display: inline-block;
min-width: 38px;
padding: 3px 5px;
border-radius: var(--radius-sm);
text-align: center;
background: rgba(66,214,164,.1);
color: var(--green);
font-weight: 850;
}
.status.warn {
background: rgba(255,171,92,.1);
color: var(--orange);
}
.status.error {
background: rgba(255,104,127,.1);
color: var(--red);
}
.empty-cell {
padding: 35px;
text-align: center;
color: var(--muted);
}
.chat-list {
max-height: 560px;
overflow: auto;
}
.chat-item {
padding: 13px 16px;
border-bottom: 1px solid rgba(50,42,59,.72);
}
.chat-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
font-size: 11px;
}
.chat-meta strong {
color: #fff;
}
.chat-thread {
padding: 3px 6px;
border-radius: var(--radius-sm);
background: rgba(156,99,245,.12);
color: #c7a5ff;
}
.chat-time {
margin-left: auto;
color: var(--muted);
}
.chat-body {
color: #ddd6e3;
overflow-wrap: anywhere;
}
.chat-members {
margin-top: 5px;
color: #82798a;
font-size: 10px;
}
.load-more {
display: block;
width: calc(100% - 32px);
margin: 12px 16px;
padding: 9px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #15111a;
color: var(--muted);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.load-more:hover {
color: #fff;
border-color: #665372;
}
.load-more:disabled {
opacity: .5;
cursor: default;
}
@media (max-width:1200px) {
.metric-grid {
grid-template-columns: repeat(3,minmax(0,1fr));
}
.online-list {
grid-template-columns: repeat(2,minmax(0,1fr));
}
.online-player:nth-child(4n) {
border-right: 1px solid var(--line);
}
.online-player:nth-child(2n) {
border-right: 0;
}
.split-grid {
grid-template-columns: 1fr;
}
}
@media (max-width:760px) {
.ops-header {
padding: 0 16px;
}
.ops-brand small,.stream-state {
display: none;
}
main {
width: min(100% - 24px,1500px);
padding-top: 26px;
}
.page-heading {
align-items: flex-start;
flex-direction: column;
}
.updated {
text-align: left;
}
.metric-grid {
grid-template-columns: repeat(2,minmax(0,1fr));
}
.chart-grid {
grid-template-columns: 1fr;
}
.chart-panel.wide {
grid-column: auto;
}
.online-list {
grid-template-columns: 1fr;
}
.online-player,.online-player:nth-child(4n) {
border-right: 0;
}
.controls-heading {
align-items: flex-start;
flex-direction: column;
}
.panel-controls,.panel-controls input,.controls-heading>input {
width: 100%;
}
.table-wrap {
overflow: auto;
}
.split-grid {
display: block;
}
.activity-panel+ .activity-panel {
margin-top: 18px;
}
}
+98
View File
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#100d14">
<title>Mocha Live Ops</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap">
<link rel="stylesheet" href="/recnet/tokens.css?v=2">
<link rel="stylesheet" href="/recnet/font.css?v=2">
<link rel="stylesheet" href="/recnet/developer.css?v=1">
</head>
<body>
<header class="ops-header">
<a class="ops-brand" href="/recnet/#admin">
<span class="brand-mark">M</span>
<span><strong>Mocha</strong><small>Developer Live Ops</small></span>
</a>
<div class="header-actions">
<span id="streamState" class="stream-state connecting"><i></i> Connecting</span>
<a class="back-button" href="/recnet/#admin">Back to Admin</a>
</div>
</header>
<main>
<section class="page-heading">
<div>
<span class="eyebrow">Developer only</span>
<h1>Server pulse</h1>
<p>Live traffic, player presence, requests, and chat activity in one place.</p>
</div>
<div class="updated"><span>Last update</span><strong id="lastUpdated">Waiting for data</strong></div>
</section>
<section class="metric-grid" aria-label="Live server metrics">
<article class="metric-card accent-purple"><span>Players online</span><strong id="onlineMetric">—</strong><small id="socketMetric">— sockets</small></article>
<article class="metric-card accent-blue"><span>Requests</span><strong id="requestMetric">—</strong><small id="requestRateMetric">— per second</small></article>
<article class="metric-card accent-green"><span>Network traffic</span><strong id="networkMetric">—</strong><small id="networkRateMetric">— per second</small></article>
<article class="metric-card accent-orange"><span>CPU / Memory</span><strong id="resourceMetric">—</strong><small id="uptimeMetric">— uptime</small></article>
<article class="metric-card accent-red"><span>Errors</span><strong id="errorMetric">—</strong><small id="errorRateMetric">— error rate</small></article>
<article class="metric-card accent-pink"><span>Chat messages</span><strong id="chatMetric">—</strong><small id="chatThreadMetric">— threads</small></article>
</section>
<section class="chart-grid">
<article class="panel chart-panel">
<div class="panel-heading"><div><span class="panel-kicker">Last 2 minutes</span><h2>Request volume</h2></div><div class="legend"><span class="purple">Requests</span><span class="orange">Latency</span></div></div>
<canvas id="requestChart" aria-label="Requests and latency over time"></canvas>
</article>
<article class="panel chart-panel">
<div class="panel-heading"><div><span class="panel-kicker">Last 2 minutes</span><h2>Network throughput</h2></div><div class="legend"><span class="blue">Inbound</span><span class="green">Outbound</span></div></div>
<canvas id="networkChart" aria-label="Inbound and outbound bytes over time"></canvas>
</article>
<article class="panel chart-panel wide">
<div class="panel-heading"><div><span class="panel-kicker">This browser session</span><h2>Server resources</h2></div><div class="legend"><span class="purple">CPU</span><span class="pink">Memory</span></div></div>
<canvas id="resourceChart" aria-label="CPU and memory usage over time"></canvas>
</article>
</section>
<section class="panel">
<div class="panel-heading">
<div><span class="panel-kicker">Presence</span><h2>Players online</h2></div>
<span id="onlineCountBadge" class="count-badge">0 online</span>
</div>
<div id="onlinePlayers" class="online-list"><div class="empty">Waiting for player presence…</div></div>
</section>
<section class="split-grid">
<article class="panel activity-panel">
<div class="panel-heading controls-heading">
<div><span class="panel-kicker">Live HTTP</span><h2>API requests</h2></div>
<div class="panel-controls">
<input id="requestFilter" type="search" placeholder="Filter path or status">
<button id="pauseRequests" type="button">Pause</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Time</th><th>Status</th><th>Method</th><th>Path</th><th>Latency</th><th>Out</th></tr></thead>
<tbody id="requestRows"><tr><td colspan="6" class="empty-cell">Waiting for requests…</td></tr></tbody>
</table>
</div>
</article>
<article class="panel activity-panel">
<div class="panel-heading controls-heading">
<div><span class="panel-kicker">All threads</span><h2>Live chats</h2></div>
<input id="chatFilter" type="search" placeholder="Filter messages or players">
</div>
<div id="chatRows" class="chat-list"><div class="empty">Waiting for chat activity…</div></div>
<button id="loadOlderChats" class="load-more" type="button">Load older messages</button>
</article>
</section>
</main>
<script src="/recnet/developer.js?v=1"></script>
</body>
</html>
+200
View File
@@ -0,0 +1,200 @@
const recnetFetch = (input, init = {}) => fetch(input, { ...init, credentials: init.credentials ?? 'same-origin' });
const state = {
snapshot: null,
requests: [],
chats: [],
paused: false,
cpuHistory: [],
memoryHistory: [],
stream: null
};
const byId = id => document.getElementById(id);
const esc = value => String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char]));
const formatBytes = value => {
const bytes = Number(value) || 0;
if (bytes < 1024)
return `${bytes} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let size = bytes / 1024, index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index++;
}
return `${size >= 100 ? size.toFixed(0) : size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${units[index]}`;
};
const formatRate = value => `${formatBytes(value)}/s`;
const formatTime = value => new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(value));
const formatUptime = seconds => {
seconds = Math.max(0, Number(seconds) || 0);
const days = Math.floor(seconds / 86400), hours = Math.floor(seconds % 86400 / 3600), minutes = Math.floor(seconds % 3600 / 60);
return days ? `${days}d ${hours}h` : hours ? `${hours}h ${minutes}m` : `${minutes}m`;
};
function mergeBy(items, incoming, key, limit) {
const map = new Map(items.map(item => [key(item), item]));
incoming.forEach(item => map.set(key(item), item));
return [...map.values()].sort((a, b) => new Date(b.at || b.createdAt) - new Date(a.at || a.createdAt)).slice(0, limit);
}
function setStreamState(mode, label) {
const el = byId('streamState');
el.className = `stream-state ${mode}`;
el.innerHTML = `<i></i> ${esc(label)}`;
}
function renderSnapshot(data) {
state.snapshot = data;
if (!state.paused)
state.requests = mergeBy(state.requests, data.requests || [], item => `${item.at}|${item.method}|${item.path}|${item.status}|${item.durationMs}`, 2000);
state.chats = mergeBy(state.chats, data.chats || [], item => item.messageId, 2000);
state.cpuHistory.push({ at: data.generatedAt, value: Number(data.server.cpuPercent) || 0 });
state.memoryHistory.push({ at: data.generatedAt, value: (Number(data.server.memoryBytes) || 0) / 1048576 });
state.cpuHistory = state.cpuHistory.slice(-120);
state.memoryHistory = state.memoryHistory.slice(-120);
const latest = (data.series || []).at(-1) || {};
const networkRate = (Number(latest.inboundBytes) || 0) + (Number(latest.outboundBytes) || 0);
const errorRate = data.totals.requests ? data.totals.errors / data.totals.requests * 100 : 0;
byId('lastUpdated').textContent = formatTime(data.generatedAt);
byId('onlineMetric').textContent = Number(data.totals.onlinePlayers).toLocaleString();
byId('socketMetric').textContent = `${Number(data.totals.connectedSockets).toLocaleString()} live sockets`;
byId('requestMetric').textContent = Number(data.totals.requests).toLocaleString();
byId('requestRateMetric').textContent = `${Number(latest.requests || 0).toLocaleString()} per second`;
byId('networkMetric').textContent = formatBytes(Number(data.totals.inboundBytes) + Number(data.totals.outboundBytes));
byId('networkRateMetric').textContent = formatRate(networkRate);
byId('resourceMetric').textContent = `${Number(data.server.cpuPercent).toFixed(1)}% / ${formatBytes(data.server.memoryBytes)}`;
byId('uptimeMetric').textContent = `${formatUptime(data.server.uptimeSeconds)} uptime`;
byId('errorMetric').textContent = Number(data.totals.errors).toLocaleString();
byId('errorRateMetric').textContent = `${errorRate.toFixed(2)}% of requests`;
byId('chatMetric').textContent = Number(data.totals.chatMessages).toLocaleString();
byId('chatThreadMetric').textContent = `${Number(data.totals.chatThreads).toLocaleString()} threads`;
renderOnline(data.onlinePlayers || []);
renderRequests();
renderChats();
drawAllCharts();
}
function renderOnline(players) {
byId('onlineCountBadge').textContent = `${players.length} online`;
byId('onlinePlayers').innerHTML = players.length ? players.map(player => `
<article class="online-player">
<img src="${esc(player.profileImage)}" alt="">
<div><strong>${esc(player.displayName || player.username || `Player ${player.accountId}`)}</strong><small>${esc(player.room || 'Online')} · ${esc(player.device || 'Unknown')}</small></div>
<span class="socket-pill">${Number(player.sockets) || 0} WS</span>
</article>`).join('') : '<div class="empty">Nobody is online right now.</div>';
}
function renderRequests() {
const filter = byId('requestFilter').value.trim().toLowerCase();
const rows = state.requests.filter(item => !filter || `${item.status} ${item.method} ${item.path}`.toLowerCase().includes(filter)).slice(0, 500);
byId('requestRows').innerHTML = rows.length ? rows.map(item => {
const statusClass = item.status >= 500 ? 'error' : item.status >= 400 ? 'warn' : '';
return `<tr><td>${formatTime(item.at)}</td><td><span class="status ${statusClass}">${item.status}</span></td><td class="method">${esc(item.method)}</td><td class="request-path" title="${esc(item.path)}">${esc(item.path)}</td><td>${Number(item.durationMs).toLocaleString()} ms</td><td>${formatBytes(item.outboundBytes)}</td></tr>`;
}).join('') : '<tr><td colspan="6" class="empty-cell">No requests match this filter.</td></tr>';
}
function renderChats() {
const filter = byId('chatFilter').value.trim().toLowerCase();
const rows = state.chats.filter(item => !filter || `${item.sender?.displayName} ${item.sender?.username} ${item.body} ${item.threadName} ${(item.members || []).map(x => x.displayName).join(' ')}`.toLowerCase().includes(filter));
byId('chatRows').innerHTML = rows.length ? rows.map(item => `
<article class="chat-item">
<div class="chat-meta"><strong>${esc(item.sender?.displayName || 'Unknown')}</strong><span class="chat-thread">${esc(item.threadName || `Thread ${item.threadId}`)}</span><span class="chat-time">${formatTime(item.createdAt)}</span></div>
<div class="chat-body">${esc(item.body)}</div>
<div class="chat-members">${esc((item.members || []).map(member => member.displayName).join(', '))}</div>
</article>`).join('') : '<div class="empty">No chats match this filter.</div>';
}
function setupCanvas(canvas) {
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.round(rect.width * ratio));
canvas.height = Math.max(1, Math.round(rect.height * ratio));
const ctx = canvas.getContext('2d');
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
return { ctx, width: rect.width, height: rect.height };
}
function drawChart(canvas, series, lines) {
const { ctx, width, height } = setupCanvas(canvas), pad = { left: 38, right: 18, top: 16, bottom: 25 }, plotW = width - pad.left - pad.right, plotH = height - pad.top - pad.bottom;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(130,115,145,.18)';
ctx.lineWidth = 1;
ctx.fillStyle = '#786f80';
ctx.font = '10px Inter, sans-serif';
for (let i = 0; i <= 4; i++) {
const y = pad.top + plotH * i / 4;
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(width - pad.right, y);
ctx.stroke();
}
if (!series.length)
return;
const normalized = lines.map(line => ({ line, values: series.map(item => Number(line.value(item)) || 0) }));
const max = Math.max(1, ...normalized.flatMap(item => item.values)) * 1.12;
ctx.fillText(max >= 1048576 ? formatBytes(max) : max.toFixed(max < 10 ? 1 : 0), 3, pad.top + 4);
ctx.fillText('0', 20, pad.top + plotH + 3);
normalized.forEach(({ line, values }) => {
ctx.beginPath();
ctx.strokeStyle = line.color;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
values.forEach((value, index) => { const x = pad.left + (values.length === 1 ? plotW : plotW * index / (values.length - 1)), y = pad.top + plotH - (value / max * plotH); index ? ctx.lineTo(x, y) : ctx.moveTo(x, y); });
ctx.stroke();
});
const first = new Date(series[0].at), last = new Date(series.at(-1).at);
ctx.fillStyle = '#786f80';
ctx.fillText(formatTime(first), pad.left, height - 7);
const label = formatTime(last);
ctx.fillText(label, width - pad.right - ctx.measureText(label).width, height - 7);
}
function drawAllCharts() {
if (!state.snapshot)
return;
const series = state.snapshot.series || [];
drawChart(byId('requestChart'), series, [{ color: '#9c63f5', value: x => x.requests }, { color: '#ffab5c', value: x => x.latencyMs }]);
drawChart(byId('networkChart'), series, [{ color: '#4da3ff', value: x => x.inboundBytes }, { color: '#42d6a4', value: x => x.outboundBytes }]);
const resources = state.cpuHistory.map((point, index) => ({ at: point.at, cpu: point.value, memory: state.memoryHistory[index]?.value || 0 }));
drawChart(byId('resourceChart'), resources, [{ color: '#9c63f5', value: x => x.cpu }, { color: '#f373d1', value: x => x.memory }]);
}
async function loadOlderChats() {
const button = byId('loadOlderChats'), oldest = state.chats.length ? Math.min(...state.chats.map(item => Number(item.messageId))) : null;
button.disabled = true;
button.textContent = 'Loading…';
try {
const url = `/recnet/api/developer/chats?take=100${oldest ? `&beforeMessageId=${oldest}` : ''}`;
const response = await recnetFetch(url);
if (response.status === 403) {
location.href = '/recnet/';
return;
}
if (!response.ok)
throw new Error('Could not load older chats.');
const data = await response.json();
state.chats = mergeBy(state.chats, data.messages || [], item => item.messageId, 10000);
renderChats();
button.hidden = !data.hasMore;
}
catch (error) {
button.textContent = error.message;
return;
}
button.disabled = false;
button.textContent = 'Load older messages';
}
function connect() {
state.stream?.close();
setStreamState('connecting', 'Connecting');
const stream = new EventSource('/recnet/api/developer/stream');
state.stream = stream;
stream.addEventListener('snapshot', event => {
setStreamState('', 'Live');
renderSnapshot(JSON.parse(event.data));
});
stream.onerror = () => {
setStreamState('offline', 'Reconnecting');
};
}
byId('requestFilter').addEventListener('input', renderRequests);
byId('chatFilter').addEventListener('input', renderChats);
byId('pauseRequests').addEventListener('click', event => {
state.paused = !state.paused;
event.currentTarget.classList.toggle('active', state.paused);
event.currentTarget.textContent = state.paused ? 'Resume' : 'Pause';
});
byId('loadOlderChats').addEventListener('click', loadOlderChats);
window.addEventListener('resize', () => requestAnimationFrame(drawAllCharts));
window.addEventListener('beforeunload', () => state.stream?.close());
connect();
+326
View File
@@ -0,0 +1,326 @@
.event-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.event-card {
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow);
}
.event-image {
display: block;
width: 100%;
aspect-ratio: 16 / 8;
object-fit: cover;
background: #2a2132;
}
.event-image-placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #5521ae, #9b59ea);
color: #fff;
font-size: 37px;
}
.event-body {
padding: 17px;
}
.event-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.event-heading h3 {
margin: 0;
font-size: 19px;
}
.event-pin {
display: inline-flex;
align-items: center;
gap: 5px;
flex: none;
padding: 4px 8px;
border-radius: var(--radius-pill);
background: var(--purple-soft);
color: #d8c0ff;
font-size: 10px;
font-weight: 800;
}
.event-date {
margin-top: 7px;
color: #b99ada;
font-size: 12px;
font-weight: 700;
}
.event-date i {
margin-right: 4px;
}
.event-body p {
margin: 10px 0 0;
color: var(--muted);
white-space: pre-wrap;
}
.admin-event-panel {
margin: 0 0 20px;
}
.admin-event-heading h2 {
display: flex;
align-items: center;
gap: 9px;
}
.admin-event-heading h2 i {
color: #bd7dff;
}
.admin-event-heading p {
margin: 0;
color: var(--muted);
}
.admin-event-layout {
display: grid;
grid-template-columns: minmax(300px, .85fr) minmax(360px, 1.15fr);
gap: 22px;
margin-top: 18px;
}
.admin-event-layout > div {
min-width: 0;
}
.admin-event-layout h3 {
margin: 0 0 9px;
color: #dfc9f2;
font-size: 13px;
text-transform: uppercase;
letter-spacing: .05em;
}
.admin-event-form {
min-width: 0;
}
.admin-event-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 11px;
}
.admin-event-fields .wide {
grid-column: 1 / -1;
}
.admin-event-upload {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px;
border: 1px dashed #76509a;
border-radius: var(--radius-sm);
background: #181020;
color: #eee3f7;
cursor: pointer;
}
.admin-event-upload > span {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 1px 8px;
}
.admin-event-upload i {
grid-row: 1 / 3;
color: #bd7dff;
font-size: 20px;
}
.admin-event-upload small {
color: var(--muted);
font-size: 10px;
}
.admin-event-upload input {
max-width: 205px;
color: var(--muted);
font-size: 11px;
}
.admin-event-preview {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border: 1px solid #4c3b58;
border-radius: var(--radius-sm);
background: #120e16;
color: var(--muted);
font-size: 11px;
}
.admin-event-preview[hidden] {
display: none;
}
.admin-event-preview img {
width: 92px;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: var(--radius-sm);
background: #28202f;
}
.admin-event-form > .primary-button {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 12px;
}
.admin-event-form > .form-status {
display: block;
min-height: 20px;
margin-top: 9px;
}
.admin-event-list {
display: grid;
gap: 8px;
max-height: 540px;
overflow: auto;
padding-right: 4px;
}
.admin-event-card {
display: grid;
grid-template-columns: 88px minmax(0, 1fr) auto;
align-items: center;
gap: 11px;
padding: 9px;
border: 1px solid #493657;
border-radius: var(--radius-md);
background: #151019;
}
.admin-event-card > img,
.admin-event-no-image {
width: 88px;
aspect-ratio: 16 / 10;
object-fit: cover;
border-radius: var(--radius-sm);
background: #281b34;
}
.admin-event-no-image {
display: grid;
place-items: center;
color: #bd7dff;
font-size: 20px;
}
.admin-event-card-body {
display: grid;
min-width: 0;
gap: 3px;
}
.admin-event-card-body > div {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.admin-event-card-body strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-event-card-body > span {
color: #bbaac8;
font-size: 10px;
}
.admin-event-card-body p {
margin: 2px 0 0;
color: var(--muted);
font-size: 11px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: pre-wrap;
}
.admin-event-pinned {
display: inline-flex;
align-items: center;
gap: 4px;
flex: none;
padding: 3px 6px;
border-radius: var(--radius-pill);
background: #35204a;
color: #d9b8ff;
font-size: 9px;
}
.admin-event-empty {
padding: 28px 14px;
font-size: 12px;
}
@media (max-width: 900px) {
.admin-event-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.event-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.admin-event-fields {
grid-template-columns: 1fr;
}
.admin-event-fields .wide {
grid-column: auto;
}
.admin-event-upload {
display: grid;
}
.admin-event-upload input {
max-width: 100%;
}
.admin-event-card {
grid-template-columns: 70px minmax(0, 1fr);
}
.admin-event-card > img,
.admin-event-no-image {
width: 70px;
}
.admin-event-card > button {
grid-column: 1 / -1;
width: 100%;
}
}
+624
View File
@@ -0,0 +1,624 @@
.room-grid {
display: grid;
grid-template-columns: repeat(3,minmax(0,1fr));
gap: 18px;
}
.event-row {
display: flex;
overflow-x: auto;
gap: 16px;
padding-bottom: 6px;
scroll-snap-type: x proximity;
}
.event-card {
flex: 0 0 280px;
scroll-snap-align: start;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow);
}
.event-card.pinned {
border-color: var(--purple);
}
.event-image {
display: block;
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
background: #28212f;
}
.event-body {
position: relative;
padding: 14px 16px;
}
.event-pinned-badge {
display: inline-flex;
align-items: center;
gap: 5px;
margin-bottom: 8px;
padding: 3px 8px;
border-radius: var(--radius-pill);
background: var(--purple-soft);
color: var(--purple);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .05em;
}
.event-date {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 6px;
color: var(--purple);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .04em;
}
.event-body h3 {
margin: 0 0 6px;
font: 900 16px var(--futura-extra);
}
.event-body p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.event-row::-webkit-scrollbar {
height: 8px;
}
.event-row::-webkit-scrollbar-thumb {
background: #453a50;
border-radius: var(--radius-pill);
}
.event-row::-webkit-scrollbar-track {
background: transparent;
}
.home-room-row {
display: flex;
grid-template-columns: none;
overflow-x: auto;
gap: 16px;
padding-bottom: 6px;
scroll-snap-type: x proximity;
}
.home-room-row .room-card {
flex: 0 0 260px;
scroll-snap-align: start;
}
.home-room-row::-webkit-scrollbar {
height: 8px;
}
.home-room-row::-webkit-scrollbar-thumb {
background: #453a50;
border-radius: var(--radius-pill);
}
.home-room-row::-webkit-scrollbar-track {
background: transparent;
}
.room-card {
display: block;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
color: inherit;
text-decoration: none;
box-shadow: var(--shadow);
transition: transform .18s,border-color .18s;
}
.room-card:hover {
transform: translateY(-3px);
border-color: #7d58ad;
}
.room-cover {
display: block;
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
background: #28212f;
}
.room-card-body {
padding: 15px;
}
.room-name {
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.room-description {
height: 42px;
margin: 5px 0 12px;
color: var(--muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.room-meta,.room-stats {
display: flex;
gap: 12px;
align-items: center;
color: #a59bad;
font-size: 12px;
}
.room-meta {
justify-content: space-between;
}
.room-stats {
margin-top: 11px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.room-pill {
padding: 3px 7px;
border-radius: var(--radius-pill);
background: var(--purple-soft);
color: #d8c0ff;
font-weight: 700;
}
.room-hero {
display: flex;
align-items: stretch;
gap: 28px;
margin: 22px 0;
}
.room-detail-cover {
flex: 1 1 55%;
max-width: 55%;
max-height: 400px;
object-fit: cover;
border-radius: var(--radius-md);
background: #28212f;
}
.room-hero-info {
flex: 1 1 45%;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
padding-top: 4px;
}
.room-hero-info h1 {
font: 800 32px/1.15 var(--futura-extra);
margin: 0;
}
.room-hero-desc {
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.55;
}
.room-hero-info .tag-list:last-child {
margin-top: auto;
}
.room-detail-stats {
display: flex;
gap: 0;
margin: 25px 0;
padding: 18px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.room-stat {
flex: 1;
padding: 0 20px;
border-left: 1px solid var(--line);
text-align: left;
}
.room-stat:first-child {
padding-left: 0;
border-left: 0;
}
.room-stat strong {
display: block;
font: 800 22px var(--futura-extra);
}
.tag-list {
display: flex;
gap: 7px;
flex-wrap: wrap;
}
.settings-wrap {
max-width: 820px;
}
.settings-card {
margin-top: 18px;
padding: 24px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow);
}
.settings-title {
margin: 0 0 4px;
font-size: 20px;
}
.settings-card>p {
margin: 0 0 19px;
color: var(--muted);
}
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.settings-grid .wide {
grid-column: 1/-1;
}
.field {
display: grid;
gap: 6px;
color: #d8d0df;
font-size: 13px;
font-weight: 700;
}
.field input,.field textarea {
width: 100%;
padding: 12px 13px;
border: 1px solid #453a50;
border-radius: var(--radius-sm);
background: #120e16;
color: #fff;
font: 15px system-ui;
outline: 0;
}
.field textarea {
min-height: 110px;
resize: vertical;
}
.field input:focus,.field textarea:focus {
border-color: var(--purple);
box-shadow: 0 0 0 3px var(--purple-soft);
}
.field-hint {
color: var(--muted);
font-size: 11px;
font-weight: 400;
}
.form-actions {
display: flex;
align-items: center;
gap: 13px;
margin-top: 18px;
}
.form-status {
font-size: 13px;
color: #83dcac;
}
.danger-zone {
border-color: #6f2d3a;
background: linear-gradient(135deg,#25171d,#1c1822);
}
.danger-zone .settings-title {
color: #ff8292;
}
.danger-button {
padding: 11px 15px;
border: 1px solid #d94d62;
border-radius: var(--radius-sm);
background: #9d2638;
color: #fff;
font: 800 14px var(--futura-extra);
cursor: pointer;
}
.danger-button:hover {
background: #c43148;
}
.gated-settings {
text-align: center;
padding: 70px 25px;
}
.gated-settings i {
font-size: 42px;
color: var(--purple);
margin-bottom: 15px;
}
@media (max-width:950px) {
.room-grid {
grid-template-columns: repeat(2,minmax(0,1fr));
}
}
@media (max-width:620px) {
.room-grid {
grid-template-columns: 1fr;
}
.settings-grid {
grid-template-columns: 1fr;
}
.settings-grid .wide {
grid-column: auto;
}
.settings-card {
padding: 18px;
}
.room-hero {
flex-direction: column;
}
.room-detail-cover {
max-width: 100%;
}
.room-detail-stats {
flex-wrap: wrap;
}
.room-stat {
flex: 0 0 50%;
border-left: 0;
padding: 8px 0;
}
}
.announcement-feed {
display: grid;
gap: 14px;
}
.announcement-card {
--announcement-accent: var(--pink);
overflow: hidden;
padding: 18px 20px;
border: 1px solid var(--line);
border-left: 5px solid var(--announcement-accent);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow);
}
.announcement-card.pinned {
box-shadow: 0 0 0 1px color-mix(in srgb,var(--announcement-accent) 38%,transparent),var(--shadow);
}
.announcement-card.announcement-update {
--announcement-accent: #3ca978;
}
.announcement-card.announcement-warning {
--announcement-accent: #d79a38;
}
.announcement-card.announcement-maintenance {
--announcement-accent: #d35a6e;
}
.announcement-card.unpublished {
opacity: .72;
border-style: dashed;
}
.announcement-card>header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 9px;
}
.announcement-kind,.announcement-pin,.announcement-draft {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
border-radius: var(--radius-pill);
background: color-mix(in srgb,var(--announcement-accent) 18%,transparent);
color: var(--announcement-accent);
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .06em;
}
.announcement-pin {
background: var(--purple-soft);
color: var(--pink);
}
.announcement-draft {
background: #3a2d1a;
color: #e6b866;
}
.announcement-card time {
margin-left: auto;
color: var(--muted);
font-size: 11px;
}
.announcement-card>h3 {
margin: 0 0 10px;
font: 900 21px var(--futura-extra);
}
.announcement-card>footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 15px;
padding-top: 12px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 11px;
}
.announcement-card>footer>div {
display: flex;
gap: 7px;
flex-wrap: wrap;
}
.markdown-body {
color: inherit;
font-size: 14px;
line-height: 1.58;
overflow-wrap: anywhere;
}
.markdown-body>*:first-child {
margin-top: 0;
}
.markdown-body>*:last-child {
margin-bottom: 0;
}
.markdown-body p {
margin: 8px 0;
}
.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4 {
margin: 16px 0 7px;
font-family: var(--futura-extra);
line-height: 1.2;
}
.markdown-body h1 {
font-size: 23px;
}
.markdown-body h2 {
font-size: 20px;
}
.markdown-body h3 {
font-size: 17px;
}
.markdown-body h4 {
font-size: 15px;
}
.markdown-body ul,.markdown-body ol {
margin: 8px 0;
padding-left: 24px;
}
.markdown-body li+li {
margin-top: 4px;
}
.markdown-body a {
color: #c495ff;
font-weight: 750;
}
.markdown-body code {
padding: 2px 5px;
border: 1px solid color-mix(in srgb,var(--line) 80%,transparent);
border-radius: var(--radius-sm);
background: #120e16;
font: 12px ui-monospace,SFMono-Regular,Consolas,monospace;
}
.markdown-body pre {
max-width: 100%;
overflow: auto;
margin: 11px 0;
padding: 13px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #0d0a10;
}
.markdown-body pre code {
padding: 0;
border: 0;
background: transparent;
color: #e8dff0;
white-space: pre;
}
.markdown-body blockquote {
margin: 10px 0;
padding: 8px 12px;
border-left: 3px solid var(--announcement-accent);
background: color-mix(in srgb,var(--announcement-accent) 9%,transparent);
color: var(--muted);
}
.markdown-body hr {
margin: 16px 0;
border: 0;
border-top: 1px solid var(--line);
}
@media (max-width:620px) {
.announcement-card {
padding: 15px;
}
.announcement-card time {
width: 100%;
margin-left: 0;
}
.announcement-card>footer {
align-items: flex-start;
flex-direction: column;
}
}
+153
View File
@@ -0,0 +1,153 @@
@font-face {
font-family: "Futura PT";
src: url("/recnet/fonts/FuturaPT-Book.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Futura PT";
src: url("/recnet/fonts/FuturaPT-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Futura PT";
src: url("/recnet/fonts/FuturaPT-Heavy.ttf") format("truetype");
font-weight: 800 900;
font-style: normal;
font-display: swap;
}
body {
font-family: var(--font-body);
}
.wordmark,.page-title,.hero h1,.profile-side-body h1,.name,.section-head h2,.room-name,.settings-title,.primary-button,.nav-link {
font-family: var(--font-display);
font-weight: 700;
}
.profile-upload-row {
display: flex;
align-items: center;
gap: 15px;
padding: 14px;
border: 1px dashed var(--line);
border-radius: var(--radius-md);
background: var(--surface-solid);
}
.profile-upload-preview {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--muted);
}
.file-picker {
flex: 1;
}
.file-picker input[type=file] {
padding: 9px;
}
.banner-upload-row {
display: grid;
gap: 12px;
padding: 14px;
border: 1px dashed var(--line);
border-radius: var(--radius-md);
background: var(--surface-solid);
}
.banner-upload-preview {
display: block;
width: 100%;
height: 150px;
border-radius: var(--radius-sm);
object-fit: cover;
background: var(--grad-brand);
border: 1px solid var(--line);
}
.wordmark {
width: 190px;
height: 48px;
display: flex;
align-items: center;
overflow: hidden;
}
.mocha-logo {
display: block;
width: 190px;
height: 48px;
max-width: 100%;
object-fit: contain;
object-position: left center;
}
@media (max-width:620px) {
.wordmark {
width: 125px;
min-width: 125px;
height: 38px;
}
.mocha-logo {
width: 125px;
height: 38px;
}
}
.secondary-link {
display: block;
width: 100%;
margin-top: 14px;
padding: 8px;
border: 0;
background: none;
color: var(--pink);
font: 700 14px var(--font-display);
cursor: pointer;
}
.secondary-link:hover {
color: var(--purple);
}
.register-dialog {
width: min(470px,calc(100% - 28px));
}
.register-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.login-dialog select {
width: 100%;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-solid);
color: #fff;
font: inherit;
outline: 0;
}
.login-dialog select:focus {
border-color: var(--purple);
}
@media (max-width:500px) {
.register-row {
grid-template-columns: 1fr;
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,158 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="refresh" content="5;url=https://fontsgeek.com/futura-pt-font?ref=readme">
<title>Futura PTFontsgeek</title>
<style>
* {
margin:0;
padding:0;
font-family: "Helvetica Neue", "Helvetica", Helvetica, Arial, sans-serif;
font-size: 100%;
line-height: 1.6;
}
img {
max-width: 100%;
}
body {
-webkit-font-smoothing:antialiased;
-webkit-text-size-adjust:none;
width: 100%!important;
height: 100%;
background:#DDD;
}
a {
color: #348eda;
}
.btn-primary, .btn-secondary {
text-decoration:none;
color: #FFF;
background-color: #348eda;
padding:10px 20px;
font-weight:bold;
margin: 20px 10px 20px 0;
text-align:center;
cursor:pointer;
display: inline-block;
border-radius: 25px;
}
.btn-secondary{
background: #aaa;
}
.last {
margin-bottom: 0;
}
.first{
margin-top: 0;
}
table.body-wrap {
width: 100%;
padding: 20px;
}
table.body-wrap .container{
border: 1px solid #f0f0f0;
}
table.footer-wrap {
width: 100%;
clear:both!important;
}
.footer-wrap .container p {
font-size:12px;
color:#666;
}
table.footer-wrap a{
color: #999;
}
h1,h2,h3{
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; line-height: 1.1; margin-bottom:15px; color:#000;
margin: 40px 0 10px;
line-height: 1.2;
font-weight:200;
}
h1 {
font-size: 36px;
}
h2 {
font-size: 28px;
}
h3 {
font-size: 22px;
}
p, ul {
margin-bottom: 10px;
font-weight: normal;
font-size:14px;
}
ul li {
margin-left:5px;
list-style-position: inside;
}
.container {
display:block!important;
max-width:600px!important;
margin:0 auto!important;
clear:both!important;
}
.content {
padding:20px;
max-width:600px;
margin:0 auto;
display:block;
}
.content table {
width: 100%;
}
</style>
</head>
<body bgcolor="#f6f6f6">
<table class="body-wrap">
<tr>
<td></td>
<td class="container" bgcolor="#FFFFFF">
<div class="content">
<table>
<tr>
<td>
<h1>Futura PT</h1>
<p>This font was downloaded from <a href="http://fontsgeek.com?ref=readme">fontsgeek.com</a> . You can visit <a href="http://fontsgeek.com?ref=readme">fontsgeek.com</a> for thousands of free fonts.</p>
<p><a href="http://fontsgeek.com/futura-pt-font?ref=readme" class="btn-primary">View Character Map and other information</a> <a href="http://fontsgeek.com?ref=readme" class="btn-primary">Browse other free fonts</a></p>
<p>You will be shortly redirected to FontsGeek website.</p>
</td>
</tr>
</table>
</div>
</td>
<td></td>
</tr>
</table>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#1d1d1d">
<meta name="description" content="Mocha RecNet — discover rooms, creators, photos, events, and live community updates.">
<title>Mocha RecNet</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap">
<link rel="stylesheet" href="/recnet/tokens.css?v=3">
<link rel="stylesheet" href="/recnet/styles.css?v=11">
<link rel="stylesheet" href="/recnet/dark.css?v=5">
<link rel="stylesheet" href="/recnet/badges.css?v=2">
<link rel="stylesheet" href="/recnet/features.css?v=4">
<link rel="stylesheet" href="/recnet/font.css?v=4">
<link rel="stylesheet" href="/recnet/photo.css">
<link rel="stylesheet" href="/recnet/admin.css?v=11">
<link rel="stylesheet" href="/recnet/community-board.css?v=2">
<link rel="stylesheet" href="/recnet/shop.css?v=2">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
</head>
<body>
<header class="masthead">
<a class="wordmark" href="#home" aria-label="Mocha home"><img class="mocha-logo" src="/recnet/mocha-logo.png?v=2" width="190" height="48" alt="Mocha"></a>
<nav class="masthead-links">
<button class="nav-link" data-view="rooms">Rooms</button>
<button class="nav-link" data-view="users">Users</button>
<button class="nav-link" data-view="shop">Shop</button>
</nav>
<div class="search-shell">
<label class="global-search"><span>Search</span><input id="globalSearch" placeholder="Search players and rooms" autocomplete="off" aria-label="Search players and rooms"></label>
<div id="globalSearchResults" class="global-search-results" hidden></div>
</div>
<div class="mast-actions"><button id="serverStatus" class="server-status connecting" type="button" aria-label="Server status"><i></i><span id="serverStatusLabel">Connecting</span></button><button id="loginButton" class="login-button">Log in</button><button id="miniAvatar" class="mini-avatar" type="button" aria-label="Open profile">R</button><div class="account-menu-wrap"><button id="settingsMenuButton" class="circle-button settings-gear" type="button" aria-label="Settings menu"><i class="fa-solid fa-gear"></i></button><div id="accountMenu" class="account-menu" hidden><button class="account-menu-item nav-link" data-view="settings"><i class="fa-solid fa-gear"></i>Settings</button><a id="adminNav" class="account-menu-item" href="/recnet/mocha" hidden><i class="fa-solid fa-shield-halved"></i>Admin</a><a id="moderatorNav" class="account-menu-item moderator-nav" href="/recnet/mocha" hidden><i class="fa-solid fa-gavel"></i>Moderator</a><a id="developerNav" class="account-menu-item developer-nav" href="/recnet/developer" hidden><i class="fa-solid fa-chart-line"></i>Live Ops</a></div></div>
</header>
<div class="shell">
<main id="app" tabindex="-1"><div class="loading"><i></i>Loading Mocha</div></main>
</div>
<footer class="site-footer">
<span>Mocha</span>
<span class="site-footer-tag">Built by players, for players.</span>
<a href="/privacy">Privacy</a>
<a href="/tos">Terms</a>
</footer>
<dialog id="loginDialog" class="login-dialog"><button class="dialog-close" aria-label="Close">&times;</button><div class="dialog-logo">M</div><h2>Log in to Mocha</h2><p>Use your in-game account details.</p><form id="loginForm"><label>Username or account ID<input name="identity" autocomplete="username" required></label><label>Password<input name="password" type="password" autocomplete="current-password" required></label><div id="loginError" class="login-error"></div><button class="primary-button" type="submit">Log in</button></form><button id="openRegister" class="secondary-link" type="button">New here? Create an account</button></dialog>
<dialog id="registerDialog" class="login-dialog register-dialog"><button class="register-close dialog-close" aria-label="Close">&times;</button><div class="dialog-logo">M</div><h2>Create a Mocha account</h2><p>Connect an account to your gaming platform.</p><form id="registerForm"><label>@Username<input name="username" minlength="3" maxlength="20" autocomplete="username" pattern="[A-Za-z0-9_]+" required></label><div class="register-row"><label>Platform<select name="platform" required><option value="">Choose one</option><option>Steam</option><option>Oculus</option><option>PlayStation</option><option>Xbox</option><option>IOS</option><option>GooglePlay</option></select></label><label>Platform ID<input name="platformId" inputmode="numeric" pattern="[0-9]+" placeholder="Numeric ID" required></label></div><label>Password<input name="password" type="password" minlength="8" autocomplete="new-password" required></label><label>Confirm password<input name="confirmPassword" type="password" minlength="8" autocomplete="new-password" required></label><div id="registerError" class="login-error"></div><button class="primary-button" type="submit">Create account</button></form><button id="backToLogin" class="secondary-link" type="button">Already registered? Log in</button></dialog>
<dialog id="photoDialog" class="photo-dialog"><button id="photoClose" class="photo-close" aria-label="Close photo"><i class="fa-solid fa-xmark"></i></button><div id="photoContent" class="photo-dialog-content"></div></dialog>
<script src="/recnet/app.js?v=26"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+342
View File
@@ -0,0 +1,342 @@
@font-face {
font-family: 'Futura PT';
src: url('fonts/FuturaPT-Book.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Futura PT';
src: url('fonts/FuturaPT-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Futura PT';
src: url('fonts/FuturaPT-Heavy.ttf') format('truetype');
font-weight: 900;
font-style: normal;
}
:root {
--bg: #0a0510;
--panel: #120a16;
--panel-alt: #1c1024;
--text: #f6f2fa;
--text-dim: #a89cb3;
--border: rgba(255,255,255,.10);
--primary: #bf5af2;
--primary-dark: #8b3ccf;
--accent: var(--primary);
--accent-hover: #a760ff;
--grad-accent: linear-gradient(120deg, var(--primary-dark), var(--primary));
--danger: #ff5c72;
--danger-hover: #ff3b52;
--warn: #ffa93d;
--warn-hover: #ff9414;
--success: #3ddc9b;
--radius: 8px;
--font: 'Futura PT', system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
radial-gradient(60% 45% at 82% -8%, rgba(191,90,242,.16) 0%, transparent 60%),
radial-gradient(45% 35% at -6% 12%, rgba(139,60,207,.10) 0%, transparent 60%),
var(--bg);
background-attachment: fixed;
color: var(--text);
font-family: var(--font);
font-size: 15px;
}
a { color: inherit; text-decoration: none; }
.shell { display: flex; flex-direction: column; min-height: 100vh; }
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--panel);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 20;
}
.nav-toggle {
display: none;
background: none;
border: none;
color: var(--text);
font-size: 20px;
padding: 4px 8px;
cursor: pointer;
}
.brand { font-weight: 700; font-size: 17px; display: flex; align-items: center; gap: 8px; }
.env-badge {
font-size: 11px;
font-weight: 700;
color: #fff;
background: var(--danger);
padding: 2px 7px;
border-radius: 5px;
letter-spacing: 0.03em;
}
.env-badge.dev { background: var(--success); color: #072; }
.topbar-user { margin-left: auto; font-size: 13px; color: var(--text-dim); }
.backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 15;
}
.backdrop.open { display: block; }
.sidenav {
width: 240px;
flex-shrink: 0;
background: var(--panel);
border-right: 1px solid var(--border);
padding: 14px 10px;
position: fixed;
top: 49px;
bottom: 0;
left: 0;
overflow-y: auto;
z-index: 20;
transition: transform 0.2s ease;
}
.nav-group-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
font-weight: 700;
padding: 14px 10px 6px;
}
.nav-group { display: flex; flex-direction: column; gap: 2px; }
.nav-link {
padding: 9px 10px;
border-radius: 7px;
color: var(--text);
font-size: 14px;
}
.nav-link:hover { background: var(--panel-alt); }
.nav-link.active { background: var(--grad-accent); color: #fff; font-weight: 600; }
.nav-link.nav-disabled { color: var(--text-dim); cursor: default; }
.nav-link.nav-disabled:hover { background: none; }
.nav-link.nav-disabled::after { content: " · soon"; font-size: 11px; opacity: 0.7; }
.app {
flex: 1;
margin-left: 240px;
padding: 20px;
max-width: 1100px;
width: 100%;
}
@media (max-width: 860px) {
.nav-toggle { display: inline-flex; }
.sidenav {
transform: translateX(-100%);
width: 78vw;
max-width: 300px;
}
.sidenav.open { transform: translateX(0); }
.app { margin-left: 0; padding: 14px; }
}
.back-link { font-size: 13px; color: var(--accent); display: inline-block; margin-bottom: 14px; }
.page-title { font-size: 20px; font-weight: 700; margin: 0 0 14px; }
.field-hint { color: var(--text-dim); font-size: 13px; }
.loading { display: flex; align-items: center; gap: 10px; color: var(--text-dim); padding: 30px 0; }
.loading i { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
.form-status { font-size: 13px; color: var(--text-dim); }
.form-status.error { color: var(--danger); }
input, textarea, select {
background: var(--panel-alt);
border: 1px solid var(--border);
color: var(--text);
border-radius: 7px;
padding: 8px 10px;
font-size: 14px;
font-family: inherit;
width: 100%;
}
input:focus, textarea:focus, select:focus { outline: 1px solid var(--accent); }
.btn {
border: none;
border-radius: 7px;
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
color: #fff;
background: var(--grad-accent);
box-shadow: 0 6px 20px rgba(191,90,242,.28);
white-space: nowrap;
transition: .15s filter, .15s transform;
}
.btn:hover { filter: brightness(1.1); transform: translateY(-1px); }
.btn:disabled { opacity: 0.5; cursor: default; transform: none; }
.btn.danger { background: var(--danger); box-shadow: none; }
.btn.danger:hover { background: var(--danger-hover); filter: none; transform: none; }
.btn.warn { background: var(--warn); color: #241300; box-shadow: none; }
.btn.warn:hover { background: var(--warn-hover); filter: none; transform: none; }
.btn.ghost { background: var(--panel-alt); border: 1px solid var(--border); color: var(--text); box-shadow: none; }
.btn.ghost:hover { background: #2a1c30; filter: none; transform: none; }
.btn.small { padding: 6px 10px; font-size: 12px; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; margin-bottom: 16px; }
.stat-pill { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; text-align: center; }
.stat-pill strong { display: block; font-size: 18px; }
.stat-pill span { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; }
.search-row { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.search-row input { flex: 1; min-width: 180px; }
.player-list { display: flex; flex-direction: column; gap: 8px; }
.player-row {
display: flex;
align-items: center;
gap: 12px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
cursor: pointer;
}
.player-row:hover { border-color: var(--accent); }
.player-row img { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; background: var(--panel-alt); }
.player-row .meta { flex: 1; min-width: 0; }
.player-row .name { font-weight: 600; }
.player-row .sub { font-size: 12px; color: var(--text-dim); }
.badge { font-size: 10px; padding: 2px 6px; border-radius: 5px; background: var(--panel-alt); border: 1px solid var(--border); }
.badge.dev { background: #1f242c; border-color: #4a4f5c; }
.badge.mod { background: #22314a; border-color: #3b5a8a; }
.badge.banned { background: #4a2229; border-color: var(--danger); color: #ff9aa6; }
.profile-header { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 18px; }
.profile-header img { width: 96px; height: 96px; border-radius: 12px; object-fit: cover; background: var(--panel-alt); flex-shrink: 0; }
.profile-header .name { font-size: 24px; font-weight: 700; }
.profile-header .username-sub { color: var(--text-dim); font-size: 13px; }
.profile-header .stat-line { font-size: 13px; color: var(--text-dim); margin-top: 4px; }
.profile-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.profile-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; overflow-x: auto; }
.tab {
padding: 9px 12px;
font-size: 13px;
color: var(--text-dim);
border-bottom: 2px solid transparent;
white-space: nowrap;
cursor: pointer;
}
.tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 14px; }
.card h2 { font-size: 15px; margin: 0 0 12px; }
.card h3 { font-size: 13px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; margin: 16px 0 8px; }
.card h3:first-child { margin-top: 0; }
.page-kicker { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--text-dim); font-weight: 700; margin-bottom: 4px; }
.field { display: block; margin-bottom: 12px; font-size: 13px; color: var(--text-dim); }
.field > input, .field > textarea, .field > select { display: block; margin-top: 6px; }
.field-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.field-row > .field { flex: 1; min-width: 160px; margin-bottom: 0; }
.room-row { display: flex; align-items: center; gap: 12px; padding: 10px; margin-bottom: 10px; background: var(--panel-alt); border: 1px solid var(--border); border-radius: var(--radius); }
.room-row img { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }
.room-row .meta { flex: 1; min-width: 0; }
.room-row.clickable { cursor: pointer; }
.room-row.clickable:hover { border-color: var(--accent); }
.flag-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; margin: 10px 0; }
.flag-grid label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); }
.flag-grid input[type="checkbox"] { width: 15px; height: 15px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; color: var(--text-dim); font-weight: 600; padding: 8px 6px; border-bottom: 1px solid var(--border); }
td { padding: 6px; border-bottom: 1px solid var(--border); vertical-align: middle; }
td input { font-size: 13px; padding: 6px 8px; }
.actions-cell { display: flex; gap: 6px; white-space: nowrap; }
.settings-add-row { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
.settings-add-row input { flex: 1; min-width: 140px; }
@media (max-width: 640px) {
table, thead, tbody, tr { display: block; width: 100%; }
thead { display: none; }
tr { border-bottom: 1px solid var(--border); padding: 8px 0; }
td { display: flex; align-items: center; gap: 8px; border-bottom: none; padding: 4px 0; }
td::before { content: attr(data-label); flex-shrink: 0; width: 70px; color: var(--text-dim); font-size: 11px; }
}
.placeholder-card { text-align: center; padding: 50px 20px; color: var(--text-dim); }
.placeholder-card i { font-size: 26px; display: block; margin-bottom: 10px; }
.login-gate { text-align: center; padding: 60px 20px; }
.login-gate .btn { margin-top: 14px; }
.dev-only { display: none; }
.dev-block { display: none; }
.troll-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
.troll-banner {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; margin-bottom: 10px;
background: rgba(255,92,114,.08);
border: 1px solid rgba(255,92,114,.3);
border-radius: var(--radius);
}
.troll-banner img { width: 48px; height: 48px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }
.troll-banner strong { display: block; font-size: 13px; color: var(--danger); text-transform: uppercase; letter-spacing: .04em; }
.troll-banner span { display: block; font-size: 12px; color: var(--text-dim); margin-top: 2px; }
.inventory-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
margin-top: 10px;
}
.inventory-item {
padding: 10px;
background: var(--panel-alt);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.inventory-item label {
display: flex;
align-items: center;
gap: 5px;
margin-top: 5px;
font-size: 12px;
}
.inventory-item input[type="checkbox"] {
width: 16px;
height: 16px;
}
.inventory-item input[type="number"] {
padding: 4px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--panel);
color: var(--text);
}
.avatar-img {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 50%;
}
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Mocha Admin</title>
<link rel="stylesheet" href="mocha.css?v=20260823a">
</head>
<body>
<div class="shell">
<header class="topbar">
<button id="navToggle" class="nav-toggle" aria-label="Toggle navigation"><i>&#9776;</i></button>
<div class="brand">Mocha Admin <span id="envBadge" class="env-badge">PROD</span></div>
<div class="topbar-user" id="topbarUser"></div>
</header>
<div class="backdrop" id="navBackdrop"></div>
<nav class="sidenav" id="sidenav">
<div class="nav-group" data-group="live">
<a href="#instances" class="nav-link" data-view="instances">Live Room Instances</a>
</div>
<div class="nav-group-title">Moderation</div>
<div class="nav-group" data-group="moderation">
<a href="#players" class="nav-link" data-view="players">Players</a>
<a href="#player-reports" class="nav-link" data-view="player-reports">Player Reports</a>
<a href="#bug-reports" class="nav-link" data-view="bug-reports">Bug Reports</a>
<a href="#rooms" class="nav-link" data-view="rooms">Rooms</a>
<a href="#clubs" class="nav-link" data-view="clubs">Clubs</a>
<a href="#age-verification" class="nav-link" data-view="age-verification">Age Verification</a>
<a href="#community-board" class="nav-link" data-view="community-board" data-requires="developer">Community Board</a>
<a href="#coach-gifting" class="nav-link" data-view="coach-gifting" data-requires="developer">Coach Gifting</a>
<a href="#announcements" class="nav-link" data-view="announcements" data-requires="developer">Announcements</a>
<a href="#player-events" class="nav-link" data-view="player-events" data-requires="developer">Player Events</a>
<a href="#room-importer" class="nav-link" data-view="room-importer" data-requires="developer">Room Importer</a>
<a href="#logs" class="nav-link" data-view="logs" data-requires="developer">Logs</a>
<a href="#scanner-logs" class="nav-link" data-view="scanner-logs" data-requires="developer">Loser Scanner Logs</a>
<a href="#anticheat-logs" class="nav-link" data-view="anticheat-logs" data-requires="developer">Anticheat Logs</a>
</div>
<div class="nav-group-title" data-requires="developer">Configuration</div>
<div class="nav-group" data-group="configuration" data-requires="developer">
<a href="#config" class="nav-link" data-view="config">Config</a>
<a href="#account-creation" class="nav-link" data-view="account-creation">Account Creation</a>
<a href="#avatar-items" class="nav-link" data-view="avatar-items">Avatar Items</a>
<a href="#ip-bans" class="nav-link" data-view="ip-bans">IP Bans</a>
<a href="#steam-blacklist" class="nav-link" data-view="steam-blacklist">Steam Blacklist</a>
<a href="#shop" class="nav-link" data-view="shop">Shop</a>
</div>
<div class="nav-group-title">Settings</div>
<div class="nav-group" data-group="settings">
<a href="#preferences" class="nav-link" data-view="preferences">Preferences</a>
</div>
</nav>
<main id="app" class="app" tabindex="-1"></main>
</div>
<script src="mocha.js?v=20260823a"></script>
</body>
</html>
+3282
View File
@@ -0,0 +1,3282 @@
const API = '/recnet/api';
const app = document.querySelector('#app');
const sidenav = document.querySelector('#sidenav');
const navBackdrop = document.querySelector('#navBackdrop');
const navToggle = document.querySelector('#navToggle');
const topbarUser = document.querySelector('#topbarUser');
const envBadge = document.querySelector('#envBadge');
let currentUser = null;
const esc = s => String(s ?? '').replace(/[&<>'"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[c]));
function fetchJson(url, init = {}) {
return fetch(url, { ...init, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(init.headers || {}) } });
}
async function get(url) {
const r = await fetchJson(url);
if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `Request failed (${r.status})`); }
return r.json();
}
async function send(url, method, body) {
const r = await fetchJson(url, { method, body: body !== undefined ? JSON.stringify(body) : undefined });
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || `Request failed (${r.status})`);
return data;
}
function openNav() { sidenav.classList.add('open'); navBackdrop.classList.add('open'); }
function closeNav() { sidenav.classList.remove('open'); navBackdrop.classList.remove('open'); }
navToggle.addEventListener('click', () => sidenav.classList.contains('open') ? closeNav() : openNav());
navBackdrop.addEventListener('click', closeNav);
function applyRoleGating() {
const isDev = currentUser?.isDeveloper === true;
document.querySelectorAll('[data-requires="developer"]').forEach(el => {
el.style.display = isDev ? '' : 'none';
});
document.querySelectorAll('.dev-only').forEach(el => {
el.style.display = isDev ? 'flex' : 'none';
});
document.querySelectorAll('.dev-block').forEach(el => {
el.style.display = isDev ? 'block' : 'none';
});
topbarUser.textContent = currentUser ? `${currentUser.displayName || currentUser.username} · ${currentUser.isDeveloper ? 'Developer' : currentUser.isModerator ? 'Moderator' : 'Staff'}` : '';
envBadge.textContent = currentUser?.isDeveloper ? 'DEV ACCESS' : 'PROD';
envBadge.className = 'env-badge' + (currentUser?.isDeveloper ? ' dev' : '');
}
function renderLoginGate() {
app.innerHTML = `<div class="login-gate"><i class="fa-solid fa-lock" style="font-size:26px;color:var(--text-dim)"></i><h1 class="page-title">Staff sign-in required</h1><p class="field-hint">Log in with a Moderator or Developer account through the main site, then come back here.</p><a class="btn" href="/recnet/">Go to main site</a></div>`;
}
function setActiveNav(view) {
document.querySelectorAll('.nav-link').forEach(link => link.classList.toggle('active', link.dataset.view === view));
}
const DEVELOPER_ONLY_VIEWS = new Set([
'community-board', 'coach-gifting', 'announcements', 'player-events',
'room-importer', 'logs', 'scanner-logs', 'anticheat-logs', 'config', 'account-creation',
'avatar-items', 'ip-bans', 'steam-blacklist', 'shop'
]);
function renderDeveloperOnlyPlaceholder(view) {
const title = document.querySelector(`.nav-link[data-view="${view}"]`)?.textContent || 'This section';
app.innerHTML = `<div class="placeholder-card"><i class="fa-solid fa-lock"></i><h1 class="page-title">${esc(title)} needs Developer access</h1><p>This section's actions all require the Developer role on the server, not just Moderator.</p></div>`;
}
async function route() {
closeNav();
const hash = location.hash.slice(1);
const [view, ...rest] = hash.split('/');
setActiveNav(view || 'players');
if (DEVELOPER_ONLY_VIEWS.has(view) && currentUser?.isDeveloper !== true) {
renderDeveloperOnlyPlaceholder(view);
return;
}
try {
if (view === 'players' && rest[0]) await playerDetail(rest[0]);
else if (view === 'players' || view === '') await playersList();
else if (view === 'player-reports') await playerReportsView();
else if (view === 'bug-reports') await bugReportsView();
else if (view === 'rooms' && rest[0]) await roomDetail(rest[0]);
else if (view === 'rooms') await roomsList();
else if (view === 'instances') await liveInstances();
else if (view === 'age-verification') await ageVerificationView();
else if (view === 'announcements') await announcementsView();
else if (view === 'player-events') await playerEventsView();
else if (view === 'config') await configView();
else if (view === 'account-creation') await accountCreationView();
else if (view === 'avatar-items') await avatarItemsView();
else if (view === 'room-importer') await roomImporterView();
else if (view === 'logs') await logsView();
else if (view === 'scanner-logs') await scannerLogsView();
else if (view === 'anticheat-logs') await anticheatLogsView();
else if (view === 'ip-bans') await ipBansView();
else if (view === 'community-board') await communityBoardView();
else if (view === 'clubs' && rest[0]) await clubDetail(rest[0]);
else if (view === 'clubs') await clubsView();
else if (view === 'coach-gifting') await coachGiftingView();
else if (view === 'steam-blacklist') await steamBlacklistView();
else if (view === 'shop') await shopView();
else if (view === 'preferences') await preferencesView();
else await placeholder(view);
} catch (error) {
app.innerHTML = `<div class="card"><p class="form-status error">${esc(error.message)}</p></div>`;
}
}
addEventListener('hashchange', route);
function placeholder(view) {
const title = document.querySelector(`.nav-link[data-view="${view}"]`)?.textContent.replace(' · soon', '') || 'This section';
app.innerHTML = `<div class="placeholder-card"><i class="fa-solid fa-hammer"></i><h1 class="page-title">${esc(title)} isn't built here yet</h1><p>This part of the new panel doesn't exist yet - it still works fine in the original admin panel for now.</p></div>`;
}
async function playersList() {
app.innerHTML = `<h1 class="page-title">Players</h1><div id="playerOverview" class="stat-grid"><div class="loading"><i></i>Loading overview</div></div><div class="search-row"><input id="playerSearch" placeholder="Search by username, display name, or ID"><button class="btn" id="playerSearchGo">Search</button></div><div id="playerListResults" class="player-list"></div>`;
(async () => {
const overview = document.querySelector('#playerOverview');
try {
const stats = await get(`${API}/admin/overview`);
const entries = [
['Accounts', stats.accounts], ['Admins', stats.admins], ['Mod Locks', stats.moderationLocks],
['Verified', stats.verified], ['RR+', stats.rrPlus], ['Rooms', stats.rooms],
['Photos', stats.photos], ['Cheers', stats.cheers], ['Comments', stats.comments]
];
overview.innerHTML = entries.map(([label, value]) => `<div class="stat-pill"><strong>${value}</strong><span>${esc(label)}</span></div>`).join('');
} catch (error) {
overview.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
})();
const input = document.querySelector('#playerSearch'), results = document.querySelector('#playerListResults');
async function runSearch() {
results.innerHTML = '<div class="loading"><i></i>Loading players</div>';
try {
const players = await get(`${API}/admin/accounts?search=${encodeURIComponent(input.value.trim())}`);
if (!players.length) { results.innerHTML = '<p class="field-hint">No players found.</p>'; return; }
results.innerHTML = players.map(p => `<a class="player-row" href="#players/${p.accountId}"><img src="${esc(p.profileImage || '')}" class="avatar-img" alt=""><div class="meta"><div class="name">${esc(p.displayName || p.username)}</div><div class="sub">@${esc(p.username)} · ID ${p.accountId} · Level ${p.level}${p.ban ? ' · <span class="badge banned">Banned</span>' : ''}</div></div></a>`).join('');
} catch (error) {
results.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const playerSearchGo = document.querySelector('#playerSearchGo');
if (playerSearchGo) playerSearchGo.addEventListener('click', runSearch);
if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') runSearch(); });
await runSearch();
}
const ROLE_BADGE_CLASS = { Developer: 'dev', Moderator: 'mod' };
async function playerDetail(accountIdRaw) {
const accountId = Number(accountIdRaw);
app.innerHTML = '<div class="loading"><i></i>Loading player</div>';
let player;
try { player = await get(`${API}/admin/accounts/${accountId}`); }
catch (error) { app.innerHTML = `<a class="back-link" href="#players">&larr; Back to Players</a><div class="card"><p class="form-status error">${esc(error.message)}</p></div>`; return; }
const joined = player.createdAt ? new Date(player.createdAt).toLocaleDateString() : 'Unknown';
const badges = [...player.roles.map(r => `<span class="badge ${ROLE_BADGE_CLASS[r] || ''}">${esc(r)}</span>`)];
if (player.isJunior) badges.push('<span class="badge">Junior</span>');
if (player.ban) badges.push('<span class="badge banned">Banned</span>');
app.innerHTML = `
<a class="back-link" href="#players">&larr; Back to Players</a>
<div class="profile-header">
<img src="${esc(player.profileImage || '')}" class="avatar-img" alt="">
<div>
<div class="name">@${esc(player.username)}</div>
<div class="username-sub">${esc(player.displayName || '')}</div>
<div class="stat-line">Level: ${player.level}</div>
<div class="stat-line">Bio: ${esc(player.bio || '(none)')}</div>
<div class="stat-line">ID: ${player.accountId} &middot; Joined ${joined}</div>
<div class="profile-actions">
<button class="btn" data-act="message">Send Coach Message</button>
<button class="btn" data-act="force-join">Force Join</button>
<button class="btn ghost" data-act="edit-profile">Edit Profile</button>
<button class="btn ghost" data-act="reset-username">Reset Username</button>
<button class="btn ghost" data-act="reset-pfp">Reset PFP</button>
<button class="btn ghost" data-act="reset-banner">Reset Banner</button>
<button class="btn ghost" data-act="reset-password">Reset Password</button>
<button class="btn ghost" data-act="copy-id">Copy ID</button>
<button class="btn danger" data-act="ban">${player.ban ? 'Unban' : 'Ban'}</button>
<button class="btn danger" data-act="mod-lock">${player.moderationLock ? 'Remove Moderation Lock' : 'Apply Moderation Lock'}</button>
<button class="btn danger" data-act="delete-account">Delete Account</button>
</div>
<div class="profile-actions" style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border)">
<select id="roleSelect" style="width:auto">
<option value="Moderator">Moderator</option>
<option value="Developer">Developer</option>
<option value="RRPlus">RRPlus</option>
<option value="Verified">Verified</option>
</select>
<button class="btn ghost" data-act="add-role">Add Role</button>
<button class="btn ghost" data-act="clear-roles">Clear Roles</button>
<button class="btn ghost" data-act="force-into-arbitrary">Force Into Another Player's Instance</button>
</div>
<div class="profile-badges">${badges.join('') || '<span class="field-hint">No roles</span>'}${player.moderationLock ? ' <span class="badge banned">Mod Locked</span>' : ''}</div>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="settings">Settings</div>
<div class="tab" data-tab="relationships">Relationships</div>
<div class="tab" data-tab="threads">Threads</div>
<div class="tab" data-tab="inventory">Inventory</div>
<div class="tab" data-tab="photos-mine">My Photos</div>
<div class="tab" data-tab="photos-of-me">Photos of Me</div>
<div class="tab" data-tab="gifts">Gifts</div>
<div class="tab" data-tab="reputation">Reputation</div>
<div class="tab" data-tab="progression">Progression</div>
<div class="tab dev-only" data-tab="troll">Troll</div>
</div>
<div id="tabBody"></div>`;
document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === tab));
renderPlayerTab(tab.dataset.tab, accountId);
}));
renderPlayerTab('settings', accountId);
applyRoleGating();
const msgBtn = document.querySelector('[data-act="message"]');
if (msgBtn) msgBtn.addEventListener('click', async () => {
const message = prompt('Message to send as Coach:');
if (!message) return;
try { await send(`${API}/admin/accounts/${accountId}/message`, 'POST', { message }); alert('Sent.'); }
catch (error) { alert(error.message); }
});
const forceJoinBtn = document.querySelector('[data-act="force-join"]');
if (forceJoinBtn) forceJoinBtn.addEventListener('click', async () => {
try { const r = await send(`${API}/admin/accounts/${accountId}/force-join-instance`, 'POST', {}); alert(r.delivered ? `Pushed live into "${r.roomName}".` : `Their record now points at "${r.roomName}" - it'll surface next time they connect.`); }
catch (error) { alert(error.message); }
});
const resetUserBtn = document.querySelector('[data-act="reset-username"]');
if (resetUserBtn) resetUserBtn.addEventListener('click', async () => {
if (!confirm('Reset this account\'s username to a randomly generated one?')) return;
try {
const r = await send(`${API}/admin/accounts/${accountId}/username/reset`, 'POST', {});
alert(`Username reset from "${r.previousUsername}" to "${r.username}".`);
await playerDetail(accountIdRaw);
} catch (error) { alert(error.message); }
});
const editProfileBtn = document.querySelector('[data-act="edit-profile"]');
if (editProfileBtn) editProfileBtn.addEventListener('click', async () => {
const newUsername = prompt('New username (3-20 letters, numbers, underscores):', player.username);
if (!newUsername) return;
const newDisplayName = prompt('New display name (1-32 characters):', player.displayName || '');
if (newDisplayName === null) return;
const bio = prompt('Bio (up to 500 characters):', player.bio || '');
if (bio === null) return;
const email = prompt('Email:', player.email || '');
if (email === null) return;
try {
await send(`${API}/admin/accounts/${accountId}/profile`, 'PUT', { username: newUsername, displayName: newDisplayName, bio, email });
alert('Profile updated successfully.');
await playerDetail(accountIdRaw);
} catch (error) { alert(error.message); }
});
const resetPfpBtn = document.querySelector('[data-act="reset-pfp"]');
if (resetPfpBtn) resetPfpBtn.addEventListener('click', async () => {
if (!confirm('Reset this account\'s profile picture to the default?')) return;
try { await send(`${API}/admin/accounts/${accountId}/images/pfp/reset`, 'POST', {}); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
});
const resetBannerBtn = document.querySelector('[data-act="reset-banner"]');
if (resetBannerBtn) resetBannerBtn.addEventListener('click', async () => {
if (!confirm('Reset this account\'s banner image?')) return;
try { await send(`${API}/admin/accounts/${accountId}/images/banner/reset`, 'POST', {}); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
});
const resetPasswordBtn = document.querySelector('[data-act="reset-password"]');
if (resetPasswordBtn) resetPasswordBtn.addEventListener('click', async () => {
const newPassword = prompt('New password (minimum 8 characters):');
if (!newPassword) return;
try { await send(`${API}/admin/accounts/${accountId}/password`, 'PUT', { newPassword }); alert('Password reset.'); }
catch (error) { alert(error.message); }
});
const copyIdBtn = document.querySelector('[data-act="copy-id"]');
if (copyIdBtn) copyIdBtn.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(String(accountId)); copyIdBtn.textContent = 'Copied!'; setTimeout(() => copyIdBtn.textContent = 'Copy ID', 1200); }
catch (error) { alert('Could not copy to clipboard.'); }
});
const modLockBtn = document.querySelector('[data-act="mod-lock"]');
if (modLockBtn) modLockBtn.addEventListener('click', async () => {
if (player.moderationLock) {
const removeLinked = player.moderationLock.isRelated ? confirm('Also remove the Moderation Lock from linked accounts?') : false;
if (!confirm('Remove this account\'s Moderation Lock?')) return;
try { await send(`${API}/admin/accounts/${accountId}/moderation-lock`, 'DELETE', { removeLinkedAccounts: removeLinked }); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
return;
}
if (!confirm('Apply a permanent Moderation Lock to this account?')) return;
try { await send(`${API}/admin/accounts/${accountId}/moderation-lock`, 'POST', {}); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
});
const deleteAccountBtn = document.querySelector('[data-act="delete-account"]');
if (deleteAccountBtn) deleteAccountBtn.addEventListener('click', async () => {
const confirmation = prompt(`This permanently deletes the account. Type DELETE ${accountId} to confirm:`);
if (!confirmation) return;
try {
await send(`${API}/admin/accounts/${accountId}`, 'DELETE', { confirmation });
alert('Account deleted.');
location.hash = '#players';
} catch (error) { alert(error.message); }
});
const addRoleBtn = document.querySelector('[data-act="add-role"]');
if (addRoleBtn) addRoleBtn.addEventListener('click', async () => {
const roleSelect = document.querySelector('#roleSelect');
const role = roleSelect ? roleSelect.value : prompt('Role to add (e.g. Moderator, Developer, RRPlus):');
if (!role) return;
try { await send(`${API}/admin/accounts/${accountId}/roles`, 'POST', { role, enabled: true }); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
});
const clearRolesBtn = document.querySelector('[data-act="clear-roles"]');
if (clearRolesBtn) clearRolesBtn.addEventListener('click', async () => {
if (!confirm('Clear all roles from this account?')) return;
try { await send(`${API}/admin/accounts/${accountId}/roles`, 'DELETE'); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
});
const forceIntoArbitraryBtn = document.querySelector('[data-act="force-into-arbitrary"]');
if (forceIntoArbitraryBtn) forceIntoArbitraryBtn.addEventListener('click', async () => {
const targetAccountId = prompt('Account ID of the player whose live instance to force this player into:');
if (!targetAccountId) return;
try {
const r = await send(`${API}/admin/accounts/${accountId}/force-into/${Number(targetAccountId)}`, 'POST', {});
alert(r.delivered ? `Forced into "${r.roomName}" live.` : `Record updated to "${r.roomName}" - will apply on next connection.`);
} catch (error) { alert(error.message); }
});
const banBtn = document.querySelector('[data-act="ban"]');
if (banBtn) banBtn.addEventListener('click', async () => {
if (player.ban) {
if (!confirm('Unban this account?')) return;
try { await send(`${API}/admin/accounts/${accountId}/ban`, 'DELETE'); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
return;
}
const reason = prompt('Ban reason:');
if (!reason) return;
const durationAmount = Number(prompt('Duration amount (ignored if permanent):', '1') || '1');
const durationUnit = prompt('Duration unit (seconds/minutes/hours/days/weeks/permanent):', 'days') || 'days';
try {
await send(`${API}/admin/accounts/${accountId}/ban`, 'POST', { reason, linkBan: false, durationAmount, durationUnit });
await playerDetail(accountIdRaw);
} catch (error) { alert(error.message); }
});
}
async function renderPlayerTab(tab, accountId) {
const body = document.querySelector('#tabBody');
if (tab === 'settings') {
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Settings</h2>
<div class="card">
<h2>Token Balance</h2>
<p class="field-hint" id="balanceCurrent">Loading...</p>
<div class="field-row">
<label class="field">Amount<input type="number" id="balanceAmount" min="0" value="0"></label>
<label class="field">Mode<select id="balanceMode"><option value="set">Set to</option><option value="add">Add</option></select></label>
</div>
<button class="btn" id="applyBalance">Apply</button>
<span class="form-status" id="balanceStatus"></span>
</div>
<div class="card">
<h2>Linked Platforms</h2>
<div id="platformList"><div class="loading"><i></i>Loading platforms</div></div>
<div class="field-row" style="margin-top:10px">
<label class="field">Platform<select id="newPlatform"><option>Steam</option><option>Oculus</option><option>PlayStation</option><option>Xbox</option><option>IOS</option><option>GooglePlay</option></select></label>
<label class="field">Platform ID<input id="newPlatformId" inputmode="numeric" placeholder="Numeric ID"></label>
</div>
<button class="btn" id="addPlatform">Link Platform</button>
<span class="form-status" id="platformStatus"></span>
</div>
<h2 class="page-title" style="font-size:16px">Custom Settings</h2>
<div class="settings-add-row"><input id="newSettingKey" placeholder="New key"><input id="newSettingValue" placeholder="Value"><button class="btn" id="addSetting">Add / set</button></div><div id="settingsTable"><div class="loading"><i></i>Loading settings</div></div>`;
(async () => {
const balanceCurrent = document.querySelector('#balanceCurrent');
try {
const account = await get(`${API}/admin/accounts/${accountId}`);
balanceCurrent.textContent = `Current balance: ${account.balance} tokens.`;
renderPlatforms(account.platforms);
} catch (error) {
balanceCurrent.textContent = error.message;
balanceCurrent.classList.add('error');
}
})();
function renderPlatforms(platforms) {
const list = document.querySelector('#platformList');
if (!platforms || !platforms.length) { list.innerHTML = '<p class="field-hint">No linked platforms.</p>'; return; }
list.innerHTML = platforms.map(p => `<div class="room-row"><div class="meta"><strong>${esc(p.platform)}</strong><br><small>${esc(p.platformId)}</small></div><button class="btn small danger unlink-platform" data-platform="${esc(p.platform)}" data-platform-id="${esc(p.platformId)}">Unlink</button></div>`).join('');
list.querySelectorAll('.unlink-platform').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm(`Unlink ${btn.dataset.platform} identity ${btn.dataset.platformId}?`)) return;
try {
const r = await send(`${API}/admin/accounts/${accountId}/platforms`, 'POST', { platform: btn.dataset.platform, platformId: btn.dataset.platformId, enabled: false });
renderPlatforms(r.platforms);
} catch (error) { alert(error.message); }
}));
}
const applyBalanceBtn = document.querySelector('#applyBalance');
if (applyBalanceBtn) applyBalanceBtn.addEventListener('click', async () => {
const status = document.querySelector('#balanceStatus');
const amount = Number(document.querySelector('#balanceAmount').value);
const add = document.querySelector('#balanceMode').value === 'add';
if (amount < 0) { status.textContent = 'Amount cannot be negative.'; status.classList.add('error'); return; }
try {
const r = await send(`${API}/admin/accounts/${accountId}/balance`, 'POST', { amount, add });
status.textContent = `Balance is now ${r.balance} tokens.`;
status.classList.remove('error');
document.querySelector('#balanceCurrent').textContent = `Current balance: ${r.balance} tokens.`;
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
const addPlatformBtn = document.querySelector('#addPlatform');
if (addPlatformBtn) addPlatformBtn.addEventListener('click', async () => {
const status = document.querySelector('#platformStatus');
const platform = document.querySelector('#newPlatform').value;
const platformId = document.querySelector('#newPlatformId').value.trim();
if (!platformId) { status.textContent = 'Enter a platform ID.'; status.classList.add('error'); return; }
try {
const r = await send(`${API}/admin/accounts/${accountId}/platforms`, 'POST', { platform, platformId, enabled: true });
status.textContent = 'Linked.';
status.classList.remove('error');
renderPlatforms(r.platforms);
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
async function loadSettings() {
const table = document.querySelector('#settingsTable');
try {
const settings = await get(`${API}/admin/accounts/${accountId}/settings`);
if (!settings.length) { table.innerHTML = '<p class="field-hint">No settings stored.</p>'; return; }
table.innerHTML = `<table><thead><tr><th>Key</th><th>Value</th><th>Actions</th></tr></thead><tbody>${settings.map(s => `<tr data-key="${esc(s.key)}"><td data-label="Key">${esc(s.key)}</td><td data-label="Value"><input class="setting-value" value="${esc(s.value)}"></td><td data-label="Actions" class="actions-cell"><button class="btn small save-setting">Save</button><button class="btn small danger delete-setting">Delete</button></td></tr>`).join('')}</tbody></table>`;
table.querySelectorAll('.save-setting').forEach(btn => btn.addEventListener('click', async () => {
const row = btn.closest('tr'), key = row.dataset.key;
const settingValue = row.querySelector('.setting-value');
if (!settingValue) return;
const value = settingValue.value;
try { await send(`${API}/admin/accounts/${accountId}/settings`, 'PUT', { key, value }); }
catch (error) { alert(error.message); }
}));
table.querySelectorAll('.delete-setting').forEach(btn => btn.addEventListener('click', async () => {
const row = btn.closest('tr'), key = row.dataset.key;
if (!confirm(`Delete "${key}"?`)) return;
try { await send(`${API}/admin/accounts/${accountId}/settings/${encodeURIComponent(key)}`, 'DELETE'); row.remove(); }
catch (error) { alert(error.message); }
}));
} catch (error) {
table.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const addSettingBtn = document.querySelector('#addSetting');
if (addSettingBtn) addSettingBtn.addEventListener('click', async () => {
const newSettingKey = document.querySelector('#newSettingKey');
const newSettingValue = document.querySelector('#newSettingValue');
if (!newSettingKey || !newSettingValue) return;
const key = newSettingKey.value.trim(), value = newSettingValue.value;
if (!key) return;
try {
await send(`${API}/admin/accounts/${accountId}/settings`, 'PUT', { key, value });
newSettingKey.value = '';
newSettingValue.value = '';
await loadSettings();
} catch (error) { alert(error.message); }
});
await loadSettings();
} else if (tab === 'progression') {
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Progression &amp; Account State</h2><div class="card"><h2>Update Progression</h2><div class="field-row"><label class="field">Level (1-50)<input type="number" id="progLevel" min="1" max="50"></label><label class="field">XP<input type="number" id="progXP" min="0"></label></div><button class="btn" id="updateProgression">Update</button><span class="form-status" id="progStatus"></span></div>
<div class="card"><h2>Account state &amp; appearance</h2>
<div class="field-row">
<label class="field">Available username changes<input type="number" id="stateUsernameChanges" min="0"></label>
<label class="field">Personal pronouns (bitmask 0-63)<input type="number" id="statePronouns" min="0" max="63"></label>
</div>
<label class="field">Display emoji<input id="stateEmoji" maxlength="16"></label>
<label class="field">Profile image path<input id="stateProfileImage"></label>
<label class="field">Banner image path<input id="stateBannerImage"></label>
<label class="field" style="display:flex;align-items:center;gap:6px;flex-direction:row"><input type="checkbox" id="stateIsJunior" style="width:auto"> Junior account</label>
<button class="btn" id="updateAccountState">Save account state</button>
<span class="form-status" id="stateStatus"></span>
</div>`;
let account;
try {
account = await get(`${API}/admin/accounts/${accountId}`);
document.querySelector('#progLevel').value = account.level;
document.querySelector('#progXP').value = account.xp;
document.querySelector('#stateUsernameChanges').value = account.availableUsernameChanges ?? 0;
document.querySelector('#statePronouns').value = account.personalPronouns ?? 0;
document.querySelector('#stateEmoji').value = account.displayEmoji || '';
document.querySelector('#stateProfileImage').value = account.profileImagePath || '';
document.querySelector('#stateBannerImage').value = account.bannerImagePath || '';
document.querySelector('#stateIsJunior').checked = !!account.isJunior;
} catch (error) {
body.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
return;
}
function detailsPayload(overrides) {
return {
level: account.level,
xp: account.xp,
isJunior: document.querySelector('#stateIsJunior').checked,
availableUsernameChanges: parseInt(document.querySelector('#stateUsernameChanges').value) || 0,
displayEmoji: document.querySelector('#stateEmoji').value,
personalPronouns: parseInt(document.querySelector('#statePronouns').value) || 0,
profileImage: document.querySelector('#stateProfileImage').value,
bannerImage: document.querySelector('#stateBannerImage').value,
...overrides
};
}
const updateProgBtn = document.querySelector('#updateProgression');
if (updateProgBtn) updateProgBtn.addEventListener('click', async () => {
const status = document.querySelector('#progStatus');
const level = parseInt(document.querySelector('#progLevel').value);
const xp = parseInt(document.querySelector('#progXP').value);
if (level < 1 || level > 50) { status.textContent = 'Level must be 1-50.'; status.classList.add('error'); return; }
if (xp < 0) { status.textContent = 'XP cannot be negative.'; status.classList.add('error'); return; }
try {
await send(`${API}/admin/accounts/${accountId}/details`, 'PUT', detailsPayload({ level, xp }));
account.level = level; account.xp = xp;
status.textContent = 'Updated.';
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
const updateStateBtn = document.querySelector('#updateAccountState');
if (updateStateBtn) updateStateBtn.addEventListener('click', async () => {
const status = document.querySelector('#stateStatus');
try {
await send(`${API}/admin/accounts/${accountId}/details`, 'PUT', detailsPayload({}));
status.textContent = 'Saved.';
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
} else if (tab === 'relationships') {
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Relationships</h2>
<div class="card dev-block">
<h2>Force add friend</h2>
<div class="field-row"><label class="field">Account ID<input type="number" id="forceAddFriendId" min="1"></label></div>
<button class="btn" id="forceAddFriend">Add friendship</button>
<span class="form-status" id="forceAddFriendStatus"></span>
</div>
<div id="relationshipsList"><div class="loading"><i></i>Loading relationships</div></div>`;
applyRoleGating();
async function loadRelationships() {
const list = document.querySelector('#relationshipsList');
list.innerHTML = '<div class="loading"><i></i>Loading relationships</div>';
try {
const relationships = await get(`${API}/admin/accounts/${accountId}/relationships`);
if (!relationships.length) { list.innerHTML = '<p class="field-hint">No relationships found.</p>'; return; }
const isDev = currentUser?.isDeveloper === true;
list.innerHTML = `<table><thead><tr><th>Player ID</th><th>Username</th><th>Display Name</th><th>Type</th><th>Flags</th>${isDev ? '<th>Actions</th>' : ''}</tr></thead><tbody>${relationships.map(r => `
<tr><td>${r.playerId}</td><td>${esc(r.username || 'N/A')}</td><td>${esc(r.displayName || 'N/A')}</td><td>${esc(r.relationshipType)}</td><td>${r.favorited ? '⭐ ' : ''}${r.muted ? '🔇 ' : ''}${r.ignored ? '🚫' : ''}</td>${isDev ? `<td><button class="btn small danger remove-relationship" data-id="${r.playerId}">Unadd</button></td>` : ''}</tr>
`).join('')}</tbody></table>`;
list.querySelectorAll('.remove-relationship').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm('Remove this relationship?')) return;
try {
await send(`${API}/admin/accounts/${accountId}/relationships/friend/${btn.dataset.id}`, 'DELETE');
await loadRelationships();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const forceAddFriendBtn = document.querySelector('#forceAddFriend');
if (forceAddFriendBtn) forceAddFriendBtn.addEventListener('click', async () => {
const status = document.querySelector('#forceAddFriendStatus');
const targetAccountId = Number(document.querySelector('#forceAddFriendId').value);
if (!targetAccountId) return;
try {
await send(`${API}/admin/accounts/${accountId}/relationships/friend`, 'POST', { targetAccountId });
status.textContent = 'Added.';
status.classList.remove('error');
await loadRelationships();
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
await loadRelationships();
} else if (tab === 'threads') {
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Threads</h2><div id="threadsList"><div class="loading"><i></i>Loading threads</div></div><div id="threadMessages"></div>`;
const list = document.querySelector('#threadsList');
const messagesBox = document.querySelector('#threadMessages');
async function openThread(threadId, name) {
messagesBox.innerHTML = `<div class="card"><h2>${esc(name)}</h2><div class="loading"><i></i>Loading messages</div></div>`;
try {
const messages = await get(`${API}/admin/accounts/${accountId}/chat/threads/${threadId}/messages`);
messagesBox.innerHTML = `<div class="card"><h2>${esc(name)}</h2>${messages.length ? messages.map(m => `<div class="room-row"><div class="meta"><strong>${esc(m.senderDisplayName)}</strong><br>${esc(m.body)}<br><small class="field-hint">${new Date(m.createdAt).toLocaleString()}</small></div></div>`).join('') : '<p class="field-hint">No messages.</p>'}</div>`;
} catch (error) {
messagesBox.innerHTML = `<div class="card"><p class="form-status error">${esc(error.message)}</p></div>`;
}
}
try {
const threads = await get(`${API}/admin/accounts/${accountId}/chat/threads`);
if (!threads.length) { list.innerHTML = '<p class="field-hint">No message threads.</p>'; return; }
list.innerHTML = threads.map(t => {
const name = t.name || t.members.map(m => m.displayName).join(', ') || `Thread ${t.threadId}`;
return `<div class="room-row clickable" data-id="${t.threadId}" data-name="${esc(name)}"><div class="meta"><strong>${esc(name)}</strong><br><small class="field-hint">${t.lastMessage ? esc(t.lastMessage.body) : 'No messages yet'}</small></div></div>`;
}).join('');
list.querySelectorAll('.room-row').forEach(row => row.addEventListener('click', () => openThread(row.dataset.id, row.dataset.name)));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
} else if (tab === 'inventory') {
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Inventory</h2><div class="card"><input id="inventorySearch" placeholder="Search avatar items, equipment, consumables"><button class="btn" id="searchInventory">Search</button></div><div id="inventoryResults"><div class="loading"><i></i>Loading inventory</div></div>`;
const searchInput = document.querySelector('#inventorySearch');
const results = document.querySelector('#inventoryResults');
async function loadInventory() {
const search = searchInput.value.trim();
results.innerHTML = '<div class="loading"><i></i>Loading inventory</div>';
try {
const data = await get(`${API}/admin/accounts/${accountId}/inventory?search=${encodeURIComponent(search)}`);
const sections = [];
if (data.avatarItems && data.avatarItems.length) {
sections.push(`<h3>Avatar Items</h3><div class="inventory-grid">${data.avatarItems.map(item => `
<div class="inventory-item" data-type="avatar" data-id="${item.avatarItemId}" data-desc="${esc(item.avatarItemDesc)}">
<strong>${esc(item.friendlyName)}</strong><br>
<small>ID: ${item.avatarItemId}</small><br>
<label><input type="checkbox" ${item.owned ? 'checked' : ''} class="inventory-check"> Owned</label>
</div>
`).join('')}</div>`);
}
if (data.consumables && data.consumables.length) {
sections.push(`<h3>Consumables</h3><div class="inventory-grid">${data.consumables.map(item => `
<div class="inventory-item" data-type="consumable" data-id="${item.consumableItemId}" data-desc="${esc(item.consumableItemDesc)}">
<strong>${esc(item.friendlyName)}</strong><br>
<small>ID: ${item.consumableItemId}</small><br>
<label>Quantity: <input type="number" class="inventory-quantity" value="${item.quantity}" min="0" max="100000" style="width:60px"></label>
</div>
`).join('')}</div>`);
}
if (!sections.length) { results.innerHTML = '<p class="field-hint">No items found.</p>'; return; }
results.innerHTML = sections.join('<br><br>');
results.querySelectorAll('.inventory-check').forEach(chk => chk.addEventListener('change', async () => {
const item = chk.closest('.inventory-item');
try {
await send(`${API}/admin/accounts/${accountId}/inventory/avatar`, 'POST', {
avatarItemId: Number(item.dataset.id),
avatarItemDesc: item.dataset.desc,
owned: chk.checked
});
} catch (error) { alert(error.message); chk.checked = !chk.checked; }
}));
results.querySelectorAll('.inventory-quantity').forEach(input => input.addEventListener('change', async () => {
const item = input.closest('.inventory-item');
const qty = Number(input.value);
if (qty < 0 || qty > 100000) { alert('Quantity must be 0-100000'); input.value = item.dataset.prevQty || 0; return; }
try {
await send(`${API}/admin/accounts/${accountId}/inventory/consumable`, 'POST', {
consumableItemId: Number(item.dataset.id),
consumableItemDesc: item.dataset.desc,
quantity: qty
});
item.dataset.prevQty = qty;
} catch (error) { alert(error.message); input.value = item.dataset.prevQty || 0; }
}));
} catch (error) {
results.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const searchInvBtn = document.querySelector('#searchInventory');
if (searchInvBtn) searchInvBtn.addEventListener('click', loadInventory);
if (searchInput) searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') loadInventory(); });
await loadInventory();
} else if (tab === 'reputation') {
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Reputation / Cheer Badges</h2><div id="reputationContent"><div class="loading"><i></i>Loading reputation</div></div>`;
const content = document.querySelector('#reputationContent');
async function loadReputation() {
content.innerHTML = '<div class="loading"><i></i>Loading reputation</div>';
try {
const data = await get(`${API}/admin/accounts/${accountId}/reputation`);
content.innerHTML = `
<div class="card">
<label><input type="checkbox" id="isCheerful" ${data.isCheerful ? 'checked' : ''}> Is Cheerful</label><br><br>
<label>Selected Badge<select id="selectedCheer">
${data.availableBadges.map(b => `<option value="${b}" ${data.selectedCheer === b ? 'selected' : ''}>${b}</option>`).join('')}
</select></label><br><br>
<h3>Cheer Counts</h3>
<label>General: <input type="number" id="cheerGeneral" value="${data.cheerGeneral}" min="0"></label><br>
<label>Helpful: <input type="number" id="cheerHelpful" value="${data.cheerHelpful}" min="0"></label><br>
<label>Sportsmanship: <input type="number" id="cheerSportsman" value="${data.cheerSportsman}" min="0"></label><br>
<label>Great Host: <input type="number" id="cheerGreatHost" value="${data.cheerGreatHost}" min="0"></label><br>
<label>Creative: <input type="number" id="cheerCreative" value="${data.cheerCreative}" min="0"></label><br><br>
<button class="btn" id="saveReputation">Save Reputation</button>
<span class="form-status" id="reputationStatus"></span>
</div>`;
const saveRepBtn = document.querySelector('#saveReputation');
if (saveRepBtn) saveRepBtn.addEventListener('click', async () => {
try {
const status = document.querySelector('#reputationStatus');
const isCheerful = document.querySelector('#isCheerful');
const selectedCheer = document.querySelector('#selectedCheer');
const cheerGeneral = document.querySelector('#cheerGeneral');
const cheerHelpful = document.querySelector('#cheerHelpful');
const cheerSportsman = document.querySelector('#cheerSportsman');
const cheerGreatHost = document.querySelector('#cheerGreatHost');
const cheerCreative = document.querySelector('#cheerCreative');
if (!status || !isCheerful || !selectedCheer || !cheerGeneral || !cheerHelpful || !cheerSportsman || !cheerGreatHost || !cheerCreative) return;
status.textContent = 'Saving...';
status.classList.remove('error');
await send(`${API}/admin/accounts/${accountId}/reputation`, 'PUT', {
isCheerful: isCheerful.checked,
selectedCheer: selectedCheer.value,
cheerGeneral: Number(cheerGeneral.value),
cheerHelpful: Number(cheerHelpful.value),
cheerSportsman: Number(cheerSportsman.value),
cheerGreatHost: Number(cheerGreatHost.value),
cheerCreative: Number(cheerCreative.value)
});
status.textContent = 'Saved.';
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
} catch (error) {
content.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
await loadReputation();
} else if (tab === 'photos-mine' || tab === 'photos-of-me') {
const type = tab === 'photos-mine' ? 'mine' : 'of-me';
body.innerHTML = `<h2 class="page-title" style="font-size:16px">${tab === 'photos-mine' ? 'My Photos' : 'Photos of Me'}</h2><div id="photosContent"><div class="loading"><i></i>Loading photos</div></div>`;
const content = document.querySelector('#photosContent');
async function loadPhotos() {
content.innerHTML = '<div class="loading"><i></i>Loading photos</div>';
try {
const data = await get(`${API}/admin/accounts/${accountId}/photos?type=${type}`);
if (!data.photos.length) { content.innerHTML = '<p class="field-hint">No photos found.</p>'; return; }
content.innerHTML = `<p class="field-hint">${data.photoCount} photo(s)</p><div class="inventory-grid">${data.photos.map(p => `
<div class="inventory-item">
<img src="${esc(p.url)}" style="width:100%;height:150px;object-fit:cover;border-radius:4px;margin-bottom:5px" alt="">
<small>${esc(p.path)}</small><br>
<small>${new Date(p.takenAt).toLocaleString()}</small>
</div>
`).join('')}</div>`;
} catch (error) {
content.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
await loadPhotos();
} else if (tab === 'gifts') {
const isDev = currentUser?.isDeveloper === true;
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Pending Gifts</h2>${isDev ? '<button class="btn danger small" id="clearAllGifts">Clear All</button><span class="form-status" id="giftsStatus"></span>' : ''}<div id="giftsList" style="margin-top:10px"><div class="loading"><i></i>Loading pending gifts</div></div>`;
const list = document.querySelector('#giftsList');
async function loadGifts() {
list.innerHTML = '<div class="loading"><i></i>Loading pending gifts</div>';
try {
const gifts = await get(`${API}/admin/accounts/${accountId}/gifts`);
list.innerHTML = gifts.length ? gifts.map(g => `<div class="room-row" data-id="${g.giftPackageId}">
${g.thumbnailImage ? `<img src="${esc(g.thumbnailImage)}" alt="">` : ''}
<div class="meta">
<strong>${esc(g.friendlyName || (g.currency ? `${g.currency} tokens` : g.xp ? `${g.xp} XP` : 'Gift'))}</strong>
<br><small>From ${esc(g.fromDisplayName)} &middot; GiftContext ${g.giftContext}${g.consumableQuantity > 1 ? ` &middot; x${g.consumableQuantity}` : ''}</small>
${g.message ? `<br><small class="field-hint">"${esc(g.message)}"</small>` : ''}
</div>
${isDev ? `<button class="btn small danger clear-gift">Clear</button>` : ''}
</div>`).join('') : '<p class="field-hint">No pending gifts.</p>';
list.querySelectorAll('.clear-gift').forEach(btn => btn.addEventListener('click', async () => {
const row = btn.closest('[data-id]');
if (!confirm('Clear this pending gift? It cannot be recovered.')) return;
try {
await send(`${API}/admin/accounts/${accountId}/gifts/${row.dataset.id}`, 'DELETE');
await loadGifts();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const clearAllBtn = document.querySelector('#clearAllGifts');
if (clearAllBtn) clearAllBtn.addEventListener('click', async () => {
const status = document.querySelector('#giftsStatus');
if (!confirm('Clear every pending gift for this player? This cannot be recovered.')) return;
try {
const r = await send(`${API}/admin/accounts/${accountId}/gifts`, 'DELETE');
status.textContent = `Cleared ${r.removedBoxes} gift(s).`;
status.classList.remove('error');
await loadGifts();
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
await loadGifts();
} else if (tab === 'troll') {
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Troll</h2><div class="loading"><i></i>Loading</div>`;
let username = accountId;
try { username = (await get(`${API}/admin/accounts/${accountId}`)).username || accountId; } catch (error) { }
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Troll</h2>
<div class="troll-banner">
<img src="/recnet/troll-logo.png" alt="Grim Labubu">
<div><strong>Troll</strong><span>Developer-only. These are joke/punitive tools - use with care.</span></div>
</div>
<div class="profile-actions">
<button class="btn danger" data-act="fake-box">GRIM LABUBU ${esc(username)}</button>
<button class="btn danger" data-act="fake-box-no-ban">Send Fake Box</button>
<button class="btn danger" data-act="kick-to-room">Kick to Room</button>
<button class="btn danger" data-act="force-into">Force Into My Instance</button>
</div>`;
const fakeBoxBtn = document.querySelector('[data-act="fake-box"]');
if (fakeBoxBtn) fakeBoxBtn.addEventListener('click', async () => {
if (!confirm('Send fake level-up box and ban this account? This is a developer-only troll feature.')) return;
const tokenAmount = Number(prompt('Fake token amount (for display only):', '100000') || 100000);
try {
const r = await send(`${API}/admin/accounts/${accountId}/troll/fakebox-and-ban`, 'POST', { tokenAmount, banImmediately: true });
alert(`Fake box sent with ${r.tokenAmount} tokens. Account banned.`);
await playerDetail(accountId);
} catch (error) { alert(error.message); }
});
const fakeBoxNoBanBtn = document.querySelector('[data-act="fake-box-no-ban"]');
if (fakeBoxNoBanBtn) fakeBoxNoBanBtn.addEventListener('click', async () => {
if (!confirm('Send a fake level-up box without banning this account? This is a developer-only troll feature.')) return;
const tokenAmount = Number(prompt('Fake token amount (for display only):', '100000') || 100000);
try {
const r = await send(`${API}/admin/accounts/${accountId}/troll/fakebox-and-ban`, 'POST', { tokenAmount, banImmediately: false });
alert(`Fake box sent with ${r.tokenAmount} tokens. Account not banned.`);
} catch (error) { alert(error.message); }
});
const kickToRoomBtn = document.querySelector('[data-act="kick-to-room"]');
if (kickToRoomBtn) kickToRoomBtn.addEventListener('click', async () => {
const roomId = prompt('Room ID (leave empty to kick to your current instance):');
if (roomId === null) return;
try {
const r = await send(`${API}/admin/accounts/${accountId}/troll/kick-to-room`, 'POST', { roomId: roomId ? Number(roomId) : null });
alert(`Kicked to room: ${r.roomName || 'your instance'}`);
} catch (error) { alert(error.message); }
});
const forceIntoBtn = document.querySelector('[data-act="force-into"]');
if (forceIntoBtn) forceIntoBtn.addEventListener('click', async () => {
if (!currentUser?.isDeveloper) return;
if (!confirm('Force this player into your current room instance?')) return;
try {
const r = await send(`${API}/admin/accounts/${accountId}/force-into/${currentUser.accountId}`, 'POST', {});
alert(r.delivered ? `Forced into "${r.roomName}" live.` : `Record updated to "${r.roomName}" - will apply on next connection.`);
} catch (error) { alert(error.message); }
});
} else {
body.innerHTML = `<div class="placeholder-card"><i class="fa-solid fa-hammer"></i><p>This tab isn't wired up yet.</p></div>`;
}
}
async function ageVerificationView() {
app.innerHTML = `<h1 class="page-title">Age Verification</h1><p class="field-hint">Players submit a photo (ID or face) against a code from the website; the photo goes to the staff Discord for manual review. Cross-check the code below against the Discord post before approving.</p><div id="ageVerificationList"><div class="loading"><i></i>Loading queue</div></div>`;
const list = document.querySelector('#ageVerificationList');
async function load() {
list.innerHTML = '<div class="loading"><i></i>Loading queue</div>';
try {
const queue = await get(`${API}/admin/age-verification`);
if (!queue.length) { list.innerHTML = '<p class="field-hint">Nothing awaiting review.</p>'; return; }
list.innerHTML = queue.map(item => `<div class="room-row" data-code="${esc(item.code)}">
<div class="meta">
<strong>${esc(item.displayName)}</strong> <span class="field-hint">@${esc(item.username || '')} &middot; ID ${item.accountId}</span>
<br><small>Code <code>${esc(item.code)}</code> &middot; ${esc(item.method === 'ManualId' ? 'Manual ID Verification' : 'Face Verification (Manually)')}${item.submittedAt ? ` &middot; ${new Date(item.submittedAt).toLocaleString()}` : ''}</small>
</div>
<button class="btn small ghost" data-act="approve">Approve</button>
<button class="btn small danger" data-act="reject">Reject</button>
</div>`).join('');
list.querySelectorAll('[data-act="approve"]').forEach(btn => btn.addEventListener('click', async () => {
const code = btn.closest('[data-code]').dataset.code;
if (!confirm(`Approve age verification for code ${code}? This clears their Junior status.`)) return;
try { await send(`${API}/admin/age-verification/${code}/approve`, 'POST', {}); await load(); }
catch (error) { alert(error.message); }
}));
list.querySelectorAll('[data-act="reject"]').forEach(btn => btn.addEventListener('click', async () => {
const code = btn.closest('[data-code]').dataset.code;
const reason = prompt('Reason for rejection (optional):') || null;
try { await send(`${API}/admin/age-verification/${code}/reject`, 'POST', { reason }); await load(); }
catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
await load();
}
async function liveInstances() {
app.innerHTML = `
<h1 class="page-title">Live Room Instances</h1>
<div class="card">
<div id="instancesList"><div class="loading"><i></i>Loading live instances</div></div>
</div>`;
const list = document.querySelector('#instancesList');
async function loadInstances() {
list.innerHTML = '<div class="loading"><i></i>Loading live instances</div>';
try {
const data = await get(`${API}/admin/instances`);
if (!data.instances.length) { list.innerHTML = '<p class="field-hint">No players currently online.</p>'; return; }
list.innerHTML = `<p class="field-hint">${data.totalOnline} players online across ${data.instances.length} instances.</p>` + data.instances.map(instance => `
<div class="card" style="margin-top:10px">
<div style="display:flex;justify-content:space-between;align-items:center">
<h3>${esc(instance.room)} ${instance.roomId ? `(Room #${instance.roomId})` : ''}</h3>
<button class="btn small danger shutdown-instance" data-instance-id="${instance.roomInstanceId}">Shutdown (Send to Dorm)</button>
</div>
<p class="field-hint">${instance.playerCount} player(s) · Instance ID: ${instance.roomInstanceId || 'N/A'}</p>
<div style="margin-top:10px">
${instance.players.map(p => `
<div class="room-row">
<img src="${esc(p.profileImage || '')}" class="avatar-img" style="width:32px;height:32px" alt="">
<div class="meta">
<strong>${esc(p.displayName || p.username)}</strong><br>
<small>@${esc(p.username)} · ${p.device} · ${p.sockets} socket(s)</small>
</div>
<a class="btn small ghost" href="#players/${p.accountId}">View</a>
</div>
`).join('')}
</div>
</div>
`).join('');
list.querySelectorAll('.shutdown-instance').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm('Shutdown this instance? All players will be sent to their dorm.')) return;
try {
await send(`${API}/admin/instances/${btn.dataset.instanceId}/shutdown`, 'POST', {});
await loadInstances();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
await loadInstances();
}
async function roomsList() {
app.innerHTML = `<h1 class="page-title">Rooms</h1>
<div class="search-row">
<input id="roomSearch" placeholder="Search by name, ID, description, or creator ID">
<label style="display:flex;align-items:center;gap:6px;white-space:nowrap"><input type="checkbox" id="roomIncludeDorms" style="width:auto"> Include dorms</label>
<button class="btn" id="roomSearchGo">Search</button>
</div>
<div id="roomListResults" class="player-list"></div>
<div class="search-row" id="roomListPager" style="margin-top:10px;display:none">
<button class="btn ghost small" id="roomListPrev">&larr; Prev</button>
<span class="field-hint" id="roomListPageInfo" style="align-self:center"></span>
<button class="btn ghost small" id="roomListNext">Next &rarr;</button>
</div>`;
const input = document.querySelector('#roomSearch'), results = document.querySelector('#roomListResults');
const includeDorms = document.querySelector('#roomIncludeDorms');
const pager = document.querySelector('#roomListPager');
let roomListSkip = 0;
const roomListTake = 20;
async function runSearch() {
results.innerHTML = '<div class="loading"><i></i>Loading rooms</div>';
try {
const data = await get(`${API}/admin/rooms?search=${encodeURIComponent(input.value.trim())}&includeDorms=${includeDorms.checked}&skip=${roomListSkip}&take=${roomListTake}`);
const rooms = data.results ?? data;
const total = data.total ?? rooms.length;
results.innerHTML = rooms.length ? rooms.map(r => `<a class="player-row" href="#rooms/${r.roomId}"><img src="${esc(r.image || '')}" alt=""><div class="meta"><div class="name">${esc(r.name || 'Untitled room')}</div><div class="sub">ID ${r.roomId} &middot; by ${esc(r.creatorName || r.creatorAccountId)}${r.onlinePlayers ? ` &middot; ${r.onlinePlayers} online` : ''}</div></div></a>`).join('') : '<p class="field-hint">No rooms found.</p>';
pager.style.display = total > roomListTake ? 'flex' : 'none';
document.querySelector('#roomListPageInfo').textContent = total ? `${roomListSkip + 1}-${Math.min(roomListSkip + roomListTake, total)} of ${total}` : '';
document.querySelector('#roomListPrev').disabled = roomListSkip <= 0;
document.querySelector('#roomListNext').disabled = roomListSkip + roomListTake >= total;
} catch (error) {
results.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const roomSearchGo = document.querySelector('#roomSearchGo');
if (roomSearchGo) roomSearchGo.addEventListener('click', () => { roomListSkip = 0; runSearch(); });
if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') { roomListSkip = 0; runSearch(); } });
if (includeDorms) includeDorms.addEventListener('change', () => { roomListSkip = 0; runSearch(); });
document.querySelector('#roomListPrev').addEventListener('click', () => { roomListSkip = Math.max(0, roomListSkip - roomListTake); runSearch(); });
document.querySelector('#roomListNext').addEventListener('click', () => { roomListSkip += roomListTake; runSearch(); });
await runSearch();
}
async function roomDetail(roomIdRaw) {
const roomId = Number(roomIdRaw);
app.innerHTML = '<div class="loading"><i></i>Loading room</div>';
let room;
try { room = await get(`${API}/admin/rooms/${roomId}`); }
catch (error) { app.innerHTML = `<a class="back-link" href="#rooms">&larr; Back to Rooms</a><div class="card"><p class="form-status error">${esc(error.message)}</p></div>`; return; }
app.innerHTML = `
<a class="back-link" href="#rooms">&larr; Back to Rooms</a>
<div class="profile-header">
<img src="${esc(room.summary?.image || '')}" alt="">
<div>
<div class="name">${esc(room.name || 'Untitled room')}</div>
<div class="stat-line">${esc(room.description || '(no description)')}</div>
<div class="stat-line">ID: ${room.roomId} &middot; Creator: ${esc(room.creator?.displayName || room.creator?.username || room.creatorAccountId)}</div>
<div class="profile-actions">
<a class="btn ghost" href="/recnet/#room/${roomId}" target="_blank" rel="noopener">Public page</a>
<a class="btn ghost" href="${API}/admin/rooms/${roomId}/export" target="_blank" rel="noopener">Download backup</a>
<a class="btn ghost" href="/recnet/#admin" target="_blank" rel="noopener">Full editor (classic panel)</a>
</div>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="details">Details</div>
<div class="tab" data-tab="subrooms">Subrooms</div>
<div class="tab" data-tab="roles">Roles</div>
<div class="tab" data-tab="bans">Bans</div>
</div>
<div id="tabBody"></div>`;
document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === tab));
renderRoomTab(tab.dataset.tab, roomId);
}));
renderRoomTab('details', roomId);
}
const ROOM_FLAG_LABELS = {
cloningAllowed: 'Cloning allowed', disableMicAutoMute: 'Disable mic auto-mute',
disableRoomComments: 'Disable room comments', encryptVoiceChat: 'Encrypt voice chat',
toxmodEnabled: 'Toxmod enabled', loadScreenLocked: 'Lock loading screen',
autoLocalizeRoom: 'Auto-localize room', isDeveloperOwned: 'Developer owned',
supportsLevelVoting: 'Supports level voting', isRRO: 'Rec Room Original',
supportsScreens: 'Supports screens', supportsWalkVR: 'Walk VR',
supportsTeleportVR: 'Teleport VR', supportsVRLow: 'Low-end VR',
supportsQuest2: 'Quest 2', supportsMobile: 'Mobile', supportsJuniors: 'Junior accounts'
};
const ROOM_ASSIGNED_ROLES = ['None', 'Host', 'Moderator', 'CoOwner', 'TemporaryCoOwner', 'Banned'];
const ROOM_INVITED_ROLES = ['None', 'Host', 'Moderator', 'CoOwner', 'TemporaryCoOwner'];
async function renderRoomTab(tab, roomId) {
const body = document.querySelector('#tabBody');
if (tab === 'details') {
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Room Details</h2>
<div class="card" id="roomSummaryCard"><h2>Summary</h2><p class="field-hint">Loading...</p></div>
<div class="card dev-block"><h2>Edit Room</h2>
<p class="field-hint">Developer-only.</p>
<div class="field-row">
<label class="field">Name<input id="roomName"></label>
<label class="field">Image path<input id="roomImage"></label>
</div>
<div class="field-row">
<label class="field">Accessibility<select id="roomAccessibility"><option>Private</option><option>Public</option><option>Unlisted</option></select></label>
<label class="field">State<select id="roomState"><option>Active</option><option>PendingJunior</option><option>Moderation_PendingReview</option><option>Moderation_Closed</option><option>MarkedForDelete</option></select></label>
</div>
<div class="field-row">
<label class="field">Max players<input type="number" id="roomMaxPlayers" min="1" max="100"></label>
<label class="field">Minimum level<input type="number" id="roomMinLevel" min="0" max="50"></label>
</div>
<label class="field">Tags (comma-separated)<input id="roomTags"></label>
<label class="field">Description<textarea id="roomDesc" rows="3"></textarea></label>
<h3>Compatibility &amp; behavior flags</h3>
<div class="flag-grid" id="roomFlagGrid">${Object.entries(ROOM_FLAG_LABELS).map(([key, label]) => `<label><input type="checkbox" data-flag="${key}"> ${esc(label)}</label>`).join('')}</div>
<button class="btn" id="saveRoom">Save Changes</button><span class="form-status" id="roomStatus"></span>
</div>
<div class="card dev-block"><h2>Room stats</h2>
<div class="field-row">
<label class="field">Cheers<input type="number" id="statCheers" min="0"></label>
<label class="field">Favorites<input type="number" id="statFavorites" min="0"></label>
<label class="field">Visitors<input type="number" id="statVisitors" min="0"></label>
<label class="field">Visits<input type="number" id="statVisits" min="0"></label>
</div>
<button class="btn" id="saveStats">Save stats</button><span class="form-status" id="statStatus"></span>
</div>
<div class="card dev-block"><h2>Transfer ownership</h2>
<p class="field-hint">Developer-only. The current owner becomes CoOwner.</p>
<div class="field-row"><label class="field">New owner account ID<input type="number" id="newOwnerId" min="1"></label></div>
<button class="btn danger" id="transferOwner">Transfer owner</button><span class="form-status" id="transferStatus"></span>
</div>
<div class="card dev-block"><h2>Change room ID</h2>
<p class="field-hint">Developer-only. Renumbers this room's ID and migrates its subrooms, saves, bans, and creator-feature data (cloud variables, inventions, room player data). Anything that only softly references the old ID (players' visited/favorited room lists) is left as-is - it'll just stop resolving.</p>
<div class="field-row"><label class="field">New room ID<input type="number" id="newRoomId" min="1"></label></div>
<button class="btn danger" id="changeRoomId">Change ID</button><span class="form-status" id="changeRoomIdStatus"></span>
</div>`;
try {
const room = await get(`${API}/admin/rooms/${roomId}`);
document.querySelector('#roomName').value = room.name || '';
document.querySelector('#roomImage').value = room.imageName || '';
document.querySelector('#roomAccessibility').value = room.accessibility || 'Public';
document.querySelector('#roomState').value = room.state || 'Active';
document.querySelector('#roomMaxPlayers').value = room.maxPlayers || 8;
document.querySelector('#roomMinLevel').value = room.minLevel || 0;
document.querySelector('#roomTags').value = (room.tags || []).join(', ');
document.querySelector('#roomDesc').value = room.description || '';
document.querySelectorAll('#roomFlagGrid [data-flag]').forEach(input => {
input.checked = !!room.flags?.[input.dataset.flag];
});
document.querySelector('#statCheers').value = room.stats?.cheers ?? 0;
document.querySelector('#statFavorites').value = room.stats?.favorites ?? 0;
document.querySelector('#statVisitors').value = room.stats?.visitors ?? 0;
document.querySelector('#statVisits').value = room.stats?.visits ?? 0;
document.querySelector('#roomSummaryCard').innerHTML = `<h2>Summary</h2><p class="field-hint">Accessibility: ${esc(room.accessibility)} &middot; State: ${esc(room.state)} &middot; Max players: ${room.maxPlayers} &middot; Min level: ${room.minLevel}</p><p class="field-hint">Tags: ${esc((room.tags || []).join(', ') || 'none')}</p>`;
} catch (error) {
body.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
return;
}
applyRoleGating();
const saveRoomBtn = document.querySelector('#saveRoom');
if (saveRoomBtn) saveRoomBtn.addEventListener('click', async () => {
const status = document.querySelector('#roomStatus');
const flags = {};
document.querySelectorAll('#roomFlagGrid [data-flag]').forEach(input => { flags[input.dataset.flag] = input.checked; });
try {
status.textContent = 'Saving...';
status.classList.remove('error');
await send(`${API}/admin/rooms/${roomId}`, 'PUT', {
name: document.querySelector('#roomName').value,
description: document.querySelector('#roomDesc').value,
imageName: document.querySelector('#roomImage').value,
accessibility: document.querySelector('#roomAccessibility').value,
state: document.querySelector('#roomState').value,
maxPlayers: parseInt(document.querySelector('#roomMaxPlayers').value) || 1,
minLevel: parseInt(document.querySelector('#roomMinLevel').value) || 0,
tags: document.querySelector('#roomTags').value.split(',').map(t => t.trim()).filter(Boolean),
...flags
});
status.textContent = 'Saved successfully.';
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
const saveStatsBtn = document.querySelector('#saveStats');
if (saveStatsBtn) saveStatsBtn.addEventListener('click', async () => {
const status = document.querySelector('#statStatus');
try {
await send(`${API}/admin/rooms/${roomId}/stats`, 'PUT', {
cheers: parseInt(document.querySelector('#statCheers').value) || 0,
favorites: parseInt(document.querySelector('#statFavorites').value) || 0,
visitors: parseInt(document.querySelector('#statVisitors').value) || 0,
visits: parseInt(document.querySelector('#statVisits').value) || 0
});
status.textContent = 'Saved.'; status.classList.remove('error');
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
const transferBtn = document.querySelector('#transferOwner');
if (transferBtn) transferBtn.addEventListener('click', async () => {
const status = document.querySelector('#transferStatus');
const accountId = Number(document.querySelector('#newOwnerId').value);
if (!accountId) return;
if (!confirm(`Transfer ownership of this room to account ${accountId}? The current owner becomes CoOwner.`)) return;
try {
await send(`${API}/admin/rooms/${roomId}/transfer-owner`, 'POST', { accountId });
status.textContent = 'Transferred.'; status.classList.remove('error');
await roomDetail(roomId);
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
const changeIdBtn = document.querySelector('#changeRoomId');
if (changeIdBtn) changeIdBtn.addEventListener('click', async () => {
const status = document.querySelector('#changeRoomIdStatus');
const newRoomId = Number(document.querySelector('#newRoomId').value);
if (!newRoomId) return;
if (!confirm(`Change this room's ID from ${roomId} to ${newRoomId}? Anything holding the old ID directly (bookmarks, external links) will break.`)) return;
try {
await send(`${API}/admin/rooms/${roomId}/change-id`, 'POST', { newRoomId });
location.hash = `#rooms/${newRoomId}`;
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
} else if (tab === 'subrooms') {
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Subrooms</h2>
<div class="card dev-block"><h2>Create subroom</h2><div class="field-row"><label class="field">Name<input id="newSubroomName" maxlength="50"></label></div><button class="btn" id="createSubroom">Add subroom</button><span class="form-status" id="createSubroomStatus"></span></div>
<div id="subroomList"><div class="loading"><i></i>Loading subrooms</div></div>`;
async function loadSubrooms() {
const list = document.querySelector('#subroomList');
try {
const room = await get(`${API}/admin/rooms/${roomId}`);
if (!room.subRooms || !room.subRooms.length) { list.innerHTML = '<p class="field-hint">No subrooms.</p>'; return; }
list.innerHTML = room.subRooms.map(sr => `
<div class="card" data-id="${sr.subRoomId}">
<h2>${esc(sr.name || 'Untitled')} <span class="field-hint">#${sr.subRoomId} &middot; Save #${sr.currentSaveId || 0}${sr.hasData ? ' &middot; Has data' : ''} &middot; ${esc(sr.accessibility)}</span></h2>
<div class="dev-block">
<div class="field-row">
<label class="field">Name<input data-field="name" maxlength="50" value="${esc(sr.name || '')}"></label>
<label class="field">Max players<input data-field="maxPlayers" type="number" min="1" max="100" value="${sr.maxPlayers || 1}"></label>
</div>
<div class="field-row">
<label class="field">Accessibility<select data-field="accessibility"><option${sr.accessibility === 'Private' ? ' selected' : ''}>Private</option><option${sr.accessibility === 'Public' ? ' selected' : ''}>Public</option><option${sr.accessibility === 'Unlisted' ? ' selected' : ''}>Unlisted</option></select></label>
<label class="field" style="display:flex;align-items:center;gap:6px;flex-direction:row"><input type="checkbox" data-field="isSandbox" style="width:auto"${sr.isSandbox ? ' checked' : ''}> Sandbox subroom</label>
</div>
<label class="field">Unity scene ID<input data-field="unitySceneId" maxlength="200" value="${esc(sr.unitySceneId || '')}"></label>
<div class="actions-cell">
<button class="btn small save-subroom">Save</button>
<button class="btn small ghost clone-subroom">Clone</button>
<button class="btn small danger delete-subroom">Delete</button>
</div>
<h3>Persistence blob pair <span class="field-hint">(files must already exist in CDN/room)</span></h3>
<div class="field-row">
<label class="field">RoomBlob ${sr.roomBlobExists ? '<span style="color:var(--success)">&check; found</span>' : '<span style="color:var(--danger)">&times; missing</span>'}<input data-field="roomBlob" maxlength="255" value="${esc(sr.roomBlob || '')}" placeholder="RoomBlob filename"></label>
<label class="field">Metadata blob ${sr.metadataBlobExists ? '<span style="color:var(--success)">&check; found</span>' : '<span style="color:var(--danger)">&times; missing</span>'}<input data-field="metadataBlob" maxlength="255" value="${esc(sr.metadataBlob || '')}" placeholder="Metadata blob filename"></label>
</div>
<button class="btn small save-blobs">Save blob pair</button>
</div>
</div>
`).join('');
applyRoleGating();
list.querySelectorAll('.save-subroom').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('[data-id]'), id = card.dataset.id;
try {
await send(`${API}/admin/rooms/${roomId}/subrooms/${id}`, 'PUT', {
name: card.querySelector('[data-field="name"]').value,
maxPlayers: parseInt(card.querySelector('[data-field="maxPlayers"]').value) || 1,
accessibility: card.querySelector('[data-field="accessibility"]').value,
isSandbox: card.querySelector('[data-field="isSandbox"]').checked,
unitySceneId: card.querySelector('[data-field="unitySceneId"]').value
});
await loadSubrooms();
} catch (error) { alert(error.message); }
}));
list.querySelectorAll('.clone-subroom').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('[data-id]'), id = card.dataset.id;
try { await send(`${API}/admin/rooms/${roomId}/subrooms/${id}/clone`, 'POST'); await loadSubrooms(); }
catch (error) { alert(error.message); }
}));
list.querySelectorAll('.delete-subroom').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('[data-id]'), id = card.dataset.id;
if (!confirm('Delete this subroom? A room must keep at least one.')) return;
try {
await send(`${API}/admin/rooms/${roomId}/subrooms/${id}`, 'DELETE');
await loadSubrooms();
} catch (error) { alert(error.message); }
}));
list.querySelectorAll('.save-blobs').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('[data-id]'), id = card.dataset.id;
try {
await send(`${API}/admin/rooms/${roomId}/subrooms/${id}/blobs`, 'PUT', {
roomBlob: card.querySelector('[data-field="roomBlob"]').value,
metadataBlob: card.querySelector('[data-field="metadataBlob"]').value
});
await loadSubrooms();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
applyRoleGating();
const createSubroomBtn = document.querySelector('#createSubroom');
if (createSubroomBtn) createSubroomBtn.addEventListener('click', async () => {
const status = document.querySelector('#createSubroomStatus');
const nameInput = document.querySelector('#newSubroomName');
const name = nameInput.value.trim();
if (!name) return;
try {
await send(`${API}/admin/rooms/${roomId}/subrooms`, 'POST', { name });
nameInput.value = '';
status.textContent = ''; status.classList.remove('error');
await loadSubrooms();
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
await loadSubrooms();
} else if (tab === 'roles') {
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Room Roles</h2><div class="card dev-block"><h2>Add / Update Role</h2><p class="field-hint">Developer-only.</p><div class="field-row"><label class="field">Account ID<input id="roleAccountId" type="number" min="1"></label><label class="field">Assigned role<select id="roleType">${ROOM_ASSIGNED_ROLES.map(r => `<option>${r}</option>`).join('')}</select></label><label class="field">Invited role<select id="invitedRoleType">${ROOM_INVITED_ROLES.map(r => `<option>${r}</option>`).join('')}</select></label></div><button class="btn" id="addRole">Save Role</button></div><div class="card"><h2>Current Roles</h2><div id="roleList"><div class="loading"><i></i>Loading roles</div></div></div>`;
applyRoleGating();
async function loadRoles() {
const list = document.querySelector('#roleList');
try {
const room = await get(`${API}/admin/rooms/${roomId}`);
if (!room.roles || !room.roles.length) { list.innerHTML = '<p class="field-hint">No roles assigned.</p>'; return; }
list.innerHTML = room.roles.map(r => `
<div class="room-row">
<div class="meta"><strong>${esc(r.player?.displayName || r.player?.username || r.accountId)}</strong><br><small>${esc(r.role)}${r.invitedRole && r.invitedRole !== 'None' ? ` &middot; Invited: ${esc(r.invitedRole)}` : ''}</small></div>
<button class="btn small danger delete-role dev-only" data-id="${r.accountId}">Remove</button>
</div>
`).join('');
applyRoleGating();
list.querySelectorAll('.delete-role').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm('Remove this role?')) return;
try {
await send(`${API}/admin/rooms/${roomId}/roles/${btn.dataset.id}`, 'DELETE');
await loadRoles();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const addRoleBtn = document.querySelector('#addRole');
if (addRoleBtn) addRoleBtn.addEventListener('click', async () => {
const accountId = document.querySelector('#roleAccountId');
const roleType = document.querySelector('#roleType');
const invitedRoleType = document.querySelector('#invitedRoleType');
const accId = Number(accountId.value);
if (!accId) return;
try {
await send(`${API}/admin/rooms/${roomId}/roles/${accId}`, 'PUT', { role: roleType.value, invitedRole: invitedRoleType.value });
accountId.value = '';
await loadRoles();
} catch (error) { alert(error.message); }
});
await loadRoles();
} else if (tab === 'bans') {
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Room Bans</h2><div class="card"><h2>Add Ban</h2><input id="banAccountId" placeholder="Account ID"><input id="banReason" placeholder="Reason"><button class="btn" id="addBan">Ban Player</button></div><div class="card"><h2>Current Bans</h2><div id="banList"><div class="loading"><i></i>Loading bans</div></div></div>`;
async function loadBans() {
const list = document.querySelector('#banList');
try {
const room = await get(`${API}/admin/rooms/${roomId}`);
if (!room.bans || !room.bans.length) { list.innerHTML = '<p class="field-hint">No bans.</p>'; return; }
list.innerHTML = room.bans.map(b => `
<div class="room-row">
<div style="flex:1"><strong>${esc(b.displayName || b.username || b.accountId)}</strong><br><small>${esc(b.reason || 'No reason')}</small></div>
<button class="btn small danger delete-ban" data-id="${b.accountId}">Unban</button>
</div>
`).join('');
list.querySelectorAll('.delete-ban').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm('Unban this player?')) return;
try {
await send(`${API}/admin/rooms/${roomId}/bans/${btn.dataset.id}`, 'DELETE');
await loadBans();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const addBanBtn = document.querySelector('#addBan');
if (addBanBtn) addBanBtn.addEventListener('click', async () => {
const accountId = document.querySelector('#banAccountId');
const banReason = document.querySelector('#banReason');
if (!accountId || !banReason) return;
const accId = Number(accountId.value);
if (!accId) return;
try {
await send(`${API}/admin/rooms/${roomId}/bans`, 'POST', { accountId: accId, reason: banReason.value });
accountId.value = '';
banReason.value = '';
await loadBans();
} catch (error) { alert(error.message); }
});
await loadBans();
}
}
async function playerEventsView() {
app.innerHTML = `<h1 class="page-title">Player Events</h1>
<div class="card">
<h2 id="evFormTitle">New event</h2>
<input type="hidden" id="evEditId">
<label class="field">Title<input id="evTitle"></label>
<label class="field">Description<textarea id="evDesc" rows="3"></textarea></label>
<div class="field-row">
<label class="field">Starts at<input id="evStart" type="datetime-local"></label>
<label class="field">Ends at (optional)<input id="evEnd" type="datetime-local"></label>
</div>
<label class="field">Image<input type="file" id="evImageFile" accept="image/*"></label>
<input type="hidden" id="evImageName">
<div id="evImagePreview" class="field-hint"></div>
<label><input type="checkbox" id="evPinned"> Pinned</label><br><br>
<button class="btn" id="evCreate">Create event</button>
<button class="btn ghost" id="evCancelEdit" hidden>Cancel edit</button>
<span class="form-status" id="evStatus"></span>
</div><div id="evList"><div class="loading"><i></i>Loading events</div></div>`;
const evTitle = document.querySelector('#evTitle');
const evDesc = document.querySelector('#evDesc');
const evStart = document.querySelector('#evStart');
const evEnd = document.querySelector('#evEnd');
const evImageFile = document.querySelector('#evImageFile');
const evImageName = document.querySelector('#evImageName');
const evImagePreview = document.querySelector('#evImagePreview');
const evPinned = document.querySelector('#evPinned');
const evEditId = document.querySelector('#evEditId');
const evFormTitle = document.querySelector('#evFormTitle');
const evCreateBtn = document.querySelector('#evCreate');
const evCancelEditBtn = document.querySelector('#evCancelEdit');
evImageFile.addEventListener('change', async () => {
const status = document.querySelector('#evStatus');
const file = evImageFile.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const r = await fetch(`${API}/admin/events/image`, { method: 'POST', body: formData, credentials: 'same-origin' });
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Upload failed');
evImageName.value = data.path;
evImagePreview.innerHTML = `<img src="${esc(data.url)}" style="max-width:160px;border-radius:8px;margin-top:6px">`;
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
function resetForm() {
evEditId.value = '';
evFormTitle.textContent = 'New event';
evCreateBtn.textContent = 'Create event';
evCancelEditBtn.hidden = true;
evTitle.value = ''; evDesc.value = ''; evStart.value = ''; evEnd.value = '';
evImageFile.value = ''; evImageName.value = ''; evImagePreview.innerHTML = '';
evPinned.checked = false;
}
function toLocalInputValue(iso) {
if (!iso) return '';
const d = new Date(iso);
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
}
function editEvent(e) {
evEditId.value = e.eventId ?? e.id;
evFormTitle.textContent = `Editing "${e.title}"`;
evCreateBtn.textContent = 'Save changes';
evCancelEditBtn.hidden = false;
evTitle.value = e.title;
evDesc.value = e.description || '';
evStart.value = toLocalInputValue(e.startsAt);
evEnd.value = toLocalInputValue(e.endsAt);
evImageName.value = e.image ? decodeURIComponent(e.image.replace('/imageserver/', '')) : '';
evImagePreview.innerHTML = e.image ? `<img src="${esc(e.image)}" style="max-width:160px;border-radius:8px;margin-top:6px">` : '';
evPinned.checked = !!e.pinned;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function load() {
const list = document.querySelector('#evList');
try {
const items = await get(`${API}/admin/events`);
if (!items.length) { list.innerHTML = '<p class="field-hint">No events yet.</p>'; return; }
list.innerHTML = items.map(e => `<div class="card" data-id="${e.eventId ?? e.id}">${e.image ? `<img src="${esc(e.image)}" style="max-width:200px;border-radius:8px;margin-bottom:8px">` : ''}<h2>${esc(e.title)} ${e.pinned ? '&#128204;' : ''}</h2><p>${esc(e.description)}</p><p class="field-hint">${e.startsAt ? new Date(e.startsAt).toLocaleString() : ''}${e.endsAt ? ' - ' + new Date(e.endsAt).toLocaleString() : ''}</p><div class="actions-cell"><button class="btn small ghost edit-ev">Edit</button><button class="btn small danger delete-ev">Delete</button></div></div>`).join('');
list.querySelectorAll('.edit-ev').forEach((btn, i) => btn.addEventListener('click', () => editEvent(items[i])));
list.querySelectorAll('.delete-ev').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('[data-id]'), id = card.dataset.id;
if (!confirm('Delete this event?')) return;
try { await send(`${API}/admin/events/${id}`, 'DELETE'); card.remove(); }
catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
if (evCreateBtn) evCreateBtn.addEventListener('click', async () => {
const status = document.querySelector('#evStatus');
const title = evTitle.value.trim();
const description = evDesc.value.trim();
const startsAt = evStart.value;
const endsAt = evEnd.value;
if (!title || !startsAt) { status.textContent = 'Title and start time are required.'; status.classList.add('error'); return; }
const payload = {
title, description,
imageName: evImageName.value || null,
startsAt: new Date(startsAt).toISOString(),
endsAt: endsAt ? new Date(endsAt).toISOString() : null,
pinned: evPinned.checked
};
try {
if (evEditId.value) {
await send(`${API}/admin/events/${evEditId.value}`, 'PUT', payload);
status.textContent = 'Saved.';
} else {
await send(`${API}/admin/events`, 'POST', payload);
status.textContent = 'Created.';
}
status.classList.remove('error');
resetForm();
await load();
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
if (evCancelEditBtn) evCancelEditBtn.addEventListener('click', resetForm);
await load();
}
function simpleMarkdownPreview(markdown) {
return esc(markdown)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\n/g, '<br>');
}
const ANNOUNCEMENT_KINDS = ['info', 'update', 'warning', 'maintenance'];
async function announcementsView() {
app.innerHTML = `<h1 class="page-title">Announcements</h1>
<div class="card">
<h2 id="annFormTitle">New announcement</h2>
<input type="hidden" id="annEditId">
<label class="field">Title<input id="annTitle"></label>
<div class="field-row">
<label class="field">Kind<select id="annKind">${ANNOUNCEMENT_KINDS.map(k => `<option>${k}</option>`).join('')}</select></label>
</div>
<label class="field">Body (markdown)<textarea id="annBody" rows="4"></textarea></label>
<div class="card" style="background:var(--panel-alt)"><h3>Preview</h3><div id="annPreview" class="field-hint"></div></div>
<label><input type="checkbox" id="annPinned"> Pinned</label> <label><input type="checkbox" id="annPublished" checked> Published</label><br><br>
<button class="btn" id="annCreate">Post announcement</button>
<button class="btn ghost" id="annCancelEdit" hidden>Cancel edit</button>
<span class="form-status" id="annStatus"></span>
</div><div id="annList"><div class="loading"><i></i>Loading announcements</div></div>`;
const annTitle = document.querySelector('#annTitle');
const annKind = document.querySelector('#annKind');
const annBody = document.querySelector('#annBody');
const annPreview = document.querySelector('#annPreview');
const annPinned = document.querySelector('#annPinned');
const annPublished = document.querySelector('#annPublished');
const annEditId = document.querySelector('#annEditId');
const annFormTitle = document.querySelector('#annFormTitle');
const annCreateBtn = document.querySelector('#annCreate');
const annCancelEditBtn = document.querySelector('#annCancelEdit');
annBody.addEventListener('input', () => { annPreview.innerHTML = simpleMarkdownPreview(annBody.value) || '<em>Nothing to preview yet.</em>'; });
function resetForm() {
annEditId.value = '';
annFormTitle.textContent = 'New announcement';
annCreateBtn.textContent = 'Post announcement';
annCancelEditBtn.hidden = true;
annTitle.value = '';
annKind.value = 'info';
annBody.value = '';
annPreview.innerHTML = '';
annPinned.checked = false;
annPublished.checked = true;
}
function editAnnouncement(a) {
annEditId.value = a.id;
annFormTitle.textContent = `Editing "${a.title}"`;
annCreateBtn.textContent = 'Save changes';
annCancelEditBtn.hidden = false;
annTitle.value = a.title;
annKind.value = a.kind;
annBody.value = a.bodyMarkdown;
annPreview.innerHTML = simpleMarkdownPreview(a.bodyMarkdown);
annPinned.checked = a.pinned;
annPublished.checked = a.published;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function load() {
const list = document.querySelector('#annList');
try {
const items = await get(`${API}/admin/announcements`);
if (!items.length) { list.innerHTML = '<p class="field-hint">No announcements yet.</p>'; return; }
list.innerHTML = items.map(a => `<div class="card" data-id="${a.id}"><h2>${esc(a.title)} <span class="badge">${esc(a.kind)}</span> ${a.pinned ? '&#128204;' : ''} ${a.published ? '' : '<span class="badge">Draft</span>'}</h2><p>${esc(a.bodyMarkdown)}</p><div class="actions-cell"><button class="btn small ghost edit-ann">Edit</button><button class="btn small danger delete-ann">Delete</button></div></div>`).join('');
list.querySelectorAll('.edit-ann').forEach((btn, i) => btn.addEventListener('click', () => editAnnouncement(items[i])));
list.querySelectorAll('.delete-ann').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('[data-id]'), id = card.dataset.id;
if (!confirm('Delete this announcement?')) return;
try { await send(`${API}/admin/announcements/${id}`, 'DELETE'); card.remove(); }
catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
if (annCreateBtn) annCreateBtn.addEventListener('click', async () => {
const status = document.querySelector('#annStatus');
const title = annTitle.value.trim();
const bodyMarkdown = annBody.value.trim();
if (!title || !bodyMarkdown) { status.textContent = 'Title and body are required.'; status.classList.add('error'); return; }
const payload = { title, bodyMarkdown, kind: annKind.value, pinned: annPinned.checked, published: annPublished.checked };
try {
if (annEditId.value) {
await send(`${API}/admin/announcements/${annEditId.value}`, 'PUT', payload);
status.textContent = 'Saved.';
} else {
await send(`${API}/admin/announcements`, 'POST', payload);
status.textContent = 'Posted.';
}
status.classList.remove('error');
resetForm();
await load();
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
if (annCancelEditBtn) annCancelEditBtn.addEventListener('click', resetForm);
await load();
}
async function communityBoardView() {
app.innerHTML = '<h1 class="page-title">Community Board</h1><div class="loading"><i></i>Loading board</div>';
try {
const board = await get(`${API}/admin/community-board`);
renderCommunityBoard(board);
} catch (error) {
app.innerHTML = `<h1 class="page-title">Community Board</h1><div class="card"><p class="form-status error">${esc(error.message)}</p></div>`;
}
}
function renderCommunityBoard(board) {
app.innerHTML = `
<h1 class="page-title">Community Board</h1>
<div class="card">
<h2>Featured Player</h2>
<label>Player ID<input type="number" id="fpId" value="${board.featuredPlayer?.id || ''}" placeholder="Leave empty to disable"></label>
<br><br>
<label>Title Override<input type="text" id="fpTitle" value="${esc(board.featuredPlayer?.titleOverride || '')}" placeholder="Custom title"></label>
<br><br>
<label>URL Override<input type="text" id="fpUrl" value="${esc(board.featuredPlayer?.urlOverride || '')}" placeholder="Custom URL"></label>
</div>
<div class="card">
<h2>Featured Rooms</h2>
<div id="featuredRoomsList"></div>
<button class="btn" id="addFeaturedRoom">+ Add Room</button>
</div>
<div class="card">
<h2>Instagram Images</h2>
<div id="instagramList"></div>
<button class="btn" id="addInstagram">+ Add Image</button>
</div>
<div class="card">
<h2>Videos</h2>
<div id="videosList"></div>
<button class="btn" id="addVideo">+ Add Video</button>
</div>
<div class="card">
<h2>Current Announcement</h2>
<label>Message<input type="text" id="annMessage" value="${esc(board.currentAnnouncement?.message || '')}" placeholder="Announcement message"></label>
<br><br>
<label>More Info URL<input type="text" id="annUrl" value="${esc(board.currentAnnouncement?.moreInfoUrl || '')}" placeholder="Link for more info"></label>
</div>
<div class="card dev-block">
<h2>Scheduled maintenance countdown</h2>
<p class="field-hint">Pushes a live in-game maintenance notice. Set to 0 to clear it.</p>
<label>Minutes until maintenance<input type="number" id="maintenanceMinutes" min="0" max="10080" value="0"></label>
<br><br>
<button class="btn danger" id="startMaintenance">Start countdown</button>
<span class="form-status" id="maintenanceStatus"></span>
</div>
<button class="btn" id="saveBoard" style="margin-top:20px">Save All Changes</button>
<button class="btn ghost" id="resetBoard" style="margin-top:20px;margin-left:10px">Reset to Server Data</button>
<span class="form-status" id="boardStatus"></span>`;
const fpId = document.querySelector('#fpId');
const fpTitle = document.querySelector('#fpTitle');
const fpUrl = document.querySelector('#fpUrl');
const annMessage = document.querySelector('#annMessage');
const annUrl = document.querySelector('#annUrl');
let featuredRooms = [...(board.featuredRoomGroup?.featuredRooms || [])];
let instagramImages = [...(board.instagramImages || [])];
let videos = [...(board.videos || [])];
function renderFeaturedRooms() {
const container = document.querySelector('#featuredRoomsList');
container.innerHTML = featuredRooms.map((room, i) => `
<div class="room-row">
<label>Room ID<input type="number" class="fr-roomId" value="${room.roomId || ''}" data-index="${i}" placeholder="Room ID"></label>
<label>Room Name<input type="text" class="fr-roomName" value="${esc(room.roomName || '')}" data-index="${i}" placeholder="Room Name"></label>
<label>Image Name<input type="text" class="fr-imageName" value="${esc(room.imageName || '')}" data-index="${i}" placeholder="Image Name"></label>
<button class="btn small danger remove-fr" data-index="${i}">Remove</button>
</div>
`).join('');
container.querySelectorAll('.remove-fr').forEach(btn => btn.addEventListener('click', () => {
featuredRooms.splice(btn.dataset.index, 1);
renderFeaturedRooms();
}));
container.querySelectorAll('input').forEach(input => input.addEventListener('change', (e) => {
const idx = e.target.dataset.index;
const field = e.target.classList.contains('fr-roomId') ? 'roomId' :
e.target.classList.contains('fr-roomName') ? 'roomName' : 'imageName';
featuredRooms[idx][field] = e.target.value;
}));
}
function renderInstagram() {
const container = document.querySelector('#instagramList');
container.innerHTML = instagramImages.map((img, i) => `
<div class="insta-row" style="margin-bottom:10px;padding:10px;background:var(--panel-alt);border-radius:var(--radius)">
<label>Image URL<input type="text" class="insta-url" value="${esc(img.imageUrl || '')}" data-index="${i}" placeholder="https://..." style="width:400px"></label>
<button class="btn small danger remove-insta" data-index="${i}">Remove</button>
</div>
`).join('');
container.querySelectorAll('.remove-insta').forEach(btn => btn.addEventListener('click', () => {
instagramImages.splice(btn.dataset.index, 1);
renderInstagram();
}));
container.querySelectorAll('input').forEach(input => input.addEventListener('change', (e) => {
instagramImages[e.target.dataset.index].imageUrl = e.target.value;
}));
}
function renderVideos() {
const container = document.querySelector('#videosList');
container.innerHTML = videos.map((vid, i) => `
<div class="video-row" style="margin-bottom:10px;padding:10px;background:var(--panel-alt);border-radius:var(--radius)">
<label>Title<input type="text" class="vid-title" value="${esc(vid.title || '')}" data-index="${i}" placeholder="Video title"></label>
<label>Description<input type="text" class="vid-desc" value="${esc(vid.description || '')}" data-index="${i}" placeholder="Description"></label>
<button class="btn small danger remove-vid" data-index="${i}">Remove</button>
</div>
`).join('');
container.querySelectorAll('.remove-vid').forEach(btn => btn.addEventListener('click', () => {
videos.splice(btn.dataset.index, 1);
renderVideos();
}));
container.querySelectorAll('input').forEach(input => input.addEventListener('change', (e) => {
const idx = e.target.dataset.index;
const field = e.target.classList.contains('vid-title') ? 'title' : 'description';
videos[idx][field] = e.target.value;
}));
}
renderFeaturedRooms();
renderInstagram();
renderVideos();
const addFeaturedRoomBtn = document.querySelector('#addFeaturedRoom');
if (addFeaturedRoomBtn) addFeaturedRoomBtn.addEventListener('click', () => {
featuredRooms.push({ roomId: '', roomName: '', imageName: '' });
renderFeaturedRooms();
});
const addInstagramBtn = document.querySelector('#addInstagram');
if (addInstagramBtn) addInstagramBtn.addEventListener('click', () => {
instagramImages.push({ imageUrl: '' });
renderInstagram();
});
const addVideoBtn = document.querySelector('#addVideo');
if (addVideoBtn) addVideoBtn.addEventListener('click', () => {
videos.push({ title: '', description: '' });
renderVideos();
});
applyRoleGating();
const startMaintenanceBtn = document.querySelector('#startMaintenance');
if (startMaintenanceBtn) startMaintenanceBtn.addEventListener('click', async () => {
const status = document.querySelector('#maintenanceStatus');
const minutes = parseInt(document.querySelector('#maintenanceMinutes').value) || 0;
if (minutes > 0 && !confirm(`Push a live ${minutes}-minute maintenance countdown to every player in-game?`)) return;
try {
const r = await send(`${API}/admin/maintenance`, 'POST', { minutes });
status.textContent = r.message;
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
const saveBoardBtn = document.querySelector('#saveBoard');
if (saveBoardBtn) saveBoardBtn.addEventListener('click', async () => {
const status = document.querySelector('#boardStatus');
if (!status) return;
const newBoard = {
featuredPlayer: fpId.value ? { id: Number(fpId.value), titleOverride: fpTitle.value, urlOverride: fpUrl.value } : null,
featuredRoomGroup: featuredRooms.length ? { featuredRooms } : null,
instagramImages: instagramImages,
videos: videos,
currentAnnouncement: annMessage.value ? { message: annMessage.value, moreInfoUrl: annUrl.value } : null
};
status.textContent = 'Saving...';
status.classList.remove('error');
try {
await send(`${API}/admin/community-board`, 'PUT', newBoard);
status.textContent = 'Saved successfully.';
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
const resetBoardBtn = document.querySelector('#resetBoard');
if (resetBoardBtn) resetBoardBtn.addEventListener('click', async () => {
const status = document.querySelector('#boardStatus');
if (!status) return;
status.textContent = 'Reloading...';
status.classList.remove('error');
try {
const freshBoard = await get(`${API}/admin/community-board`);
renderCommunityBoard(freshBoard);
status.textContent = 'Reset to server data.';
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
}
async function clubsView() {
app.innerHTML = `<h1 class="page-title">Clubs</h1><div class="search-row"><input id="clubSearch" placeholder="Search clubs"><button class="btn" id="clubSearchGo">Search</button></div><div id="clubListResults" class="player-list"></div>`;
const input = document.querySelector('#clubSearch'), results = document.querySelector('#clubListResults');
async function loadClubs() {
results.innerHTML = '<div class="loading"><i></i>Loading clubs</div>';
try {
const data = await get(`${API}/admin/clubs?search=${encodeURIComponent(input.value.trim())}`);
if (!data.clubs.length) { results.innerHTML = '<p class="field-hint">No clubs found.</p>'; return; }
results.innerHTML = data.clubs.map(c => `
<a class="player-row" href="#clubs/${c.clubId}">
<div class="meta">
<div class="name">${esc(c.name)}</div>
<div class="sub">ID ${c.clubId} · ${c.memberCount} members · ${c.visibility}</div>
</div>
</a>
`).join('');
} catch (error) {
results.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const clubSearchGo = document.querySelector('#clubSearchGo');
if (clubSearchGo) clubSearchGo.addEventListener('click', loadClubs);
if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') loadClubs(); });
await loadClubs();
}
async function clubDetail(clubIdRaw) {
const clubId = Number(clubIdRaw);
app.innerHTML = '<div class="loading"><i></i>Loading club</div>';
let club;
try { club = await get(`${API}/admin/clubs/${clubId}`); }
catch (error) { app.innerHTML = `<a class="back-link" href="#clubs">&larr; Back to Clubs</a><div class="card"><p class="form-status error">${esc(error.message)}</p></div>`; return; }
const summary = club.summary;
app.innerHTML = `
<a class="back-link" href="#clubs">&larr; Back to Clubs</a>
<div class="profile-header">
<div>
<div class="name">${esc(summary.name)}</div>
<div class="username-sub">${esc(summary.description || '(no description)')}</div>
<div class="stat-line">ID: ${summary.clubId} · ${summary.memberCount} members</div>
<div class="stat-line">State: ${summary.state} · Visibility: ${summary.visibility}</div>
<div class="stat-line">Creator: ${summary.creatorAccountId}</div>
<div class="profile-actions">
<button class="btn" data-act="edit">Edit Club</button>
<a class="btn ghost" href="#clubs">Back</a>
</div>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="members">Members</div>
</div>
<div id="tabBody"></div>`;
document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === tab));
renderClubTab(tab.dataset.tab, clubId);
}));
renderClubTab('members', clubId);
const editBtn = document.querySelector('[data-act="edit"]');
if (editBtn) editBtn.addEventListener('click', async () => {
const name = prompt('Club name:', summary.name);
if (name === null) return;
const description = prompt('Description:', summary.description || '');
if (description === null) return;
const state = prompt('State (Active/MarkedForDelete):', summary.state);
if (state === null) return;
const visibility = prompt('Visibility (Public/Private):', summary.visibility);
if (visibility === null) return;
try {
await send(`${API}/admin/clubs/${clubId}`, 'PUT', { name, description, state, visibility });
await clubDetail(clubIdRaw);
} catch (error) { alert(error.message); }
});
}
async function renderClubTab(tab, clubId) {
const body = document.querySelector('#tabBody');
if (tab === 'members') {
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Members</h2><div id="membersList"><div class="loading"><i></i>Loading members</div></div>`;
try {
const members = await get(`${API}/admin/clubs/${clubId}/members`);
if (!members.length) { body.innerHTML = '<p class="field-hint">No members.</p>'; return; }
body.innerHTML = `<table><thead><tr><th>Account ID</th><th>Username</th><th>Display Name</th><th>Role</th><th>Actions</th></tr></thead><tbody>${members.map(m => `
<tr data-account="${m.accountId}">
<td>${m.accountId}</td>
<td>${esc(m.username || 'N/A')}</td>
<td>${esc(m.displayName || 'N/A')}</td>
<td>${esc(m.membershipType)}</td>
<td class="actions-cell"><button class="btn small set-role">Set Role</button><button class="btn small danger remove-member">Remove</button></td>
</tr>
`).join('')}</tbody></table>`;
body.querySelectorAll('.set-role').forEach(btn => btn.addEventListener('click', async () => {
const row = btn.closest('tr'), accountId = row.dataset.account;
const role = prompt('Membership type (Creator/Admin/Officer/Member):');
if (!role) return;
try { await send(`${API}/admin/clubs/${clubId}/members/${accountId}`, 'POST', { membershipType: role }); await renderClubTab('members', clubId); }
catch (error) { alert(error.message); }
}));
body.querySelectorAll('.remove-member').forEach(btn => btn.addEventListener('click', async () => {
const row = btn.closest('tr'), accountId = row.dataset.account;
if (!confirm('Remove this member?')) return;
try { await send(`${API}/admin/clubs/${clubId}/members/${accountId}`, 'DELETE'); await renderClubTab('members', clubId); }
catch (error) { alert(error.message); }
}));
} catch (error) {
body.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
}
const GIFT_CONTEXT_DEFS = [['None', -1], ['Default'], ['First_Activity'], ['Game_Drop'], ['All_Daily_Challenges_Complete'], ['All_Weekly_Challenge_Complete'], ['Daily_Challenge_Complete'], ['Weekly_Challenge_Complete'], ['Unassigned_Equipment', 10], ['Unassigned_Avatar'], ['Unassigned_Consumable'], ['Reacquisition', 20], ['Membership'], ['NUX_TokensAndDressUp', 30], ['NUX_Experiment1'], ['NUX_Experiment2'], ['NUX_Experiment3'], ['NUX_Experiment4'], ['NUX_Experiment5'], ['GameRewards', 50], ['GameRewards_Tokens'], ['LevelUp', 100], ['Purchased_Gift_A', 500], ['Purchased_Gift_B'], ['Purchased_Gift_C'], ['Purchased_Gift_D'], ['Holiday', 1000], ['Contest'], ['Promotion'], ['SubscribersOnly'], ['Deprecated', 1100], ['RecRoyale', 1200], ['DEPRECATED_Paintball_ClearCut', 2000], ['DEPRECATED_Paintball_Homestead'], ['DEPRECATED_Paintball_Quarry'], ['DEPRECATED_Paintball_River'], ['DEPRECATED_Paintball_Dam'], ['DEPRECATED_Paintball_DriveIn'], ['Paintball_ClearCut', 2010], ['Paintball_Homestead'], ['Paintball_Quarry'], ['Paintball_River'], ['Paintball_Dam'], ['Paintball_DriveIn'], ['DEPRECATED_Discgolf_Propulsion', 3000], ['DEPRECATED_Discgolf_Lake'], ['Discgolf_Propulsion', 3010], ['Discgolf_Lake'], ['Discgolf_Mode_CoopCatch', 3500], ['Quest_Goblin_A', 4000], ['Quest_Goblin_B'], ['Quest_Goblin_C'], ['Quest_Goblin_S'], ['Quest_Goblin_Consumable'], ['Quest_Cauldron_A', 4010], ['Quest_Cauldron_B'], ['Quest_Cauldron_C'], ['Quest_Cauldron_S'], ['Quest_Cauldron_Consumable'], ['Quest_Pirate1_A', 4100], ['Quest_Pirate1_B'], ['Quest_Pirate1_C'], ['Quest_Pirate1_S'], ['Quest_Pirate1_X'], ['Quest_Pirate1_Consumable'], ['Quest_Dracula1_A', 4200], ['Quest_Dracula1_B'], ['Quest_Dracula1_C'], ['Quest_Dracula1_S'], ['Quest_Dracula1_X'], ['Quest_Dracula1_Consumable'], ['Quest_Dracula1_SS'], ['Quest_SciFi_A', 4500], ['Quest_SciFi_B'], ['Quest_SciFi_C'], ['Quest_SciFi_S'], ['Quest_Scifi_Consumable'], ['DEPRECATED_Charades', 5000], ['Charades'], ['DEPRECATED_Soccer', 6000], ['Soccer'], ['DEPRECATED_Paddleball', 7000], ['Paddleball'], ['DEPRECATED_Dodgeball', 8000], ['Dodgeball'], ['DEPRECATED_Lasertag', 9000], ['Lasertag'], ['DEPRECATED_Bowling', 10000], ['Bowling'], ['StuntRunner_TheMainEvent_A', 11000], ['StuntRunner_TheMainEvent_B'], ['StuntRunner_TheMainEvent_C'], ['StuntRunner_TheMainEvent_D'], ['StuntRunner_TheMainEvent_S'], ['StuntRunner_TheMainEvent_X'], ['StuntRunner_TheMainEvent_Consumable'], ['StuntRunner_TheMainEvent_SS'], ['Store_LaserTag', 100000], ['Store_RecCenter', 100010], ['Consumable', 110000], ['Token', 110100], ['Punchcard_Challenge_Complete', 110200], ['All_Punchcard_Challenges_Complete'], ['Commerce_Purchase', 200000]];
const GIFT_CONTEXTS = (() => { let next = 0; return GIFT_CONTEXT_DEFS.map(([name, value]) => { const v = value !== undefined ? value : next; next = v + 1; return { name, value: v }; }); })();
async function coachGiftingView() {
app.innerHTML = `
<h1 class="page-title">Coach Gifting</h1>
<div class="card">
<h2>Send Gift</h2>
<label>Gift Type<select id="giftType">
<option value="avatar">Avatar Item</option>
<option value="equipment">Equipment</option>
<option value="consumable">Consumable</option>
<option value="tokens">Tokens</option>
<option value="xp">XP</option>
<option value="box">Mystery Box</option>
</select></label>
<br><br>
<label>Recipient Account IDs (comma-separated, or leave empty to send to all)<input type="text" id="recipientIds" placeholder="e.g. 123,456,789"></label>
<br><br>
<label id="skuLabel">SKU ID<input type="number" id="skuId" placeholder="Search catalog below"></label>
<br><br>
<label id="amountLabel">Amount<input type="number" id="amount" value="1"></label>
<br><br>
<label id="boxRarityLabel">Box Rarity (10=Common, 20=Uncommon, 30=Rare, 40=Epic, 50=Legendary)<select id="boxRarity">
<option value="10">Common (10)</option>
<option value="20">Uncommon (20)</option>
<option value="30">Rare (30)</option>
<option value="40">Epic (40)</option>
<option value="50">Legendary (50)</option>
</select></label>
<br><br>
<label>Box Design<select id="boxDesign"><option value="2" selected>Normal</option><option value="110000">Friendotron</option><option value="custom">Custom...</option></select></label>
<input type="number" id="boxDesignCustom" placeholder="Custom box design ID" hidden style="margin-top:6px">
<br><br>
<label>Message (optional)<input type="text" id="giftMessage" placeholder="Gift message"></label>
<br><br>
<label><input type="checkbox" id="sendToAll"> Send to all players</label>
<br><br>
<label><input type="checkbox" id="onlineOnly"> Online only</label>
<br><br>
<button class="btn" id="sendGift">Send Gift</button>
<span class="form-status" id="giftStatus"></span>
</div>
<div class="card">
<h2>Gift Catalog</h2>
<div class="search-row">
<input id="catalogSearch" placeholder="Search catalog">
<select id="catalogType">
<option value="">All Types</option>
<option value="avatar">Avatar</option>
<option value="equipment">Equipment</option>
<option value="consumable">Consumable</option>
</select>
<button class="btn" id="searchCatalog">Search</button>
</div>
<div id="catalogResults"></div>
<div class="search-row" style="margin-top:10px;margin-bottom:0">
<button class="btn ghost small" id="catalogPrev">&larr; Prev</button>
<span class="field-hint" id="catalogPageInfo" style="align-self:center"></span>
<button class="btn ghost small" id="catalogNext">Next &rarr;</button>
</div>
</div>`;
const giftType = document.querySelector('#giftType');
const skuLabel = document.querySelector('#skuLabel');
const amountLabel = document.querySelector('#amountLabel');
const boxRarityLabel = document.querySelector('#boxRarityLabel');
const skuId = document.querySelector('#skuId');
const amount = document.querySelector('#amount');
const boxRarity = document.querySelector('#boxRarity');
const boxDesign = document.querySelector('#boxDesign');
const boxDesignCustom = document.querySelector('#boxDesignCustom');
(function populateGiftContexts() {
const customOption = boxDesign.querySelector('option[value="custom"]');
for (const { name, value } of GIFT_CONTEXTS) {
if (value === 2 || value === 110000) continue;
const opt = document.createElement('option');
opt.value = String(value);
opt.textContent = `${name} (${value})`;
boxDesign.insertBefore(opt, customOption);
}
})();
function resolveBoxDesign() {
return boxDesign.value === 'custom' ? Number(boxDesignCustom.value || 0) : Number(boxDesign.value);
}
function updateFormVisibility() {
const type = giftType.value;
skuLabel.style.display = (type === 'avatar' || type === 'equipment' || type === 'consumable') ? '' : 'none';
amountLabel.style.display = (type === 'tokens' || type === 'xp' || type === 'consumable') ? '' : 'none';
boxRarityLabel.style.display = type === 'box' ? '' : 'none';
boxDesign.parentElement.style.display = type === 'box' ? '' : 'none';
boxDesignCustom.hidden = !(type === 'box' && boxDesign.value === 'custom');
}
if (giftType) giftType.addEventListener('change', updateFormVisibility);
if (boxDesign) boxDesign.addEventListener('change', updateFormVisibility);
updateFormVisibility();
let catalogSkip = 0;
const catalogTake = 20;
async function loadCatalog() {
const catalogSearch = document.querySelector('#catalogSearch');
const catalogType = document.querySelector('#catalogType');
const results = document.querySelector('#catalogResults');
const pageInfo = document.querySelector('#catalogPageInfo');
const prevBtn = document.querySelector('#catalogPrev');
const nextBtn = document.querySelector('#catalogNext');
if (!catalogSearch || !catalogType || !results) return;
const search = catalogSearch.value;
const type = catalogType.value;
results.innerHTML = '<div class="loading"><i></i>Loading catalog</div>';
try {
const data = await get(`${API}/admin/gifts/catalog?search=${encodeURIComponent(search)}&type=${type}&skip=${catalogSkip}&take=${catalogTake}`);
const items = data.results ?? data;
const total = data.total ?? items.length;
if (!items.length) { results.innerHTML = '<p class="field-hint">No items found.</p>'; }
else {
results.innerHTML = items.map(item => `
<div class="room-row clickable" data-sku="${item.skuId}">
<div class="meta"><strong>${esc(item.friendlyName)}</strong><br><small>SKU ${item.skuId} · ${item.type} ${item.avatarItemId ? `· Avatar #${item.avatarItemId}` : ''}</small></div>
<button class="btn small select-sku">Select</button>
</div>
`).join('');
results.querySelectorAll('.select-sku').forEach(btn => btn.addEventListener('click', () => {
skuId.value = btn.closest('[data-sku]').dataset.sku;
}));
}
if (pageInfo) pageInfo.textContent = total ? `${catalogSkip + 1}-${Math.min(catalogSkip + catalogTake, total)} of ${total}` : '';
if (prevBtn) prevBtn.disabled = catalogSkip <= 0;
if (nextBtn) nextBtn.disabled = catalogSkip + catalogTake >= total;
} catch (error) {
results.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const searchCatalogBtn = document.querySelector('#searchCatalog');
if (searchCatalogBtn) searchCatalogBtn.addEventListener('click', () => { catalogSkip = 0; loadCatalog(); });
const catalogSearch = document.querySelector('#catalogSearch');
if (catalogSearch) catalogSearch.addEventListener('keydown', e => { if (e.key === 'Enter') { catalogSkip = 0; loadCatalog(); } });
const catalogPrevBtn = document.querySelector('#catalogPrev');
if (catalogPrevBtn) catalogPrevBtn.addEventListener('click', () => { catalogSkip = Math.max(0, catalogSkip - catalogTake); loadCatalog(); });
const catalogNextBtn = document.querySelector('#catalogNext');
if (catalogNextBtn) catalogNextBtn.addEventListener('click', () => { catalogSkip += catalogTake; loadCatalog(); });
loadCatalog();
const sendGiftBtn = document.querySelector('#sendGift');
if (sendGiftBtn) sendGiftBtn.addEventListener('click', async () => {
const status = document.querySelector('#giftStatus');
const recipientIds = document.querySelector('#recipientIds');
const sendToAll = document.querySelector('#sendToAll');
const onlineOnly = document.querySelector('#onlineOnly');
const giftMessage = document.querySelector('#giftMessage');
if (!status || !recipientIds || !sendToAll || !onlineOnly || !giftMessage) return;
const recipientAccountIds = recipientIds.value ? recipientIds.value.split(',').map(id => Number(id.trim())).filter(id => id > 0) : [];
const request = {
GiftType: giftType.value,
RecipientAccountIds: recipientAccountIds,
SendToAll: sendToAll.checked,
OnlineOnly: onlineOnly.checked,
Message: giftMessage.value
};
if (giftType.value === 'avatar' || giftType.value === 'equipment' || giftType.value === 'consumable') {
request.SkuId = Number(skuId.value) || 0;
if (giftType.value === 'consumable') request.Amount = Number(amount.value) || 1;
} else if (giftType.value === 'tokens' || giftType.value === 'xp') {
request.Amount = Number(amount.value) || 0;
} else if (giftType.value === 'box') {
request.BoxRarity = Number(boxRarity.value) || 10;
request.BoxDesign = resolveBoxDesign() || 2;
}
status.textContent = 'Sending...';
status.classList.remove('error');
try {
const result = await send(`${API}/admin/gifts`, 'POST', request);
status.textContent = `Gift sent to ${result.queued} players.`;
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
}
async function configView() {
app.innerHTML = `
<h1 class="page-title">Server Security</h1>
<div class="card">
<h2>Access Control</h2>
<label><input type="checkbox" id="accountCreationEnabled"> Allow all account creation</label>
<p class="field-hint">Master switch for every account-creation path, including the admin form.</p>
<br><br>
<label><input type="checkbox" id="signupEnabled"> Allow public RecNet signup</label>
<p class="field-hint">Controls only the website registration form and is also limited by the master switch.</p>
<br><br>
<label><input type="checkbox" id="vpnBlockingEnabled"> Block VPNs, proxies, Tor, and hosting IPs</label>
<p class="field-hint">Checks gameplay and account endpoints. Results are cached and provider outages fail open.</p>
<p class="field-hint" id="proxyCheckHint"></p>
<br><br>
<span class="form-status" id="configStatus"></span>
</div>`;
const accountCreationToggle = document.querySelector('#accountCreationEnabled');
const signupToggle = document.querySelector('#signupEnabled');
const vpnToggle = document.querySelector('#vpnBlockingEnabled');
const proxyCheckHint = document.querySelector('#proxyCheckHint');
const status = document.querySelector('#configStatus');
async function loadSettings() {
try {
const settings = await get(`${API}/admin/settings`);
accountCreationToggle.checked = !!settings.accountCreationEnabled;
signupToggle.checked = !!settings.recNetSignupEnabled;
vpnToggle.checked = !!settings.vpnBlockingEnabled;
proxyCheckHint.textContent = `Proxy-check API key: ${settings.proxyCheckConfigured ? 'configured' : 'not configured (set in server config file)'}`;
status.textContent = `Account creation ${settings.accountCreationEnabled ? 'enabled' : 'disabled'} · RecNet signup ${settings.recNetSignupEnabled ? 'enabled' : 'disabled'} · VPN blocking ${settings.vpnBlockingEnabled ? 'enabled' : 'disabled'}`;
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
}
async function saveSettings() {
try {
accountCreationToggle.disabled = signupToggle.disabled = vpnToggle.disabled = true;
status.textContent = 'Saving...';
status.classList.remove('error');
await send(`${API}/admin/settings`, 'PUT', {
accountCreationEnabled: accountCreationToggle.checked,
recNetSignupEnabled: signupToggle.checked,
vpnBlockingEnabled: vpnToggle.checked
});
status.textContent = 'Settings saved.';
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
} finally {
accountCreationToggle.disabled = signupToggle.disabled = vpnToggle.disabled = false;
}
}
accountCreationToggle.addEventListener('change', saveSettings);
signupToggle.addEventListener('change', saveSettings);
vpnToggle.addEventListener('change', saveSettings);
await loadSettings();
}
async function accountCreationView() {
await configView();
}
async function avatarItemsView() {
app.innerHTML = `
<h1 class="page-title">Avatar Items</h1>
<div class="card">
<div style="margin-bottom:15px">
<label><input type="radio" name="avatarMode" value="simple" checked> Simple Mode</label>
<label style="margin-left:20px"><input type="radio" name="avatarMode" value="advanced"> Advanced Mode</label>
</div>
<div id="simpleMode">
<label>Upload JSON file<input type="file" id="avatarJsonFile" accept=".json"></label>
<p class="field-hint">Upload a JSON file containing the avatar item definition.</p>
</div>
<div id="advancedMode" style="display:none">
<label>Avatar Item JSON<textarea id="avatarJsonText" rows="15" placeholder='{
"AvatarItemDesc": "05b7af56-71f2-45ba-a377-c105bf6a6f7a,4lwF00Rvb0Kr3FJz07HlOQ",
"AvatarItemType": 0,
"PlatformMask": -1,
"FriendlyName": "Werewolf Wrist (Gilded)",
"Tooltip": "",
"Rarity": 0,
"TagList": null,
"AvatarItemId": 9796,
"IsBaseAvatarItem": false,
"CreatedAt": "2026-08-16T00:00:00.000Z",
"ThumbnailImage": null
}'></textarea></label>
</div>
<br>
<button class="btn" id="uploadAvatarItem">Upload Avatar Item</button>
<span class="form-status" id="avatarStatus"></span>
</div>
<div class="card">
<h2>Browse Avatar Items</h2>
<div class="search-row"><input id="avatarBrowseSearch" placeholder="Search name, SKU, or avatar item ID"><button class="btn" id="avatarBrowseSearchGo">Search</button></div>
<div id="avatarBrowseResults"><p class="field-hint">Search to browse the avatar item catalog.</p></div>
<div class="search-row" id="avatarBrowsePager" style="margin-top:10px;margin-bottom:0;display:none">
<button class="btn ghost small" id="avatarBrowsePrev">&larr; Prev</button>
<span class="field-hint" id="avatarBrowsePageInfo" style="align-self:center"></span>
<button class="btn ghost small" id="avatarBrowseNext">Next &rarr;</button>
</div>
</div>`;
let avatarBrowseSkip = 0;
const avatarBrowseTake = 20;
async function runAvatarBrowse() {
const searchInput = document.querySelector('#avatarBrowseSearch');
const results = document.querySelector('#avatarBrowseResults');
const pager = document.querySelector('#avatarBrowsePager');
const query = searchInput.value.trim();
results.innerHTML = '<div class="loading"><i></i>Loading</div>';
try {
const data = await get(`${API}/admin/gifts/catalog?search=${encodeURIComponent(query)}&type=avatar&skip=${avatarBrowseSkip}&take=${avatarBrowseTake}`);
const items = data.results ?? data;
const total = data.total ?? items.length;
results.innerHTML = items.length ? items.map(item => `
<div class="room-row">
${item.thumbnailImage ? `<img src="${esc(item.thumbnailImage)}" alt="">` : ''}
<div class="meta"><strong>${esc(item.friendlyName)}</strong><br><small>SKU ${item.skuId} &middot; Avatar #${item.avatarItemId} &middot; Rarity ${item.rarity}</small></div>
</div>
`).join('') : '<p class="field-hint">No items found.</p>';
pager.style.display = total > avatarBrowseTake ? 'flex' : 'none';
document.querySelector('#avatarBrowsePageInfo').textContent = total ? `${avatarBrowseSkip + 1}-${Math.min(avatarBrowseSkip + avatarBrowseTake, total)} of ${total}` : '';
document.querySelector('#avatarBrowsePrev').disabled = avatarBrowseSkip <= 0;
document.querySelector('#avatarBrowseNext').disabled = avatarBrowseSkip + avatarBrowseTake >= total;
} catch (error) {
results.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const avatarBrowseSearchGo = document.querySelector('#avatarBrowseSearchGo');
if (avatarBrowseSearchGo) avatarBrowseSearchGo.addEventListener('click', () => { avatarBrowseSkip = 0; runAvatarBrowse(); });
const avatarBrowseSearchInput = document.querySelector('#avatarBrowseSearch');
if (avatarBrowseSearchInput) avatarBrowseSearchInput.addEventListener('keydown', e => { if (e.key === 'Enter') { avatarBrowseSkip = 0; runAvatarBrowse(); } });
document.querySelector('#avatarBrowsePrev').addEventListener('click', () => { avatarBrowseSkip = Math.max(0, avatarBrowseSkip - avatarBrowseTake); runAvatarBrowse(); });
document.querySelector('#avatarBrowseNext').addEventListener('click', () => { avatarBrowseSkip += avatarBrowseTake; runAvatarBrowse(); });
const simpleMode = document.querySelector('#simpleMode');
const advancedMode = document.querySelector('#advancedMode');
const fileInput = document.querySelector('#avatarJsonFile');
const textArea = document.querySelector('#avatarJsonText');
const status = document.querySelector('#avatarStatus');
document.querySelectorAll('input[name="avatarMode"]').forEach(radio => {
radio.addEventListener('change', () => {
simpleMode.style.display = radio.value === 'simple' ? '' : 'none';
advancedMode.style.display = radio.value === 'advanced' ? '' : 'none';
});
});
if (fileInput) fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const avatarTextArea = document.querySelector('#avatarJsonText');
if (avatarTextArea) avatarTextArea.value = e.target.result;
};
reader.readAsText(file);
});
const uploadAvatarItemBtn = document.querySelector('#uploadAvatarItem');
if (uploadAvatarItemBtn) uploadAvatarItemBtn.addEventListener('click', async () => {
let json;
try {
const simpleMode = document.querySelector('#simpleMode');
const advancedMode = document.querySelector('#advancedMode');
const avatarFileInput = document.querySelector('#avatarJsonFile');
const avatarTextArea = document.querySelector('#avatarJsonText');
if (!simpleMode || !advancedMode || !avatarFileInput || !avatarTextArea) return;
if (simpleMode.style.display !== 'none') {
const file = avatarFileInput.files[0];
if (!file) { alert('Please select a JSON file.'); return; }
const text = await file.text();
json = JSON.parse(text);
} else {
const text = avatarTextArea.value.trim();
if (!text) { alert('Please enter JSON.'); return; }
json = JSON.parse(text);
}
} catch (error) {
alert('Invalid JSON: ' + error.message);
return;
}
try {
const status = document.querySelector('#avatarStatus');
if (!status) return;
status.textContent = 'Uploading...';
status.classList.remove('error');
const result = await send(`${API}/admin/avatar-items`, 'POST', json);
status.textContent = result.message || 'Avatar item uploaded successfully.';
} catch (error) {
const status = document.querySelector('#avatarStatus');
if (status) {
status.textContent = error.message;
status.classList.add('error');
}
}
});
}
async function preferencesView() {
app.innerHTML = `<h1 class="page-title">Preferences</h1><div class="card"><div class="loading"><i></i>Loading preferences</div></div>`;
try {
const prefs = await get(`${API}/admin/preferences`);
app.innerHTML = `
<h1 class="page-title">Preferences</h1>
<div class="card">
<h2>Appearance</h2>
<label>Theme<select id="themeSelect">
<option value="dark" ${prefs.theme === 'dark' ? 'selected' : ''}>Dark</option>
<option value="light" ${prefs.theme === 'light' ? 'selected' : ''}>Light</option>
</select></label>
<br><br>
<label>Accent Color<input type="color" id="accentColor" value="${prefs.accentColor || '#7c3aed'}"></label>
<br><br>
<button class="btn" id="savePrefs">Save Preferences</button>
<span class="form-status" id="prefsStatus"></span>
</div>`;
const savePrefsBtn = document.querySelector('#savePrefs');
if (savePrefsBtn) savePrefsBtn.addEventListener('click', async () => {
const status = document.querySelector('#prefsStatus');
const themeSelect = document.querySelector('#themeSelect');
const accentColor = document.querySelector('#accentColor');
if (!status || !themeSelect || !accentColor) return;
status.textContent = 'Saving...';
status.classList.remove('error');
try {
await send(`${API}/admin/preferences`, 'PUT', {
theme: themeSelect.value,
accentColor: accentColor.value
});
status.textContent = 'Saved successfully.';
applyPreferences({
theme: themeSelect.value,
accentColor: accentColor.value
});
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
applyPreferences(prefs);
} catch (error) {
app.innerHTML = `<h1 class="page-title">Preferences</h1><div class="card"><p class="form-status error">${esc(error.message)}</p></div>`;
}
}
function applyPreferences(prefs) {
if (!prefs) return;
const root = document.documentElement;
if (prefs.theme === 'light') {
root.style.setProperty('--bg', '#f5f5f5');
root.style.setProperty('--panel', '#ffffff');
root.style.setProperty('--panel-alt', '#f0f0f0');
root.style.setProperty('--text', '#1a1a1a');
root.style.setProperty('--text-dim', '#666666');
root.style.setProperty('--border', '#e0e0e0');
} else {
root.style.removeProperty('--bg');
root.style.removeProperty('--panel');
root.style.removeProperty('--panel-alt');
root.style.removeProperty('--text');
root.style.removeProperty('--text-dim');
root.style.removeProperty('--border');
}
if (prefs.accentColor) {
root.style.setProperty('--primary', prefs.accentColor);
}
}
async function playerReportsView() {
app.innerHTML = `
<h1 class="page-title">Player Reports</h1>
<div class="card">
<label>Status
<select id="reportStatusFilter">
<option value="Pending">Pending</option>
<option value="Banned">Banned</option>
<option value="TimedOut">Timed Out</option>
<option value="NoAction">No Action</option>
<option value="all">All</option>
</select>
</label>
</div>
<div class="card">
<div id="playerReportList"><div class="loading"><i></i>Loading reports</div></div>
</div>`;
const statusFilter = document.querySelector('#reportStatusFilter');
const list = document.querySelector('#playerReportList');
async function resolveReport(id, action) {
const body = { action };
if (action === 'noaction') {
body.reason = prompt('Optional note:') || '';
} else if (action === 'ban') {
const reason = prompt('Ban reason:');
if (!reason) return;
body.reason = reason;
const durationUnit = (prompt('Duration unit (seconds/minutes/hours/days/weeks/permanent):', 'permanent') || 'permanent').trim().toLowerCase();
body.durationUnit = durationUnit;
if (durationUnit !== 'permanent') {
body.durationAmount = Number(prompt('Duration amount:', '1') || '1');
}
} else {
const reason = prompt('Timeout reason:');
if (!reason) return;
body.reason = reason;
body.durationUnit = (prompt('Timeout unit (seconds/minutes/hours/days), capped at 30 days:', 'hours') || 'hours').trim().toLowerCase();
body.durationAmount = Number(prompt('Timeout amount:', '24') || '24');
}
try {
await send(`${API}/admin/reports/players/${encodeURIComponent(id)}/resolve`, 'POST', body);
await loadReports();
} catch (error) { alert(error.message); }
}
async function loadReports() {
list.innerHTML = '<div class="loading"><i></i>Loading reports</div>';
try {
const reports = await get(`${API}/admin/reports/players?status=${encodeURIComponent(statusFilter.value)}`);
if (!reports.length) { list.innerHTML = '<p class="field-hint">No reports here.</p>'; return; }
list.innerHTML = reports.map(r => `
<div class="room-row">
<div style="flex:1">
<strong>${esc(r.reportedUsername || (r.reportedPlayerId ? `Account #${r.reportedPlayerId}` : 'Unknown player'))}</strong>
<span class="badge">${esc(r.status)}</span>
<br><small>Reported by ${esc(r.reporterUsername || `Account #${r.reporterId}`)} on ${new Date(r.createdAt).toLocaleString()}</small>
${r.reportCategory != null ? `<br><small>Category #${esc(r.reportCategory)}</small>` : ''}
${r.details ? `<br><small>${esc(r.details)}</small>` : ''}
${r.roomId ? `<br><small>Room ${esc(r.roomId)}${r.roomInstanceType ? ` (${esc(r.roomInstanceType)})` : ''}</small>` : ''}
${r.resolutionNote ? `<br><small>Resolution: ${esc(r.resolutionNote)}</small>` : ''}
</div>
${r.status === 'Pending' ? `
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:flex-start">
<button class="btn small danger act-ban" data-id="${esc(r.id)}">Ban</button>
<button class="btn small warn act-timeout" data-id="${esc(r.id)}">Timeout</button>
<button class="btn small ghost act-noaction" data-id="${esc(r.id)}">No Action</button>
</div>` : ''}
</div>
`).join('');
list.querySelectorAll('.act-ban').forEach(btn => btn.addEventListener('click', () => resolveReport(btn.dataset.id, 'ban')));
list.querySelectorAll('.act-timeout').forEach(btn => btn.addEventListener('click', () => resolveReport(btn.dataset.id, 'timeout')));
list.querySelectorAll('.act-noaction').forEach(btn => btn.addEventListener('click', () => resolveReport(btn.dataset.id, 'noaction')));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
statusFilter.addEventListener('change', loadReports);
await loadReports();
}
async function bugReportsView() {
app.innerHTML = `
<h1 class="page-title">Bug Reports</h1>
<div class="card">
<label>Status
<select id="bugStatusFilter">
<option value="Open">Open</option>
<option value="Closed">Closed</option>
<option value="all">All</option>
</select>
</label>
</div>
<div class="card">
<div id="bugReportList"><div class="loading"><i></i>Loading bug reports</div></div>
</div>`;
const statusFilter = document.querySelector('#bugStatusFilter');
const list = document.querySelector('#bugReportList');
async function loadBugReports() {
list.innerHTML = '<div class="loading"><i></i>Loading bug reports</div>';
try {
const reports = await get(`${API}/admin/reports/bugs?status=${encodeURIComponent(statusFilter.value)}`);
if (!reports.length) { list.innerHTML = '<p class="field-hint">No bug reports here.</p>'; return; }
list.innerHTML = reports.map(r => `
<div class="room-row">
<div style="flex:1">
<strong>${esc(r.category || 'Uncategorized')}</strong>
<span class="badge">${esc(r.status)}</span>
<br><small>From ${esc(r.reporterUsername || `Account #${r.reporterId}`)} on ${new Date(r.createdAt).toLocaleString()}</small>
<br><small>${esc(r.description || '')}</small>
</div>
<button class="btn small ${r.status === 'Open' ? 'ghost' : 'danger'} toggle-bug" data-id="${esc(r.id)}" data-next="${r.status === 'Open' ? 'Closed' : 'Open'}">${r.status === 'Open' ? 'Close' : 'Reopen'}</button>
</div>
`).join('');
list.querySelectorAll('.toggle-bug').forEach(btn => btn.addEventListener('click', async () => {
try {
await send(`${API}/admin/reports/bugs/${encodeURIComponent(btn.dataset.id)}/resolve`, 'POST', { status: btn.dataset.next });
await loadBugReports();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
statusFilter.addEventListener('change', loadBugReports);
await loadBugReports();
}
async function ipBansView() {
app.innerHTML = `
<h1 class="page-title">IP Bans</h1>
<div class="card">
<h2>Add IP Ban</h2>
<label>IP / CIDR<input id="ipBanNetwork" placeholder="203.0.113.4 or 203.0.113.0/24"></label>
<br><br>
<label>Reason<input id="ipBanReason" placeholder="Ban reason"></label>
<br><br>
<button class="btn danger" id="addIpBan">Add IP Ban</button>
<span class="form-status" id="ipBanStatus"></span>
</div>
<div class="card">
<h2>Active Bans</h2>
<div id="ipBanList"><div class="loading"><i></i>Loading IP bans</div></div>
</div>`;
const networkInput = document.querySelector('#ipBanNetwork');
const reasonInput = document.querySelector('#ipBanReason');
const status = document.querySelector('#ipBanStatus');
const list = document.querySelector('#ipBanList');
async function loadIpBans() {
list.innerHTML = '<div class="loading"><i></i>Loading IP bans</div>';
try {
const bans = await get(`${API}/admin/ip-bans`);
if (!bans.length) { list.innerHTML = '<p class="field-hint">No IP addresses or ranges are banned.</p>'; return; }
list.innerHTML = bans.map(b => `
<div class="room-row">
<div style="flex:1"><strong>${esc(b.network)}</strong><br><small>${esc(b.reason || 'Blocked by an administrator.')}</small><br><small>Added ${new Date(b.createdAt).toLocaleString()} by account #${b.createdByAccountId || 0}</small></div>
<button class="btn small danger remove-ip" data-id="${esc(b.id)}">Remove</button>
</div>
`).join('');
list.querySelectorAll('.remove-ip').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm('Remove this IP ban?')) return;
try {
await send(`${API}/admin/ip-bans/${encodeURIComponent(btn.dataset.id)}`, 'DELETE', {});
await loadIpBans();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const addIpBanBtn = document.querySelector('#addIpBan');
if (addIpBanBtn) addIpBanBtn.addEventListener('click', async () => {
const network = networkInput.value.trim();
const reason = reasonInput.value.trim();
if (!network) return;
try {
status.textContent = 'Adding IP ban...';
status.classList.remove('error');
await send(`${API}/admin/ip-bans`, 'POST', { network, reason });
networkInput.value = '';
reasonInput.value = '';
status.textContent = 'IP ban added.';
await loadIpBans();
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
await loadIpBans();
}
async function steamBlacklistView() {
app.innerHTML = `
<h1 class="page-title">Steam Blacklist</h1>
<div class="card">
<h2>Blacklist Steam ID</h2>
<p class="field-hint">Every Steam ID is allowed by default. Add an ID here to return HTTP 403 on cached login, account creation, password login, refresh, and RecNet login.</p>
<label>Steam ID<input id="steamId" placeholder="7656119..." type="number"></label>
<br><br>
<label>Reason<input id="steamReason" placeholder="Why this Steam ID is blocked"></label>
<br><br>
<button class="btn danger" id="addSteam">Blacklist Steam ID</button>
<span class="form-status" id="steamStatus"></span>
</div>
<div class="card">
<h2>Blacklisted IDs</h2>
<div id="steamList"><div class="loading"><i></i>Loading Steam blacklist</div></div>
</div>`;
const steamInput = document.querySelector('#steamId');
const reasonInput = document.querySelector('#steamReason');
const status = document.querySelector('#steamStatus');
const list = document.querySelector('#steamList');
async function loadSteamBlacklist() {
list.innerHTML = '<div class="loading"><i></i>Loading Steam blacklist</div>';
try {
const items = await get(`${API}/admin/steam-blacklist`);
if (!items.length) { list.innerHTML = '<p class="field-hint">No Steam IDs are blacklisted.</p>'; return; }
list.innerHTML = items.map(item => `
<div class="room-row">
<div style="flex:1"><strong>${esc(item.steamId)}</strong><br><small>${esc(item.reason || 'Blacklisted by an administrator.')}</small><br><small>Added by ${esc(item.addedByDisplayName || item.addedByByUsername || `Account #${item.addedByAccountId}`)} on ${new Date(item.addedAt || item.AddedAt).toLocaleString()}</small></div>
<button class="btn small danger remove-steam" data-steam="${esc(item.steamId)}">Unblacklist</button>
</div>
`).join('');
list.querySelectorAll('.remove-steam').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm(`Remove Steam ID ${btn.dataset.steam} from the blacklist?`)) return;
try {
await send(`${API}/admin/steam-blacklist/${encodeURIComponent(btn.dataset.steam)}`, 'DELETE', {});
await loadSteamBlacklist();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const addSteamBtn = document.querySelector('#addSteam');
if (addSteamBtn) addSteamBtn.addEventListener('click', async () => {
const steamId = steamInput.value.trim();
const reason = reasonInput.value.trim();
if (!steamId) return;
if (!confirm(`Blacklist Steam ID ${steamId}? Future login and connect attempts will receive HTTP 403.`)) return;
try {
status.textContent = 'Saving...';
status.classList.remove('error');
await send(`${API}/admin/steam-blacklist`, 'POST', { steamId, reason });
steamInput.value = '';
reasonInput.value = '';
status.textContent = 'Steam ID blacklisted.';
await loadSteamBlacklist();
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
await loadSteamBlacklist();
}
async function shopView() {
app.innerHTML = `
<h1 class="page-title">Shop Controls</h1>
<div class="card">
<h2>Shop Actions</h2>
<button class="btn" id="refreshShop">Refresh Shop Now</button>
<span class="form-status" id="shopStatus"></span>
</div>
<div class="card">
<h2>Custom Shop Items</h2>
<div id="customShopItems"><div class="loading"><i></i>Loading custom items</div></div>
</div>
<div class="card">
<h2>Add Custom Item</h2>
<input id="shopSearch" placeholder="Search item name, SKU, or avatar item ID">
<div id="shopSearchResults"></div>
<div class="search-row" id="shopSearchPager" style="margin-top:10px;margin-bottom:0;display:none">
<button class="btn ghost small" id="shopSearchPrev">&larr; Prev</button>
<span class="field-hint" id="shopSearchPageInfo" style="align-self:center"></span>
<button class="btn ghost small" id="shopSearchNext">Next &rarr;</button>
</div>
</div>`;
const status = document.querySelector('#shopStatus');
const customItems = document.querySelector('#customShopItems');
const searchInput = document.querySelector('#shopSearch');
const searchResults = document.querySelector('#shopSearchResults');
async function loadCustomItems() {
customItems.innerHTML = '<div class="loading"><i></i>Loading custom items</div>';
try {
const items = await get(`${API}/admin/shop`);
if (!items.customItems || !items.customItems.length) { customItems.innerHTML = '<p class="field-hint">No custom items pinned. All 10 slots are random 5-stars.</p>'; return; }
customItems.innerHTML = items.customItems.map(item => `
<div class="room-row">
<div style="flex:1"><strong>${esc(item.friendlyName)}</strong><br><small>SKU ${item.skuId} · ${item.price} tokens</small></div>
<button class="btn small danger remove-shop" data-sku="${item.skuId}">Remove</button>
</div>
`).join('');
customItems.querySelectorAll('.remove-shop').forEach(btn => btn.addEventListener('click', async () => {
try {
const status = document.querySelector('#boardStatus');
status.textContent = 'Saving...';
status.classList.remove('error');
try {
await send(`${API}/admin/shop/items/${btn.dataset.sku}`, 'DELETE', {});
status.textContent = 'Saved successfully.';
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
} catch (error) {
alert(error.message);
}
}));
} catch (error) {
customItems.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
const refreshShopBtn = document.querySelector('#refreshShop');
if (refreshShopBtn) refreshShopBtn.addEventListener('click', async () => {
const status = document.querySelector('#shopStatus');
if (!status) return;
try {
status.textContent = 'Refreshing shop...';
status.classList.remove('error');
await send(`${API}/admin/shop/refresh`, 'POST', {});
status.textContent = 'Shop refreshed.';
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
let shopSearchSkip = 0;
const shopSearchTake = 20;
async function runShopSearch() {
const query = searchInput.value.trim();
const pager = document.querySelector('#shopSearchPager');
if (!query) { searchResults.innerHTML = ''; pager.style.display = 'none'; return; }
try {
const data = await get(`${API}/admin/shop/catalog?search=${encodeURIComponent(query)}&skip=${shopSearchSkip}&take=${shopSearchTake}`);
const items = data.results ?? data;
const total = data.total ?? items.length;
if (!items.length) { searchResults.innerHTML = '<p class="field-hint">No items found.</p>'; }
else {
searchResults.innerHTML = items.map(item => `
<div class="room-row clickable" data-sku="${item.skuId}">
<div class="meta"><strong>${esc(item.friendlyName)}</strong><br><small>SKU ${item.skuId} · Avatar #${item.avatarItemId || 'N/A'}</small></div>
<button class="btn small add-shop">Add</button>
</div>
`).join('');
searchResults.querySelectorAll('.add-shop').forEach(btn => btn.addEventListener('click', async (e) => {
e.stopPropagation();
const sku = btn.closest('[data-sku]').dataset.sku;
try {
await send(`${API}/admin/shop/custom-items`, 'POST', { skuId: Number(sku) });
await loadCustomItems();
} catch (error) { alert(error.message); }
}));
}
pager.style.display = total > shopSearchTake ? 'flex' : 'none';
document.querySelector('#shopSearchPageInfo').textContent = total ? `${shopSearchSkip + 1}-${Math.min(shopSearchSkip + shopSearchTake, total)} of ${total}` : '';
document.querySelector('#shopSearchPrev').disabled = shopSearchSkip <= 0;
document.querySelector('#shopSearchNext').disabled = shopSearchSkip + shopSearchTake >= total;
} catch (error) {
searchResults.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
let searchTimer;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { shopSearchSkip = 0; runShopSearch(); }, 300);
});
document.querySelector('#shopSearchPrev').addEventListener('click', () => { shopSearchSkip = Math.max(0, shopSearchSkip - shopSearchTake); runShopSearch(); });
document.querySelector('#shopSearchNext').addEventListener('click', () => { shopSearchSkip += shopSearchTake; runShopSearch(); });
await loadCustomItems();
}
function describeImportResult(result) {
let text = `Imported! Room ID: ${result.roomId}, Subrooms: ${result.subRoomsImported}, Saves: ${result.savesImported}`;
if (result.bakedAssetsImported) text += `, Baked assets: ${result.bakedAssetsImported}`;
if (result.assetBundlesCopied) text += ` (${result.assetBundlesCopied} bundles copied${result.assetBundlesMissing ? `, ${result.assetBundlesMissing} missing` : ''})`;
if (result.unityEngineVersions?.length) text += `. Unity versions: ${result.unityEngineVersions.join(', ')}`;
return text;
}
async function pollMassImportJob(jobId, status, progressBox) {
while (true) {
let job;
try {
job = await get(`${API}/admin/rooms/import-batch/${jobId}`);
} catch (error) {
status.textContent = `Lost track of the import job: ${error.message}`;
status.classList.add('error');
return;
}
const total = job.totalFound || 0;
const done = job.completedCount || 0;
if (job.status === 'pending') {
status.textContent = 'Finding rooms...';
} else if (job.status === 'running') {
status.textContent = `Importing ${done}/${total}${job.currentRoomName ? ` - ${job.currentRoomName}` : ''}...`;
} else if (job.status === 'failed') {
status.textContent = `Batch failed: ${job.fatalError || 'unknown error'}`;
status.classList.add('error');
} else {
status.textContent = `Done: ${job.successCount}/${total} imported${job.failedCount ? `, ${job.failedCount} failed` : ''}.`;
}
if (progressBox) {
progressBox.innerHTML = (job.results || []).map(r => {
const detail = r.success
? ` (room ${r.roomId}, ${r.subRoomsImported} subroom${r.subRoomsImported === 1 ? '' : 's'}${r.bakedAssetsImported ? `, ${r.bakedAssetsImported} baked assets` : ''})`
: ` — ${r.error}`;
const color = r.success ? '#4caf50' : '#ff5555';
return `<div style="color:${color};font-family:monospace;font-size:12px;padding:2px 0">${r.success ? '✓' : '✗'} ${r.name}${detail}</div>`;
}).join('');
}
if (job.status === 'completed' || job.status === 'failed') return;
await new Promise(resolve => setTimeout(resolve, 1500));
}
}
async function roomImporterView() {
app.innerHTML = `
<h1 class="page-title">Room Importer</h1>
<div class="card">
<h2>Import Room</h2>
<p class="field-hint">Upload a room export ZIP, paste Unity scene JSON metadata, or import straight from an archive URL (e.g. an <a href="https://archive.splootybean.com/Rooms/" target="_blank" rel="noopener">archive.splootybean.com</a>-style room folder). Baked asset bundles are picked up automatically in all three modes - no separate step needed.</p>
<br>
<label>Import Method<select id="importMethod">
<option value="zip">ZIP File Upload</option>
<option value="url">Import from URL</option>
<option value="json">JSON Metadata</option>
</select></label>
<br><br>
<label><input type="checkbox" id="massImportToggle"> Mass import (multiple rooms at once)</label>
<p class="field-hint" id="massImportHint" style="display:none">ZIP: a folder of multiple room exports (e.g. a whole archived <code>Rooms/</code> folder), zipped up - every folder inside containing a room.json gets imported. URL: point at the index folder itself (e.g. <code>.../Rooms/</code>) instead of one room's folder, and every room folder listed under it gets crawled and imported. JSON: paste a JSON array of room metadata objects instead of one object. Runs as a background job so it isn't limited by how long a single request can stay open - the panel below polls for progress.</p>
<br>
<div id="zipSection">
<label id="roomZipLabel">Room Export ZIP (.zip)<input type="file" id="roomZip" accept=".zip"></label>
<br><br>
<label>Creator Account ID (optional, defaults to you)<input type="number" id="creatorAccountId" placeholder="Account ID"></label>
<br><br>
<label><input type="checkbox" id="replaceExisting" checked> Replace existing room if it exists</label>
</div>
<div id="urlSection" style="display:none">
<label id="roomUrlLabel">Room folder URL<input type="url" id="roomUrl" placeholder="https://archive.splootybean.com/Rooms/Showdown/"></label>
<p class="field-hint" id="roomUrlHint">Point this at the page that directly lists room.json and the numbered subroom folders - not the parent Rooms/ index. The server crawls that listing itself: room.json, every subroom's saves, and only the specific baked asset bundles this room's save data actually references (not the whole shared AssetBundles/ folder). Capped at 1 GiB total.</p>
<br>
<label>Creator Account ID (optional, defaults to you)<input type="number" id="urlCreatorAccountId" placeholder="Account ID"></label>
<br><br>
<label><input type="checkbox" id="urlReplaceExisting" checked> Replace existing room if it exists</label>
</div>
<div id="jsonSection" style="display:none">
<label id="roomJsonLabel">Unity Scene JSON Metadata<textarea id="roomJson" rows="10" placeholder="Paste Unity scene JSON metadata here..."></textarea></label>
</div>
<br><br>
<button class="btn" id="importRoom">Import Room</button>
<span class="form-status" id="importStatus"></span>
<div id="massProgress" style="max-height:320px;overflow-y:auto;margin-top:10px"></div>
</div>`;
const importMethod = document.querySelector('#importMethod');
const zipSection = document.querySelector('#zipSection');
const urlSection = document.querySelector('#urlSection');
const jsonSection = document.querySelector('#jsonSection');
const massToggle = document.querySelector('#massImportToggle');
const massHint = document.querySelector('#massImportHint');
const importBtnLabel = document.querySelector('#importRoom');
function applyMassLabels() {
const isMass = massToggle && massToggle.checked;
massHint.style.display = isMass ? '' : 'none';
document.querySelector('#roomZipLabel').firstChild.textContent = isMass
? 'Rooms Export ZIP (multiple room folders)'
: 'Room Export ZIP (.zip)';
document.querySelector('#roomUrlLabel').firstChild.textContent = isMass
? 'Rooms index URL'
: 'Room folder URL';
document.querySelector('#roomUrl').placeholder = isMass
? 'https://archive.splootybean.com/Rooms/'
: 'https://archive.splootybean.com/Rooms/Showdown/';
document.querySelector('#roomUrlHint').textContent = isMass
? 'Point this at the index folder that lists every room\'s own folder (e.g. .../Rooms/) - each room folder found directly under it gets crawled and imported in turn. Same per-room 1 GiB cap, plus a 300 room-folder cap on the listing itself.'
: 'Point this at the page that directly lists room.json and the numbered subroom folders - not the parent Rooms/ index. The server crawls that listing itself: room.json, every subroom\'s saves, and only the specific baked asset bundles this room\'s save data actually references (not the whole shared AssetBundles/ folder). Capped at 1 GiB total.';
document.querySelector('#roomJsonLabel').firstChild.textContent = isMass
? 'JSON array of room metadata objects'
: 'Unity Scene JSON Metadata';
document.querySelector('#roomJson').placeholder = isMass
? 'Paste a JSON array here: [{...room 1...}, {...room 2...}]'
: 'Paste Unity scene JSON metadata here...';
if (importBtnLabel) importBtnLabel.textContent = isMass ? 'Import Rooms' : 'Import Room';
}
if (massToggle) massToggle.addEventListener('change', applyMassLabels);
applyMassLabels();
if (importMethod) importMethod.addEventListener('change', () => {
zipSection.style.display = importMethod.value === 'zip' ? '' : 'none';
urlSection.style.display = importMethod.value === 'url' ? '' : 'none';
jsonSection.style.display = importMethod.value === 'json' ? '' : 'none';
});
const importBtn = document.querySelector('#importRoom');
if (importBtn) importBtn.addEventListener('click', async () => {
const status = document.querySelector('#importStatus');
const progressBox = document.querySelector('#massProgress');
if (!status) return;
status.textContent = 'Importing...';
status.classList.remove('error');
if (progressBox) progressBox.innerHTML = '';
const isMass = massToggle && massToggle.checked;
try {
if (isMass) {
let startResult;
if (importMethod.value === 'json') {
const roomJson = document.querySelector('#roomJson');
if (!roomJson || !roomJson.value.trim()) {
status.textContent = 'Please paste a JSON array of room metadata objects.';
status.classList.add('error');
return;
}
startResult = await send(`${API}/admin/rooms/import-batch/json`, 'POST', roomJson.value.trim());
} else if (importMethod.value === 'url') {
const roomUrl = document.querySelector('#roomUrl');
const urlCreatorAccountId = document.querySelector('#urlCreatorAccountId');
const urlReplaceExisting = document.querySelector('#urlReplaceExisting');
if (!roomUrl || !roomUrl.value.trim()) {
status.textContent = 'Please enter the rooms index URL.';
status.classList.add('error');
return;
}
status.textContent = 'Finding room folders...';
startResult = await send(`${API}/admin/rooms/import-batch/url`, 'POST', {
url: roomUrl.value.trim(),
creatorAccountId: urlCreatorAccountId?.value ? Number(urlCreatorAccountId.value) : null,
replaceExisting: urlReplaceExisting ? urlReplaceExisting.checked : true
});
} else {
const roomZip = document.querySelector('#roomZip');
const creatorAccountId = document.querySelector('#creatorAccountId');
const replaceExisting = document.querySelector('#replaceExisting');
if (!roomZip || !roomZip.files || !roomZip.files[0]) {
status.textContent = 'Please select a ZIP file.';
status.classList.add('error');
return;
}
const formData = new FormData();
formData.append('file', roomZip.files[0]);
if (creatorAccountId && creatorAccountId.value) {
formData.append('creatorAccountId', creatorAccountId.value);
}
if (replaceExisting) {
formData.append('replaceExisting', replaceExisting.checked.toString());
}
status.textContent = 'Uploading...';
const response = await fetch(`${API}/admin/rooms/import-batch/zip`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `Request failed (${response.status})`);
}
startResult = await response.json();
}
await pollMassImportJob(startResult.jobId, status, progressBox);
return;
}
if (importMethod.value === 'json') {
const roomJson = document.querySelector('#roomJson');
if (!roomJson || !roomJson.value.trim()) {
status.textContent = 'Please paste JSON metadata.';
status.classList.add('error');
return;
}
const result = await send(`${API}/admin/rooms/import`, 'POST', roomJson.value.trim());
status.textContent = describeImportResult(result);
} else if (importMethod.value === 'url') {
const roomUrl = document.querySelector('#roomUrl');
const urlCreatorAccountId = document.querySelector('#urlCreatorAccountId');
const urlReplaceExisting = document.querySelector('#urlReplaceExisting');
if (!roomUrl || !roomUrl.value.trim()) {
status.textContent = 'Please enter the room folder URL.';
status.classList.add('error');
return;
}
status.textContent = 'Starting crawl...';
const startResult = await send(`${API}/admin/rooms/import-url`, 'POST', {
url: roomUrl.value.trim(),
creatorAccountId: urlCreatorAccountId?.value ? Number(urlCreatorAccountId.value) : null,
replaceExisting: urlReplaceExisting ? urlReplaceExisting.checked : true
});
await pollMassImportJob(startResult.jobId, status, progressBox);
return;
} else {
const roomZip = document.querySelector('#roomZip');
const creatorAccountId = document.querySelector('#creatorAccountId');
const replaceExisting = document.querySelector('#replaceExisting');
if (!roomZip || !roomZip.files || !roomZip.files[0]) {
status.textContent = 'Please select a ZIP file.';
status.classList.add('error');
return;
}
const formData = new FormData();
formData.append('file', roomZip.files[0]);
if (creatorAccountId && creatorAccountId.value) {
formData.append('creatorAccountId', creatorAccountId.value);
}
if (replaceExisting) {
formData.append('replaceExisting', replaceExisting.checked.toString());
}
status.textContent = 'Uploading...';
const response = await fetch(`${API}/admin/rooms/import`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `Request failed (${response.status})`);
}
const startResult = await response.json();
await pollMassImportJob(startResult.jobId, status, progressBox);
return;
}
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
}
async function logsView() {
app.innerHTML = `
<h1 class="page-title">Server Logs</h1>
<div class="card">
<p class="field-hint">The last few thousand lines of server console output, kept in memory (cleared on restart). Not a substitute for a real log file, but enough to see what's happening right now.</p>
<div style="display:flex;gap:10px;align-items:center;margin:10px 0">
<label>Lines<input type="number" id="logsTake" value="500" min="50" max="5000" style="width:90px"></label>
<button class="btn" id="logsRefresh">Refresh</button>
<label><input type="checkbox" id="logsAutoRefresh"> Auto-refresh (5s)</label>
</div>
<pre id="logsOutput" style="background:#111;color:#ddd;padding:12px;border-radius:6px;max-height:600px;overflow:auto;font-size:12px;white-space:pre-wrap;word-break:break-all"></pre>
</div>`;
const output = document.querySelector('#logsOutput');
const takeInput = document.querySelector('#logsTake');
const autoRefresh = document.querySelector('#logsAutoRefresh');
let timer = null;
async function load() {
const take = Math.max(50, Math.min(5000, Number(takeInput.value) || 500));
try {
const result = await get(`${API}/admin/logs?take=${take}`);
output.textContent = (result.lines || []).join('\n') || '(no log lines yet)';
output.scrollTop = output.scrollHeight;
} catch (error) {
output.textContent = `Error loading logs: ${error.message}`;
}
}
document.querySelector('#logsRefresh').addEventListener('click', load);
autoRefresh.addEventListener('change', () => {
if (timer) { clearInterval(timer); timer = null; }
if (autoRefresh.checked) timer = setInterval(load, 5000);
});
await load();
}
async function scannerLogsView() {
app.innerHTML = `
<h1 class="page-title">Loser Scanner Logs</h1>
<div class="card">
<p class="field-hint">Every request that hit a path nothing on this server recognizes - nobody legitimate does that by hand, so this is scanner/bot traffic going fishing for stuff like .env, aws config files, wp-login.php, .git/config, and the like. Highlighted rows matched one of those patterns specifically; everything else just hit a URL that doesn't exist here.</p>
<div style="display:flex;gap:10px;align-items:center;margin:10px 0;flex-wrap:wrap">
<span id="scannerTotal" class="field-hint"></span>
<input id="scannerIpFilter" placeholder="Filter by IP" style="width:160px">
<button class="btn" id="scannerRefresh">Refresh</button>
</div>
<div id="scannerResults"></div>
<div style="margin-top:10px"><button class="btn" id="scannerLoadMore" style="display:none">Load more</button></div>
</div>`;
const resultsEl = document.querySelector('#scannerResults');
const totalEl = document.querySelector('#scannerTotal');
const ipFilter = document.querySelector('#scannerIpFilter');
const loadMoreBtn = document.querySelector('#scannerLoadMore');
const pageSize = 100;
let skip = 0;
let rows = [];
function renderRows() {
if (!rows.length) {
resultsEl.innerHTML = '<p class="field-hint">No scanner attempts logged yet.</p>';
return;
}
resultsEl.innerHTML = rows.map(r => `
<div style="border-bottom:1px solid #333;padding:6px 0;font-family:monospace;font-size:12px${r.matchedPattern ? ';background:rgba(255,85,85,0.08)' : ''}">
<div>${esc(new Date(r.timestamp).toLocaleString())} &middot; <strong>${esc(r.ipAddress)}</strong> &middot; ${esc(r.method)} ${esc(r.path)}${r.queryString ? esc(r.queryString) : ''}</div>
${r.matchedPattern ? `<div style="color:#ff5555">matched: ${esc(r.matchedPattern)}</div>` : ''}
${r.userAgent ? `<div style="color:#888">${esc(r.userAgent)}</div>` : ''}
</div>
`).join('');
}
async function load(reset) {
if (reset) { skip = 0; rows = []; }
try {
const ip = ipFilter.value.trim();
const result = await get(`${API}/admin/scanner-logs?skip=${skip}&take=${pageSize}${ip ? `&ip=${encodeURIComponent(ip)}` : ''}`);
rows = rows.concat(result.results || []);
skip = rows.length;
totalEl.textContent = `${result.total} total attempt${result.total === 1 ? '' : 's'} logged`;
loadMoreBtn.style.display = (result.results || []).length === pageSize ? '' : 'none';
renderRows();
} catch (error) {
resultsEl.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
document.querySelector('#scannerRefresh').addEventListener('click', () => load(true));
ipFilter.addEventListener('keydown', e => { if (e.key === 'Enter') load(true); });
loadMoreBtn.addEventListener('click', () => load(false));
await load(true);
}
async function anticheatLogsView() {
app.innerHTML = `
<h1 class="page-title">Anticheat Logs</h1>
<div class="card">
<p class="field-hint">Every time the client mod caught something bad on its own machine - a debugger attached, a proxy sitting in the traffic path, a known injector process or module, or an unrecognized MelonLoader mod riding along - it reported here and force-closed itself.</p>
<div style="display:flex;gap:10px;align-items:center;margin:10px 0;flex-wrap:wrap">
<span id="acTotal" class="field-hint"></span>
<input id="acIpFilter" placeholder="Filter by IP" style="width:160px">
<button class="btn" id="acRefresh">Refresh</button>
</div>
<div id="acResults"></div>
<div style="margin-top:10px"><button class="btn" id="acLoadMore" style="display:none">Load more</button></div>
</div>`;
const resultsEl = document.querySelector('#acResults');
const totalEl = document.querySelector('#acTotal');
const ipFilter = document.querySelector('#acIpFilter');
const loadMoreBtn = document.querySelector('#acLoadMore');
const pageSize = 100;
let skip = 0;
let rows = [];
function renderRows() {
if (!rows.length) {
resultsEl.innerHTML = '<p class="field-hint">No anticheat flags logged yet.</p>';
return;
}
resultsEl.innerHTML = rows.map(r => `
<div style="border-bottom:1px solid #333;padding:6px 0;font-family:monospace;font-size:12px;background:rgba(255,85,85,0.08)">
<div>${esc(new Date(r.timestamp).toLocaleString())} &middot; <strong>${esc(r.ipAddress)}</strong>${r.accountId ? ` &middot; <a href="#players/${r.accountId}">account ${r.accountId}</a>` : ''} &middot; steam ${esc(r.steamId || '0')} &middot; build ${esc(r.build || '?')}</div>
<div style="color:#ff5555">${esc(r.flags)}</div>
${r.userAgent ? `<div style="color:#888">${esc(r.userAgent)}</div>` : ''}
</div>
`).join('');
}
async function load(reset) {
if (reset) { skip = 0; rows = []; }
try {
const ip = ipFilter.value.trim();
const result = await get(`${API}/admin/anticheat-logs?skip=${skip}&take=${pageSize}${ip ? `&ip=${encodeURIComponent(ip)}` : ''}`);
rows = rows.concat(result.results || []);
skip = rows.length;
totalEl.textContent = `${result.total} total flag${result.total === 1 ? '' : 's'} logged`;
loadMoreBtn.style.display = (result.results || []).length === pageSize ? '' : 'none';
renderRows();
} catch (error) {
resultsEl.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
}
}
document.querySelector('#acRefresh').addEventListener('click', () => load(true));
ipFilter.addEventListener('keydown', e => { if (e.key === 'Enter') load(true); });
loadMoreBtn.addEventListener('click', () => load(false));
await load(true);
}
async function boot() {
try {
currentUser = await get(`${API}/auth/me`);
} catch {
currentUser = null;
}
if (!currentUser || (!currentUser.isModerator && !currentUser.isDeveloper && !currentUser.isAdmin)) {
renderLoginGate();
return;
}
applyRoleGating();
try {
const prefs = await get(`${API}/admin/preferences`);
if (prefs) applyPreferences(prefs);
} catch (e) {
}
await route();
setInterval(checkSystemStatus, 10000);
}
async function checkSystemStatus() {
try {
const status = await get(`${API}/admin/system-status`);
const banner = document.querySelector('#ddosBanner');
if (status.isUnderAttack) {
if (!banner) {
const bannerEl = document.createElement('div');
bannerEl.id = 'ddosBanner';
bannerEl.style.cssText = 'position:fixed;top:60px;left:0;right:0;background:#ff4444;color:white;padding:12px;text-align:center;font-weight:bold;z-index:9999;animation:pulse 1s infinite';
bannerEl.innerHTML = '⚠️ WE\'RE GETTING DDOSED :c ⚠️';
document.body.appendChild(bannerEl);
}
} else if (banner) {
banner.remove();
}
} catch {}
}
boot();
+253
View File
@@ -0,0 +1,253 @@
.photo-open {
display: block;
width: 100%;
padding: 0;
border: 0;
background: none;
cursor: zoom-in;
}
.photo-open .photo-image {
transition: transform .25s;
}
.photo-open:hover .photo-image {
transform: scale(1.018);
}
.photo-dialog {
width: min(1420px,96vw);
height: min(900px,94vh);
max-width: none;
max-height: none;
padding: 0;
border: 1px solid #3d3445;
border-radius: var(--radius-md);
background: #100d14;
color: var(--ink);
overflow: hidden;
box-shadow: 0 35px 120px rgba(0,0,0,.78);
}
.photo-dialog::backdrop {
background: rgba(3,2,5,.9);
backdrop-filter: blur(7px);
}
.photo-close {
position: absolute;
right: 13px;
top: 13px;
z-index: 9;
display: grid;
place-items: center;
width: 38px;
height: 38px;
border: 1px solid rgba(255,255,255,.25);
border-radius: 50%;
background: rgba(10,7,13,.74);
color: #fff;
font-size: 20px;
cursor: pointer;
}
.photo-dialog-content {
display: grid;
grid-template-columns: minmax(0,1fr) 390px;
height: 100%;
}
.photo-stage {
display: grid;
place-items: center;
min-width: 0;
background: #08060a;
}
.photo-full {
display: block;
max-width: 100%;
max-height: 94vh;
object-fit: contain;
}
.photo-side {
display: flex;
flex-direction: column;
min-height: 0;
border-left: 1px solid var(--line);
background: #19151e;
}
.photo-author {
display: flex;
align-items: center;
gap: 11px;
padding: 18px 20px;
border-bottom: 1px solid var(--line);
}
.photo-author .owner {
font: 800 16px var(--futura-extra);
}
.photo-actions {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--line);
}
.cheer-button {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
border: 1px solid #55445f;
border-radius: var(--radius-pill);
background: #251d2d;
color: #ddd4e5;
font: 700 14px "Futura PT",sans-serif;
cursor: pointer;
}
.cheer-button.cheered {
border-color: #d8436a;
background: #7c203d;
color: #fff;
}
.cheer-button i {
color: #ff5c84;
}
.comment-title {
padding: 15px 20px 8px;
font: 800 17px var(--futura-extra);
}
.comment-list {
flex: 1;
overflow: auto;
padding: 0 20px;
}
.comment {
display: flex;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid #302938;
}
.comment .avatar-wrap {
--avatar-size: 34px;
}
.comment-body {
min-width: 0;
}
.comment-name {
font-weight: 800;
}
.comment-text {
color: #d6cfda;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.comment-date {
margin-top: 3px;
color: var(--muted);
font-size: 11px;
}
.no-comments {
padding: 28px 0;
text-align: center;
color: var(--muted);
}
.comment-form {
display: flex;
gap: 8px;
padding: 14px 16px;
border-top: 1px solid var(--line);
}
.comment-form input {
min-width: 0;
flex: 1;
padding: 11px 12px;
border: 1px solid #453a50;
border-radius: var(--radius-sm);
background: #100d14;
color: #fff;
font: 14px "Futura PT",sans-serif;
outline: 0;
}
.comment-form input:focus {
border-color: var(--purple);
}
.comment-form button {
width: 42px;
border: 0;
border-radius: var(--radius-sm);
background: var(--purple-dark);
color: #fff;
cursor: pointer;
}
.comment-login {
margin: 14px 18px;
padding: 11px;
border: 1px solid #4a3c54;
border-radius: var(--radius-sm);
background: #221b29;
color: #cfc5d7;
text-align: center;
}
.comment-login button {
border: 0;
background: none;
color: var(--purple);
font: 700 14px "Futura PT",sans-serif;
cursor: pointer;
}
@media (max-width:800px) {
.photo-dialog {
width: 100vw;
height: 100dvh;
border: 0;
border-radius: 0;
}
.photo-dialog-content {
grid-template-columns: 1fr;
grid-template-rows: minmax(280px,52dvh) minmax(0,1fr);
}
.photo-full {
max-height: 52dvh;
}
.photo-side {
border-left: 0;
border-top: 1px solid var(--line);
}
.photo-author {
padding: 12px 16px;
}
.photo-actions {
padding: 10px 16px;
}
.photo-close {
position: fixed;
}
.comment-list {
padding: 0 16px;
}
}
+348
View File
@@ -0,0 +1,348 @@
.privacy-page {
min-height: 100vh;
background: radial-gradient(circle at 78% -5%,rgba(109,53,211,.23),transparent 30%),var(--canvas);
}
.privacy-header {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 68px;
padding: 7px max(24px,calc((100vw - 1180px)/2));
border-bottom: 1px solid var(--line);
background: rgba(23,17,31,.92);
backdrop-filter: blur(16px);
}
.privacy-logo {
display: flex;
align-items: center;
height: 54px;
}
.privacy-logo img {
display: block;
width: 190px;
height: 48px;
object-fit: contain;
object-position: left center;
}
.privacy-home-link {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 10px 15px;
border: 1px solid #453850;
border-radius: var(--radius-sm);
color: #eee6f8;
text-decoration: none;
font-weight: 800;
transition: .15s border-color,.15s background;
}
.privacy-home-link:hover {
border-color: var(--purple);
background: var(--purple-soft);
}
.privacy-main {
max-width: 1180px;
margin: 0 auto;
padding: 54px 28px 90px;
}
.privacy-hero {
position: relative;
overflow: hidden;
padding: 48px 52px;
border: 1px solid #4a3565;
border-radius: var(--radius-lg);
background: linear-gradient(125deg,#3f197e,#7540c7 70%,#9256db);
color: #fff;
box-shadow: 0 20px 60px rgba(23,9,42,.34);
}
.privacy-hero:after {
content: "";
position: absolute;
right: -75px;
top: -115px;
width: 320px;
height: 320px;
border: 58px solid rgba(255,255,255,.08);
border-radius: 50%;
pointer-events: none;
}
.privacy-eyebrow {
margin-bottom: 12px;
font-size: 12px;
font-weight: 900;
letter-spacing: .14em;
text-transform: uppercase;
color: #ddcdf6;
}
.privacy-hero h1 {
max-width: 720px;
margin: 0 0 14px;
font-size: clamp(40px,6vw,66px);
line-height: 1;
letter-spacing: -2px;
}
.privacy-hero p {
max-width: 730px;
margin: 0;
color: #eadff9;
font-size: 17px;
line-height: 1.65;
}
.privacy-effective {
display: inline-flex;
margin-top: 26px;
padding: 7px 12px;
border: 1px solid rgba(255,255,255,.24);
border-radius: var(--radius-pill);
background: rgba(255,255,255,.1);
font-size: 12px;
font-weight: 800;
}
.privacy-layout {
display: grid;
grid-template-columns: 230px minmax(0,1fr);
gap: 34px;
align-items: start;
margin-top: 38px;
}
.privacy-toc {
position: sticky;
top: 98px;
display: grid;
gap: 4px;
padding: 19px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow);
}
.privacy-toc strong {
margin: 0 8px 9px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--muted);
}
.privacy-toc a {
padding: 8px;
border-radius: var(--radius-sm);
color: #c7bfce;
text-decoration: none;
font-size: 13px;
font-weight: 700;
}
.privacy-toc a:hover {
background: var(--purple-soft);
color: #d8c0ff;
}
.privacy-content {
min-width: 0;
}
.privacy-card {
display: flex;
gap: 18px;
padding: 25px 27px;
border: 1px solid #4d3a68;
border-radius: var(--radius-md);
background: linear-gradient(125deg,#241b30,#201825);
box-shadow: var(--shadow);
}
.privacy-card-icon {
display: grid;
place-items: center;
flex: 0 0 43px;
width: 43px;
height: 43px;
border-radius: 50%;
background: var(--purple);
color: #fff;
font-size: 21px;
font-weight: 900;
}
.privacy-card h2 {
margin: 0 0 5px;
font-size: 21px;
}
.privacy-card p {
margin: 0;
color: #bbb2c4;
line-height: 1.65;
}
.privacy-section {
position: relative;
padding: 38px 12px 34px 61px;
border-bottom: 1px solid var(--line);
scroll-margin-top: 96px;
}
.privacy-section:last-child {
border-bottom: 0;
}
.privacy-number {
position: absolute;
left: 10px;
top: 41px;
color: #9e76d9;
font-size: 12px;
font-weight: 900;
letter-spacing: .12em;
}
.privacy-section h2 {
margin: 0 0 13px;
font-size: 25px;
letter-spacing: -.3px;
}
.privacy-section p {
margin: 0 0 15px;
color: #b7afc0;
font-size: 15px;
line-height: 1.72;
}
.privacy-section ul {
margin: 8px 0 0;
padding-left: 21px;
color: #b7afc0;
}
.privacy-section li {
margin: 9px 0;
padding-left: 4px;
line-height: 1.62;
}
.privacy-section strong {
color: var(--ink);
}
.privacy-section a {
color: #b88cff;
font-weight: 800;
text-underline-offset: 3px;
}
.privacy-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
min-height: 86px;
padding: 22px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
}
.privacy-footer span {
color: var(--ink);
font-weight: 900;
}
.privacy-footer a {
color: var(--muted);
text-decoration: none;
}
.privacy-footer a:hover {
color: var(--purple);
}
@media (max-width:820px) {
.privacy-layout {
grid-template-columns: 1fr;
}
.privacy-toc {
position: static;
grid-template-columns: repeat(2,minmax(0,1fr));
}
.privacy-toc strong {
grid-column: 1/-1;
}
.privacy-hero {
padding: 40px 36px;
}
}
@media (max-width:560px) {
.privacy-header {
min-height: 60px;
padding: 5px 14px;
}
.privacy-logo img {
width: 142px;
height: 42px;
}
.privacy-home-link {
padding: 8px 11px;
font-size: 12px;
}
.privacy-main {
padding: 26px 14px 64px;
}
.privacy-hero {
padding: 33px 24px;
border-radius: var(--radius-lg);
}
.privacy-hero h1 {
letter-spacing: -1.2px;
}
.privacy-hero p {
font-size: 15px;
}
.privacy-layout {
margin-top: 22px;
}
.privacy-toc {
grid-template-columns: 1fr;
padding: 15px;
}
.privacy-section {
padding: 32px 4px 29px;
}
.privacy-number {
position: static;
display: block;
margin-bottom: 7px;
}
.privacy-section h2 {
font-size: 22px;
}
.privacy-card {
padding: 21px 20px;
}
.privacy-card-icon {
display: none;
}
.privacy-footer {
gap: 16px;
}
}
+133
View File
@@ -0,0 +1,133 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#17111f">
<meta name="description" content="Learn how Mocha collects, uses, and protects information when you use RecNet and the game server.">
<title>Privacy Policy · Mocha</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap">
<link rel="stylesheet" href="/recnet/tokens.css?v=2">
<link rel="stylesheet" href="/recnet/styles.css">
<link rel="stylesheet" href="/recnet/dark.css">
<link rel="stylesheet" href="/recnet/font.css?v=2">
<link rel="stylesheet" href="/recnet/privacy.css?v=1">
</head>
<body class="privacy-page">
<header class="privacy-header">
<a class="privacy-logo" href="/recnet/" aria-label="Mocha home">
<img src="/recnet/mocha-logo.png?v=2" width="190" height="48" alt="Mocha">
</a>
<a class="privacy-home-link" href="/recnet/">
<span aria-hidden="true">&larr;</span>
Back to RecNet
</a>
</header>
<main class="privacy-main">
<section class="privacy-hero" aria-labelledby="privacy-title">
<div class="privacy-eyebrow">Your data, explained</div>
<h1 id="privacy-title">Privacy Policy</h1>
<p>Mocha uses a small amount of information to run RecNet, connect you to rooms, and keep the community safe. This page explains what we collect and what choices you have.</p>
<div class="privacy-effective">Effective July 26, 2026</div>
</section>
<div class="privacy-layout">
<nav class="privacy-toc" aria-label="Privacy policy sections">
<strong>On this page</strong>
<a href="#information">Information we collect</a>
<a href="#use">How we use it</a>
<a href="#sharing">When it is shared</a>
<a href="#retention">Storage and security</a>
<a href="#children">Junior accounts</a>
<a href="#choices">Your choices</a>
<a href="#changes">Policy changes</a>
<a href="#contact">Contact</a>
</nav>
<article class="privacy-content">
<section class="privacy-card privacy-summary">
<div class="privacy-card-icon" aria-hidden="true">✓</div>
<div>
<h2>The short version</h2>
<p>We use your information to provide Mocha and RecNet. We do not sell your personal information. Public profile details and content you choose to share can be seen by other players.</p>
</div>
</section>
<section id="information" class="privacy-section">
<span class="privacy-number">01</span>
<h2>Information we collect</h2>
<p>The information collected depends on which parts of Mocha you use.</p>
<ul>
<li><strong>Account information:</strong> your account ID, username, display name, linked platform and platform ID, password credentials, email address if you add one, profile image, banner, bio, level, balance, roles, and account settings.</li>
<li><strong>Gameplay and social activity:</strong> rooms and instances you join, presence, friends, messages, invites, cheers, gifts, subscriptions, events, progression, and moderation activity.</li>
<li><strong>Content you provide:</strong> photos, comments, rooms, inventions, profile text, reports, and other content you upload or create.</li>
<li><strong>Technical information:</strong> connection details, device or platform identifiers provided by the game client, request times, error details, and server logs used to operate and secure the service.</li>
</ul>
</section>
<section id="use" class="privacy-section">
<span class="privacy-number">02</span>
<h2>How we use information</h2>
<p>We use this information to:</p>
<ul>
<li>create and maintain your account;</li>
<li>connect you to rooms, friends, messages, invites, and other game features;</li>
<li>display your public profile and content on RecNet and in game;</li>
<li>save progression, purchases, preferences, and room data;</li>
<li>detect abuse, investigate reports, enforce community rules, and protect players;</li>
<li>diagnose errors and improve reliability.</li>
</ul>
</section>
<section id="sharing" class="privacy-section">
<span class="privacy-number">03</span>
<h2>When information is shared</h2>
<p>Your public profile, public rooms, photos, comments, and other public activity may be visible to other players. Private account details are available only where needed to operate or moderate the service.</p>
<p>Information may also be disclosed when required by law, to respond to a valid legal request, or when reasonably necessary to protect Mocha, its players, or another person. Mocha does not sell your personal information.</p>
</section>
<section id="retention" class="privacy-section">
<span class="privacy-number">04</span>
<h2>Storage, retention, and security</h2>
<p>Information is kept for as long as your account is active or as needed to operate Mocha, resolve disputes, enforce rules, prevent abuse, and meet legal obligations. Some logs and moderation records may remain after account deletion when they are needed for safety or integrity.</p>
<p>We use reasonable safeguards to protect stored information. No online service can guarantee perfect security, so use a unique password and do not share it with anyone.</p>
</section>
<section id="children" class="privacy-section">
<span class="privacy-number">05</span>
<h2>Junior accounts</h2>
<p>Mocha may support junior accounts and junior-friendly rooms. Junior accounts may receive restricted communication and social features. A parent or guardian should supervise a child’s use of the service and contact server staff if they need help with the child’s information.</p>
</section>
<section id="choices" class="privacy-section">
<span class="privacy-number">06</span>
<h2>Your choices</h2>
<p>You can update most profile information from <a href="/recnet/#settings">RecNet Settings</a>. You can also delete your account there. Account deletion is permanent and removes the active server account, although limited safety, moderation, or backup records may remain where necessary.</p>
<p>You may ask server staff to help you access, correct, or remove your information. Some requests may require verification that you own the account.</p>
</section>
<section id="changes" class="privacy-section">
<span class="privacy-number">07</span>
<h2>Changes to this policy</h2>
<p>This policy may be updated as Mocha changes. The effective date at the top of this page will be changed when a new version is published. Material changes may also be announced through RecNet or an in-game notice.</p>
</section>
<section id="contact" class="privacy-section">
<span class="privacy-number">08</span>
<h2>Contact</h2>
<p>For privacy questions or account-data requests, contact a Mocha administrator through the community’s official support channel. Include your account ID, but never send your password.</p>
</section>
</article>
</div>
</main>
<footer class="privacy-footer">
<span>Mocha</span>
<a href="/recnet/">RecNet</a>
<a href="/privacy" aria-current="page">Privacy</a>
<a href="/tos">Terms</a>
</footer>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+341
View File
@@ -0,0 +1,341 @@
.shop-hero {
position: relative;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
overflow: hidden;
margin-bottom: 24px;
padding: 34px;
border: 1px solid var(--purple);
border-radius: var(--radius-lg);
background: radial-gradient(circle at 86% 15%,rgba(255,190,150,.3),transparent 28%),linear-gradient(135deg,var(--pink),#5c2410);
box-shadow: 0 18px 50px rgba(40,14,4,.35);
color: #fff;
}
.shop-hero:after {
content: "";
position: absolute;
right: -45px;
bottom: -80px;
width: 250px;
height: 250px;
border: 35px solid rgba(255,255,255,.07);
border-radius: 50%;
}
.shop-hero>div {
position: relative;
z-index: 1;
}
.shop-hero h1 {
margin: 5px 0 7px;
font: 900 38px/1 var(--futura-extra);
}
.shop-hero p {
max-width: 620px;
margin: 0;
color: #ffe0d0;
}
.shop-wallet {
display: grid;
grid-template-columns: auto auto;
align-items: center;
gap: 0 9px;
min-width: 150px;
padding: 14px 17px;
border: 1px solid rgba(255,255,255,.28);
border-radius: var(--radius-md);
background: rgba(18,8,28,.34);
backdrop-filter: blur(7px);
}
.shop-wallet>i {
grid-row: 1/3;
color: #f2c85b;
font-size: 25px;
}
.shop-wallet strong {
font: 900 21px var(--futura-extra);
}
.shop-wallet span {
color: #d9b8a8;
font-size: 11px;
}
.shop-section-head {
align-items: flex-end;
}
.shop-section-head>div {
display: grid;
gap: 3px;
}
.shop-section-head h2 {
margin: 0;
}
.shop-message {
min-height: 19px;
font-size: 12px;
}
.shop-message.success {
color: #77e6a6;
}
.shop-message.error {
color: #ff8ea0;
}
.shop-grid {
display: grid;
grid-template-columns: repeat(4,minmax(0,1fr));
gap: 15px;
}
.shop-card {
position: relative;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow);
transition: transform .18s ease,border-color .18s ease;
}
.shop-card:hover {
transform: translateY(-3px);
border-color: var(--pink);
}
.shop-art {
position: relative;
display: grid;
place-items: center;
aspect-ratio: 1.12/1;
overflow: hidden;
background: radial-gradient(circle at 50% 35%,#3a2015,#171109 72%);
}
.shop-image {
width: 100%;
height: 100%;
object-fit: contain;
padding: 13px;
filter: drop-shadow(0 13px 10px rgba(0,0,0,.34));
}
.shop-image-fallback {
display: grid;
place-items: center;
width: 92px;
height: 92px;
border: 1px solid #8a5236;
border-radius: 50%;
background: #2b1a12;
color: var(--pink);
font-size: 39px;
}
.shop-image-fallback[hidden] {
display: none;
}
.shop-stars {
position: absolute;
right: 9px;
top: 9px;
padding: 5px 7px;
border-radius: var(--radius-pill);
background: rgba(13,8,17,.77);
color: #ffd665;
font-size: 10px;
letter-spacing: 1px;
backdrop-filter: blur(4px);
}
.shop-card-body {
display: grid;
gap: 7px;
padding: 14px;
}
.shop-rarity {
color: #e8935f;
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .09em;
}
.shop-card h3 {
min-height: 42px;
margin: 0;
font: 900 17px/1.18 var(--futura-extra);
}
.shop-price {
display: flex;
align-items: center;
gap: 6px;
color: #f5e4da;
font-weight: 900;
}
.shop-price i {
color: #f1c65a;
}
.shop-buy-button {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
width: 100%;
margin-top: 3px;
padding: 10px;
border: 1px solid var(--pink);
border-radius: var(--radius-sm);
background: linear-gradient(135deg,var(--purple),var(--pink));
color: #fff;
font: 900 12px var(--futura-extra);
cursor: pointer;
}
.shop-buy-button:hover:not(:disabled) {
filter: brightness(1.12);
}
.shop-buy-button:disabled {
border-color: #4a3f38;
background: #2a2420;
color: #a89890;
cursor: default;
}
.rarity-5 {
border-color: #91662b;
}
.rarity-5 .shop-art {
background: radial-gradient(circle at 50% 35%,#74531c,#1b1211 74%);
}
.rarity-5 .shop-rarity {
color: #ffc861;
}
.rarity-4 {
border-color: #704095;
}
.rarity-3 {
border-color: #365d8c;
}
.website-badge-picker {
display: grid;
grid-template-columns: repeat(6,minmax(0,1fr));
gap: 9px;
margin-top: 15px;
}
.website-badge {
display: grid;
place-items: center;
gap: 6px;
min-width: 0;
padding: 12px 7px;
border: 1px solid #4a3f38;
border-radius: var(--radius-md);
background: #17130f;
color: #ede6de;
cursor: pointer;
}
.website-badge:hover:not(:disabled) {
border-color: var(--pink);
background: #2a2018;
}
.website-badge.selected {
border-color: var(--pink);
box-shadow: 0 0 0 2px rgba(241,110,70,.25);
background: #3a2015;
}
.website-badge:disabled {
opacity: .38;
cursor: not-allowed;
}
.website-badge-icon {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: var(--radius-lg);
background: #3a2818;
color: var(--pink);
font-size: 23px;
}
.website-badge-icon.developer-badge {
background: linear-gradient(145deg,#f04c49,#a72036);
color: #fff;
font: 900 17px var(--futura-extra);
}
.website-badge strong {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
white-space: nowrap;
font-size: 11px;
}
.website-badge small {
color: var(--muted);
font-size: 9px;
text-align: center;
}
@media (max-width:1100px) {
.shop-grid {
grid-template-columns: repeat(3,minmax(0,1fr));
}
.website-badge-picker {
grid-template-columns: repeat(3,minmax(0,1fr));
}
}
@media (max-width:760px) {
.shop-hero {
display: grid;
padding: 25px;
}
.shop-wallet {
width: 100%;
}
.shop-grid {
grid-template-columns: repeat(2,minmax(0,1fr));
}
}
@media (max-width:480px) {
.shop-grid {
grid-template-columns: 1fr;
}
.website-badge-picker {
grid-template-columns: repeat(2,minmax(0,1fr));
}
}
+1338
View File
@@ -0,0 +1,1338 @@
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html {
scroll-behavior: smooth;
}
button,a,.nav-link,.room-card,.user-card,.photo-card,.shop-card,.website-badge {
touch-action: manipulation;
}
body {
margin: 0;
background: var(--canvas-glow), var(--canvas);
background-attachment: fixed;
color: var(--ink);
font: 15px/1.5 var(--font-body);
--shadow: var(--shadow-sm);
}
.masthead {
height: 64px;
background: var(--appbar-bg);
display: flex;
align-items: center;
gap: 8px;
padding: 0 24px;
color: #fff;
position: sticky;
top: 0;
z-index: 20;
box-shadow: 0px 2px 4px -1px rgba(0,0,0,.2), 0px 4px 5px 0px rgba(0,0,0,.14), 0px 1px 10px 0px rgba(0,0,0,.12);
}
.masthead-links {
display: flex;
align-items: center;
gap: 4px;
margin-left: 12px;
}
.masthead-links .nav-link {
width: auto;
padding: 8px 12px;
border-radius: var(--radius-sm);
border-left: 0;
font-size: 14px;
font-weight: 500;
color: var(--ink);
}
.masthead-links .nav-link:hover {
background: var(--surface-hover);
color: var(--ink);
}
.masthead-links .nav-link.active {
background: transparent;
color: var(--pink);
box-shadow: none;
}
.wordmark {
display: flex;
align-items: center;
gap: 10px;
width: auto;
color: #fff;
text-decoration: none;
}
.logo-mark {
display: grid;
place-items: center;
width: 37px;
height: 37px;
border: 3px solid #fff;
border-radius: var(--radius-md);
font-size: 21px;
line-height: 1;
transform: rotate(-3deg);
}
.global-search {
height: 36px;
max-width: 320px;
flex: 1;
position: relative;
}
.global-search span {
position: absolute;
left: 12px;
top: 5px;
color: var(--muted);
font-size: 0;
}
.global-search span:before {
content: "\2315";
font-size: 18px;
}
.global-search input {
width: 100%;
height: 100%;
border: 0;
border-radius: var(--radius-sm);
background: rgba(255,255,255,.09);
padding: 0 14px 0 38px;
font: inherit;
font-size: 14px;
color: var(--ink);
outline: 0;
}
.global-search input::placeholder {
color: var(--muted);
}
.global-search input:focus {
background: rgba(255,255,255,.13);
}
.mast-actions {
margin-left: auto;
padding-left: 22px;
display: flex;
align-items: center;
gap: 16px;
}
.circle-button {
border: 0;
background: transparent;
color: #fff;
opacity: .75;
cursor: pointer;
transition: .15s opacity;
}
.circle-button:hover {
opacity: 1;
}
.mini-avatar {
display: grid;
place-items: center;
width: 37px;
height: 37px;
border: 2px solid rgba(255,255,255,.75);
border-radius: 50%;
font-weight: 800;
transition: .15s border-color;
cursor: pointer;
}
.mini-avatar:hover {
border-color: #fff;
}
.account-menu-wrap {
position: relative;
}
.settings-gear {
display: grid;
place-items: center;
width: 37px;
height: 37px;
font-size: 18px;
}
.account-menu {
position: absolute;
top: calc(100% + 12px);
right: 0;
z-index: 30;
min-width: 200px;
padding: 8px;
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
background: var(--surface-solid);
box-shadow: var(--shadow-lg);
}
.account-menu-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
border: 0;
border-left: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink);
font: inherit;
font-size: 14px;
font-weight: 600;
text-align: left;
text-decoration: none;
cursor: pointer;
transition: .15s background;
}
.account-menu-item:hover {
background: var(--surface-hover);
color: var(--ink);
}
.account-menu-item i {
width: 18px;
text-align: center;
color: var(--muted);
}
.shell {
max-width: 1180px;
margin: 0 auto;
}
.site-footer {
max-width: 1180px;
margin: 20px auto 0;
padding: 24px 42px 40px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
color: var(--faint);
font-size: 12px;
border-top: 1px solid var(--line);
}
.site-footer span:first-child {
font-weight: 800;
color: var(--muted);
}
.site-footer a {
color: inherit;
text-decoration: none;
}
.site-footer a:hover {
color: var(--pink);
text-decoration: underline;
}
.nav-link {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: 14px;
padding: 11px 14px 11px 17px;
border: 0;
border-left: 3px solid transparent;
border-radius: 0 7px 7px 0;
background: transparent;
color: #5d5864;
font: inherit;
font-weight: 680;
text-align: left;
cursor: pointer;
transition: .15s background,.15s color,.15s border-color;
}
.nav-link:hover {
background: #f0edf4;
color: var(--purple);
}
.nav-link.active {
color: var(--purple);
background: var(--purple-soft);
border-left-color: var(--purple);
}
main {
width: 100%;
max-width: 1180px;
margin: 0 auto;
padding: 38px 42px 80px;
}
.page-kicker {
color: var(--purple);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .12em;
}
.page-title {
font-size: 32px;
line-height: 1.15;
letter-spacing: -.7px;
margin: 5px 0;
}
.subtitle,.muted {
color: var(--muted);
}
.section-head {
display: flex;
align-items: end;
justify-content: space-between;
margin: 35px 0 15px;
}
.section-head h2 {
margin: 0;
font-size: 21px;
}
.section-head button {
border: 0;
background: none;
color: var(--purple);
font: inherit;
font-weight: 750;
cursor: pointer;
}
.hero {
min-height: 190px;
border-radius: var(--radius-md);
padding: 32px 36px;
background: var(--grad-brand-strong);
color: #fff;
box-shadow: 0 10px 30px rgba(30,10,55,.35);
position: relative;
overflow: hidden;
}
.hero:after {
content: "";
position: absolute;
width: 260px;
height: 260px;
border: 50px solid rgba(255,255,255,.09);
border-radius: 50%;
right: -45px;
top: -86px;
}
.hero h1 {
font-size: 34px;
margin: 0 0 8px;
letter-spacing: -.8px;
}
.hero p {
max-width: 500px;
margin: 0;
color: #e6dcf8;
font-size: 16px;
}
.hero .hero-chip {
display: inline-block;
margin-bottom: 18px;
padding: 5px 10px;
background: rgba(255,255,255,.17);
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .1em;
}
.photo-grid {
display: grid;
grid-template-columns: repeat(3,minmax(0,1fr));
gap: 18px;
}
.photo-card {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow);
transition: .18s transform,.18s box-shadow;
}
.photo-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(38,27,55,.12);
}
.photo-image {
display: block;
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
background: #e9e6ed;
}
.photo-meta {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 12px;
}
.avatar {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 50%;
background: #e2dce9;
border: 1px solid #ddd6e5;
}
.owner {
font-weight: 780;
color: var(--ink);
text-decoration: none;
}
.owner:hover {
color: var(--purple);
}
.date {
font-size: 12px;
color: var(--muted);
}
.search-panel {
display: flex;
gap: 12px;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 16px;
margin: 22px 0;
box-shadow: var(--shadow);
}
.search {
width: 100%;
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid #d8d3dc;
background: #fff;
color: var(--ink);
font: inherit;
outline: 0;
}
.search:focus {
border-color: var(--purple);
box-shadow: 0 0 0 3px var(--purple-soft);
}
.user-grid {
display: grid;
grid-template-columns: repeat(2,minmax(0,1fr));
gap: 14px;
}
.user-card {
display: flex;
align-items: center;
gap: 15px;
padding: 17px;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius-md);
box-shadow: var(--shadow);
color: inherit;
text-decoration: none;
transition: .15s border-color,.15s transform;
}
.user-card:hover {
border-color: #c5aeea;
transform: translateY(-1px);
}
.user-card .avatar {
width: 62px;
height: 62px;
}
.name {
font-weight: 820;
font-size: 17px;
}
.bio {
color: var(--muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-top: 3px;
}
.profile-layout {
display: flex;
align-items: flex-start;
gap: 30px;
margin-bottom: 8px;
}
.profile-side {
flex: 0 0 300px;
width: 300px;
}
.profile-banner {
height: 150px;
border-radius: var(--radius-md);
background: var(--grad-brand-strong) center/cover;
}
.profile-avatar-row {
margin-top: -44px;
padding-left: 14px;
}
.profile-avatar-row .avatar-wrap {
display: inline-flex;
}
.profile-side-body {
padding: 12px 4px 0;
}
.profile-side-body h1 {
margin: 0;
font-size: 21px;
display: flex;
align-items: center;
gap: 6px;
}
.chips {
display: flex;
gap: 7px;
flex-wrap: wrap;
margin-top: 10px;
}
.chip {
padding: 5px 10px;
border-radius: var(--radius-pill);
background: var(--purple-soft);
color: var(--purple-dark);
font-size: 12px;
font-weight: 700;
}
.profile-side-rule {
height: 1px;
background: var(--line);
margin: 16px 0;
}
.profile-bio {
margin: 0 0 16px;
color: #625d68;
font-size: 14px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.profile-joined {
display: flex;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 12px;
}
.profile-main {
flex: 1 1 auto;
min-width: 0;
}
.empty,.loading {
padding: 65px 20px;
text-align: center;
color: var(--muted);
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius-md);
}
.landing-hero {
min-height: 230px;
display: flex;
flex-direction: column;
justify-content: center;
}
.landing-hero h1 {
font-size: 50px;
margin: 6px 0 14px;
letter-spacing: -1.2px;
}
.landing-hero p {
max-width: 560px;
font-size: 16px;
line-height: 1.55;
}
.landing-cta {
display: flex;
gap: 12px;
margin-top: 24px;
}
.ghost-button {
padding: 12px 18px;
border: 1px solid rgba(255,255,255,.4);
border-radius: var(--radius-sm);
background: rgba(255,255,255,.08);
color: #fff;
font: inherit;
font-weight: 800;
cursor: pointer;
transition: .15s background;
}
.ghost-button:hover {
background: rgba(255,255,255,.18);
}
.landing-features {
display: grid;
grid-template-columns: repeat(4,minmax(0,1fr));
gap: 16px;
}
.feature-card {
padding: 22px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow);
}
.feature-icon {
display: grid;
place-items: center;
width: 44px;
height: 44px;
margin-bottom: 14px;
border-radius: var(--radius-md);
background: var(--purple-soft);
color: var(--purple);
font-size: 19px;
}
.feature-card h3 {
margin: 0 0 6px;
font-size: 16px;
}
.feature-card p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
@media (max-width:900px) {
.landing-features {
grid-template-columns: repeat(2,minmax(0,1fr));
}
}
@media (max-width:620px) {
.landing-hero h1 {
font-size: 36px;
}
.landing-cta {
flex-direction: column;
}
.landing-features {
grid-template-columns: 1fr;
}
}
.stats-strip {
display: flex;
gap: 14px;
margin: 26px 0 6px;
flex-wrap: wrap;
}
.stat-pill {
display: flex;
align-items: center;
gap: 11px;
padding: 13px 19px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow);
}
.stat-pill i {
color: var(--purple);
font-size: 18px;
}
.stat-pill strong {
display: block;
font: 900 20px var(--futura-extra);
}
.stat-pill span {
color: var(--muted);
font-size: 11px;
}
.how-steps {
display: grid;
grid-template-columns: repeat(3,minmax(0,1fr));
gap: 18px;
}
.how-step {
padding: 24px 22px 22px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
}
.how-step-num {
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
margin-bottom: 14px;
border-radius: var(--radius-md);
background: var(--grad-brand);
color: #fff;
font: 900 15px var(--futura-extra);
}
.how-step h3 {
margin: 0 0 6px;
font-size: 15px;
}
.how-step p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.cta-band {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin: 8px 0 10px;
padding: 30px 34px;
border-radius: var(--radius-lg);
background: var(--grad-brand-strong);
color: #fff;
box-shadow: 0 10px 30px rgba(30,10,55,.35);
}
.cta-band h2 {
margin: 0 0 4px;
font-size: 22px;
}
.cta-band p {
margin: 0;
color: #e6dcf8;
font-size: 14px;
}
@media (max-width:700px) {
.how-steps {
grid-template-columns: 1fr;
}
.cta-band {
flex-direction: column;
align-items: flex-start;
}
.stats-strip {
gap: 10px;
}
.stat-pill {
flex: 1 1 140px;
}
}
.loading i {
display: inline-block;
width: 17px;
height: 17px;
margin-right: 9px;
border: 2px solid #d9d1e6;
border-top-color: var(--purple);
border-radius: 50%;
vertical-align: -3px;
animation: spin .75s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width:900px) {
.photo-grid {
grid-template-columns: repeat(2,minmax(0,1fr));
}
main {
padding: 30px 25px;
}
}
@media (max-width:620px) {
.masthead {
height: 60px;
padding: 0 14px;
flex-wrap: wrap;
row-gap: 8px;
}
.wordmark {
width: auto;
}
.wordmark>span:last-child {
display: none;
}
.logo-mark {
width: 35px;
height: 35px;
}
.masthead-links {
order: 3;
width: 100%;
margin-left: 0;
overflow-x: auto;
}
.mast-actions {
gap: 8px;
padding-left: 8px;
}
.global-search {
margin-left: 16px;
}
main {
padding: 22px 14px 85px;
}
.hero {
min-height: 170px;
padding: 26px 23px;
}
.hero h1 {
font-size: 28px;
}
.photo-grid {
grid-template-columns: 1fr;
gap: 14px;
}
.user-grid {
grid-template-columns: 1fr;
}
.profile-layout {
flex-direction: column;
}
.profile-side {
width: 100%;
flex-basis: auto;
}
.profile-banner {
height: 130px;
}
.profile-avatar-row {
margin-top: -38px;
}
.profile-side-body h1 {
font-size: 19px;
}
.section-head {
margin-top: 28px;
}
}
button,input,select,textarea,a {
font: inherit;
}
button,a {
-webkit-appearance: none;
appearance: none;
}
button:focus,button:active,a:focus,a:active,.nav-link:focus,.nav-link:active,.mini-avatar:focus,.mini-avatar:active,.circle-button:focus,.circle-button:active {
outline: none;
}
button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,a:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb,var(--purple) 55%,transparent);
}
.mini-avatar:focus-visible,.circle-button:focus-visible,.logo-mark:focus-visible {
box-shadow: 0 0 0 3px color-mix(in srgb,var(--purple) 55%,transparent);
border-radius: 50%;
}
#app:focus,#app:focus-visible {
outline: none;
}
.search-shell {
position: relative;
max-width: 600px;
flex: 1;
margin-left: 40px;
}
.search-shell .global-search {
display: block;
max-width: none;
}
.global-search-results {
position: absolute;
top: 48px;
left: 0;
right: 0;
z-index: 50;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
color: var(--ink);
box-shadow: 0 18px 55px rgba(17,9,25,.28);
}
.search-result-label {
padding: 9px 13px 6px;
color: var(--muted);
font-size: 10px;
font-weight: 850;
text-transform: uppercase;
letter-spacing: .11em;
}
.global-result {
display: grid;
grid-template-columns: 38px minmax(0,1fr) auto;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 13px;
border: 0;
border-top: 1px solid var(--line);
background: transparent;
color: inherit;
text-decoration: none;
text-align: left;
cursor: pointer;
}
.global-result:hover,.global-result.active {
background: var(--purple-soft);
}
.global-result img,.global-result-icon {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: var(--radius-sm);
object-fit: cover;
background: var(--purple-soft);
color: var(--purple);
}
.global-result.player img {
border-radius: 50%;
}
.global-result strong,.global-result small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.global-result small {
color: var(--muted);
}
.global-result>span:last-child {
color: var(--muted);
font-size: 11px;
}
.global-search-empty {
padding: 18px;
text-align: center;
color: var(--muted);
}
.server-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 11px;
border: 1px solid rgba(255,255,255,.2);
border-radius: var(--radius-pill);
background: rgba(255,255,255,.09);
color: #d8fff0;
font-size: 11px;
font-weight: 800;
cursor: pointer;
}
.server-status i {
width: 8px;
height: 8px;
border-radius: 50%;
background: #42d6a4;
box-shadow: 0 0 0 4px rgba(66,214,164,.14);
}
.server-status.connecting {
color: #ffe0b6;
}
.server-status.connecting i {
background: #ffab5c;
}
.server-status.offline {
color: #ffd0d8;
}
.server-status.offline i {
background: #ff687f;
}
.home-hero {
display: grid;
grid-template-columns: minmax(0,1fr) auto;
align-items: center;
min-height: 230px;
}
.home-hero:before {
content: "";
position: absolute;
inset: auto 70px -100px auto;
width: 210px;
height: 210px;
border-radius: 50%;
background: rgba(255,255,255,.07);
}
.home-hero-content {
position: relative;
z-index: 1;
}
.home-hero-actions {
display: flex;
gap: 10px;
margin-top: 24px;
}
.hero-action {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 11px 15px;
border: 1px solid rgba(255,255,255,.32);
border-radius: var(--radius-sm);
background: rgba(255,255,255,.1);
color: #fff;
text-decoration: none;
font-weight: 800;
cursor: pointer;
}
.hero-action.primary {
border-color: #fff;
background: #fff;
color: var(--purple-dark);
}
.hero-action:hover {
transform: translateY(-1px);
background: rgba(255,255,255,.18);
}
.hero-action.primary:hover {
background: #f7f2ff;
}
.home-live {
position: relative;
z-index: 1;
display: grid;
place-items: center;
width: 145px;
height: 145px;
border: 1px solid rgba(255,255,255,.24);
border-radius: 50%;
background: rgba(25,7,55,.22);
text-align: center;
backdrop-filter: blur(8px);
}
.home-live strong {
display: block;
font-size: 40px;
line-height: 1;
}
.home-live span {
display: block;
margin-top: 5px;
color: #e8dcf8;
font-size: 11px;
font-weight: 850;
text-transform: uppercase;
letter-spacing: .09em;
}
.home-overview {
display: grid;
grid-template-columns: repeat(4,minmax(0,1fr));
gap: 13px;
margin: 20px 0 6px;
}
.overview-card {
display: flex;
align-items: center;
gap: 15px;
padding: 17px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow);
}
.overview-card .overview-icon {
display: grid;
place-items: center;
flex: 0 0 52px;
width: 52px;
height: 52px;
border-radius: var(--radius-md);
background: var(--purple-soft);
color: var(--purple);
font-size: 23px;
line-height: 1;
}
.overview-card .overview-icon i {
font-size: 23px;
color: inherit;
}
.overview-card strong,.overview-card>div>span {
display: block;
}
.overview-card strong {
font-size: 21px;
}
.overview-card>div>span {
color: var(--muted);
font-size: 12px;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(3,minmax(0,1fr));
gap: 14px;
}
.quick-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
color: inherit;
text-decoration: none;
box-shadow: var(--shadow);
transition: .15s transform,.15s border-color;
}
.quick-card:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb,var(--purple) 45%,var(--line));
}
.quick-card i {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: var(--radius-md);
background: var(--purple-soft);
color: var(--purple);
font-size: 18px;
}
.quick-card strong,.quick-card small {
display: block;
}
.quick-card small {
margin-top: 2px;
color: var(--muted);
}
.route-error {
padding: 48px 25px;
text-align: center;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--card);
}
.route-error i {
display: grid;
place-items: center;
width: 54px;
height: 54px;
margin: 0 auto 16px;
border-radius: var(--radius-lg);
background: var(--purple-soft);
color: var(--purple);
font-size: 23px;
}
.route-error h2 {
margin: 0 0 6px;
}
.route-error p {
margin: 0 0 18px;
color: var(--muted);
}
.route-error button {
padding: 10px 16px;
border: 0;
border-radius: var(--radius-sm);
background: var(--purple);
color: #fff;
font-weight: 800;
cursor: pointer;
}
.image-broken {
opacity: .18;
}
@media (max-width:900px) {
.home-overview {
grid-template-columns: repeat(2,minmax(0,1fr));
}
.quick-grid {
grid-template-columns: 1fr;
}
.server-status span {
display: none;
}
.server-status {
padding: 9px;
}
}
@media (max-width:620px) {
.search-shell {
margin-left: 12px;
}
.global-search-results {
position: fixed;
top: 60px;
left: 8px;
right: 8px;
}
.server-status {
display: flex;
}
.home-hero {
display: block;
}
.home-live {
display: none;
}
.home-hero-actions {
flex-wrap: wrap;
}
.home-overview {
grid-template-columns: repeat(2,minmax(0,1fr));
gap: 9px;
}
.overview-card {
padding: 13px 11px;
}
.global-search {
margin-left: 0;
}
.search-shell {
margin-left: 0;
}
.mast-actions {
gap: 8px;
}
.login-button {
white-space: nowrap;
}
}
+66
View File
@@ -0,0 +1,66 @@
:root {
color-scheme: dark;
/* These values are pulled directly from the real RecNet's MUI dark
theme (background.default / background.paper / primary / shape /
shadows), not guessed. Token *names* below (--pink/--purple etc.)
are kept as-is since every stylesheet in this site references them -
only the values changed. */
--canvas: #1d1d1d;
--canvas-glow: none;
--surface: #303030;
--surface-hover: rgba(255,255,255,.08);
--surface-solid: #303030;
--appbar-bg: #212121;
--card: var(--surface);
--glass-border: rgba(255,255,255,.12);
--glass-blur: 0px;
--pink: #f16e46;
--purple: #a84d31;
--purple-dark: #a84d31;
--purple-soft: rgba(241,110,70,.16);
--grad-brand: linear-gradient(120deg, var(--pink), var(--purple));
--grad-brand-strong: linear-gradient(120deg, #f38b6b, #f16e46);
--glow-brand: 0 4px 14px rgba(241,110,70,.3);
--ink: #ffffff;
--muted: #bdbdbd;
--faint: #9e9e9e;
--line: rgba(255,255,255,.12);
--green: #66bb6a;
--blue: #42a5f5;
--orange: #f16e46;
--red: #e57373;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 999px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
/* Real MUI dark-theme elevation shadows (elevation 2 / 4 / 10) */
--shadow-sm: 0px 2px 1px -1px rgba(0,0,0,.2), 0px 1px 1px 0px rgba(0,0,0,.14), 0px 1px 3px 0px rgba(0,0,0,.12);
--shadow-md: 0px 3px 3px -2px rgba(0,0,0,.2), 0px 3px 4px 0px rgba(0,0,0,.14), 0px 1px 8px 0px rgba(0,0,0,.12);
--shadow-lg: 0px 6px 6px -3px rgba(0,0,0,.2), 0px 10px 14px 1px rgba(0,0,0,.14), 0px 4px 18px 3px rgba(0,0,0,.12);
--font-display: "Roboto", "Helvetica", "Arial", sans-serif;
--font-body: "Roboto", "Helvetica", "Arial", sans-serif;
--futura-extra: var(--font-display);
}
.photo-card, .user-card, .profile-card, .search-panel, .overview-card,
.quick-card, .feature-card, .stat-pill, .how-step, .route-error,
.empty, .loading, .login-dialog, .register-dialog, .global-search-results,
.overview-card, .room-card, .admin-account, .admin-tools, .shop-item,
.community-video-tile, .community-photo-tile, .event-card, .badge-card {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
+141
View File
@@ -0,0 +1,141 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#17111f">
<meta name="description" content="Terms of Service for the Mocha game server and RecNet community.">
<title>Terms of Service &middot; Mocha</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap">
<link rel="stylesheet" href="/recnet/tokens.css?v=2">
<link rel="stylesheet" href="/recnet/styles.css">
<link rel="stylesheet" href="/recnet/dark.css">
<link rel="stylesheet" href="/recnet/font.css?v=2">
<link rel="stylesheet" href="/recnet/privacy.css?v=1">
</head>
<body class="privacy-page">
<header class="privacy-header">
<a class="privacy-logo" href="/recnet/" aria-label="Mocha home">
<img src="/recnet/mocha-logo.png?v=2" width="190" height="48" alt="Mocha">
</a>
<a class="privacy-home-link" href="/recnet/">
<span aria-hidden="true">&larr;</span>
Back to RecNet
</a>
</header>
<main class="privacy-main">
<section class="privacy-hero" aria-labelledby="terms-title">
<div class="privacy-eyebrow">Community terms</div>
<h1 id="terms-title">Terms of Service</h1>
<p>These terms explain the rules for using Mocha, RecNet, and services operated as part of the Mocha game server.</p>
<div class="privacy-effective">Effective July 27, 2026</div>
</section>
<div class="privacy-layout">
<nav class="privacy-toc" aria-label="Terms of Service sections">
<strong>On this page</strong>
<a href="#agreement">Agreement</a>
<a href="#accounts">Accounts</a>
<a href="#conduct">Acceptable use</a>
<a href="#content">Player content</a>
<a href="#items">Virtual items</a>
<a href="#moderation">Moderation</a>
<a href="#availability">Availability</a>
<a href="#changes">Changes</a>
<a href="#contact">Contact</a>
</nav>
<article class="privacy-content">
<section class="privacy-card privacy-summary">
<div class="privacy-card-icon" aria-hidden="true">&#10003;</div>
<div>
<h2>The short version</h2>
<p>Be respectful, protect your account, follow community rules, and do not abuse the server or other players. Mocha may moderate accounts and content to keep the service safe and operational.</p>
</div>
</section>
<section id="agreement" class="privacy-section">
<span class="privacy-number">01</span>
<h2>Agreement to these terms</h2>
<p>By creating an account, accessing RecNet, or connecting to the Mocha game server, you agree to these Terms of Service and the <a href="/privacy">Privacy Policy</a>. If you do not agree, do not use the service.</p>
<p>If you use Mocha on behalf of a child or another person, you confirm that you are authorized to accept these terms for them.</p>
</section>
<section id="accounts" class="privacy-section">
<span class="privacy-number">02</span>
<h2>Accounts and security</h2>
<ul>
<li>Provide accurate account information and keep it reasonably up to date.</li>
<li>Keep your password and access credentials private. You are responsible for activity performed through your account.</li>
<li>Do not sell, share, impersonate, or gain unauthorized access to another person&rsquo;s account.</li>
<li>Tell a Mocha administrator promptly if you believe your account has been compromised.</li>
<li>Junior accounts must be used with appropriate parent or guardian supervision.</li>
</ul>
</section>
<section id="conduct" class="privacy-section">
<span class="privacy-number">03</span>
<h2>Acceptable use</h2>
<p>You must follow the in-game Code of Conduct and community rules. You may not:</p>
<ul>
<li>harass, threaten, discriminate against, exploit, or deliberately disrupt other players;</li>
<li>share illegal, sexually explicit, hateful, deceptive, or seriously harmful content;</li>
<li>cheat, evade moderation, abuse bugs, automate requests, or interfere with the server&rsquo;s security or operation;</li>
<li>attempt to obtain passwords, tokens, private information, or unauthorized access;</li>
<li>use Mocha to violate a law or another person&rsquo;s rights.</li>
</ul>
</section>
<section id="content" class="privacy-section">
<span class="privacy-number">04</span>
<h2>Player content</h2>
<p>You retain ownership of content you create where applicable. By uploading or publishing content through Mocha, you give the server permission to store, process, display, copy, resize, and distribute that content as needed to provide and moderate the service.</p>
<p>You confirm that you have the rights needed to share your content. Public rooms, photos, comments, inventions, profile details, and similar content may be visible to other players. Mocha may remove or restrict content that violates these terms or creates a safety, legal, or technical risk.</p>
</section>
<section id="items" class="privacy-section">
<span class="privacy-number">05</span>
<h2>Virtual currency and items</h2>
<p>Tokens, cosmetics, skins, gifts, progression, and other virtual items are server records made available for entertainment within Mocha. They are not legal currency, have no guaranteed cash value, and may not be sold or exchanged outside features expressly provided by the service.</p>
<p>Balances and inventories may be corrected when they result from an error, exploit, unauthorized activity, or data repair. Availability and pricing may change.</p>
</section>
<section id="moderation" class="privacy-section">
<span class="privacy-number">06</span>
<h2>Moderation and termination</h2>
<p>Mocha may investigate reports and restrict content, communication, rooms, devices, or accounts when reasonably necessary to enforce these terms or protect the community. Actions may include warnings, temporary restrictions, suspensions, or permanent bans.</p>
<p>You may stop using Mocha at any time and may delete your account through RecNet where that option is available. Account deletion is permanent, subject to limited records retained for safety, moderation, backups, or legal obligations as described in the Privacy Policy.</p>
</section>
<section id="availability" class="privacy-section">
<span class="privacy-number">07</span>
<h2>Service availability and responsibility</h2>
<p>Mocha is provided on an &ldquo;as available&rdquo; basis. Features may contain errors, change, become unavailable, or be discontinued. There is no guarantee that accounts, rooms, content, progression, inventories, or other data will always be available or free from loss.</p>
<p>To the extent permitted by law, Mocha&rsquo;s operators are not responsible for indirect, incidental, special, consequential, or punitive damages arising from use of or inability to use the service.</p>
</section>
<section id="changes" class="privacy-section">
<span class="privacy-number">08</span>
<h2>Changes to these terms</h2>
<p>These terms may be updated as Mocha changes. The effective date will be revised when a new version is published. Continued use after an update means you accept the revised terms. Material changes may also be announced through RecNet or an in-game notice.</p>
</section>
<section id="contact" class="privacy-section">
<span class="privacy-number">09</span>
<h2>Contact</h2>
<p>For questions about these terms, an account action, or the service, contact a Mocha administrator through the community&rsquo;s official support channel. Include your account ID when useful, but never send your password or access token.</p>
</section>
</article>
</div>
</main>
<footer class="privacy-footer">
<span>Mocha</span>
<a href="/recnet/">RecNet</a>
<a href="/privacy">Privacy</a>
<a href="/tos" aria-current="page">Terms</a>
</footer>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB