updated agents and created the ability to rename
This commit is contained in:
parent
c2e852f2cc
commit
48cdf912bc
18
AGENTS.md
18
AGENTS.md
|
|
@ -110,10 +110,26 @@ GET /api/library → List all tracks with id, filename, title, dura
|
|||
## Files
|
||||
|
||||
### Server
|
||||
- **server.ts** — Bun entrypoint. HTTP routes and WebSocket handlers.
|
||||
- **server.ts** — Bun entrypoint. Imports and starts the server.
|
||||
- **config.ts** — Config types, loading, and exports (port, musicDir, etc).
|
||||
- **state.ts** — Shared application state (channels Map, userConnections Map, library).
|
||||
- **init.ts** — Server initialization, channel loading, tick interval, library events.
|
||||
- **broadcast.ts** — Broadcast utilities (broadcastToAll, sendToUser, broadcastChannelList).
|
||||
- **websocket.ts** — WebSocket open/close/message handlers.
|
||||
- **channel.ts** — `Channel` class. Queue, current index, time tracking, broadcasting.
|
||||
- **library.ts** — `Library` class. Scans music directory, computes content hashes.
|
||||
- **db.ts** — SQLite database for users, sessions, tracks.
|
||||
- **auth.ts** — Auth helpers (getUser, requirePermission, session cookies).
|
||||
- **ytdlp.ts** — yt-dlp integration for fetching audio from URLs.
|
||||
|
||||
### Routes (routes/)
|
||||
- **index.ts** — Main router, dispatches to route handlers.
|
||||
- **helpers.ts** — Shared route helpers (getOrCreateUser, userHasPermission).
|
||||
- **auth.ts** — Auth endpoints (signup, login, logout, me, admin).
|
||||
- **channels.ts** — Channel CRUD and control (list, create, delete, jump, seek, queue, mode).
|
||||
- **tracks.ts** — Library listing, file upload, audio serving with range support.
|
||||
- **fetch.ts** — yt-dlp fetch endpoints (check URL, confirm playlist, queue status).
|
||||
- **static.ts** — Static file serving (index.html, styles.css, JS files).
|
||||
|
||||
### Client (public/)
|
||||
- **core.js** — Global state namespace (`window.MusicRoom`)
|
||||
|
|
|
|||
4
db.ts
4
db.ts
|
|
@ -384,6 +384,10 @@ export function deleteChannelFromDb(id: string): void {
|
|||
db.query("DELETE FROM channels WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
export function updateChannelName(id: string, name: string): void {
|
||||
db.query("UPDATE channels SET name = ? WHERE id = ?").run(name, id);
|
||||
}
|
||||
|
||||
// Queue persistence functions
|
||||
export function saveChannelQueue(channelId: string, trackIds: string[]): void {
|
||||
db.query("BEGIN").run();
|
||||
|
|
|
|||
|
|
@ -158,16 +158,60 @@
|
|||
(M.currentUser.isAdmin || ch.createdBy === M.currentUser.id);
|
||||
const deleteBtn = canDelete ? `<button class="btn-delete-channel" title="Delete channel">×</button>` : "";
|
||||
|
||||
// Show rename button for signed-in non-guests
|
||||
const canRename = M.currentUser && !M.currentUser.isGuest;
|
||||
const renameBtn = canRename ? `<button class="btn-rename-channel" title="Rename channel">✏️</button>` : "";
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="channel-header">
|
||||
<span class="channel-name">${ch.name}</span>
|
||||
<input class="channel-name-input" type="text" value="${ch.name.replace(/"/g, '"')}" style="display:none;">
|
||||
${renameBtn}
|
||||
${deleteBtn}
|
||||
<span class="listener-count">${ch.listenerCount}</span>
|
||||
</div>
|
||||
<div class="channel-listeners">${listenersHtml}</div>
|
||||
`;
|
||||
const headerEl = div.querySelector(".channel-header");
|
||||
headerEl.querySelector(".channel-name").onclick = () => M.switchChannel(ch.id);
|
||||
const nameSpan = headerEl.querySelector(".channel-name");
|
||||
const nameInput = headerEl.querySelector(".channel-name-input");
|
||||
nameSpan.onclick = () => M.switchChannel(ch.id);
|
||||
|
||||
const renBtn = headerEl.querySelector(".btn-rename-channel");
|
||||
if (renBtn) {
|
||||
renBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
nameSpan.style.display = "none";
|
||||
renBtn.style.display = "none";
|
||||
nameInput.style.display = "inline-block";
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
};
|
||||
}
|
||||
|
||||
// Handle inline rename
|
||||
const submitRename = async () => {
|
||||
const newName = nameInput.value.trim();
|
||||
if (newName && newName !== ch.name) {
|
||||
await M.renameChannel(ch.id, newName);
|
||||
} else {
|
||||
// Restore original display
|
||||
nameSpan.style.display = "";
|
||||
if (renBtn) renBtn.style.display = "";
|
||||
nameInput.style.display = "none";
|
||||
}
|
||||
};
|
||||
nameInput.onblur = submitRename;
|
||||
nameInput.onkeydown = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
nameInput.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
nameInput.value = ch.name;
|
||||
nameInput.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const delBtn = headerEl.querySelector(".btn-delete-channel");
|
||||
if (delBtn) {
|
||||
delBtn.onclick = (e) => {
|
||||
|
|
@ -411,4 +455,23 @@
|
|||
}
|
||||
M.updateUI();
|
||||
};
|
||||
|
||||
// Rename channel
|
||||
M.renameChannel = async function(channelId, newName) {
|
||||
try {
|
||||
const res = await fetch(`/api/channels/${channelId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName.trim() })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
M.showToast(data.error || "Failed to rename channel", "error");
|
||||
return;
|
||||
}
|
||||
M.showToast(`Channel renamed to "${data.name}"`);
|
||||
} catch (e) {
|
||||
M.showToast("Failed to rename channel", "error");
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="site-header">
|
||||
<h1>Blastoise <span id="sync-indicator"></span></h1>
|
||||
<a href="https://git.peterino.com/peterino/blastoise/issues/new" target="_blank" id="btn-report-bug">Suggestions/Bugs</a>
|
||||
</div>
|
||||
|
||||
<div id="login-panel">
|
||||
<h2>Sign in to continue</h2>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,14 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
|||
#auth-section .admin-badge { background: #c4f; color: #111; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.65rem; }
|
||||
#btn-logout { background: none; border: none; color: #e44; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; }
|
||||
#btn-logout:hover { opacity: 1; text-decoration: underline; }
|
||||
#btn-logout.guest-signin { background: #4e8; color: #111; font-size: 0.85rem; font-weight: 600; padding: 0.4rem 1rem; border-radius: 4px; opacity: 1; }
|
||||
#btn-logout.guest-signin:hover { background: #5fa; text-decoration: none; }
|
||||
#btn-kick-others { background: none; border: none; color: #ea4; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; }
|
||||
#btn-kick-others:hover { opacity: 1; text-decoration: underline; }
|
||||
#site-header { display: flex; align-items: baseline; gap: 1rem; margin-bottom: 0.5rem; }
|
||||
#site-header h1 { margin: 0; }
|
||||
#btn-report-bug { color: #4e8; font-size: 0.8rem; text-decoration: none; padding: 0.3rem 0.6rem; border: 1px solid #4e8; border-radius: 4px; }
|
||||
#btn-report-bug:hover { background: #4e8; color: #111; }
|
||||
#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.85rem; }
|
||||
|
||||
/* Main content - library and queue */
|
||||
|
|
@ -28,10 +34,14 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
|||
#channels-list .channel-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 0.1rem 0; border-radius: 3px; }
|
||||
#channels-list .channel-header:hover { background: #222; }
|
||||
#channels-list .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; }
|
||||
#channels-list .channel-name-input { flex: 1; font-size: 0.8rem; background: #111; color: #eee; border: 1px solid #4e8; border-radius: 3px; padding: 0.1rem 0.3rem; outline: none; min-width: 0; }
|
||||
#channels-list .listener-count { font-size: 0.65rem; color: #666; flex-shrink: 0; margin-left: 0.3rem; }
|
||||
#channels-list .btn-delete-channel { background: none; border: none; color: #666; font-size: 0.9rem; cursor: pointer; padding: 0 0.2rem; line-height: 1; opacity: 0; transition: opacity 0.15s; }
|
||||
#channels-list .channel-header:hover .btn-delete-channel { opacity: 1; }
|
||||
#channels-list .btn-delete-channel:hover { color: #e44; }
|
||||
#channels-list .btn-rename-channel { background: none; border: none; color: #666; font-size: 0.7rem; cursor: pointer; padding: 0 0.2rem; line-height: 1; opacity: 0; transition: opacity 0.15s; }
|
||||
#channels-list .channel-header:hover .btn-rename-channel { opacity: 1; }
|
||||
#channels-list .btn-rename-channel:hover { color: #4e8; }
|
||||
#channels-list .channel-listeners { display: flex; flex-direction: column; margin-left: 0.5rem; border-left: 1px solid #333; padding-left: 0.3rem; }
|
||||
#channels-list .listener { font-size: 0.65rem; color: #aaa; padding: 0.05rem 0; position: relative; }
|
||||
#channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; }
|
||||
|
|
|
|||
|
|
@ -40,11 +40,15 @@
|
|||
if (M.currentUser.isGuest) {
|
||||
M.$("#current-username").textContent = "Guest";
|
||||
M.$("#btn-logout").textContent = "Sign In";
|
||||
M.$("#btn-logout").classList.add("guest-signin");
|
||||
} else {
|
||||
M.$("#current-username").textContent = M.currentUser.username;
|
||||
M.$("#btn-logout").textContent = "Logout";
|
||||
M.$("#btn-logout").classList.remove("guest-signin");
|
||||
}
|
||||
M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none";
|
||||
// Re-render channel list to update rename/delete buttons
|
||||
if (M.renderChannelList) M.renderChannelList();
|
||||
} else {
|
||||
M.$("#login-panel").classList.remove("hidden");
|
||||
M.$("#player-content").classList.remove("visible");
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
deleteChannelFromDb,
|
||||
saveChannelQueue,
|
||||
updateChannelState,
|
||||
updateChannelName,
|
||||
} from "../db";
|
||||
import { state } from "../state";
|
||||
import { broadcastChannelList } from "../broadcast";
|
||||
|
|
@ -130,6 +131,38 @@ export function handleDeleteChannel(req: Request, server: any, channelId: string
|
|||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
// PATCH /api/channels/:id - rename channel
|
||||
export async function handleRenameChannel(req: Request, server: any, channelId: string): Promise<Response> {
|
||||
const { user } = getOrCreateUser(req, server);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
if (user.is_guest) {
|
||||
return Response.json({ error: "Guests cannot rename channels" }, { status: 403 });
|
||||
}
|
||||
const channel = state.channels.get(channelId);
|
||||
if (!channel) {
|
||||
return Response.json({ error: "Channel not found" }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const { name } = await req.json();
|
||||
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
||||
return Response.json({ error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
if (name.trim().length > 64) {
|
||||
return Response.json({ error: "Name must be 64 characters or less" }, { status: 400 });
|
||||
}
|
||||
const newName = name.trim();
|
||||
channel.name = newName;
|
||||
updateChannelName(channelId, newName);
|
||||
console.log(`[Channel] Renamed "${channelId}" to "${newName}" by user ${user.id}`);
|
||||
broadcastChannelList();
|
||||
return Response.json({ success: true, name: newName });
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/channels/:id - get channel state
|
||||
export function handleGetChannel(channelId: string): Response {
|
||||
const channel = state.channels.get(channelId);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
handleSeek,
|
||||
handleModifyQueue,
|
||||
handleSetMode,
|
||||
handleRenameChannel,
|
||||
} from "./channels";
|
||||
|
||||
// Track routes
|
||||
|
|
@ -85,6 +86,11 @@ export function createRouter() {
|
|||
return handleDeleteChannel(req, server, channelDeleteMatch[1]);
|
||||
}
|
||||
|
||||
const channelPatchMatch = path.match(/^\/api\/channels\/([^/]+)$/);
|
||||
if (channelPatchMatch && req.method === "PATCH") {
|
||||
return handleRenameChannel(req, server, channelPatchMatch[1]);
|
||||
}
|
||||
|
||||
const channelGetMatch = path.match(/^\/api\/channels\/([^/]+)$/);
|
||||
if (channelGetMatch && req.method === "GET") {
|
||||
return handleGetChannel(channelGetMatch[1]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue