[READ-ONLY] Mirror of https://github.com/aaronateataco/ermine. A stoat web client erminechat.vercel.app
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: update UI styles to match Discord design, enhance accessibility, and add new features like spoilers and server indicators

Fascinating Pistachio (May 18, 2026, 5:33 PM +0100) cc0ec4d3 710b5155

+182 -107
+53 -10
index.html
··· 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 6 <meta name="description" content="Ermine — a refined client for stoat.chat." /> 7 - <meta name="theme-color" content="#0F1115" /> 8 - <title>Ermine</title> 9 - <!-- Favicon: inline SVG so it always loads without needing file resolution --> 10 - <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230F1115'/%3E%3Cpath d='M18 16h28v8H28v8h14v8H28v8h18v8H18V16z' fill='%23E6EDF3'/%3E%3C/svg%3E" /> 7 + <meta name="theme-color" content="#1e1f22" /> 8 + <title>Ermine — stoat.chat</title> 9 + <link rel="icon" type="image/png" sizes="192x192" href="/assets/android-chrome-192x192.png" /> 10 + <link rel="shortcut icon" href="/assets/android-chrome-192x192.png" /> 11 11 <!-- PNG icons for home screen / PWA (served from public/assets/) --> 12 12 <link rel="apple-touch-icon" sizes="192x192" href="/assets/android-chrome-192x192.png" /> 13 13 <link rel="preconnect" href="https://fonts.googleapis.com" /> ··· 39 39 margin: 0; 40 40 min-height: 100vh; 41 41 font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 42 - background: #0f1115; 42 + background: #313338; 43 + -webkit-font-smoothing: antialiased; 44 + text-rendering: optimizeLegibility; 43 45 } 44 46 45 47 @keyframes ermineModalIn { ··· 50 52 #root { min-height: 100vh; } 51 53 52 54 /* ── Thin scrollbars ── */ 53 - ::-webkit-scrollbar { width: 5px; height: 5px; } 54 - ::-webkit-scrollbar-track { background: transparent; } 55 - ::-webkit-scrollbar-thumb { background: #3f4249; border-radius: 3px; } 56 - ::-webkit-scrollbar-thumb:hover { background: #5c606a; } 57 - * { scrollbar-width: thin; scrollbar-color: #3f4249 transparent; } 55 + ::-webkit-scrollbar { width: 8px; height: 8px; } 56 + ::-webkit-scrollbar-track { background: #2b2d31; border-radius: 4px; } 57 + ::-webkit-scrollbar-thumb { background: #1a1b1e; border-radius: 4px; border: 2px solid #2b2d31; } 58 + ::-webkit-scrollbar-thumb:hover { background: #111214; } 59 + * { scrollbar-width: thin; scrollbar-color: #1a1b1e #2b2d31; } 58 60 59 61 /* ── Auto-growing textarea ── */ 60 62 .composer-textarea { ··· 90 92 100% { background: transparent; } 91 93 } 92 94 .msg-highlight { animation: msgHighlight 1.2s ease-out forwards; } 95 + /* ── Spoiler ── */ 96 + .spoiler { background: #202225; border-radius: 3px; cursor: pointer; } 97 + .spoiler.revealed { background: #383a40; cursor: default; } 98 + .spoiler:not(.revealed) * { color: transparent !important; user-select: none; } 99 + 100 + /* ── Server pill indicator (Discord signature) ── */ 101 + .srv-pill { 102 + position: absolute; left: 0; top: 50%; transform: translateY(-50%); 103 + width: 4px; background: #fff; border-radius: 0 4px 4px 0; 104 + transition: height 150ms ease; 105 + } 106 + .srv-wrap:hover .srv-pill { height: 20px; } 107 + .srv-wrap.active .srv-pill { height: 40px; } 108 + .srv-wrap .srv-icon { 109 + border-radius: 50%; 110 + transition: border-radius 150ms ease, background 150ms ease; 111 + } 112 + .srv-wrap:hover .srv-icon, 113 + .srv-wrap.active .srv-icon { border-radius: 16px; } 114 + 115 + /* ── Channel item Discord-style ── */ 116 + .ch-item { color: #80848e; border-radius: 4px; } 117 + .ch-item:hover { color: #dcddde; background: #35373c; } 118 + .ch-item.active { color: #f2f3f5; background: #404249; } 119 + .ch-item.unread { color: #f2f3f5; font-weight: 600; } 120 + .ch-item.unread::before { 121 + content: ''; position: absolute; left: -8px; top: 50%; 122 + transform: translateY(-50%); width: 8px; height: 8px; 123 + border-radius: 50%; background: #f2f3f5; 124 + } 125 + 126 + /* ── New messages divider ── */ 127 + .new-messages-divider { 128 + display: flex; align-items: center; gap: 8px; 129 + margin: 8px 16px 4px; user-select: none; 130 + } 131 + .new-messages-divider::before, 132 + .new-messages-divider::after { 133 + content: ''; flex: 1; height: 1px; background: #ed4245; 134 + } 135 + 93 136 </style> 94 137 </head> 95 138 <body>
+129 -97
src/App.jsx
··· 110 110 }; 111 111 const { apiUrl: DEF_API, wsUrl: DEF_WS, cdnUrl: DEF_CDN } = getRuntimeConfig(); 112 112 113 - const inputBase = 'w-full rounded-md border border-[#242A35] bg-[#141821] px-3 py-2 text-sm text-[#E6EDF3] placeholder:text-[#8892A6] focus:border-[#8AB4F8] focus:outline-none focus:ring-1 focus:ring-[#8AB4F8]'; 113 + // ─── Discord design tokens ──────────────────────────────────────────────────── 114 + const C = { 115 + bg1: '#1e1f22', // server rail 116 + bg2: '#2b2d31', // sidebar 117 + bg3: '#313338', // chat 118 + bg4: '#383a40', // input 119 + elevated: '#292b2f', // modals, embeds 120 + hover: '#35373c', 121 + active: '#404249', 122 + border: '#1e1f22', 123 + brand: '#5865f2', 124 + brandHov: '#4752c4', 125 + text1: '#f2f3f5', // primary 126 + text2: '#b5bac1', // secondary 127 + text3: '#80848e', // muted 128 + green: '#23a55a', 129 + yellow: '#f0b232', 130 + red: '#f23f43', 131 + blue: '#4f7dff', 132 + }; 133 + const inputBase = 'w-full rounded bg-[#1e1f22] border border-transparent px-3 py-2 text-sm text-[#f2f3f5] placeholder:text-[#80848e] focus:border-[#5865f2] focus:outline-none focus:ring-0 transition-colors'; 114 134 115 135 const TK = 'ermine_session_token'; 116 136 const UK = 'ermine_user_id'; ··· 162 182 163 183 // ─── Avatar component ───────────────────────────────────────────────────────── 164 184 const uidToHue = (id = '') => { let h = 0; for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) & 0xffff; return h % 360; }; 165 - const PRESENCE_COLOR = { Online: '#3ba55d', Busy: '#ed4245', Idle: '#f0b232', Focus: '#4f7dff' }; 185 + const PRESENCE_COLOR = { Online: '#23a55a', Busy: '#f23f43', Idle: '#f0b232', Focus: '#4f7dff' }; 166 186 const getPresenceColor = (p) => PRESENCE_COLOR[p] ?? '#747f8d'; 167 187 168 188 const Avatar = ({ user, cdn, size = 'md', hover = false, always = false }) => { ··· 351 371 }; 352 372 const renderMdInline = (text, key, openLink) => { 353 373 if (!text) return null; 354 - return text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|__[^_]+__|~~[^~]+~~|\[[^\]]+\]\([^)]+\))/g).map((tok, i) => { 355 - if (/^`[^`]+`$/.test(tok)) return <code key={`${key}-${i}`} className="rounded bg-[#232428] px-1 py-0.5 text-xs font-mono text-[#f2f3f5]">{tok.slice(1,-1)}</code>; 374 + return text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|__[^_]+__|~~[^~]+~~|\|\|.+?\|\||\[[^\]]+\]\([^)]+\))/g).map((tok, i) => { 375 + if (/^`[^`]+`$/.test(tok)) return <code key={`${key}-${i}`} className="rounded bg-[#1e1f22] px-1 py-0.5 text-xs font-mono text-[#f2f3f5]">{tok.slice(1,-1)}</code>; 376 + // Spoiler: ||text|| 377 + if (/^\|\|.+\|\|$/.test(tok)) { 378 + const SpoilerSpan = () => { const [rev, setRev] = React.useState(false); return <span className={`spoiler rounded px-0.5 ${rev ? 'revealed' : ''}`} onClick={() => setRev(true)}>{tok.slice(2,-2)}</span>; }; 379 + return <SpoilerSpan key={`${key}-${i}`} />; 380 + } 356 381 if (/^\*\*[^*]+\*\*$/.test(tok)) return <strong key={`${key}-${i}`}>{tok.slice(2,-2)}</strong>; 357 382 if (/^\*[^*]+\*$/.test(tok)) return <em key={`${key}-${i}`}>{tok.slice(1,-1)}</em>; 358 383 if (/^__[^_]+__$/.test(tok)) return <span key={`${key}-${i}`} className="underline">{tok.slice(2,-2)}</span>; ··· 443 468 if (type === 'Website' || type === 'Text') { 444 469 const imgSrc = image?.url || null; 445 470 return ( 446 - <div className="mt-1.5 rounded-lg border-l-4 bg-[#1e2024] p-3 max-w-sm" style={{ borderColor: colour || '#3a3d42' }}> 471 + <div className="mt-2 rounded rounded-l-none border-l-4 bg-[#2b2d31] p-3 max-w-sm" style={{ borderColor: colour || '#1e1f22' }}> 447 472 {site_name && <p className="text-xs text-gray-400 mb-1">{site_name}</p>} 448 473 {title && <a className="text-sm font-semibold text-[#8ea1ff] hover:underline block" href={url} onClick={(e) => { e.preventDefault(); openLink(url); }} rel="noreferrer" target="_blank">{title}</a>} 449 474 {description && <p className="text-xs text-gray-300 mt-1 line-clamp-3">{description}</p>} ··· 602 627 603 628 return ( 604 629 <article 605 - className={`group relative flex gap-3 px-4 ${grouped ? 'py-0.5' : 'pt-2 pb-0.5'} transition-colors duration-75 hover:bg-[#2e3035] ${replyTarget === message._id ? 'bg-[#3a3f66]/20' : ''}`} 630 + className={`group relative flex gap-3 px-4 ${grouped ? 'py-0.5' : 'pt-3 pb-0.5'} hover:bg-[#2e3035]/50 ${replyTarget === message._id ? 'bg-[#5865f2]/10' : ''}`} 606 631 onMouseEnter={() => setHovered(true)} 607 632 onMouseLeave={() => setHovered(false)} 608 633 ref={(n) => regRef(message._id, n)} ··· 618 643 </div> 619 644 <div className="min-w-0 flex-1 py-1"> 620 645 {rid && ( 621 - <button className="mb-1 flex max-w-full items-center gap-1 text-xs text-gray-400 hover:text-gray-200 transition-colors" onClick={() => jumpTo(rid)} type="button"> 646 + <button className="mb-1 flex max-w-full items-center gap-1 text-[12px] text-[#80848e] hover:text-[#b5bac1] transition-colors" onClick={() => jumpTo(rid)} type="button"> 622 647 <Reply size={11} /> 623 648 <span className="truncate">{replyUser?.username || 'Unknown'}: {replyMsg?.content || 'Attachment/embed'}</span> 624 649 </button> ··· 626 651 627 652 {!grouped && ( 628 653 <div className="flex items-baseline gap-2 mb-0.5"> 629 - <button className="text-sm font-semibold hover:underline" onClick={() => onUser(displayUser, authorId)} style={{ color: displayColor || '#fff' }} type="button">{displayUser.username}</button> 654 + <button className="text-[15px] font-semibold leading-none hover:underline" onClick={() => onUser(displayUser, authorId)} style={{ color: displayColor || '#f2f3f5' }} type="button">{displayUser.username}</button> 630 655 {masq && <span className="rounded bg-[#f0b232]/20 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-[#f0b232]">BOT</span>} 631 656 {mine && !masq && <span className="rounded bg-[#5865f2]/20 px-1.5 py-0.5 text-[10px] font-semibold text-[#bdc3ff]">YOU</span>} 632 - <time className="text-[11px] text-gray-500" title={ts}>{ts}</time> 657 + <time className="text-[11px] text-[#80848e] font-medium" title={ts}>{ts}</time> 633 658 {message.edited && <span className="text-[10px] text-gray-600">(edited)</span>} 634 659 </div> 635 660 )} ··· 641 666 onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (editVal.trim() !== message.content) onEdit(message._id, editVal); setEditing(false); } if (e.key === 'Escape') setEditing(false); }} /> 642 667 <div className="text-[11px] text-gray-400 flex gap-2"><span className="flex-1">esc cancel · enter save</span><button onClick={() => setEditing(false)} className="text-[#ed4245] hover:underline">cancel</button><button onClick={() => { if (editVal.trim() !== message.content) onEdit(message._id, editVal); setEditing(false); }} className="text-[#5865f2] hover:underline">save</button></div> 643 668 </div> 644 - ) : message.content ? ( 645 - <p className="whitespace-pre-wrap break-words text-sm text-gray-200">{renderContent(message.content, users, channels, onUser, ceById, cdn, openLink)}</p> 646 - ) : null} 669 + ) : message.content ? (() => { 670 + // Discord: message containing ONLY emoji(s) renders them large 671 + const trimmed = message.content.trim(); 672 + const onlyEmoji = /^(\s*(?::[A-Z0-9]{26}:|[\uD800-\uDBFF][\uDC00-\uDFFF]|[^\S\r\n])+\s*)$/i.test(trimmed) && trimmed.length < 100; 673 + return <p className={`whitespace-pre-wrap break-words ${onlyEmoji ? 'text-4xl leading-relaxed' : 'text-[15px] text-[#dcddde] leading-[1.375]'}`}>{renderContent(message.content, users, channels, onUser, ceById, cdn, openLink)}</p>; 674 + })() : null} 647 675 648 676 {/* API embeds */} 649 677 {Array.isArray(message.embeds) && message.embeds.map((em, i) => <UrlEmbed key={i} embed={em} cdn={cdn} openLink={openLink} />)} ··· 677 705 const reacted = ids.includes(me); 678 706 const ce = resolveCE(emoji, ceById); 679 707 return ( 680 - <button key={emoji} className={`reaction-btn rounded-full border px-2 py-0.5 text-xs flex items-center gap-1 ${reacted ? 'border-[#5865f2] bg-[#5865f2]/20 text-[#d7ddff]' : 'border-[#4c4f56] bg-[#2b2d31] text-gray-200 hover:bg-[#35373c]'}`} 708 + <button key={emoji} className={`reaction-btn rounded-lg border h-7 px-2 text-xs flex items-center gap-1 ${reacted ? 'border-[#5865f2]/60 bg-[#5865f2]/15 text-[#c4c9ff]' : 'border-[#35373c] bg-[#2b2d31] text-[#b5bac1] hover:bg-[#35373c] hover:border-[#5865f2]/40'}`} 681 709 onClick={() => onReact(message, emoji, reacted)} title={ce?.name || emoji} type="button"> 682 710 {renderEmojiVis(emoji, ce, cdn)} {ids.length} 683 711 </button> ··· 686 714 </div> 687 715 </div> 688 716 689 - {/* Hover action bar */} 690 - <div className={`absolute right-4 -top-3 z-10 transition-opacity duration-100 ${hovered || pickerOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}> 691 - <div className="flex items-center bg-[#313338] border border-[#2f3237] rounded-md shadow-md"> 692 - <button className="p-1.5 text-gray-400 hover:text-gray-200 hover:bg-[#404249] rounded-l-md" onClick={() => onReply(message)} title="Reply"><Reply size={14} /></button> 693 - <button className="p-1.5 text-gray-400 hover:text-gray-200 hover:bg-[#404249]" onClick={() => setPickerOpen((p) => !p)} title="React"><span className="text-sm leading-none">😊</span></button> 717 + {/* Hover action bar — Discord-style floating toolbar */} 718 + <div className={`absolute right-4 -top-4 z-10 transition-all duration-75 ${hovered || pickerOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-1 pointer-events-none'}`}> 719 + <div className="flex items-center bg-[#2b2d31] border border-[#1e1f22] rounded-lg shadow-xl divide-x divide-[#1e1f22]"> 720 + <button className="px-2 py-1.5 text-[#b5bac1] hover:text-[#f2f3f5] hover:bg-[#35373c] rounded-l-lg transition-colors flex items-center gap-1" onClick={() => onReply(message)} title="Reply"><Reply size={14} /><span className="text-[11px] hidden sm:inline">Reply</span></button> 721 + <button className="px-2 py-1.5 text-[#b5bac1] hover:text-[#f2f3f5] hover:bg-[#35373c] transition-colors" onClick={() => setPickerOpen((p) => !p)} title="Add Reaction"><span className="text-base leading-none">😊</span></button> 694 722 {mine && <> 695 - <button className="p-1.5 text-gray-400 hover:text-gray-200 hover:bg-[#404249]" onClick={() => { setEditVal(message.content || ''); setEditing(true); setPickerOpen(false); }} title="Edit"><Edit2 size={14} /></button> 696 - <button className="p-1.5 text-red-400 hover:text-red-200 hover:bg-[#404249] rounded-r-md" onClick={() => onDelete(message)} title="Delete"><Trash2 size={14} /></button> 723 + <button className="px-2 py-1.5 text-[#b5bac1] hover:text-[#f2f3f5] hover:bg-[#35373c] transition-colors" onClick={() => { setEditVal(message.content || ''); setEditing(true); setPickerOpen(false); }} title="Edit Message"><Edit2 size={14} /></button> 724 + <button className="px-2 py-1.5 text-[#b5bac1] hover:text-[#f23f43] hover:bg-[#35373c] rounded-r-lg transition-colors" onClick={() => onDelete(message)} title="Delete Message"><Trash2 size={14} /></button> 697 725 </>} 726 + {!mine && <button className="px-2 py-1.5 text-[#b5bac1] hover:text-[#f2f3f5] hover:bg-[#35373c] rounded-r-lg transition-colors" title="More"><Info size={14} /></button>} 698 727 </div> 699 728 </div> 700 729 ··· 1041 1070 1042 1071 const renderMemberItem = useCallback((item) => { 1043 1072 if (item.type === 'header') return ( 1044 - <div className="flex items-center px-4 text-xs font-bold uppercase tracking-wide text-gray-400 h-full"> 1073 + <div className="flex items-center px-3 text-[11px] font-bold uppercase tracking-widest text-[#80848e] h-full"> 1045 1074 {item.name} — {item.count} 1046 1075 </div> 1047 1076 ); ··· 2052 2081 )} 2053 2082 2054 2083 {/* ════ SERVER RAIL ════ */} 2055 - <aside className="flex w-[72px] flex-col items-center gap-2 overflow-y-auto bg-[#111214] py-3 shrink-0"> 2056 - <button className={`grid h-12 w-12 place-items-center transition-all duration-150 ${selServer === '@me' ? 'rounded-2xl bg-[#5865f2]' : 'rounded-full bg-[#313338] hover:rounded-2xl hover:bg-[#5865f2]'}`} onClick={() => selectServer('@me')} title="Direct messages"><MessageSquare size={20} /></button> 2057 - <div className="h-px w-8 bg-[#202225]" /> 2084 + <aside className="flex w-[72px] flex-col items-center gap-2 overflow-y-auto bg-[#1e1f22] py-3 shrink-0"> 2085 + {/* Home button */} 2086 + <div className={`srv-wrap relative flex items-center ${selServer === '@me' ? 'active' : ''}`} style={{width:72}}> 2087 + <span className="srv-pill" /> 2088 + <button 2089 + className={`srv-icon mx-auto grid h-12 w-12 place-items-center ${selServer === '@me' ? 'bg-[#5865f2] text-white' : 'bg-[#313338] text-[#c3c4ca] hover:text-white'}`} 2090 + onClick={() => selectServer('@me')} title="Direct Messages" 2091 + ><MessageSquare size={20} /></button> 2092 + </div> 2093 + <div className="w-8 h-px bg-[#35373c] mx-auto" /> 2058 2094 {serverList.map((s) => { 2059 2095 const ic = iconUrl(s, config.cdnUrl); const active = selServer === s._id; 2096 + const hasUnread = !active && Object.values(channels).some((c) => c.server === s._id && unreadChannels.has(c._id)); 2060 2097 return ( 2061 - <div key={s._id} className="relative"> 2062 - <button className={`grid h-12 w-12 place-items-center overflow-hidden transition-all duration-150 ${active ? 'rounded-2xl bg-[#5865f2]' : 'rounded-full bg-[#313338] hover:rounded-2xl hover:bg-[#5865f2]'}`} onClick={() => selectServer(s._id)} title={s.name}> 2063 - {ic ? <img alt={s.name} className="h-full w-full object-contain p-1" src={ic} /> : <span className="text-sm font-bold">{s.name.slice(0,2).toUpperCase()}</span>} 2098 + <div key={s._id} className={`srv-wrap relative flex items-center ${active ? 'active' : ''}`} style={{width:72}} title={s.name}> 2099 + <span className="srv-pill" style={{height: active ? 40 : hasUnread ? 8 : 0}} /> 2100 + <button className={`srv-icon mx-auto grid h-12 w-12 place-items-center overflow-hidden ${active ? 'bg-[#5865f2]' : 'bg-[#313338]'} text-[#c3c4ca] hover:text-white`} onClick={() => selectServer(s._id)}> 2101 + {ic 2102 + ? <img alt={s.name} className="h-full w-full object-cover" src={ic} onError={(e) => { e.currentTarget.style.display='none'; }}/> 2103 + : <span className="text-sm font-bold select-none">{s.name.slice(0,2).toUpperCase()}</span> 2104 + } 2064 2105 </button> 2065 - {/* Unread indicator */} 2066 - {Object.values(channels).some((c) => c.server === s._id && unreadChannels.has(c._id)) && !active && ( 2067 - <span className="absolute -bottom-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-white unread-dot pointer-events-none" /> 2068 - )} 2069 2106 </div> 2070 2107 ); 2071 2108 })} 2072 - <button className="grid h-12 w-12 place-items-center rounded-full bg-[#313338] text-[#3ba55d] transition-all duration-150 hover:rounded-2xl hover:bg-[#3ba55d] hover:text-white" onClick={() => setActiveModal('create-server')} title="Create a space"><Plus size={20} /></button> 2073 - <button className="grid h-12 w-12 place-items-center rounded-full bg-[#313338] text-blue-400 transition-all duration-150 hover:rounded-2xl hover:bg-blue-600 hover:text-white" onClick={() => setActiveModal('join-server')} title="Join a space"><Link size={18} /></button> 2109 + <button className="srv-icon grid h-12 w-12 place-items-center bg-[#313338] text-[#23a55a] hover:bg-[#23a55a] hover:text-white transition-colors mx-auto" onClick={() => setActiveModal('create-server')} title="Add a Server"><Plus size={20} /></button> 2110 + <button className="srv-icon grid h-12 w-12 place-items-center bg-[#313338] text-[#5865f2] hover:bg-[#5865f2] hover:text-white transition-colors mx-auto" onClick={() => setActiveModal('join-server')} title="Join with Invite"><Link size={18} /></button> 2074 2111 </aside> 2075 2112 2076 2113 {/* ════ CHANNEL SIDEBAR ════ */} 2077 2114 <aside className={`${showMobileSide ? 'flex' : 'hidden'} md:flex w-60 flex-col bg-[#2b2d31] shrink-0 z-30 md:z-auto absolute md:relative h-full`}> 2078 - {/* Server header */} 2079 - <div className="border-b border-[#202225] px-4 py-3 flex justify-between items-center shrink-0"> 2080 - <div className="truncate text-sm font-bold text-white">{selServer === '@me' ? 'Direct Messages' : selServerObj?.name || 'Space'}</div> 2115 + {/* Server header — Discord-style with shadow */} 2116 + <div className="px-4 py-3 flex justify-between items-center shrink-0 shadow-md border-b border-black/30 bg-[#2b2d31]"> 2117 + <div className="truncate text-sm font-bold text-[#f2f3f5]">{selServer === '@me' ? 'Direct Messages' : selServerObj?.name || 'Space'}</div> 2081 2118 <div className="flex items-center gap-1"> 2082 2119 {isOwner && selServer !== '@me' && <> 2083 - <button onClick={() => setActiveModal('create-invite')} className="text-gray-400 hover:text-white transition-colors p-0.5" title="Create invite"><Link size={14} /></button> 2084 - <button onClick={() => { setEditServerName(selServerObj.name); setActiveModal('server-settings'); }} className="text-gray-400 hover:text-white transition-colors p-0.5" title="Space Settings"><Settings size={14} /></button> 2120 + <button onClick={() => setActiveModal('create-invite')} className="text-[#80848e] hover:text-[#b5bac1] transition-colors p-1 rounded hover:bg-[#35373c]" title="Create invite"><Link size={14} /></button> 2121 + <button onClick={() => { setEditServerName(selServerObj.name); setActiveModal('server-settings'); }} className="text-[#80848e] hover:text-[#b5bac1] transition-colors p-1 rounded hover:bg-[#35373c]" title="Server Settings"><Settings size={14} /></button> 2085 2122 </>} 2086 2123 </div> 2087 2124 </div> ··· 2108 2145 const rid = ch.channel_type === 'DirectMessage' ? (ch.recipients || []).find((id) => id !== auth.userId) : null; 2109 2146 const unread = unreadChannels.has(ch._id); 2110 2147 return ( 2111 - <button key={ch._id} className={`mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors ${selChannel === ch._id ? 'bg-[#404249] text-white' : `text-gray-400 hover:bg-[#35373c] hover:text-white ${unread ? 'text-white' : ''}`}`} onClick={() => selectChannel(ch._id)}> 2148 + <button key={ch._id} className={`relative mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors ch-item ${selChannel === ch._id ? 'active' : unread ? 'unread' : ''}`} onClick={() => selectChannel(ch._id)}> 2112 2149 {rid ? ( 2113 2150 <div className="relative shrink-0"> 2114 2151 <Avatar user={users[rid]} cdn={config.cdnUrl} size="sm" /> 2115 - {users[rid]?.status?.presence && users[rid]?.status?.presence !== 'Invisible' && ( 2116 - <span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full border-2 border-[#2b2d31]" style={{ background: getPresenceColor(users[rid].status.presence) }} /> 2117 - )} 2152 + <span className="absolute -bottom-px -right-px w-3 h-3 rounded-full border-2 border-[#2b2d31]" style={{ background: users[rid]?.status?.presence ? getPresenceColor(users[rid].status.presence) : '#747f8d' }} /> 2118 2153 </div> 2119 - ) : <span className="text-gray-400 shrink-0">{icon}</span>} 2120 - <span className="truncate flex-1">{label}</span> 2121 - {unread && <span className="unread-dot w-2 h-2 rounded-full bg-white shrink-0" />} 2154 + ) : <span className="text-[#80848e] shrink-0">{icon}</span>} 2155 + <span className="truncate flex-1 text-sm">{label}</span> 2156 + {unread && <span className="w-2 h-2 rounded-full bg-[#f2f3f5] shrink-0" />} 2122 2157 </button> 2123 2158 ); 2124 2159 })} ··· 2131 2166 if (item.type === 'category') { 2132 2167 const collapsed = collapsedCats[item.id]; 2133 2168 return ( 2134 - <button key={item.id} className="flex w-full items-center gap-1 px-1 py-1 text-left" onClick={() => setCollapsedCats((p) => ({ ...p, [item.id]: !p[item.id] }))}> 2135 - {collapsed ? <ChevronRight size={12} className="text-gray-500 shrink-0" /> : <ChevronDown size={12} className="text-gray-500 shrink-0" />} 2136 - <span className="text-xs font-bold uppercase tracking-wide text-gray-400 truncate">{item.title}</span> 2169 + <button key={item.id} className="flex w-full items-center gap-1 px-2 py-1.5 mt-3 text-left group" onClick={() => setCollapsedCats((p) => ({ ...p, [item.id]: !p[item.id] }))}> 2170 + {collapsed ? <ChevronRight size={12} className="text-[#80848e] shrink-0 group-hover:text-[#b5bac1]" /> : <ChevronDown size={12} className="text-[#80848e] shrink-0 group-hover:text-[#b5bac1]" />} 2171 + <span className="text-[11px] font-bold uppercase tracking-widest text-[#80848e] truncate group-hover:text-[#b5bac1]">{item.title}</span> 2137 2172 </button> 2138 2173 ); 2139 2174 } 2140 2175 // type === 'channel' 2141 2176 const ch = item.channel; const isVoice = ch.channel_type === 'VoiceChannel'; const unread = unreadChannels.has(ch._id); 2142 2177 return ( 2143 - <button key={ch._id} className={`mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors ${isVoice ? 'opacity-40 cursor-not-allowed' : selChannel === ch._id ? 'bg-[#404249] text-white' : `text-gray-400 hover:bg-[#35373c] hover:text-white ${unread ? 'text-white font-semibold' : ''}`}`} 2178 + <button key={ch._id} 2179 + className={`relative mb-px flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left ch-item transition-none ${isVoice ? 'opacity-40 cursor-not-allowed' : selChannel === ch._id ? 'active' : unread ? 'unread' : ''}`} 2144 2180 onClick={() => !isVoice && selectChannel(ch._id)} disabled={isVoice} title={ch.name}> 2145 - <span className={isVoice ? 'text-gray-600' : 'text-gray-400'}>{chIcon(ch)}</span> 2146 - <span className="truncate flex-1">{ch.name || 'channel'}</span> 2147 - {unread && <span className="unread-dot w-2 h-2 rounded-full bg-white shrink-0" />} 2148 - {ch.nsfw && <span className="text-[9px] text-red-400 font-bold">NSFW</span>} 2181 + <span className="shrink-0 w-4 h-4 flex items-center justify-center text-[#80848e]">{chIcon(ch)}</span> 2182 + <span className="truncate flex-1 text-sm">{ch.name || 'channel'}</span> 2183 + {ch.nsfw && <span className="text-[9px] font-bold px-1 py-0.5 rounded bg-[#f23f43]/20 text-[#f23f43]">NSFW</span>} 2149 2184 </button> 2150 2185 ); 2151 2186 }) 2152 2187 )} 2153 2188 </div> 2154 2189 2155 - {/* Account footer */} 2156 - <div className="border-t border-[#202225] p-2 shrink-0"> 2157 - <div className="flex items-center justify-between rounded bg-[#232428] p-2" onMouseEnter={() => setIsAccHovered(true)} onMouseLeave={() => setIsAccHovered(false)}> 2158 - <button className="flex min-w-0 items-center gap-2 rounded p-1 text-left hover:bg-[#2d3036] flex-1 transition-colors" onClick={openStatusEditor} type="button"> 2159 - <div className="relative shrink-0"> 2160 - <Avatar user={users[auth.userId]} cdn={config.cdnUrl} size="sm" always={isAccHovered} /> 2161 - <span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-[#232428]" style={{ background: getPresenceColor(users[auth.userId]?.status?.presence) }} /> 2162 - </div> 2163 - <div className="min-w-0"> 2164 - <div className="truncate text-xs font-semibold text-white">{users[auth.userId]?.username || 'Connected'}</div> 2165 - <div className="truncate text-[10px] text-gray-400">{users[auth.userId]?.status?.text || status}</div> 2166 - </div> 2167 - </button> 2168 - <div className="flex items-center gap-0.5 shrink-0"> 2169 - <button className="rounded p-1 text-gray-400 hover:text-white transition-colors" onClick={() => setActiveModal('user-settings')} title="Preferences"><Settings size={13} /></button> 2170 - <button className="rounded p-1 text-gray-400 hover:text-white transition-colors" onClick={logout} title="Logout"><LogOut size={13} /></button> 2190 + {/* Account footer — Discord-style */} 2191 + <div className="h-[52px] bg-[#232428] px-2 flex items-center gap-2 shrink-0 border-t border-black/30"> 2192 + <button className="flex min-w-0 items-center gap-2 rounded p-1.5 text-left hover:bg-[#35373c] flex-1 transition-colors min-h-0" onClick={openStatusEditor} type="button" onMouseEnter={() => setIsAccHovered(true)} onMouseLeave={() => setIsAccHovered(false)}> 2193 + <div className="relative shrink-0"> 2194 + <Avatar user={users[auth.userId]} cdn={config.cdnUrl} size="sm" always={isAccHovered} /> 2195 + <span className="absolute -bottom-px -right-px w-3 h-3 rounded-full border-2 border-[#232428]" style={{ background: getPresenceColor(users[auth.userId]?.status?.presence) }} /> 2171 2196 </div> 2197 + <div className="min-w-0"> 2198 + <div className="truncate text-[13px] font-semibold text-[#f2f3f5] leading-none mb-0.5">{users[auth.userId]?.username || '…'}</div> 2199 + <div className="truncate text-[11px] text-[#80848e] leading-none">{users[auth.userId]?.status?.text || users[auth.userId]?.status?.presence || 'Online'}</div> 2200 + </div> 2201 + </button> 2202 + <div className="flex items-center gap-0.5 shrink-0"> 2203 + <button className="p-1.5 rounded text-[#b5bac1] hover:text-[#f2f3f5] hover:bg-[#35373c] transition-colors" onClick={() => setActiveModal('user-settings')} title="User Settings"><Settings size={16} /></button> 2204 + <button className="p-1.5 rounded text-[#b5bac1] hover:text-[#f23f43] hover:bg-[#35373c] transition-colors" onClick={logout} title="Log Out"><LogOut size={16} /></button> 2172 2205 </div> 2173 2206 </div> 2174 2207 </aside> ··· 2178 2211 2179 2212 {/* ════ MAIN ════ */} 2180 2213 <main className="relative flex min-w-0 flex-1 flex-col bg-[#313338]"> 2181 - {/* Header */} 2182 - <header className="flex items-center justify-between border-b border-[#202225] px-4 py-2.5 shrink-0"> 2214 + {/* Header — Discord-style */} 2215 + <header className="flex h-12 items-center justify-between border-b border-black/30 px-4 bg-[#313338] shadow-sm shrink-0 z-10"> 2183 2216 <div className="flex items-center gap-2 min-w-0"> 2184 - <button className="md:hidden text-gray-400 hover:text-white transition-colors mr-1" onClick={() => setShowMobileSide((p) => !p)}><Menu size={20} /></button> 2185 - <span className="text-gray-400 shrink-0">{chIcon(channels[selChannel])}</span> 2186 - <span className="text-sm font-semibold text-white truncate">{curChName}</span> 2187 - {curChTopic && <span className="hidden lg:block text-xs text-gray-400 border-l border-[#3f4249] pl-2 ml-1 truncate max-w-xs">{curChTopic}</span>} 2217 + <button className="md:hidden text-[#80848e] hover:text-[#b5bac1] transition-colors mr-1" onClick={() => setShowMobileSide((p) => !p)}><Menu size={20} /></button> 2218 + <span className="text-[#80848e] shrink-0">{chIcon(channels[selChannel])}</span> 2219 + <span className="text-base font-bold text-[#f2f3f5] truncate">{curChName}</span> 2220 + {curChTopic && <span className="hidden lg:block text-[13px] text-[#80848e] border-l border-[#35373c] pl-3 ml-1 truncate max-w-xs">{curChTopic}</span>} 2188 2221 </div> 2189 - <div className="flex items-center gap-2 shrink-0"> 2222 + <div className="flex items-center gap-1 shrink-0"> 2190 2223 {selChannel !== 'friends' && ( 2191 2224 <> 2192 - <button className="text-gray-400 hover:text-white transition-colors p-1" onClick={() => { fetchPins(selChannel); setActiveModal('pinned'); }} title="Pinned messages"><Pin size={16} /></button> 2193 - {isOwner && selServer !== '@me' && selChannel !== 'friends' && ( 2194 - <button className="text-gray-400 hover:text-white transition-colors p-1" onClick={() => setActiveModal('create-invite')} title="Create invite"><Link size={16} /></button> 2225 + <button className="p-1.5 rounded text-[#b5bac1] hover:text-[#f2f3f5] hover:bg-[#35373c] transition-colors" onClick={() => { fetchPins(selChannel); setActiveModal('pinned'); }} title="Pinned Messages"><Pin size={18} /></button> 2226 + {isOwner && selServer !== '@me' && ( 2227 + <button className="p-1.5 rounded text-[#b5bac1] hover:text-[#f2f3f5] hover:bg-[#35373c] transition-colors" onClick={() => setActiveModal('create-invite')} title="Create Invite"><Link size={18} /></button> 2195 2228 )} 2196 2229 </> 2197 2230 )} 2198 - <StatusBadge status={status} /> 2231 + <div className="ml-2"><StatusBadge status={status} /></div> 2199 2232 </div> 2200 2233 </header> 2201 2234 2202 - {voiceNotice && <div className="mx-4 mt-2 rounded-md border border-[#665200] bg-[#5c4a00]/25 px-3 py-2 text-xs text-yellow-200">{voiceNotice}</div>} 2235 + {voiceNotice && <div className="mx-4 mt-2 flex items-center gap-2 rounded bg-[#f0b232]/10 border border-[#f0b232]/20 px-3 py-2 text-[13px] text-[#f0b232]"><Info size={14} className="shrink-0" />{voiceNotice}</div>} 2203 2236 2204 2237 {/* Message area */} 2205 2238 <section className="flex-1 overflow-y-auto py-2" ref={scrollRef}> ··· 2243 2276 ))} 2244 2277 </div> 2245 2278 ) : curMsgs.length === 0 ? ( 2246 - <div className="flex flex-col items-center justify-center h-full text-center p-8 text-gray-400"> 2247 - <Hash size={40} className="mb-3 opacity-25" /> 2248 - <p className="text-sm font-semibold">No messages yet</p> 2249 - <p className="text-xs mt-1 opacity-60">Be the first to say something!</p> 2279 + <div className="flex flex-col items-center justify-center h-full text-center p-8"> 2280 + <div className="mb-4 p-4 rounded-full bg-[#2b2d31]"><Hash size={32} className="text-[#80848e]" /></div> 2281 + <p className="text-2xl font-bold text-[#f2f3f5] mb-1">Welcome to #{curChName}!</p> 2282 + <p className="text-[#80848e] text-sm">This is the start of #{curChName}.</p> 2250 2283 </div> 2251 2284 ) : ( 2252 2285 curMsgs.map((msg, idx) => { ··· 2309 2342 </div> 2310 2343 )} 2311 2344 2312 - <div className="flex items-end gap-2 rounded-lg bg-[#383a40] px-2 py-2" 2345 + <div className="flex items-end gap-1 rounded-lg bg-[#383a40] px-2 py-2" 2313 2346 onDragOver={(e) => e.preventDefault()} 2314 2347 onDrop={(e) => { e.preventDefault(); const f = Array.from(e.dataTransfer?.files || []); if (f.length) setPendingFiles((p) => [...p, ...f]); }}> 2315 - <button className="p-2 text-gray-300 hover:text-white transition-colors mb-0.5 shrink-0" disabled={selChannel === 'friends' || isUploading} onClick={() => fileInputRef.current?.click()} title="Attach file"><Paperclip size={16} /></button> 2348 + <button className="p-2 shrink-0 text-[#80848e] hover:text-[#b5bac1] transition-colors disabled:opacity-30" disabled={selChannel === 'friends' || isUploading} onClick={() => fileInputRef.current?.click()} title="Upload a File"><Paperclip size={20} /></button> 2316 2349 <input className="hidden" multiple onChange={onFilePick} ref={fileInputRef} type="file" /> 2317 2350 2318 2351 <div className="relative shrink-0"> 2319 - <button className="p-2 text-gray-300 hover:text-white transition-colors mb-0.5" disabled={selChannel === 'friends'} onClick={() => setShowEmojiPicker((p) => !p)}> 2320 - <span className="text-base leading-none select-none">😊</span> 2352 + <button className="p-2 text-[#80848e] hover:text-[#b5bac1] transition-colors disabled:opacity-30" disabled={selChannel === 'friends'} onClick={() => setShowEmojiPicker((p) => !p)}> 2353 + <span className="text-xl leading-none select-none">😊</span> 2321 2354 </button> 2322 2355 {showEmojiPicker && selChannel !== 'friends' && ( 2323 2356 <div ref={pickerRef} className="absolute bottom-12 left-0 z-20 w-72 rounded-lg border border-[#4c4f56] bg-[#232428] shadow-2xl max-h-72 overflow-hidden flex flex-col"> ··· 2346 2379 2347 2380 <textarea 2348 2381 ref={composerRef} 2349 - className="composer-textarea flex-1 bg-transparent px-2 py-1.5 text-sm text-gray-100 placeholder:text-gray-400 focus:outline-none resize-none" 2382 + className="composer-textarea flex-1 bg-transparent px-2 py-1.5 text-[15px] text-[#dcddde] placeholder:text-[#4f5660] focus:outline-none resize-none leading-[1.375]" 2350 2383 rows={1} 2351 2384 disabled={selChannel === 'friends'} 2352 2385 value={inputText} ··· 2364 2397 placeholder={selChannel === 'friends' ? 'Select a channel to chat.' : `Message #${curChName}`} 2365 2398 /> 2366 2399 2367 - <button className="p-2 text-gray-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed mb-0.5 shrink-0" 2400 + <button className="p-2 shrink-0 text-[#80848e] hover:text-[#b5bac1] transition-colors disabled:opacity-30" 2368 2401 disabled={isUploading || (!inputText.trim() && !pendingFiles.length) || selChannel === 'friends'} 2369 2402 onClick={() => { clearTimeout(sendTypingTimRef.current); sendMessage(); }}> 2370 - {isUploading ? <Loader className="animate-spin" size={16} /> : <Send size={16} />} 2403 + {isUploading ? <Loader className="animate-spin text-[#5865f2]" size={20} /> : <Send size={20} />} 2371 2404 </button> 2372 2405 </div> 2373 - <p className="mt-1 text-right text-[10px] text-gray-500">Shift+Enter for newline · Esc cancel reply</p> 2406 + <p className="mt-1 px-1 text-[11px] text-[#4f5660]"><strong className="text-[#80848e]">Enter</strong> to send · <strong className="text-[#80848e]">Shift+Enter</strong> for newline</p> 2374 2407 </footer> 2375 2408 </main> 2376 2409 2377 2410 {/* ════ MEMBERS SIDEBAR ════ */} 2378 - <aside className="hidden w-56 flex-col border-l border-[#202225] bg-[#2b2d31] lg:flex shrink-0"> 2379 - <div className="border-b border-[#202225] px-4 py-3 shrink-0"> 2380 - <div className="text-xs font-bold uppercase tracking-wide text-gray-400">Members — {allMembers.length}</div> 2381 - {isMembLoading && <div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-[#242A35]"><div className="h-full w-1/3 animate-pulse rounded-full bg-[#8AB4F8]" /></div>} 2411 + <aside className="hidden w-[240px] flex-col bg-[#2b2d31] lg:flex shrink-0"> 2412 + <div className="px-3 pt-4 pb-0 shrink-0"> 2413 + {isMembLoading && <div className="mb-2 h-0.5 w-full overflow-hidden rounded-full bg-[#1e1f22]"><div className="h-full w-1/2 animate-pulse rounded-full bg-[#5865f2]" /></div>} 2382 2414 </div> 2383 2415 <div className="min-h-0 flex-1"> 2384 2416 {selServer === '@me'