diff --git a/SongRequest/config.py b/SongRequest/config.py index 0e64fd2..19d650e 100644 --- a/SongRequest/config.py +++ b/SongRequest/config.py @@ -161,6 +161,16 @@ conf.registerChannelValue( ), ) +conf.registerChannelValue( + SongRequest, + "autoApprove", + registry.Boolean( + False, + _("""When True, incoming requests are automatically approved + instead of entering the pending queue."""), + ), +) + conf.registerChannelValue( SongRequest, "passiveDetection", diff --git a/SongRequest/plugin.py b/SongRequest/plugin.py index 65c7427..434e226 100644 --- a/SongRequest/plugin.py +++ b/SongRequest/plugin.py @@ -190,9 +190,18 @@ class SongRequest(callbacks.Plugin): alternates_json=alt_json, ) req = self.store.add(req) - card = render_request_card(req) - self._web_server.publish("request-new", card) - if not self.registryValue("quietQueued", msg.channel, irc.network): + + channel = msg.channel or "" + if self.registryValue("autoApprove", channel, irc.network): + req = self.store.update_status(req.id, "approved") + card = render_request_card(req) + self._web_server.publish("request-new", card) + self._on_web_status_change(req) + else: + card = render_request_card(req) + self._web_server.publish("request-new", card) + + if not self.registryValue("quietQueued", channel, irc.network): irc.reply( f"Queued: {track.display()} \x02|\x02 {track.apple_music_url}", prefixNick=True, @@ -449,5 +458,40 @@ class SongRequest(callbacks.Plugin): clearhistory = wrap(clearhistory, ["admin"]) + def startsession(self, irc, msg, args, name): + """[] + + Start a new request session. Optionally provide a name. Requires admin. + """ + active = self.store.get_active_session() + if active: + irc.reply(f"Session \"{active.name}\" is already active. Stop it first.", prefixNick=True) + return + + if not name: + name = time.strftime("%Y-%m-%d %H:%M") + + session = self.store.start_session(name) + self._web_server.publish_json({"event": "session-started", "session": session.to_dict()}) + irc.reply(f"Session \"{session.name}\" started.", prefixNick=True) + + startsession = wrap(startsession, ["admin", optional("text")]) + + def stopsession(self, irc, msg, args): + """(takes no arguments) + + Stop the active request session and archive it. Requires admin. + """ + active = self.store.get_active_session() + if not active: + irc.reply("No active session to stop.", prefixNick=True) + return + + session = self.store.stop_session(active.id) + self._web_server.publish_json({"event": "session-stopped", "session": session.to_dict() if session else None}) + irc.reply(f"Session \"{active.name}\" stopped and archived.", prefixNick=True) + + stopsession = wrap(stopsession, ["admin"]) + Class = SongRequest diff --git a/SongRequest/store.py b/SongRequest/store.py index 7d263a9..f37c130 100644 --- a/SongRequest/store.py +++ b/SongRequest/store.py @@ -28,8 +28,7 @@ CREATE TABLE IF NOT EXISTS requests ( network TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending', created_at REAL NOT NULL, - updated_at REAL NOT NULL, - alternates_json TEXT NOT NULL DEFAULT '' + updated_at REAL NOT NULL ); CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status); @@ -38,9 +37,28 @@ CREATE INDEX IF NOT EXISTS idx_requests_channel ON requests(channel); MIGRATIONS = [ "ALTER TABLE requests ADD COLUMN alternates_json TEXT NOT NULL DEFAULT ''", + "ALTER TABLE requests ADD COLUMN session_id INTEGER DEFAULT NULL", + """CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', + started_at REAL NOT NULL, + ended_at REAL + )""", + "CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id)", ] +@dataclass +class Session: + id: Optional[int] + name: str + started_at: float + ended_at: Optional[float] + + def to_dict(self) -> dict: + return asdict(self) + + @dataclass class SongRequest: id: Optional[int] @@ -58,6 +76,7 @@ class SongRequest: created_at: float updated_at: float alternates_json: str = "" + session_id: Optional[int] = None def to_dict(self) -> dict: return asdict(self) @@ -85,26 +104,101 @@ class RequestStore: conn.execute("PRAGMA journal_mode=WAL") return conn + # ------------------------------------------------------------------ + # Sessions + # ------------------------------------------------------------------ + + def start_session(self, name: str = "") -> Session: + now = time.time() + with self._lock, self._connect() as conn: + cur = conn.execute( + "INSERT INTO sessions (name, started_at) VALUES (?, ?)", + (name, now), + ) + return Session(id=cur.lastrowid, name=name, started_at=now, ended_at=None) + + def stop_session(self, session_id: int) -> Optional[Session]: + now = time.time() + with self._lock, self._connect() as conn: + conn.execute( + "UPDATE sessions SET ended_at = ? WHERE id = ?", + (now, session_id), + ) + row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + return Session(**dict(row)) if row else None + + def get_active_session(self) -> Optional[Session]: + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1" + ).fetchone() + return Session(**dict(row)) if row else None + + def get_archived_sessions(self) -> List[Session]: + with self._lock, self._connect() as conn: + rows = conn.execute( + "SELECT * FROM sessions WHERE ended_at IS NOT NULL ORDER BY started_at DESC" + ).fetchall() + return [Session(**dict(r)) for r in rows] + + def get_session_requests(self, session_id: int, status: Optional[str] = None) -> List[SongRequest]: + with self._lock, self._connect() as conn: + if status: + rows = conn.execute( + "SELECT * FROM requests WHERE session_id = ? AND status = ? ORDER BY created_at ASC", + (session_id, status), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM requests WHERE session_id = ? ORDER BY created_at ASC", + (session_id,), + ).fetchall() + return [self._row_to_request(r) for r in rows] + + def get_session_played_count(self, session_id: int) -> int: + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT COUNT(*) as cnt FROM requests WHERE session_id = ? AND status = ?", + (session_id, STATUS_PLAYED), + ).fetchone() + return row["cnt"] if row else 0 + + def clear_session_non_played(self, session_id: int) -> int: + with self._lock, self._connect() as conn: + cur = conn.execute( + "DELETE FROM requests WHERE session_id = ? AND status != ?", + (session_id, STATUS_PLAYED), + ) + return cur.rowcount + + # ------------------------------------------------------------------ + # Requests + # ------------------------------------------------------------------ + def add(self, req: SongRequest) -> SongRequest: now = time.time() req.created_at = now req.updated_at = now req.status = STATUS_PENDING with self._lock, self._connect() as conn: + active = conn.execute( + "SELECT id FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1" + ).fetchone() + req.session_id = active["id"] if active else None cur = conn.execute( """INSERT INTO requests (itunes_track_id, title, artist, album, artwork_url, apple_music_url, requester_nick, requester_host, channel, network, status, created_at, updated_at, - alternates_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + alternates_json, session_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( req.itunes_track_id, req.title, req.artist, req.album, req.artwork_url, req.apple_music_url, req.requester_nick, req.requester_host, req.channel, req.network, req.status, req.created_at, req.updated_at, - req.alternates_json, + req.alternates_json, req.session_id, ), ) req.id = cur.lastrowid @@ -140,6 +234,29 @@ class RequestStore: ).fetchall() return [self._row_to_request(r) for r in rows] + def get_channels(self) -> List[str]: + with self._lock, self._connect() as conn: + rows = conn.execute( + "SELECT DISTINCT channel FROM requests WHERE channel != '' ORDER BY channel" + ).fetchall() + return [r["channel"] for r in rows] + + def get_by_status_and_channel(self, channel: Optional[str], *statuses: str) -> List[SongRequest]: + placeholders = ",".join("?" for _ in statuses) + if channel: + with self._lock, self._connect() as conn: + rows = conn.execute( + f"SELECT * FROM requests WHERE status IN ({placeholders}) AND channel = ? ORDER BY created_at ASC", + (*statuses, channel), + ).fetchall() + else: + with self._lock, self._connect() as conn: + rows = conn.execute( + f"SELECT * FROM requests WHERE status IN ({placeholders}) ORDER BY created_at ASC", + statuses, + ).fetchall() + return [self._row_to_request(r) for r in rows] + def get_pending(self) -> List[SongRequest]: return self.get_by_status(STATUS_PENDING) @@ -149,16 +266,67 @@ class RequestStore: def get_active(self) -> List[SongRequest]: return self.get_by_status(STATUS_PENDING, STATUS_APPROVED) - def get_history(self, limit: int = 50) -> List[SongRequest]: + def get_history(self, limit: int = 50, channel: Optional[str] = None) -> List[SongRequest]: with self._lock, self._connect() as conn: - rows = conn.execute( - "SELECT * FROM requests ORDER BY created_at DESC LIMIT ?", - (limit,), - ).fetchall() + if channel: + rows = conn.execute( + "SELECT * FROM requests WHERE channel = ? ORDER BY created_at DESC LIMIT ?", + (channel, limit), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM requests ORDER BY created_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [self._row_to_request(r) for r in rows] + + def get_session_history(self, session_id: int, channel: Optional[str] = None) -> List[SongRequest]: + """Get played requests for a specific session, optionally filtered by channel.""" + with self._lock, self._connect() as conn: + if channel: + rows = conn.execute( + "SELECT * FROM requests WHERE session_id = ? AND status = ? AND channel = ? ORDER BY created_at DESC", + (session_id, STATUS_PLAYED, channel), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM requests WHERE session_id = ? AND status = ? ORDER BY created_at DESC", + (session_id, STATUS_PLAYED), + ).fetchall() + return [self._row_to_request(r) for r in rows] + + def get_current_session_history(self, channel: Optional[str] = None) -> List[SongRequest]: + """Get played/rejected from the active session, or unsessioned if no active session.""" + with self._lock, self._connect() as conn: + active = conn.execute( + "SELECT id FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1" + ).fetchone() + if active: + sid = active["id"] + if channel: + rows = conn.execute( + "SELECT * FROM requests WHERE session_id = ? AND status IN (?, ?) AND channel = ? ORDER BY created_at DESC", + (sid, STATUS_PLAYED, STATUS_REJECTED, channel), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM requests WHERE session_id = ? AND status IN (?, ?) ORDER BY created_at DESC", + (sid, STATUS_PLAYED, STATUS_REJECTED), + ).fetchall() + else: + if channel: + rows = conn.execute( + "SELECT * FROM requests WHERE status IN (?, ?) AND channel = ? ORDER BY created_at DESC LIMIT 100", + (STATUS_PLAYED, STATUS_REJECTED, channel), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM requests WHERE status IN (?, ?) ORDER BY created_at DESC LIMIT 100", + (STATUS_PLAYED, STATUS_REJECTED), + ).fetchall() return [self._row_to_request(r) for r in rows] def swap_alternate(self, request_id: int, alt: dict) -> None: - """Replace a request's track data with an alternate's data.""" now = time.time() with self._lock, self._connect() as conn: conn.execute( diff --git a/SongRequest/templates/index.html b/SongRequest/templates/index.html index e9afaec..dd20752 100644 --- a/SongRequest/templates/index.html +++ b/SongRequest/templates/index.html @@ -1,5 +1,5 @@ - + @@ -28,10 +28,48 @@ --radius: 12px; } + [data-theme="dark"] { + --bg: #0f0f0f; + --surface: #1a1a1a; + --surface-hover: #242424; + --border: #2a2a2a; + --text: #e8e8e8; + --text-muted: #888; + } + + [data-theme="light"] { + --bg: #f5f5f7; + --surface: #ffffff; + --surface-hover: #f0f0f0; + --border: #e0e0e0; + --text: #1d1d1f; + --text-muted: #6e6e73; + --pending-bg: rgba(250, 45, 72, 0.06); + --approved-bg: rgba(45, 212, 160, 0.06); + --rejected-bg: rgba(250, 45, 72, 0.04); + --played-bg: rgba(99, 102, 241, 0.06); + } + + @media (prefers-color-scheme: light) { + [data-theme="system"] { + --bg: #f5f5f7; + --surface: #ffffff; + --surface-hover: #f0f0f0; + --border: #e0e0e0; + --text: #1d1d1f; + --text-muted: #6e6e73; + --pending-bg: rgba(250, 45, 72, 0.06); + --approved-bg: rgba(45, 212, 160, 0.06); + --rejected-bg: rgba(250, 45, 72, 0.04); + --played-bg: rgba(99, 102, 241, 0.06); + } + } + * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; + font-size: 16px; background: var(--bg); color: var(--text); line-height: 1.5; @@ -47,10 +85,11 @@ header { display: flex; align-items: center; - gap: 1rem; - margin-bottom: 2.5rem; + gap: 0.75rem; + margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border); + flex-wrap: wrap; } header .logo { @@ -71,11 +110,24 @@ letter-spacing: -0.02em; } + .header-controls { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.75rem; + } + .toggle-label { display: flex; align-items: center; + gap: 0.5rem; cursor: pointer; - margin-left: auto; + } + + .toggle-text { + font-size: 0.8125rem; + color: var(--text-muted); + white-space: nowrap; } .toggle-label input { display: none; } @@ -87,6 +139,7 @@ border-radius: 10px; position: relative; transition: background 0.2s; + flex-shrink: 0; } .toggle-slider::after { @@ -104,6 +157,24 @@ .toggle-label input:checked + .toggle-slider { background: var(--approve); } .toggle-label input:checked + .toggle-slider::after { transform: translateX(16px); } + .theme-btn { + background: none; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-muted); + cursor: pointer; + font-size: 1rem; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + flex-shrink: 0; + } + + .theme-btn:hover { border-color: #444; color: var(--text); } + .closed-banner { text-align: center; padding: 0.5rem; @@ -120,8 +191,8 @@ width: 8px; height: 8px; border-radius: 50%; - margin-left: 0.75rem; transition: background 0.3s; + flex-shrink: 0; } .status-dot.connected { background: var(--approve); animation: pulse 2s ease-in-out infinite; } @@ -132,6 +203,72 @@ 50% { opacity: 0.4; } } + @keyframes tab-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(250, 45, 72, 0); } + 50% { box-shadow: 0 0 0 4px rgba(250, 45, 72, 0.3); } + } + + .tab.pulsing { + animation: tab-pulse 1.5s ease-in-out infinite; + color: var(--accent); + font-weight: 600; + } + + .session-bar { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + padding: 0.625rem 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + font-size: 0.875rem; + flex-wrap: wrap; + } + + .session-name { + font-weight: 600; + color: var(--text); + } + + .session-duration { + color: var(--text-muted); + font-size: 0.8125rem; + } + + .session-bar .btn { + margin-left: auto; + font-size: 0.75rem; + padding: 0.3rem 0.65rem; + } + + .channel-bar { + display: flex; + gap: 0.375rem; + margin-bottom: 1rem; + flex-wrap: wrap; + } + + .channel-pill { + padding: 0.35rem 0.75rem; + border: 1px solid var(--border); + border-radius: 20px; + background: transparent; + color: var(--text-muted); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + } + + .channel-pill:hover { border-color: #444; color: var(--text); } + .channel-pill.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; + } + .tabs { display: flex; gap: 0.25rem; @@ -147,7 +284,7 @@ background: transparent; border: none; color: var(--text-muted); - font-size: 0.875rem; + font-size: 0.9375rem; font-weight: 500; cursor: pointer; border-radius: 8px; @@ -180,8 +317,8 @@ .request-card.status-played { background: var(--played-bg); border-color: rgba(99, 102, 241, 0.15); } .album-art { - width: 80px; - height: 80px; + width: 96px; + height: 96px; border-radius: 8px; object-fit: cover; flex-shrink: 0; @@ -195,7 +332,7 @@ .card-title { font-weight: 600; - font-size: 1rem; + font-size: 1.125rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -203,13 +340,13 @@ .card-artist { color: var(--accent); - font-size: 0.875rem; + font-size: 1rem; font-weight: 500; } .card-album { color: var(--text-muted); - font-size: 0.8125rem; + font-size: 0.875rem; } .card-link { @@ -223,20 +360,20 @@ .btn-sm { padding: 0.2rem 0.5rem; - font-size: 0.6875rem; + font-size: 0.75rem; margin-left: auto; flex-shrink: 0; } .card-meta { color: var(--text-muted); - font-size: 0.75rem; + font-size: 0.8125rem; margin-top: 0.25rem; } .card-status-badge { display: inline-block; - font-size: 0.6875rem; + font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; @@ -264,7 +401,7 @@ padding: 0.4rem 0.85rem; border: none; border-radius: 8px; - font-size: 0.8125rem; + font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all 0.15s ease; @@ -285,13 +422,14 @@ } .empty-state p { font-size: 1rem; } - .empty-state .hint { font-size: 0.8125rem; margin-top: 0.5rem; } + .empty-state .hint { font-size: 0.875rem; margin-top: 0.5rem; } .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; + gap: 0.5rem; } .section-actions { @@ -302,7 +440,7 @@ .btn-secondary { background: var(--surface-hover); color: var(--text-muted); - font-size: 0.75rem; + font-size: 0.8125rem; padding: 0.3rem 0.65rem; } @@ -310,7 +448,7 @@ .btn-danger-text:hover { color: var(--reject); } .section-title { - font-size: 0.75rem; + font-size: 0.8125rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; @@ -334,7 +472,7 @@ .alternates summary { cursor: pointer; color: var(--text-muted); - font-size: 0.75rem; + font-size: 0.8125rem; font-weight: 500; user-select: none; } @@ -376,7 +514,7 @@ } .alt-title { - font-size: 0.8125rem; + font-size: 0.875rem; font-weight: 500; white-space: nowrap; overflow: hidden; @@ -384,13 +522,224 @@ } .alt-artist { - font-size: 0.75rem; + font-size: 0.8125rem; color: var(--text-muted); } .alternates + .card-link { border-radius: var(--radius) var(--radius) 0 0; } .card-wrapper .card-link { border-radius: var(--radius); } .card-wrapper:has(.alternates) > .card-link { border-radius: var(--radius) var(--radius) 0 0; } + + .archived-session { + margin-top: 1.5rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + } + + .archived-session summary { + cursor: pointer; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.9375rem; + font-weight: 500; + user-select: none; + list-style: none; + } + + .archived-session summary::-webkit-details-marker { display: none; } + + .archived-session summary::before { + content: '\25B6'; + font-size: 0.625rem; + color: var(--text-muted); + transition: transform 0.2s; + } + + .archived-session[open] summary::before { transform: rotate(90deg); } + + .archived-session .archive-meta { + color: var(--text-muted); + font-size: 0.8125rem; + font-weight: 400; + } + + .archived-session .archive-count { + margin-left: auto; + font-size: 0.75rem; + color: var(--text-muted); + background: var(--surface-hover); + padding: 0.15rem 0.5rem; + border-radius: 6px; + } + + .archived-session .archive-body { + padding: 0 1rem 1rem; + } + + /* ---- Toast notifications ---- */ + #toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 900; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; + max-width: 360px; + } + + .toast { + pointer-events: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem 1rem; + display: flex; + align-items: center; + gap: 0.75rem; + box-shadow: 0 4px 24px rgba(0,0,0,0.3); + animation: toast-in 0.3s ease forwards; + opacity: 1; + transition: opacity 0.3s ease; + } + + .toast.fading { opacity: 0; } + + .toast-body { + flex: 1; + min-width: 0; + font-size: 0.875rem; + color: var(--text); + } + + .toast-dismiss { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1.125rem; + line-height: 1; + padding: 0 0.25rem; + flex-shrink: 0; + transition: color 0.15s; + } + + .toast-dismiss:hover { color: var(--text); } + + @keyframes toast-in { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + + /* ---- Modal ---- */ + .modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 1000; + align-items: center; + justify-content: center; + } + + .modal-overlay.visible { + display: flex; + } + + .modal-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + max-width: 420px; + width: 90%; + padding: 1.5rem; + animation: modal-in 0.2s ease; + } + + @keyframes modal-in { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } + } + + .modal-title { + font-weight: 600; + font-size: 1.125rem; + color: var(--text); + } + + .modal-body { + color: var(--text-muted); + font-size: 0.875rem; + margin-top: 0.5rem; + line-height: 1.5; + } + + .modal-input { + width: 100%; + margin-top: 0.75rem; + padding: 0.5rem 0.75rem; + background: var(--surface-hover); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 0.875rem; + font-family: inherit; + outline: none; + transition: border-color 0.15s; + } + + .modal-input:focus { border-color: var(--accent); } + + .modal-input::placeholder { color: var(--text-muted); } + + .modal-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 1.25rem; + } + + @media (max-width: 640px) { + .container { padding: 1rem; } + + header h1 { font-size: 1.25rem; } + + .header-controls { gap: 0.5rem; } + + .toggle-text { display: none; } + + .album-art { width: 72px; height: 72px; } + + .request-card { gap: 0.75rem; padding: 0.75rem; } + + .card-title { font-size: 1rem; } + .card-artist { font-size: 0.875rem; } + + .channel-bar { + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding-bottom: 2px; + } + .channel-bar::-webkit-scrollbar { display: none; } + .channel-pill { white-space: nowrap; flex-shrink: 0; } + + .section-header { flex-wrap: wrap; } + + .card-actions .btn { min-height: 44px; padding: 0.5rem 1rem; } + + .session-bar { flex-direction: column; align-items: flex-start; gap: 0.5rem; } + .session-bar .btn { margin-left: 0; } + + #toast-container { right: 0.5rem; left: 0.5rem; max-width: none; } + } @@ -398,35 +747,43 @@

Song Requests

- -
+
+ + +
+
+ + +
+ +
+
- - + +
Pending
-
+
Approved
-
+
@@ -434,18 +791,27 @@ + +
+ + @@ -455,30 +821,155 @@ var reconnectDelay = 1000; var maxReconnectDelay = 30000; var statusDot = document.getElementById('status-dot'); + var activeChannel = null; + var currentSession = null; + var sessionTimerInterval = null; + var currentTab = 'queue'; + // ---- Theme ---- + var themeOrder = ['system', 'light', 'dark']; + var themeIcons = { system: '\u263E', light: '\u2600', dark: '\u263E' }; + + function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + var btn = document.getElementById('theme-btn'); + btn.innerHTML = themeIcons[theme] || themeIcons.system; + btn.title = theme.charAt(0).toUpperCase() + theme.slice(1) + ' theme'; + } + + function cycleTheme() { + var current = localStorage.getItem('theme') || 'system'; + var idx = themeOrder.indexOf(current); + var next = themeOrder[(idx + 1) % themeOrder.length]; + applyTheme(next); + } + + (function initTheme() { + var saved = localStorage.getItem('theme') || 'system'; + applyTheme(saved); + })(); + + // ---- URL channel routing ---- + function getChannelFromURL() { + var hash = location.hash.replace(/^#/, ''); + if (hash) return hash.startsWith('#') ? hash : (hash.startsWith('%23') ? decodeURIComponent(hash) : '#' + hash); + var path = location.pathname.replace(/^\//, ''); + if (path && path !== '' && !path.startsWith('api/') && !path.startsWith('static/') && !path.startsWith('ws')) { + return path.startsWith('#') ? path : '#' + path; + } + return null; + } + + function updateURLHash(channel) { + if (channel) { + var frag = channel.startsWith('#') ? channel.substring(1) : channel; + history.replaceState(null, '', '/#' + frag); + } else { + history.replaceState(null, '', '/'); + } + } + + (function initChannel() { + var ch = getChannelFromURL(); + if (ch) activeChannel = ch; + })(); + + window.addEventListener('hashchange', function() { + var ch = getChannelFromURL(); + if (ch !== activeChannel) { + activeChannel = ch; + fetchChannels(); + reloadLists(); + reloadArchivedSessions(); + } + }); + + // ---- Tabs ---- function showTab(tab) { - document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); }); - event.target.classList.add('active'); + currentTab = tab; + var queueBtn = document.getElementById('tab-queue'); + var historyBtn = document.getElementById('tab-history'); + queueBtn.classList.remove('active'); + historyBtn.classList.remove('active'); + if (tab === 'queue') { + queueBtn.classList.add('active'); + queueBtn.classList.remove('pulsing'); + } else { + historyBtn.classList.add('active'); + } document.getElementById('queue-view').style.display = tab === 'queue' ? '' : 'none'; document.getElementById('history-view').style.display = tab === 'history' ? '' : 'none'; } + function isQueueEmpty() { + var p = document.getElementById('pending-list'); + var a = document.getElementById('approved-list'); + return p.children.length === 0 && a.children.length === 0; + } + + // ---- Channel filter ---- + function setChannel(channel, btn) { + activeChannel = channel; + document.querySelectorAll('.channel-pill').forEach(function(p) { p.classList.remove('active'); }); + if (btn) btn.classList.add('active'); + updateURLHash(channel); + reloadLists(); + reloadArchivedSessions(); + } + + function channelParam() { + return activeChannel ? '&channel=' + encodeURIComponent(activeChannel) : ''; + } + + function reloadLists() { + var cp = channelParam(); + fetchInto('pending-list', '/api/requests?status=pending' + cp); + fetchInto('approved-list', '/api/requests?status=approved' + cp); + fetchInto('history-list', '/api/requests?status=history' + cp); + } + + function fetchInto(elementId, url) { + if (AUTH_TOKEN) url += (url.indexOf('?') >= 0 ? '&' : '?') + 'token=' + encodeURIComponent(AUTH_TOKEN); + fetch(url).then(function(r) { return r.text(); }).then(function(html) { + var el = document.getElementById(elementId); + if (el) { el.innerHTML = html; htmx.process(el); } + }).catch(function() {}); + } + + function fetchChannels() { + var url = '/api/channels'; + if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN); + fetch(url).then(function(r) { return r.json(); }).then(function(channels) { + var bar = document.getElementById('channel-bar'); + var pills = ''; + channels.forEach(function(ch) { + var isActive = activeChannel === ch ? ' active' : ''; + pills += ''; + }); + bar.innerHTML = pills; + }).catch(function() {}); + } + + // ---- Auth header for HTMX ---- document.body.addEventListener('htmx:configRequest', function(e) { if (AUTH_TOKEN) { e.detail.headers['X-Auth-Token'] = AUTH_TOKEN; } }); + // ---- Requests open/closed ---- function applyRequestsOpen(isOpen) { var toggle = document.getElementById('requests-toggle'); var banner = document.getElementById('closed-banner'); + var text = document.getElementById('toggle-text'); toggle.checked = isOpen; banner.style.display = isOpen ? 'none' : ''; + text.textContent = isOpen ? 'Open' : 'Closed'; } function toggleRequests(isOpen) { - var url = '/api/status'; - fetch(url, { + fetch('/api/status', { method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, AUTH_TOKEN ? {'X-Auth-Token': AUTH_TOKEN} : {}), @@ -495,25 +986,302 @@ }).catch(function() {}); })(); + // ---- Toast notifications ---- + function showToast(message, duration) { + duration = duration || 5000; + var container = document.getElementById('toast-container'); + var toast = document.createElement('div'); + toast.className = 'toast'; + + var body = document.createElement('div'); + body.className = 'toast-body'; + body.textContent = message; + toast.appendChild(body); + + var dismiss = document.createElement('button'); + dismiss.className = 'toast-dismiss'; + dismiss.innerHTML = '\u00D7'; + dismiss.onclick = function() { removeToast(toast); }; + toast.appendChild(dismiss); + + container.appendChild(toast); + + var fadeTimer = setTimeout(function() { removeToast(toast); }, duration); + toast._fadeTimer = fadeTimer; + } + + function removeToast(toast) { + if (toast._removed) return; + toast._removed = true; + if (toast._fadeTimer) clearTimeout(toast._fadeTimer); + toast.classList.add('fading'); + toast.addEventListener('transitionend', function() { toast.remove(); }); + setTimeout(function() { toast.remove(); }, 400); + } + + // ---- Modal ---- + var modalCallbacks = {}; + + function showModal(opts) { + var overlay = document.getElementById('modal-overlay'); + var titleEl = document.getElementById('modal-title'); + var bodyEl = document.getElementById('modal-body'); + var inputEl = document.getElementById('modal-input'); + var actionsEl = document.getElementById('modal-actions'); + + titleEl.textContent = opts.title || ''; + bodyEl.textContent = opts.body || ''; + + if (opts.input) { + inputEl.style.display = ''; + inputEl.value = opts.inputDefault || ''; + inputEl.placeholder = opts.inputPlaceholder || ''; + } else { + inputEl.style.display = 'none'; + } + + actionsEl.innerHTML = ''; + + if (opts.cancelText !== false) { + var cancelBtn = document.createElement('button'); + cancelBtn.className = 'btn btn-secondary'; + cancelBtn.textContent = opts.cancelText || 'Cancel'; + cancelBtn.onclick = function() { hideModal(); if (opts.onCancel) opts.onCancel(); }; + actionsEl.appendChild(cancelBtn); + } + + if (opts.extraBtn) { + var extraBtn = document.createElement('button'); + extraBtn.className = 'btn ' + (opts.extraBtn.className || 'btn-secondary'); + extraBtn.textContent = opts.extraBtn.text; + extraBtn.onclick = function() { hideModal(); if (opts.extraBtn.onClick) opts.extraBtn.onClick(); }; + actionsEl.appendChild(extraBtn); + } + + var confirmBtn = document.createElement('button'); + confirmBtn.className = 'btn ' + (opts.confirmClass || 'btn-approve'); + confirmBtn.textContent = opts.confirmText || 'Confirm'; + confirmBtn.onclick = function() { + var val = opts.input ? inputEl.value : undefined; + hideModal(); + if (opts.onConfirm) opts.onConfirm(val); + }; + actionsEl.appendChild(confirmBtn); + + modalCallbacks.onCancel = opts.onCancel || null; + overlay.classList.add('visible'); + + if (opts.input) { + setTimeout(function() { inputEl.focus(); }, 50); + } + } + + function hideModal() { + document.getElementById('modal-overlay').classList.remove('visible'); + modalCallbacks = {}; + } + + document.getElementById('modal-overlay').addEventListener('click', function(e) { + if (e.target === this) { + hideModal(); + if (modalCallbacks.onCancel) modalCallbacks.onCancel(); + } + }); + + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && document.getElementById('modal-overlay').classList.contains('visible')) { + var cb = modalCallbacks.onCancel; + hideModal(); + if (cb) cb(); + } + }); + + document.getElementById('modal-input').addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + var confirmBtn = document.querySelector('#modal-actions .btn-approve, #modal-actions .btn:last-child'); + if (confirmBtn) confirmBtn.click(); + } + }); + + // ---- Sessions ---- + function formatDuration(startTs) { + var elapsed = Math.floor(Date.now() / 1000 - startTs); + if (elapsed < 0) elapsed = 0; + var h = Math.floor(elapsed / 3600); + var m = Math.floor((elapsed % 3600) / 60); + if (h > 0) return h + 'h ' + m + 'm'; + return m + 'm'; + } + + function applySessionState(session) { + currentSession = session; + var bar = document.getElementById('session-bar'); + var nameEl = document.getElementById('session-name'); + var durEl = document.getElementById('session-duration'); + var btn = document.getElementById('session-btn'); + var titleEl = document.getElementById('history-title'); + + bar.style.display = ''; + + if (session) { + nameEl.textContent = session.name; + durEl.textContent = formatDuration(session.started_at); + btn.textContent = 'Stop Session'; + btn.className = 'btn btn-reject'; + btn.style.fontSize = '0.75rem'; + btn.style.padding = '0.3rem 0.65rem'; + titleEl.textContent = 'Session: ' + session.name; + if (sessionTimerInterval) clearInterval(sessionTimerInterval); + sessionTimerInterval = setInterval(function() { + durEl.textContent = formatDuration(session.started_at); + }, 30000); + } else { + nameEl.textContent = 'No active session'; + durEl.textContent = ''; + btn.textContent = 'Start Session'; + btn.className = 'btn btn-secondary'; + btn.style.fontSize = '0.75rem'; + btn.style.padding = '0.3rem 0.65rem'; + titleEl.textContent = 'History'; + if (sessionTimerInterval) { clearInterval(sessionTimerInterval); sessionTimerInterval = null; } + } + } + + function handleSessionAction() { + if (currentSession) { + showModal({ + title: 'Stop Session', + body: 'Stop session "' + currentSession.name + '"? You can clear non-played requests or keep them for later.', + confirmText: 'Stop & Clear', + confirmClass: 'btn-reject', + extraBtn: { + text: 'Stop & Keep', + className: 'btn-played', + onClick: function() { + fetch('/api/sessions/stop', { + method: 'POST', + headers: Object.assign({'Content-Type': 'application/json'}, AUTH_TOKEN ? {'X-Auth-Token': AUTH_TOKEN} : {}), + body: JSON.stringify({ clear_remaining: false }) + }).then(function() { fetchSessions(); reloadLists(); reloadArchivedSessions(); }); + } + }, + onConfirm: function() { + fetch('/api/sessions/stop', { + method: 'POST', + headers: Object.assign({'Content-Type': 'application/json'}, AUTH_TOKEN ? {'X-Auth-Token': AUTH_TOKEN} : {}), + body: JSON.stringify({ clear_remaining: true }) + }).then(function() { fetchSessions(); reloadLists(); reloadArchivedSessions(); }); + } + }); + } else { + showModal({ + title: 'Start Session', + body: 'Give the session a name, or leave blank for a timestamp.', + input: true, + inputPlaceholder: 'e.g. Friday Night Mix', + confirmText: 'Start', + confirmClass: 'btn-approve', + onConfirm: function(name) { + fetch('/api/sessions/start', { + method: 'POST', + headers: Object.assign({'Content-Type': 'application/json'}, AUTH_TOKEN ? {'X-Auth-Token': AUTH_TOKEN} : {}), + body: JSON.stringify({ name: name || '' }) + }).then(function() { fetchSessions(); reloadLists(); }); + } + }); + } + } + + function fetchSessions() { + var url = '/api/sessions'; + if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN); + fetch(url).then(function(r) { return r.json(); }).then(function(data) { + applySessionState(data.active); + renderArchivedSessions(data.archived); + }).catch(function() {}); + } + + function reloadArchivedSessions() { + fetchSessions(); + } + + function renderArchivedSessions(archived) { + var container = document.getElementById('archived-sessions'); + if (!archived || archived.length === 0) { + container.innerHTML = ''; + return; + } + var html = '
Previous Sessions
'; + archived.forEach(function(s) { + var start = new Date(s.started_at * 1000).toLocaleString(); + var end = s.ended_at ? new Date(s.ended_at * 1000).toLocaleString() : ''; + var countLabel = s.played_count + ' played'; + html += '
'; + html += ''; + html += '' + escapeHtml(s.name) + ''; + html += '' + start + (end ? ' \u2014 ' + end : '') + ''; + html += '' + countLabel + ''; + html += ''; + html += '
'; + html += '
'; + }); + html += '
'; + container.innerHTML = html; + + container.querySelectorAll('.archived-session').forEach(function(el) { + el.addEventListener('toggle', function() { + if (el.open) { + var sid = el.getAttribute('data-session-id'); + var listEl = document.getElementById('archive-list-' + sid); + if (listEl && !listEl.dataset.loaded) { + var aUrl = '/api/sessions/' + sid + '/requests?_=1' + channelParam(); + if (AUTH_TOKEN) aUrl += '&token=' + encodeURIComponent(AUTH_TOKEN); + fetch(aUrl).then(function(r) { return r.text(); }).then(function(h) { + listEl.innerHTML = h || '

No played requests

'; + listEl.dataset.loaded = '1'; + htmx.process(listEl); + }); + } + } + }); + }); + } + + function escapeHtml(str) { + var d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; + } + + // ---- Export / Clear ---- function exportMarkdown(e) { e.preventDefault(); e.stopPropagation(); - var url = '/api/export/markdown'; - if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN); + var url = '/api/export/markdown?_=1'; + if (activeChannel) url += '&channel=' + encodeURIComponent(activeChannel); + if (AUTH_TOKEN) url += '&token=' + encodeURIComponent(AUTH_TOKEN); window.location.href = url; } function clearHistory() { - if (!confirm('Clear all played and rejected requests from history?')) return; - var url = '/api/history/clear'; - if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN); - fetch(url, { method: 'POST', headers: AUTH_TOKEN ? { 'X-Auth-Token': AUTH_TOKEN } : {} }) - .then(function() { - var hl = document.getElementById('history-list'); - if (hl) hl.innerHTML = ''; - }); + showModal({ + title: 'Clear History', + body: 'This will permanently remove all played and rejected requests from history.', + confirmText: 'Clear', + confirmClass: 'btn-reject', + onConfirm: function() { + var url = '/api/history/clear'; + if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN); + fetch(url, { method: 'POST', headers: AUTH_TOKEN ? { 'X-Auth-Token': AUTH_TOKEN } : {} }) + .then(function() { + var hl = document.getElementById('history-list'); + if (hl) hl.innerHTML = ''; + }); + } + }); } + // ---- WebSocket ---- function connectWS() { var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; var url = proto + '//' + location.host + '/ws'; @@ -529,7 +1297,7 @@ ws.onclose = function() { statusDot.className = 'status-dot disconnected'; - statusDot.title = 'Disconnected — reconnecting...'; + statusDot.title = 'Disconnected \u2014 reconnecting...'; setTimeout(connectWS, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay); }; @@ -553,6 +1321,19 @@ return; } + if (msg.event === 'session-started') { + applySessionState(msg.session); + reloadLists(); + return; + } + + if (msg.event === 'session-stopped') { + applySessionState(null); + reloadLists(); + reloadArchivedSessions(); + return; + } + var cardHtml = msg.html; if (!cardHtml) return; @@ -561,6 +1342,7 @@ var newCard = temp.firstElementChild; if (!newCard) return; + var cardChannel = newCard.getAttribute('data-channel'); var status = newCard.getAttribute('data-status'); var sectionMap = { 'pending': 'pending-list', @@ -571,23 +1353,41 @@ var targetId = sectionMap[status] || 'pending-list'; if (msg.event === 'request-new') { + fetchChannels(); + if (activeChannel && cardChannel !== activeChannel) return; var list = document.getElementById(targetId); if (list) { list.insertBefore(newCard, list.firstChild); htmx.process(newCard); } + if (currentTab === 'history') { + document.getElementById('tab-queue').classList.add('pulsing'); + var titleEl = newCard.querySelector('.card-title'); + var artistEl = newCard.querySelector('.card-artist'); + var title = titleEl ? titleEl.textContent : 'Unknown'; + var artist = artistEl ? artistEl.textContent : ''; + showToast('New request: ' + title + (artist ? ' \u2014 ' + artist : '')); + } } else if (msg.event === 'request-update') { var existing = document.getElementById(newCard.id); if (existing) existing.remove(); + if (activeChannel && cardChannel !== activeChannel) return; var dest = document.getElementById(targetId); if (dest) { dest.insertBefore(newCard, dest.firstChild); htmx.process(newCard); } + if (currentTab === 'queue' && isQueueEmpty()) { + showTab('history'); + } } }; } + // ---- Init ---- + fetchChannels(); + reloadLists(); + fetchSessions(); connectWS(); diff --git a/SongRequest/web.py b/SongRequest/web.py index 2c5368f..4384b3d 100644 --- a/SongRequest/web.py +++ b/SongRequest/web.py @@ -13,7 +13,7 @@ from aiohttp import web import supybot.log as log -from .store import SongRequest as SongRequestModel, VALID_STATUSES +from .store import SongRequest as SongRequestModel, Session, VALID_STATUSES TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") @@ -94,7 +94,7 @@ def render_request_card(req: SongRequestModel) -> str: alternates_html = _render_alternates(req.id, req.alternates_json, req.status) - return f"""
+ return f"""