mirror of
https://github.com/fishtank-dashboard/fishtank-dashboard.git
synced 2026-05-02 06:12:03 -04:00
384 lines
10 KiB
HTML
384 lines
10 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>FISHTANK // CHAT</title>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Bebas+Neue&family=DM+Sans:wght@300;400;500&display=swap');
|
|
|
|
:root {
|
|
--bg: #080b0f;
|
|
--panel: #0d1117;
|
|
--border: #1a2332;
|
|
--accent: #00e5ff;
|
|
--accent2: #ff3d71;
|
|
--accent3: #ffe600;
|
|
--text: #c9d4e0;
|
|
--muted: #4a5568;
|
|
--green: #00ff88;
|
|
}
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: 'DM Sans', sans-serif;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
header {
|
|
padding: 10px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
background: var(--panel);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.logo {
|
|
font-family: 'Bebas Neue', sans-serif;
|
|
font-size: 18px;
|
|
letter-spacing: 3px;
|
|
color: var(--accent);
|
|
}
|
|
.logo span { color: var(--accent2); }
|
|
|
|
.status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 10px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.dot {
|
|
width: 7px; height: 7px;
|
|
border-radius: 50%;
|
|
background: var(--muted);
|
|
}
|
|
.dot.live {
|
|
background: var(--green);
|
|
box-shadow: 0 0 8px var(--green);
|
|
animation: pulse 2s infinite;
|
|
}
|
|
.dot.error { background: var(--accent2); }
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.3; }
|
|
}
|
|
|
|
.msg-count {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 10px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
#chatFeed {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0;
|
|
}
|
|
|
|
#chatFeed::-webkit-scrollbar { width: 4px; }
|
|
#chatFeed::-webkit-scrollbar-track { background: transparent; }
|
|
#chatFeed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
|
|
.msg {
|
|
padding: 7px 14px;
|
|
border-bottom: 1px solid rgba(26,35,50,0.5);
|
|
animation: slideIn 0.2s ease;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.msg:hover { background: rgba(255,255,255,0.02); }
|
|
|
|
@keyframes slideIn {
|
|
from { opacity: 0; transform: translateY(-4px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.msg-header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 7px;
|
|
margin-bottom: 2px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.msg-user {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.msg-clan {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
color: var(--muted);
|
|
background: rgba(255,255,255,0.05);
|
|
padding: 1px 4px;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.msg-medals {
|
|
display: flex;
|
|
gap: 2px;
|
|
align-items: center;
|
|
}
|
|
|
|
.msg-endorsement {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
font-weight: bold;
|
|
letter-spacing: 1px;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.msg-time {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
color: var(--muted);
|
|
margin-left: auto;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.msg-text {
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
word-break: break-word;
|
|
}
|
|
|
|
.msg-text .mention {
|
|
color: var(--accent);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.msg.fish { border-left: 2px solid var(--accent3); background: rgba(255,230,0,0.04); }
|
|
.msg.admin { border-left: 2px solid var(--accent2); background: rgba(255,61,113,0.06); }
|
|
.msg.mod { border-left: 2px solid var(--green); background: rgba(0,255,136,0.05); }
|
|
|
|
.badge {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 8px;
|
|
padding: 1px 5px;
|
|
border-radius: 2px;
|
|
letter-spacing: 1px;
|
|
flex-shrink: 0;
|
|
}
|
|
.badge.admin { background: rgba(255,61,113,0.2); color: var(--accent2); border: 1px solid rgba(255,61,113,0.4); }
|
|
.badge.mod { background: rgba(0,255,136,0.1); color: var(--green); border: 1px solid rgba(0,255,136,0.3); }
|
|
.badge.fish { background: rgba(255,230,0,0.1); color: var(--accent3); border: 1px solid rgba(255,230,0,0.3); }
|
|
.badge.gm { background: rgba(255,61,113,0.15); color: var(--accent2); border: 1px solid rgba(255,61,113,0.3); }
|
|
|
|
.empty {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--muted);
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 12px;
|
|
gap: 10px;
|
|
}
|
|
.empty-icon { font-size: 32px; opacity: 0.3; }
|
|
|
|
.scroll-btn {
|
|
position: fixed;
|
|
bottom: 12px;
|
|
right: 12px;
|
|
background: var(--accent);
|
|
color: var(--bg);
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 10px;
|
|
padding: 5px 10px;
|
|
cursor: pointer;
|
|
letter-spacing: 1px;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.2s;
|
|
z-index: 10;
|
|
}
|
|
.scroll-btn.visible { opacity: 1; pointer-events: auto; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<div class="logo">FISH<span>TANK</span> // CHAT</div>
|
|
<div class="status">
|
|
<div class="dot" id="wsDot"></div>
|
|
<span id="wsStatus">CONNECTING</span>
|
|
</div>
|
|
<div class="msg-count" id="msgCount">0 messages</div>
|
|
</header>
|
|
|
|
<div id="chatFeed">
|
|
<div class="empty">
|
|
<div class="empty-icon">💬</div>
|
|
<span>Waiting for messages...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="scroll-btn" id="scrollBtn" onclick="scrollToBottom()">▼ NEW MESSAGES</button>
|
|
|
|
<script>
|
|
const feed = document.getElementById('chatFeed');
|
|
const scrollBtn = document.getElementById('scrollBtn');
|
|
let msgCount = 0;
|
|
let autoScroll = true;
|
|
let ws = null;
|
|
let reconnectTimer = null;
|
|
|
|
feed.addEventListener('scroll', () => {
|
|
const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60;
|
|
autoScroll = atBottom;
|
|
scrollBtn.classList.toggle('visible', !atBottom);
|
|
});
|
|
|
|
function scrollToBottom() {
|
|
feed.scrollTop = feed.scrollHeight;
|
|
autoScroll = true;
|
|
scrollBtn.classList.remove('visible');
|
|
}
|
|
|
|
function setStatus(state) {
|
|
const dot = document.getElementById('wsDot');
|
|
const label = document.getElementById('wsStatus');
|
|
const map = {
|
|
connected: { cls: 'live', text: 'LIVE' },
|
|
disconnected: { cls: 'error', text: 'OFFLINE' },
|
|
connecting: { cls: '', text: 'CONNECTING' },
|
|
};
|
|
const s = map[state] || map.connecting;
|
|
dot.className = 'dot ' + s.cls;
|
|
label.textContent = s.text;
|
|
label.style.color = s.cls === 'live' ? 'var(--green)' : s.cls === 'error' ? 'var(--accent2)' : 'var(--muted)';
|
|
}
|
|
|
|
function formatTime(ts) {
|
|
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
}
|
|
|
|
function isRealChatMessage(msg) {
|
|
// Filter out TTS messages (user.id === 'tts')
|
|
if (msg.user && msg.user.id === 'tts') return false;
|
|
// Filter out item use messages (metadata.type === 'item')
|
|
if (msg.metadata && msg.metadata.type === 'item') return false;
|
|
// Must have a real message string
|
|
if (!msg.message || typeof msg.message !== 'string') return false;
|
|
return true;
|
|
}
|
|
|
|
function addMessage(msg) {
|
|
const empty = feed.querySelector('.empty');
|
|
if (empty) empty.remove();
|
|
|
|
const user = msg.user || {};
|
|
const meta = msg.metadata || {};
|
|
const isFish = meta.isFish || false;
|
|
const isGM = meta.isGrandMarshall || false;
|
|
const isEpic = meta.isEpic || false;
|
|
const isAdmin = meta.isAdmin || false;
|
|
const isMod = meta.isMod || false;
|
|
|
|
const cls = ['msg',
|
|
isAdmin ? 'admin' : '',
|
|
isMod ? 'mod' : '',
|
|
isFish ? 'fish' : '',
|
|
isGM ? 'grand-marshall' : '',
|
|
isEpic ? 'epic' : '',
|
|
].filter(Boolean).join(' ');
|
|
|
|
// Username color: custom > endorsement color > default
|
|
let nameStyle = '';
|
|
if (user.customUsernameColor) {
|
|
nameStyle = `style="color:${user.customUsernameColor}"`;
|
|
}
|
|
|
|
// Endorsement tag (replaces medals)
|
|
const endorsement = user.endorsement
|
|
? `<span class="msg-endorsement" style="color:${user.endorsementColor || '#888'}">${user.endorsement}</span>`
|
|
: '';
|
|
|
|
// Role badges for special users only
|
|
const badges = [
|
|
isAdmin ? '<span class="badge admin">ADMIN</span>' : '',
|
|
isMod ? '<span class="badge mod">MOD</span>' : '',
|
|
isFish ? '<span class="badge fish">FISH</span>' : '',
|
|
isGM ? '<span class="badge gm">GM</span>' : '',
|
|
].filter(Boolean).join('');
|
|
|
|
const clan = user.clan ? `<span class="msg-clan">[${user.clan}]</span>` : '';
|
|
const ts = msg.timestamp ? formatTime(msg.timestamp) : '';
|
|
|
|
const div = document.createElement('div');
|
|
div.className = cls;
|
|
div.innerHTML = `
|
|
<div class="msg-header">
|
|
<span class="msg-user" ${nameStyle}>${user.displayName || 'unknown'}</span>
|
|
${clan}
|
|
${endorsement}
|
|
${badges}
|
|
${ts ? `<span class="msg-time">${ts}</span>` : ''}
|
|
</div>
|
|
<div class="msg-text">${escapeHtml(String(msg.message))}</div>`;
|
|
|
|
feed.appendChild(div);
|
|
msgCount++;
|
|
document.getElementById('msgCount').textContent = msgCount.toLocaleString() + ' messages';
|
|
if (autoScroll) feed.scrollTop = feed.scrollHeight;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function connect() {
|
|
if (ws) { ws.close(); ws = null; }
|
|
setStatus('connecting');
|
|
ws = new WebSocket('ws://localhost:3000');
|
|
|
|
ws.addEventListener('open', () => setStatus('connected'));
|
|
|
|
ws.addEventListener('message', e => {
|
|
let msg;
|
|
try { msg = JSON.parse(e.data); } catch { return; }
|
|
if (msg._ft === 'ws_status') {
|
|
setStatus(msg.status === 'connected' ? 'connected' : 'disconnected');
|
|
return;
|
|
}
|
|
if (msg._ft === 'event' && msg.event === 'chat:message') {
|
|
const data = msg.data;
|
|
const messages = Array.isArray(data) ? data : [data];
|
|
messages.forEach(m => { if (isRealChatMessage(m)) addMessage(m); });
|
|
}
|
|
});
|
|
|
|
ws.addEventListener('close', () => {
|
|
setStatus('disconnected');
|
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
reconnectTimer = setTimeout(connect, 3000);
|
|
});
|
|
|
|
ws.addEventListener('error', () => setStatus('disconnected'));
|
|
}
|
|
|
|
connect();
|
|
</script>
|
|
</body>
|
|
</html>
|