Add emote management UI at /manage
Adds a server-side HTML management page and supporting backend for full CRUD on emotes. The /manage prefix is kept isolated so an auth middleware can be applied to it later without touching other routes. - GET /manage — serves the management UI (add, edit, delete, search) - GET /manage/emotes — admin JSON endpoint with uuid and alias included - AdminEmoteResponse model for the expanded admin representation - Client-side search filters by name or alias as you type
This commit is contained in:
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Management UI at `GET /manage` for adding, editing, and deleting emotes.
|
||||||
|
- `GET /manage/emotes` — admin JSON endpoint returning full emote data (uuid,
|
||||||
|
name, alias, url, timestamps) for use by the management UI.
|
||||||
|
- `AdminEmoteResponse` model exposing `uuid` and `alias` fields not present in
|
||||||
|
the public `EmoteResponse`.
|
||||||
|
- Client-side search on the management page filtering emotes by name or alias.
|
||||||
|
|
||||||
## [0.2.4] - 2026-04-11
|
## [0.2.4] - 2026-04-11
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ async fn main() {
|
|||||||
.route("/emotes", post(routes::emotes::create_emote))
|
.route("/emotes", post(routes::emotes::create_emote))
|
||||||
.route("/emotes/{uuid}", put(routes::emotes::update_emote))
|
.route("/emotes/{uuid}", put(routes::emotes::update_emote))
|
||||||
.route("/emotes/{uuid}", delete(routes::emotes::delete_emote))
|
.route("/emotes/{uuid}", delete(routes::emotes::delete_emote))
|
||||||
|
.route("/manage", get(routes::manage::manage_root))
|
||||||
|
.route("/manage/emotes", get(routes::manage::list_admin_emotes))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ pub struct EmoteResponse {
|
|||||||
pub modified: DateTime<Utc>,
|
pub modified: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extended JSON representation used by the management API (includes uuid and alias).
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AdminEmoteResponse {
|
||||||
|
pub uuid: String,
|
||||||
|
pub name: String,
|
||||||
|
pub alias: Option<String>,
|
||||||
|
pub url: String,
|
||||||
|
pub created: DateTime<Utc>,
|
||||||
|
pub modified: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Payload for updating an existing emote.
|
/// Payload for updating an existing emote.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateEmoteRequest {
|
pub struct UpdateEmoteRequest {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{Html, IntoResponse, Json},
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{models::AdminEmoteResponse, AppState};
|
||||||
|
|
||||||
|
/// GET /manage
|
||||||
|
/// Serves the emote management HTML page.
|
||||||
|
pub async fn manage_root() -> impl IntoResponse {
|
||||||
|
Html(include_str!("../templates/manage.html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /manage/emotes
|
||||||
|
/// Returns all emotes with full admin data (uuid, alias included).
|
||||||
|
pub async fn list_admin_emotes(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
match state.db.list_emotes().await {
|
||||||
|
Ok(rows) => {
|
||||||
|
let emotes: Vec<AdminEmoteResponse> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| AdminEmoteResponse {
|
||||||
|
uuid: row.uuid.clone(),
|
||||||
|
name: row.name.clone(),
|
||||||
|
alias: row.alias.clone(),
|
||||||
|
url: state.storage.public_url(&row.image_key),
|
||||||
|
created: row.created_dt(),
|
||||||
|
modified: row.modified_dt(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(StatusCode::OK, Json(json!({"emotes": emotes}))).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to list emotes: {e}");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": "Failed to list emotes"})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod emotes;
|
pub mod emotes;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod manage;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>MIKEBASE — Manage</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; background: #222222; color: #ffffff; }
|
||||||
|
nav {
|
||||||
|
height: 60px; display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 24px; background: #171717; border-bottom: 2px solid #4a0000;
|
||||||
|
box-shadow: 0 2px 12px rgba(74,0,0,0.5); position: relative; z-index: 10;
|
||||||
|
}
|
||||||
|
nav .logo { font-size: 1.5rem; letter-spacing: 0.08em; color: #ffffff; }
|
||||||
|
nav .logo strong { font-weight: 700; color: #ff4d4d; text-shadow: 0 0 8px rgba(74,0,0,0.8); }
|
||||||
|
nav .logo span { font-weight: 300; }
|
||||||
|
nav .logo .sub { font-size: 0.7rem; font-weight: 400; color: #888; letter-spacing: 0.15em; margin-left: 8px; vertical-align: middle; }
|
||||||
|
nav a.back { font-size: 0.85rem; color: #888; text-decoration: none; transition: color 0.2s; }
|
||||||
|
nav a.back:hover { color: #ff4d4d; }
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
}
|
||||||
|
.search-wrap { position: relative; flex: 1; max-width: 360px; }
|
||||||
|
.search-wrap input {
|
||||||
|
width: 100%; padding: 8px 12px 8px 36px;
|
||||||
|
background: #171717; border: 1px solid #333; border-radius: 8px;
|
||||||
|
color: #fff; font-size: 0.9rem; outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.search-wrap input:focus { border-color: #4a0000; }
|
||||||
|
.search-wrap input::placeholder { color: #555; }
|
||||||
|
.search-wrap svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); opacity: 0.4; }
|
||||||
|
button.add-btn {
|
||||||
|
margin-left: auto; padding: 8px 18px;
|
||||||
|
background: #4a0000; border: none; border-radius: 8px;
|
||||||
|
color: #fff; font-size: 0.9rem; cursor: pointer;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
button.add-btn:hover { background: #6b0000; box-shadow: 0 0 12px rgba(74,0,0,0.6); }
|
||||||
|
|
||||||
|
.list { padding: 0 24px 40px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
thead th {
|
||||||
|
text-align: left; font-size: 0.75rem; font-weight: 600;
|
||||||
|
letter-spacing: 0.08em; text-transform: uppercase;
|
||||||
|
color: #666; padding: 0 12px 10px; border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
tbody tr { border-bottom: 1px solid #2a2a2a; transition: background 0.15s; }
|
||||||
|
tbody tr:hover { background: rgba(74,0,0,0.08); }
|
||||||
|
tbody td { padding: 10px 12px; vertical-align: middle; }
|
||||||
|
td.td-img img { width: 48px; height: 48px; object-fit: contain; border-radius: 6px; }
|
||||||
|
td.td-name { font-size: 0.9rem; }
|
||||||
|
td.td-name .code { color: #ff4d4d; font-weight: 500; }
|
||||||
|
td.td-alias { font-size: 0.85rem; color: #888; }
|
||||||
|
td.td-actions { text-align: right; white-space: nowrap; }
|
||||||
|
.btn-edit, .btn-del, .btn-del-confirm {
|
||||||
|
padding: 5px 13px; border-radius: 6px; border: none;
|
||||||
|
font-size: 0.8rem; cursor: pointer; transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.btn-edit { background: #2a2a2a; color: #ccc; margin-right: 6px; }
|
||||||
|
.btn-edit:hover { background: #383838; color: #fff; }
|
||||||
|
.btn-del { background: transparent; border: 1px solid #4a0000; color: #cc3333; }
|
||||||
|
.btn-del:hover { background: #4a0000; color: #fff; }
|
||||||
|
.btn-del-confirm { background: #8b0000; color: #fff; border: none; margin-right: 6px; }
|
||||||
|
.btn-del-confirm:hover { background: #a00000; }
|
||||||
|
.btn-cancel-del { background: transparent; border: 1px solid #444; color: #888; padding: 5px 13px; border-radius: 6px; font-size: 0.8rem; cursor: pointer; }
|
||||||
|
.btn-cancel-del:hover { border-color: #666; color: #ccc; }
|
||||||
|
|
||||||
|
.empty { padding: 48px; text-align: center; color: #555; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.overlay {
|
||||||
|
display: none; position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.7); z-index: 100;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.overlay.open { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: #1a1a1a; border: 1px solid #3a0000;
|
||||||
|
border-radius: 12px; padding: 28px 32px;
|
||||||
|
width: 100%; max-width: 420px;
|
||||||
|
box-shadow: 0 0 40px rgba(74,0,0,0.4);
|
||||||
|
}
|
||||||
|
.modal h2 { font-size: 1.1rem; margin-bottom: 20px; color: #fff; }
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-size: 0.8rem; color: #888; margin-bottom: 6px; letter-spacing: 0.05em; }
|
||||||
|
.field input[type=text], .field input[type=file] {
|
||||||
|
width: 100%; padding: 8px 12px;
|
||||||
|
background: #111; border: 1px solid #333; border-radius: 8px;
|
||||||
|
color: #fff; font-size: 0.9rem; outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.field input[type=text]:focus { border-color: #4a0000; }
|
||||||
|
.field input[type=file] { color: #888; cursor: pointer; }
|
||||||
|
.field .hint { font-size: 0.75rem; color: #555; margin-top: 4px; }
|
||||||
|
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 8px; }
|
||||||
|
.btn-submit {
|
||||||
|
padding: 8px 20px; background: #4a0000; border: none;
|
||||||
|
border-radius: 8px; color: #fff; font-size: 0.9rem; cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.btn-submit:hover { background: #6b0000; }
|
||||||
|
.btn-submit:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 8px 16px; background: transparent; border: 1px solid #444;
|
||||||
|
border-radius: 8px; color: #888; font-size: 0.9rem; cursor: pointer;
|
||||||
|
transition: border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.btn-cancel:hover { border-color: #666; color: #ccc; }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(8px);
|
||||||
|
background: #4a0000; color: #ffffff; padding: 10px 20px; border-radius: 8px;
|
||||||
|
font-size: 0.85rem; opacity: 0;
|
||||||
|
box-shadow: 0 0 16px rgba(74,0,0,0.7);
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
pointer-events: none; z-index: 200;
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
.toast.err { background: #5a1a1a; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="logo">
|
||||||
|
<strong>MIKE</strong><span>BASE</span>
|
||||||
|
<span class="sub">MANAGE</span>
|
||||||
|
</div>
|
||||||
|
<a class="back" href="/">← Back to gallery</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input type="text" id="search" placeholder="Search by code or alias…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<button class="add-btn" id="btn-add">+ Add Emote</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list">
|
||||||
|
<table id="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:64px"></th>
|
||||||
|
<th>Code</th>
|
||||||
|
<th>Alias</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="empty" style="display:none">No emotes found.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Modal -->
|
||||||
|
<div class="overlay" id="overlay-add">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Add Emote</h2>
|
||||||
|
<form id="form-add">
|
||||||
|
<div class="field">
|
||||||
|
<label>NAME <span style="color:#555">(used as :code:)</span></label>
|
||||||
|
<input type="text" id="add-name" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>ALIAS <span style="color:#555">(optional)</span></label>
|
||||||
|
<input type="text" id="add-alias" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>IMAGE</label>
|
||||||
|
<input type="file" id="add-file" accept="image/*" required>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-cancel" id="cancel-add">Cancel</button>
|
||||||
|
<button type="submit" class="btn-submit" id="submit-add">Upload</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
<div class="overlay" id="overlay-edit">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Edit Emote</h2>
|
||||||
|
<form id="form-edit">
|
||||||
|
<div class="field">
|
||||||
|
<label>NAME</label>
|
||||||
|
<input type="text" id="edit-name" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>ALIAS <span style="color:#555">(optional)</span></label>
|
||||||
|
<input type="text" id="edit-alias" autocomplete="off">
|
||||||
|
<div class="hint">To replace the image, delete this emote and re-add it.</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-cancel" id="cancel-edit">Cancel</button>
|
||||||
|
<button type="submit" class="btn-submit" id="submit-edit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var emotes = [];
|
||||||
|
var editUuid = null;
|
||||||
|
var toastTimer;
|
||||||
|
|
||||||
|
function showToast(msg, isErr) {
|
||||||
|
var t = document.getElementById('toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.className = 'toast' + (isErr ? ' err' : '') + ' show';
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(function () { t.className = 'toast' + (isErr ? ' err' : ''); }, 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data ---
|
||||||
|
function load() {
|
||||||
|
fetch('/manage/emotes')
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
emotes = data.emotes || [];
|
||||||
|
render(document.getElementById('search').value.trim().toLowerCase());
|
||||||
|
})
|
||||||
|
.catch(function (e) { showToast('Failed to load: ' + e.message, true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
function render(filter) {
|
||||||
|
var tbody = document.getElementById('tbody');
|
||||||
|
var empty = document.getElementById('empty');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
var list = filter
|
||||||
|
? emotes.filter(function (e) {
|
||||||
|
return e.name.toLowerCase().indexOf(filter) !== -1 ||
|
||||||
|
(e.alias && e.alias.toLowerCase().indexOf(filter) !== -1);
|
||||||
|
})
|
||||||
|
: emotes;
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
empty.style.display = '';
|
||||||
|
document.getElementById('table').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.style.display = 'none';
|
||||||
|
document.getElementById('table').style.display = '';
|
||||||
|
|
||||||
|
list.forEach(function (emote) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
tr.dataset.uuid = emote.uuid;
|
||||||
|
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td class="td-img"><img src="' + esc(emote.url) + '" alt="' + esc(emote.name) + '" loading="lazy"></td>' +
|
||||||
|
'<td class="td-name"><span class="code">:' + esc(emote.name) + ':</span></td>' +
|
||||||
|
'<td class="td-alias">' + (emote.alias ? esc(emote.alias) : '<span style="color:#444">—</span>') + '</td>' +
|
||||||
|
'<td class="td-actions">' +
|
||||||
|
'<button class="btn-edit" data-uuid="' + esc(emote.uuid) + '" data-name="' + esc(emote.name) + '" data-alias="' + esc(emote.alias || '') + '">Edit</button>' +
|
||||||
|
'<button class="btn-del" data-uuid="' + esc(emote.uuid) + '">Delete</button>' +
|
||||||
|
'</td>';
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
|
document.getElementById('search').addEventListener('input', function () {
|
||||||
|
render(this.value.trim().toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Add ---
|
||||||
|
document.getElementById('btn-add').addEventListener('click', function () {
|
||||||
|
document.getElementById('form-add').reset();
|
||||||
|
document.getElementById('overlay-add').classList.add('open');
|
||||||
|
document.getElementById('add-name').focus();
|
||||||
|
});
|
||||||
|
document.getElementById('cancel-add').addEventListener('click', function () {
|
||||||
|
document.getElementById('overlay-add').classList.remove('open');
|
||||||
|
});
|
||||||
|
document.getElementById('overlay-add').addEventListener('click', function (e) {
|
||||||
|
if (e.target === this) this.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('form-add').addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var name = document.getElementById('add-name').value.trim();
|
||||||
|
var alias = document.getElementById('add-alias').value.trim();
|
||||||
|
var file = document.getElementById('add-file').files[0];
|
||||||
|
if (!name || !file) return;
|
||||||
|
|
||||||
|
var btn = document.getElementById('submit-add');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Uploading…';
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('name', name);
|
||||||
|
if (alias) fd.append('alias', alias);
|
||||||
|
fd.append('file', file);
|
||||||
|
|
||||||
|
fetch('/emotes', { method: 'POST', body: fd })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || 'HTTP ' + r.status); });
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
document.getElementById('overlay-add').classList.remove('open');
|
||||||
|
showToast('Emote added');
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch(function (err) { showToast(err.message, true); })
|
||||||
|
.finally(function () { btn.disabled = false; btn.textContent = 'Upload'; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Edit ---
|
||||||
|
document.getElementById('cancel-edit').addEventListener('click', function () {
|
||||||
|
document.getElementById('overlay-edit').classList.remove('open');
|
||||||
|
});
|
||||||
|
document.getElementById('overlay-edit').addEventListener('click', function (e) {
|
||||||
|
if (e.target === this) this.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('tbody').addEventListener('click', function (e) {
|
||||||
|
var editBtn = e.target.closest('.btn-edit');
|
||||||
|
if (editBtn) {
|
||||||
|
editUuid = editBtn.dataset.uuid;
|
||||||
|
document.getElementById('edit-name').value = editBtn.dataset.name;
|
||||||
|
document.getElementById('edit-alias').value = editBtn.dataset.alias;
|
||||||
|
document.getElementById('overlay-edit').classList.add('open');
|
||||||
|
document.getElementById('edit-name').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var delBtn = e.target.closest('.btn-del');
|
||||||
|
if (delBtn) {
|
||||||
|
var uuid = delBtn.dataset.uuid;
|
||||||
|
var td = delBtn.closest('td');
|
||||||
|
td.innerHTML =
|
||||||
|
'<button class="btn-del-confirm" data-uuid="' + esc(uuid) + '">Confirm delete</button>' +
|
||||||
|
'<button class="btn-cancel-del">Cancel</button>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmBtn = e.target.closest('.btn-del-confirm');
|
||||||
|
if (confirmBtn) {
|
||||||
|
var uuid = confirmBtn.dataset.uuid;
|
||||||
|
fetch('/emotes/' + encodeURIComponent(uuid), { method: 'DELETE' })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok && r.status !== 204)
|
||||||
|
return r.json().then(function (j) { throw new Error(j.error || 'HTTP ' + r.status); });
|
||||||
|
})
|
||||||
|
.then(function () { showToast('Emote deleted'); load(); })
|
||||||
|
.catch(function (err) { showToast(err.message, true); load(); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancelDelBtn = e.target.closest('.btn-cancel-del');
|
||||||
|
if (cancelDelBtn) {
|
||||||
|
var tr = cancelDelBtn.closest('tr');
|
||||||
|
var uuid = tr.dataset.uuid;
|
||||||
|
var emote = emotes.find(function (em) { return em.uuid === uuid; });
|
||||||
|
if (emote) {
|
||||||
|
cancelDelBtn.closest('td').innerHTML =
|
||||||
|
'<button class="btn-edit" data-uuid="' + esc(emote.uuid) + '" data-name="' + esc(emote.name) + '" data-alias="' + esc(emote.alias || '') + '">Edit</button>' +
|
||||||
|
'<button class="btn-del" data-uuid="' + esc(emote.uuid) + '">Delete</button>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('form-edit').addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editUuid) return;
|
||||||
|
var name = document.getElementById('edit-name').value.trim();
|
||||||
|
var alias = document.getElementById('edit-alias').value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
var btn = document.getElementById('submit-edit');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Saving…';
|
||||||
|
|
||||||
|
var body = { name: name };
|
||||||
|
if (alias) body.alias = alias;
|
||||||
|
|
||||||
|
fetch('/emotes/' + encodeURIComponent(editUuid), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || 'HTTP ' + r.status); });
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
document.getElementById('overlay-edit').classList.remove('open');
|
||||||
|
showToast('Emote updated');
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch(function (err) { showToast(err.message, true); })
|
||||||
|
.finally(function () { btn.disabled = false; btn.textContent = 'Save'; });
|
||||||
|
});
|
||||||
|
|
||||||
|
load();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user