From 5047cf609847ee731b18d71fd457995b459b70fd Mon Sep 17 00:00:00 2001 From: user Date: Sat, 7 Mar 2026 06:18:41 -0800 Subject: [PATCH] Redesign SPA to look like a proper IRC client - Tabs for channels, queries, and server window with unread indicators - Persistent input line at bottom with IRC-style prompt [nick] #channel > - Full IRC command support: /join /part /msg /me /nick /topic /mode /quit /help - User list on right side with @ops, +voiced, and regular user prefixes - Topic bar at top of channel windows - Messages displayed inline: [HH:MM:SS] message (same line) - /me actions rendered as: * nick does something - IRC vocabulary: 'parted' not 'left', 'has changed the topic' not 'set topic' - Input history with up/down arrow keys - Parse RPL_NAMREPLY (353) for mode prefixes in user list - Parse RPL_TOPIC (332) for topic updates from server - CTCP ACTION support for /me command - Dark theme with monospace font, classic IRC aesthetic closes #50 --- web/dist/app.js | 4 +- web/dist/style.css | 593 +++++++++++++++--------- web/src/app.jsx | 1075 ++++++++++++++++++++++++++++++++------------ web/src/style.css | 593 +++++++++++++++--------- 4 files changed, 1521 insertions(+), 744 deletions(-) diff --git a/web/dist/app.js b/web/dist/app.js index 8ad82e7..9480175 100644 --- a/web/dist/app.js +++ b/web/dist/app.js @@ -1,2 +1,2 @@ -var _e,k,$e,it,q,Ee,Ae,He,Oe,he,fe,de,ct,X={},Re=[],at=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,se=Array.isArray;function F(e,t){for(var n in t)e[n]=t[n];return e}function me(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function y(e,t,n){var r,i,_,c={};for(_ in t)_=="key"?r=t[_]:_=="ref"?i=t[_]:c[_]=t[_];if(arguments.length>2&&(c.children=arguments.length>3?_e.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(_ in e.defaultProps)c[_]===void 0&&(c[_]=e.defaultProps[_]);return ne(e,c,r,i,null)}function ne(e,t,n,r,i){var _={type:e,props:t,key:n,ref:r,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:i??++$e,__i:-1,__u:0};return i==null&&k.vnode!=null&&k.vnode(_),_}function ie(e){return e.children}function oe(e,t){this.props=e,this.context=t}function z(e,t){if(t==null)return e.__?z(e.__,e.__i+1):null;for(var n;tu&&q.sort(He),e=q.shift(),u=q.length,e.__d&&(n=void 0,r=void 0,i=(r=(t=e).__v).__e,_=[],c=[],t.__P&&((n=F({},r)).__v=r.__v+1,k.vnode&&k.vnode(n),ve(t.__P,n,r,t.__n,t.__P.namespaceURI,32&r.__u?[i]:null,_,i??z(r),!!(32&r.__u),c),n.__v=r.__v,n.__.__k[n.__i]=n,Fe(_,n,c),r.__e=r.__=null,n.__e!=i&&De(n)));re.__r=0}function Ue(e,t,n,r,i,_,c,u,m,a,v){var s,p,h,w,P,T,g,b=r&&r.__k||Re,A=t.length;for(m=lt(n,t,b,m,A),s=0;s0?c=e.__k[_]=ne(c.type,c.props,c.key,c.ref?c.ref:null,c.__v):e.__k[_]=c,m=_+p,c.__=e,c.__b=e.__b+1,u=null,(a=c.__i=ut(c,n,m,s))!=-1&&(s--,(u=n[a])&&(u.__u|=2)),u==null||u.__v==null?(a==-1&&(i>v?p--:im?p--:p++,c.__u|=4))):e.__k[_]=null;if(s)for(_=0;_(v?1:0)){for(i=n-1,_=n+1;i>=0||_=0?i--:_++])!=null&&(2&a.__u)==0&&u==a.key&&m==a.type)return c}return-1}function Ne(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||at.test(t)?n:n+"px"}function te(e,t,n,r,i){var _,c;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof r=="string"&&(e.style.cssText=r=""),r)for(t in r)n&&t in n||Ne(e.style,t,"");if(n)for(t in n)r&&n[t]==r[t]||Ne(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")_=t!=(t=t.replace(Oe,"$1")),c=t.toLowerCase(),t=c in e||t=="onFocusOut"||t=="onFocusIn"?c.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+_]=n,n?r?n.u=r.u:(n.u=he,e.addEventListener(t,_?de:fe,_)):e.removeEventListener(t,_?de:fe,_);else{if(i=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function Me(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=he++;else if(t.t0?e:se(e)?e.map(We):F({},e)}function ft(e,t,n,r,i,_,c,u,m){var a,v,s,p,h,w,P,T=n.props||X,g=t.props,b=t.type;if(b=="svg"?i="http://www.w3.org/2000/svg":b=="math"?i="http://www.w3.org/1998/Math/MathML":i||(i="http://www.w3.org/1999/xhtml"),_!=null){for(a=0;a<_.length;a++)if((h=_[a])&&"setAttribute"in h==!!b&&(b?h.localName==b:h.nodeType==3)){e=h,_[a]=null;break}}if(e==null){if(b==null)return document.createTextNode(g);e=document.createElementNS(i,b,g.is&&g),u&&(k.__m&&k.__m(t,_),u=!1),_=null}if(b==null)T===g||u&&e.data==g||(e.data=g);else{if(_=_&&_e.call(e.childNodes),!u&&_!=null)for(T={},a=0;a=n.__.length&&n.__.push({}),n.__[e]}function E(e){return ee=1,pt(tt,e)}function pt(e,t,n){var r=ke(Z++,2);if(r.t=e,!r.__c&&(r.__=[n?n(t):tt(void 0,t),function(u){var m=r.__N?r.__N[0]:r.__[0],a=r.t(m,u);m!==a&&(r.__N=[a,r.__[1]],r.__c.setState({}))}],r.__c=C,!C.__f)){var i=function(u,m,a){if(!r.__c.__H)return!0;var v=r.__c.__H.__.filter(function(p){return!!p.__c});if(v.every(function(p){return!p.__N}))return!_||_.call(this,u,m,a);var s=r.__c.props!==u;return v.forEach(function(p){if(p.__N){var h=p.__[0];p.__=p.__N,p.__N=void 0,h!==p.__[0]&&(s=!0)}}),_&&_.call(this,u,m,a)||s};C.__f=!0;var _=C.shouldComponentUpdate,c=C.componentWillUpdate;C.componentWillUpdate=function(u,m,a){if(this.__e){var v=_;_=void 0,i(u,m,a),_=v}c&&c.call(this,u,m,a)},C.shouldComponentUpdate=i}return r.__N||r.__}function $(e,t){var n=ke(Z++,3);!I.__s&&et(n.__H,t)&&(n.__=e,n.u=t,C.__H.__h.push(n))}function R(e){return ee=5,Ze(function(){return{current:e}},[])}function Ze(e,t){var n=ke(Z++,7);return et(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function G(e,t){return ee=8,Ze(function(){return e},t)}function ht(){for(var e;e=Xe.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(ce),e.__H.__h.forEach(ge),e.__H.__h=[]}catch(t){e.__H.__h=[],I.__e(t,e.__v)}}I.__b=function(e){C=null,Be&&Be(e)},I.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),Qe&&Qe(e,t)},I.__r=function(e){qe&&qe(e),Z=0;var t=(C=e.__c).__H;t&&(be===C?(t.__h=[],C.__h=[],t.__.forEach(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.forEach(ce),t.__h.forEach(ge),t.__h=[],Z=0)),be=C},I.diffed=function(e){Ke&&Ke(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(Xe.push(t)!==1&&Ve===I.requestAnimationFrame||((Ve=I.requestAnimationFrame)||mt)(ht)),t.__H.__.forEach(function(n){n.u&&(n.__H=n.u),n.u=void 0})),be=C=null},I.__c=function(e,t){t.some(function(n){try{n.__h.forEach(ce),n.__h=n.__h.filter(function(r){return!r.__||ge(r)})}catch(r){t.some(function(i){i.__h&&(i.__h=[])}),t=[],I.__e(r,n.__v)}}),ze&&ze(e,t)},I.unmount=function(e){Ge&&Ge(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.forEach(function(r){try{ce(r)}catch(i){t=i}}),n.__H=void 0,t&&I.__e(t,n.__v))};var Ye=typeof requestAnimationFrame=="function";function mt(e){var t,n=function(){clearTimeout(r),Ye&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(n,35);Ye&&(t=requestAnimationFrame(n))}function ce(e){var t=C,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),C=t}function ge(e){var t=C;e.__c=e.__(),C=t}function et(e,t){return!e||e.length!==t.length||t.some(function(n,r){return n!==e[r]})}function tt(e,t){return typeof t=="function"?t(e):t}var vt="/api/v1",yt=15,bt=3e3,gt=1e4;function M(e,t={}){let n=localStorage.getItem("neoirc_token"),r={"Content-Type":"application/json",...t.headers||{}};n&&(r.Authorization=`Bearer ${n}`);let{signal:i,..._}=t;return fetch(vt+e,{..._,headers:r,signal:i}).then(async c=>{let u=await c.json().catch(()=>null);if(!c.ok)throw{status:c.status,data:u};return u})}function nt(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}function ot(e){let t=0;for(let r=0;r{M("/server").then(p=>{p.name&&m(p.name),p.motd&&c(p.motd)}).catch(()=>{}),localStorage.getItem("neoirc_token")&&M("/state").then(p=>e(p.nick)).catch(()=>localStorage.removeItem("neoirc_token")),a.current?.focus()},[]),y("div",{class:"login-screen"},y("h1",null,u),_&&y("div",{class:"motd"},_),y("form",{onSubmit:async s=>{s.preventDefault(),i("");try{let p=await M("/session",{method:"POST",body:JSON.stringify({nick:t.trim()})});localStorage.setItem("neoirc_token",p.token),e(p.nick)}catch(p){i(p.data?.error||"Connection failed")}}},y("input",{ref:a,type:"text",placeholder:"Choose a nickname...",value:t,onInput:s=>n(s.target.value),maxLength:32,autoFocus:!0}),y("button",{type:"submit"},"Connect")),r&&y("div",{class:"error"},r))}function St({msg:e}){return e.system?y("div",{class:"message system"},y("span",{class:"timestamp"},nt(e.ts)),y("span",{class:"content"},e.text)):y("div",{class:"message"},y("span",{class:"timestamp"},nt(e.ts)),y("span",{class:"nick",style:{color:ot(e.from)}},e.from),y("span",{class:"content"},e.text))}function wt(){let[e,t]=E(!1),[n,r]=E(""),[i,_]=E([{type:"server",name:"Server"}]),[c,u]=E(0),[m,a]=E({Server:[]}),[v,s]=E({}),[p,h]=E({}),[w,P]=E({}),[T,g]=E(""),[b,A]=E(""),[D,J]=E(!0),U=R(0),Q=R(new Set),V=R(null),H=R(i),K=R(c),N=R(n),L=R(),Se=R();$(()=>{H.current=i},[i]),$(()=>{K.current=c},[c]),$(()=>{N.current=n},[n]),$(()=>{let o=i.filter(l=>l.type==="channel").map(l=>l.name);localStorage.setItem("neoirc_channels",JSON.stringify(o))},[i]),$(()=>{let o=i[c];o&&P(l=>({...l,[o.name]:0}))},[c,i]);let O=G((o,l)=>{if(l.id&&Q.current.has(l.id))return;l.id&&Q.current.add(l.id),a(d=>({...d,[o]:[...d[o]||[],l]}));let f=H.current[K.current];(!f||f.name!==o)&&P(d=>({...d,[o]:(d[o]||0)+1}))},[]),W=G((o,l)=>{a(f=>({...f,[o]:[...f[o]||[],{id:"sys-"+Date.now()+"-"+Math.random(),ts:new Date().toISOString(),text:l,system:!0}]}))},[]),B=G(o=>{let l=o.replace("#","");M(`/channels/${l}/members`).then(f=>{s(d=>({...d,[o]:f}))}).catch(()=>{})},[]),ae=G(o=>{let l=Array.isArray(o.body)?o.body.join(` -`):"",f={id:o.id,ts:o.ts,from:o.from,to:o.to,command:o.command};switch(o.command){case"PRIVMSG":case"NOTICE":{let d={...f,text:l,system:!1},S=o.to;if(S&&S.startsWith("#"))O(S,d);else{let x=o.from===N.current?o.to:o.from;_(Y=>Y.find(xe=>xe.type==="dm"&&xe.name===x)?Y:[...Y,{type:"dm",name:x}]),O(x,d)}break}case"JOIN":{let d=`${o.from} has joined ${o.to}`;o.to&&O(o.to,{...f,text:d,system:!0}),o.to&&o.to.startsWith("#")&&B(o.to);break}case"PART":{let d=l?": "+l:"",S=`${o.from} has left ${o.to}${d}`;o.to&&O(o.to,{...f,text:S,system:!0}),o.to&&o.to.startsWith("#")&&B(o.to);break}case"QUIT":{let d=l?": "+l:"",S=`${o.from} has quit${d}`;H.current.forEach(x=>{x.type==="channel"&&O(x.name,{...f,text:S,system:!0})});break}case"NICK":{let d=Array.isArray(o.body)?o.body[0]:l,S=`${o.from} is now known as ${d}`;H.current.forEach(x=>{x.type==="channel"&&O(x.name,{...f,text:S,system:!0})}),o.from===N.current&&d&&r(d),H.current.forEach(x=>{x.type==="channel"&&B(x.name)});break}case"TOPIC":{let d=`${o.from} set the topic: ${l}`;o.to&&(O(o.to,{...f,text:d,system:!0}),h(S=>({...S,[o.to]:l})));break}case"375":case"372":case"376":O("Server",{...f,text:l,system:!0});break;default:O("Server",{...f,text:l||o.command,system:!0})}},[O,B]);$(()=>{if(!e)return;let o=!0;return(async()=>{for(;o;)try{let f=new AbortController;V.current=f;let d=await M(`/messages?after=${U.current}&timeout=${yt}`,{signal:f.signal});if(!o)break;if(J(!0),d.messages)for(let S of d.messages)ae(S);d.last_id>U.current&&(U.current=d.last_id)}catch(f){if(!o)break;if(f.name==="AbortError")continue;J(!1),await new Promise(d=>setTimeout(d,bt))}})(),()=>{o=!1,V.current?.abort()}},[e,ae]),$(()=>{if(!e)return;let o=i[c];if(!o||o.type!=="channel")return;B(o.name);let l=setInterval(()=>B(o.name),gt);return()=>clearInterval(l)},[e,c,i,B]),$(()=>{L.current?.scrollIntoView({behavior:"smooth"})},[m,c]),$(()=>{Se.current?.focus()},[c]),$(()=>{if(!e)return;let o=i[c];!o||o.type!=="channel"||M("/channels").then(l=>{let f=l.find(d=>d.name===o.name);f&&f.topic&&h(d=>({...d,[o.name]:f.topic}))}).catch(()=>{})},[e,c,i]);let rt=G(async o=>{r(o),t(!0),W("Server",`Connected as ${o}`);let l=JSON.parse(localStorage.getItem("neoirc_channels")||"[]");for(let f of l)try{await M("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:f})}),_(d=>d.find(S=>S.type==="channel"&&S.name===f)?d:[...d,{type:"channel",name:f}])}catch{}},[W]),le=async o=>{if(o){o=o.trim(),o.startsWith("#")||(o="#"+o);try{await M("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:o})}),_(l=>l.find(f=>f.type==="channel"&&f.name===o)?l:[...l,{type:"channel",name:o}]),u(i.length);try{let l=await M(`/history?target=${encodeURIComponent(o)}&limit=50`);if(Array.isArray(l))for(let f of l)ae(f)}catch{}A("")}catch(l){W("Server",`Failed to join ${o}: ${l.data?.error||"error"}`)}}},we=async o=>{try{await M("/messages",{method:"POST",body:JSON.stringify({command:"PART",to:o})})}catch{}_(l=>l.filter(f=>!(f.type==="channel"&&f.name===o))),u(0)},_t=o=>{let l=i[o];l.type==="channel"?we(l.name):l.type==="dm"&&(_(f=>f.filter((d,S)=>S!==o)),c>=o&&u(Math.max(0,c-1)))},Ce=o=>{_(f=>f.find(d=>d.type==="dm"&&d.name===o)?f:[...f,{type:"dm",name:o}]);let l=i.findIndex(f=>f.type==="dm"&&f.name===o);u(l>=0?l:i.length)},Te=async()=>{let o=T.trim();if(!o)return;g("");let l=i[c];if(!(!l||l.type==="server")){if(o.startsWith("/")){let f=o.split(" "),d=f[0].toLowerCase();if(d==="/join"&&f[1]){le(f[1]);return}if(d==="/part"){l.type==="channel"&&we(l.name);return}if(d==="/msg"&&f[1]&&f.slice(2).join(" ")){let S=f[1],x=f.slice(2).join(" ");try{await M("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:S,body:[x]})}),Ce(S)}catch(Y){W("Server",`DM failed: ${Y.data?.error||"error"}`)}return}if(d==="/nick"&&f[1]){try{await M("/messages",{method:"POST",body:JSON.stringify({command:"NICK",body:[f[1]]})})}catch(S){W("Server",`Nick change failed: ${S.data?.error||"error"}`)}return}if(d==="/topic"&&l.type==="channel"){let S=f.slice(1).join(" ");try{await M("/messages",{method:"POST",body:JSON.stringify({command:"TOPIC",to:l.name,body:[S]})})}catch(x){W("Server",`Topic failed: ${x.data?.error||"error"}`)}return}W("Server",`Unknown command: ${d}`);return}try{await M("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:l.name,body:[o]})})}catch(f){W(l.name,`Send failed: ${f.data?.error||"error"}`)}}};if(!e)return y(kt,{onLogin:rt});let j=i[c]||i[0],st=m[j.name]||[],Ie=v[j.name]||[],ue=p[j.name]||"";return y("div",{class:"app"},y("div",{class:"tab-bar"},!D&&y("div",{class:"connection-status"},"\u26A0 Reconnecting..."),i.map((o,l)=>y("div",{class:`tab ${l===c?"active":""}`,onClick:()=>u(l)},o.type==="dm"?`\u2192${o.name}`:o.name,w[o.name]>0&&l!==c&&y("span",{class:"unread-badge"},w[o.name]),o.type!=="server"&&y("span",{class:"close-btn",onClick:f=>{f.stopPropagation(),_t(l)}},"\xD7"))),y("div",{class:"join-dialog"},y("input",{placeholder:"#channel",value:b,onInput:o=>A(o.target.value),onKeyDown:o=>o.key==="Enter"&&le(b)}),y("button",{onClick:()=>le(b)},"Join"))),j.type==="channel"&&ue&&y("div",{class:"topic-bar",title:ue},ue),y("div",{class:"content"},y("div",{class:"messages-pane"},y("div",{class:j.type==="server"?"server-messages":"messages"},st.map(o=>y(St,{msg:o})),y("div",{ref:L})),j.type!=="server"&&y("div",{class:"input-bar"},y("input",{ref:Se,placeholder:`Message ${j.name}...`,value:T,onInput:o=>g(o.target.value),onKeyDown:o=>o.key==="Enter"&&Te()}),y("button",{onClick:Te},"Send"))),j.type==="channel"&&y("div",{class:"user-list"},y("h3",null,"Users (",Ie.length,")"),Ie.map(o=>y("div",{class:"user",onClick:()=>Ce(o.nick),style:{color:ot(o.nick)}},o.nick)))))}Je(y(wt,null),document.getElementById("root")); +var ie,w,Ue,pt,K,Ee,He,Re,De,he,de,pe,mt,Z={},Le=[],ht=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,ce=Array.isArray;function W(e,t){for(var n in t)e[n]=t[n];return e}function ye(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function p(e,t,n){var o,a,s,c={};for(s in t)s=="key"?o=t[s]:s=="ref"?a=t[s]:c[s]=t[s];if(arguments.length>2&&(c.children=arguments.length>3?ie.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(s in e.defaultProps)c[s]===void 0&&(c[s]=e.defaultProps[s]);return oe(e,c,o,a,null)}function oe(e,t,n,o,a){var s={type:e,props:t,key:n,ref:o,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:a??++Ue,__i:-1,__u:0};return a==null&&w.vnode!=null&&w.vnode(s),s}function _e(e){return e.children}function se(e,t){this.props=e,this.context=t}function Y(e,t){if(t==null)return e.__?Y(e.__,e.__i+1):null;for(var n;t_&&K.sort(Re),e=K.shift(),_=K.length,e.__d&&(n=void 0,o=void 0,a=(o=(t=e).__v).__e,s=[],c=[],t.__P&&((n=W({},o)).__v=o.__v+1,w.vnode&&w.vnode(n),ve(t.__P,n,o,t.__n,t.__P.namespaceURI,32&o.__u?[a]:null,s,a??Y(o),!!(32&o.__u),c),n.__v=o.__v,n.__.__k[n.__i]=n,Je(s,n,c),o.__e=o.__=null,n.__e!=a&&Fe(n)));ae.__r=0}function We(e,t,n,o,a,s,c,_,h,u,b){var i,m,y,C,M,I,k,g=o&&o.__k||Le,U=t.length;for(h=yt(n,t,g,h,U),i=0;i0?c=e.__k[s]=oe(c.type,c.props,c.key,c.ref?c.ref:null,c.__v):e.__k[s]=c,h=s+m,c.__=e,c.__b=e.__b+1,_=null,(u=c.__i=vt(c,n,h,i))!=-1&&(i--,(_=n[u])&&(_.__u|=2)),_==null||_.__v==null?(u==-1&&(a>b?m--:ah?m--:m++,c.__u|=4))):e.__k[s]=null;if(i)for(s=0;s(b?1:0)){for(a=n-1,s=n+1;a>=0||s=0?a--:s++])!=null&&(2&u.__u)==0&&_==u.key&&h==u.type)return c}return-1}function Oe(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||ht.test(t)?n:n+"px"}function re(e,t,n,o,a){var s,c;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof o=="string"&&(e.style.cssText=o=""),o)for(t in o)n&&t in n||Oe(e.style,t,"");if(n)for(t in n)o&&n[t]==o[t]||Oe(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")s=t!=(t=t.replace(De,"$1")),c=t.toLowerCase(),t=c in e||t=="onFocusOut"||t=="onFocusIn"?c.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+s]=n,n?o?n.u=o.u:(n.u=he,e.addEventListener(t,s?pe:de,s)):e.removeEventListener(t,s?pe:de,s);else{if(a=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function $e(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=he++;else if(t.t0?e:ce(e)?e.map(Ve):W({},e)}function bt(e,t,n,o,a,s,c,_,h){var u,b,i,m,y,C,M,I=n.props||Z,k=t.props,g=t.type;if(g=="svg"?a="http://www.w3.org/2000/svg":g=="math"?a="http://www.w3.org/1998/Math/MathML":a||(a="http://www.w3.org/1999/xhtml"),s!=null){for(u=0;u=n.__.length&&n.__.push({}),n.__[e]}function N(e){return te=1,gt(ot,e)}function gt(e,t,n){var o=Se(ee++,2);if(o.t=e,!o.__c&&(o.__=[n?n(t):ot(void 0,t),function(_){var h=o.__N?o.__N[0]:o.__[0],u=o.t(h,_);h!==u&&(o.__N=[u,o.__[1]],o.__c.setState({}))}],o.__c=x,!x.__f)){var a=function(_,h,u){if(!o.__c.__H)return!0;var b=o.__c.__H.__.filter(function(m){return!!m.__c});if(b.every(function(m){return!m.__N}))return!s||s.call(this,_,h,u);var i=o.__c.props!==_;return b.forEach(function(m){if(m.__N){var y=m.__[0];m.__=m.__N,m.__N=void 0,y!==m.__[0]&&(i=!0)}}),s&&s.call(this,_,h,u)||i};x.__f=!0;var s=x.shouldComponentUpdate,c=x.componentWillUpdate;x.componentWillUpdate=function(_,h,u){if(this.__e){var b=s;s=void 0,a(_,h,u),s=b}c&&c.call(this,_,h,u)},x.shouldComponentUpdate=a}return o.__N||o.__}function $(e,t){var n=Se(ee++,3);!A.__s&&rt(n.__H,t)&&(n.__=e,n.u=t,x.__H.__h.push(n))}function L(e){return te=5,nt(function(){return{current:e}},[])}function nt(e,t){var n=Se(ee++,7);return rt(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function X(e,t){return te=8,nt(function(){return e},t)}function St(){for(var e;e=tt.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(le),e.__H.__h.forEach(ge),e.__H.__h=[]}catch(t){e.__H.__h=[],A.__e(t,e.__v)}}A.__b=function(e){x=null,Ke&&Ke(e)},A.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),Ze&&Ze(e,t)},A.__r=function(e){ze&&ze(e),ee=0;var t=(x=e.__c).__H;t&&(ke===x?(t.__h=[],x.__h=[],t.__.forEach(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.forEach(le),t.__h.forEach(ge),t.__h=[],ee=0)),ke=x},A.diffed=function(e){Qe&&Qe(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(tt.push(t)!==1&&Ge===A.requestAnimationFrame||((Ge=A.requestAnimationFrame)||wt)(St)),t.__H.__.forEach(function(n){n.u&&(n.__H=n.u),n.u=void 0})),ke=x=null},A.__c=function(e,t){t.some(function(n){try{n.__h.forEach(le),n.__h=n.__h.filter(function(o){return!o.__||ge(o)})}catch(o){t.some(function(a){a.__h&&(a.__h=[])}),t=[],A.__e(o,n.__v)}}),Ye&&Ye(e,t)},A.unmount=function(e){Xe&&Xe(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.forEach(function(o){try{le(o)}catch(a){t=a}}),n.__H=void 0,t&&A.__e(t,n.__v))};var et=typeof requestAnimationFrame=="function";function wt(e){var t,n=function(){clearTimeout(o),et&&cancelAnimationFrame(t),setTimeout(e)},o=setTimeout(n,35);et&&(t=requestAnimationFrame(n))}function le(e){var t=x,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),x=t}function ge(e){var t=x;e.__c=e.__(),x=t}function rt(e,t){return!e||e.length!==t.length||t.some(function(n,o){return n!==e[o]})}function ot(e,t){return typeof t=="function"?t(e):t}var Ct="/api/v1",Tt=15,xt=3e3,It=1e4,Ce="ACTION ",Te="";function E(e,t={}){let n=localStorage.getItem("neoirc_token"),o={"Content-Type":"application/json",...t.headers||{}};n&&(o.Authorization=`Bearer ${n}`);let{signal:a,...s}=t;return fetch(Ct+e,{...s,headers:o,signal:a}).then(async c=>{let _=await c.json().catch(()=>null);if(!c.ok)throw{status:c.status,data:_};return _})}function At(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1})}function we(e){let t=0;for(let o=0;o{E("/server").then(m=>{m.name&&h(m.name),m.motd&&c(m.motd)}).catch(()=>{}),localStorage.getItem("neoirc_token")&&E("/state").then(m=>e(m.nick)).catch(()=>localStorage.removeItem("neoirc_token")),u.current?.focus()},[]),p("div",{class:"login-screen"},p("div",{class:"login-box"},p("h1",null,_),s&&p("pre",{class:"motd"},s),p("form",{onSubmit:async i=>{i.preventDefault(),a("");try{let m=await E("/session",{method:"POST",body:JSON.stringify({nick:t.trim()})});localStorage.setItem("neoirc_token",m.token),e(m.nick)}catch(m){a(m.data?.error||"Connection failed")}}},p("label",null,"Nickname:"),p("input",{ref:u,type:"text",placeholder:"Enter nickname",value:t,onInput:i=>n(i.target.value),maxLength:32,autoFocus:!0}),p("button",{type:"submit"},"Connect")),o&&p("div",{class:"error"},o)))}function Mt({msg:e,myNick:t}){let n=At(e.ts);return e.system?p("div",{class:"message system-message"},p("span",{class:"timestamp"},"[",n,"]"),p("span",{class:"system-text"}," * ",e.text)):e.isAction?p("div",{class:"message action-message"},p("span",{class:"timestamp"},"[",n,"]"),p("span",{class:"action-text"}," ","* ",p("span",{style:{color:we(e.from)}},e.from)," ",e.text)):p("div",{class:"message"},p("span",{class:"timestamp"},"[",n,"]")," ",p("span",{class:"nick",style:{color:we(e.from)}},"<",e.from,">")," ",p("span",{class:"content"},e.text))}function Ot({members:e,onNickClick:t}){let n=[],o=[],a=[];for(let _ of e){let h=_.mode||"";h==="o"?n.push(_):h==="v"?o.push(_):a.push(_)}let s=(_,h)=>_.nick.toLowerCase().localeCompare(h.nick.toLowerCase());n.sort(s),o.sort(s),a.sort(s);let c=(_,h)=>p("div",{class:"nick-entry",onClick:()=>t(_.nick),title:_.nick},p("span",{class:"nick-prefix"},h),p("span",{class:"nick-name",style:{color:we(_.nick)}},_.nick));return p("div",{class:"user-list"},p("div",{class:"user-list-header"},e.length," user",e.length!==1?"s":""),p("div",{class:"user-list-entries"},n.map(_=>c(_,"@")),o.map(_=>c(_,"+")),a.map(_=>c(_,""))))}function $t(){let[e,t]=N(!1),[n,o]=N(""),[a,s]=N([{type:"server",name:"Server"}]),[c,_]=N(0),[h,u]=N({Server:[]}),[b,i]=N({}),[m,y]=N({}),[C,M]=N({}),[I,k]=N(""),[g,U]=N(!0),[O,z]=N([]),[R,B]=N(-1),j=L(0),J=L(new Set),Q=L(null),P=L(a),F=L(c),ne=L(n),xe=L(),Ie=L();$(()=>{P.current=a},[a]),$(()=>{F.current=c},[c]),$(()=>{ne.current=n},[n]),$(()=>{let r=a.filter(l=>l.type==="channel").map(l=>l.name);localStorage.setItem("neoirc_channels",JSON.stringify(r))},[a]),$(()=>{let r=a[c];r&&M(l=>({...l,[r.name]:0}))},[c,a]);let D=X((r,l)=>{if(l.id&&J.current.has(l.id))return;l.id&&J.current.add(l.id),u(f=>({...f,[r]:[...f[r]||[],l]}));let d=P.current[F.current];(!d||d.name!==r)&&M(f=>({...f,[r]:(f[r]||0)+1}))},[]),T=X((r,l)=>{u(d=>({...d,[r]:[...d[r]||[],{id:"sys-"+Date.now()+"-"+Math.random(),ts:new Date().toISOString(),text:l,system:!0}]}))},[]),q=X(r=>{let l=r.replace("#","");E(`/channels/${l}/members`).then(d=>{i(f=>({...f,[r]:d}))}).catch(()=>{})},[]),ue=X(r=>{let l=Array.isArray(r.body)?r.body.join(` +`):"",d={id:r.id,ts:r.ts,from:r.from,to:r.to,command:r.command};switch(r.command){case"PRIVMSG":case"NOTICE":{let f=l,v=!1;Pt(f)&&(f=Nt(f),v=!0);let S={...d,text:f,system:!1,isAction:v},G=r.to;if(G&&G.startsWith("#"))D(G,S);else{let H=r.from===ne.current?r.to:r.from;s(fe=>fe.find(Ne=>Ne.type==="dm"&&Ne.name===H)?fe:[...fe,{type:"dm",name:H}]),D(H,S)}break}case"JOIN":{let f=`${r.from} has joined ${r.to}`;r.to&&D(r.to,{...d,text:f,system:!0}),r.to&&r.to.startsWith("#")&&q(r.to);break}case"PART":{let f=l?" ("+l+")":"",v=`${r.from} has parted ${r.to}${f}`;r.to&&D(r.to,{...d,text:v,system:!0}),r.to&&r.to.startsWith("#")&&q(r.to);break}case"QUIT":{let f=l?" ("+l+")":"",v=`${r.from} has quit${f}`;P.current.forEach(S=>{S.type==="channel"&&D(S.name,{...d,text:v,system:!0})});break}case"NICK":{let f=Array.isArray(r.body)?r.body[0]:l,v=`${r.from} is now known as ${f}`;P.current.forEach(S=>{S.type==="channel"&&D(S.name,{...d,text:v,system:!0})}),r.from===ne.current&&f&&o(f),P.current.forEach(S=>{S.type==="channel"&&q(S.name)});break}case"TOPIC":{let f=`${r.from} has changed the topic to: ${l}`;r.to&&(D(r.to,{...d,text:f,system:!0}),y(v=>({...v,[r.to]:l})));break}case"353":{if(Array.isArray(r.params)&&r.params.length>=2&&r.body){let f=r.params[1],G=(Array.isArray(r.body)?r.body[0]:String(r.body)).split(/\s+/).filter(Boolean).map(H=>H.startsWith("@")?{nick:H.slice(1),mode:"o"}:H.startsWith("+")?{nick:H.slice(1),mode:"v"}:{nick:H,mode:""});i(H=>({...H,[f]:G}))}break}case"332":{if(Array.isArray(r.params)&&r.params.length>=1){let f=r.params[0],v=Array.isArray(r.body)?r.body[0]:l;v&&y(S=>({...S,[f]:v}))}break}case"375":case"372":case"376":D("Server",{...d,text:l,system:!0});break;default:l&&D("Server",{...d,text:l,system:!0})}},[D,q]);$(()=>{if(!e)return;let r=!0;return(async()=>{for(;r;)try{let d=new AbortController;Q.current=d;let f=await E(`/messages?after=${j.current}&timeout=${Tt}`,{signal:d.signal});if(!r)break;if(U(!0),f.messages)for(let v of f.messages)ue(v);f.last_id>j.current&&(j.current=f.last_id)}catch(d){if(!r)break;if(d.name==="AbortError")continue;U(!1),await new Promise(f=>setTimeout(f,xt))}})(),()=>{r=!1,Q.current?.abort()}},[e,ue]),$(()=>{if(!e)return;let r=a[c];if(!r||r.type!=="channel")return;q(r.name);let l=setInterval(()=>q(r.name),It);return()=>clearInterval(l)},[e,c,a,q]),$(()=>{xe.current?.scrollIntoView({behavior:"smooth"})},[h,c]),$(()=>{Ie.current?.focus()},[c]),$(()=>{if(!e)return;let r=a[c];!r||r.type!=="channel"||E("/channels").then(l=>{let d=l.find(f=>f.name===r.name);d&&d.topic&&y(f=>({...f,[r.name]:d.topic}))}).catch(()=>{})},[e,c,a]);let st=X(async r=>{o(r),t(!0),T("Server",`Connected as ${r}`);let l=JSON.parse(localStorage.getItem("neoirc_channels")||"[]");for(let d of l)try{await E("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:d})}),s(f=>f.find(v=>v.type==="channel"&&v.name===d)?f:[...f,{type:"channel",name:d}])}catch{}},[T]),at=async r=>{if(r){r=r.trim(),r.startsWith("#")||(r="#"+r);try{await E("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:r})}),s(d=>d.find(f=>f.type==="channel"&&f.name===r)?d:[...d,{type:"channel",name:r}]);let l=P.current.length;_(l);try{let d=await E(`/history?target=${encodeURIComponent(r)}&limit=50`);if(Array.isArray(d))for(let f of d)ue(f)}catch{}}catch(l){T("Server",`Failed to join ${r}: ${l.data?.error||"error"}`)}}},Ae=async(r,l)=>{try{await E("/messages",{method:"POST",body:JSON.stringify(l?{command:"PART",to:r,body:[l]}:{command:"PART",to:r})})}catch{}s(d=>d.filter(f=>!(f.type==="channel"&&f.name===r))),_(0)},it=r=>{let l=a[r];l.type==="channel"?Ae(l.name):l.type==="dm"&&(s(d=>d.filter((f,v)=>v!==r)),c>=r&&_(Math.max(0,c-1)))},Pe=r=>{if(r===ne.current)return;s(d=>d.find(f=>f.type==="dm"&&f.name===r)?d:[...d,{type:"dm",name:r}]);let l=a.findIndex(d=>d.type==="dm"&&d.name===r);_(l>=0?l:a.length)},ct=async r=>{let l=r.split(" "),d=l[0].toLowerCase(),f=a[c];switch(d){case"/join":{l[1]?at(l[1]):T("Server","Usage: /join #channel");break}case"/part":{if(f.type==="channel"){let v=l.slice(1).join(" ")||void 0;Ae(f.name,v)}else T("Server","You are not in a channel");break}case"/msg":{if(l[1]&&l.slice(2).join(" ")){let v=l[1],S=l.slice(2).join(" ");try{await E("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:v,body:[S]})}),Pe(v)}catch(G){T("Server",`Message failed: ${G.data?.error||"error"}`)}}else T("Server","Usage: /msg ");break}case"/me":{if(f.type==="server"){T("Server","Cannot use /me in server window");break}let v=l.slice(1).join(" ");if(v)try{await E("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:f.name,body:[Ce+v+Te]})})}catch(S){T(f.name,`Action failed: ${S.data?.error||"error"}`)}else T("Server","Usage: /me ");break}case"/nick":{if(l[1])try{await E("/messages",{method:"POST",body:JSON.stringify({command:"NICK",body:[l[1]]})})}catch(v){T("Server",`Nick change failed: ${v.data?.error||"error"}`)}else T("Server","Usage: /nick ");break}case"/topic":{if(f.type!=="channel"){T("Server","You are not in a channel");break}let v=l.slice(1).join(" ");if(v)try{await E("/messages",{method:"POST",body:JSON.stringify({command:"TOPIC",to:f.name,body:[v]})})}catch(S){T("Server",`Topic change failed: ${S.data?.error||"error"}`)}else T("Server",`Current topic for ${f.name}: ${m[f.name]||"(none)"}`);break}case"/mode":{if(f.type!=="channel"){T("Server","You are not in a channel");break}let v=l.slice(1);if(v.length>0)try{await E("/messages",{method:"POST",body:JSON.stringify({command:"MODE",to:f.name,params:v})})}catch(S){T("Server",`Mode change failed: ${S.data?.error||"error"}`)}else T("Server","Usage: /mode <+/-mode> [params]");break}case"/quit":{let v=l.slice(1).join(" ")||void 0;try{await E("/messages",{method:"POST",body:JSON.stringify(v?{command:"QUIT",body:[v]}:{command:"QUIT"})})}catch{}localStorage.removeItem("neoirc_token"),localStorage.removeItem("neoirc_channels"),window.location.reload();break}case"/help":{let v=["Available commands:"," /join #channel \u2014 Join a channel"," /part [reason] \u2014 Part the current channel"," /msg nick message \u2014 Send a private message"," /me action \u2014 Send an action"," /nick newnick \u2014 Change your nickname"," /topic [text] \u2014 View or set channel topic"," /mode +/-flags \u2014 Set channel modes"," /quit [reason] \u2014 Disconnect from server"," /help \u2014 Show this help"];for(let S of v)T("Server",S);break}default:T("Server",`Unknown command: ${d}`)}},_t=async()=>{let r=I.trim();if(!r)return;z(d=>{let f=[...d,r];return f.length>100&&f.shift(),f}),B(-1),k("");let l=a[c];if(l){if(r.startsWith("/")){await ct(r);return}if(l.type==="server"){T("Server","Cannot send messages to the server window. Use /join #channel first.");return}try{await E("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:l.name,body:[r]})})}catch(d){T(l.name,`Send failed: ${d.data?.error||"error"}`)}}},lt=r=>{if(r.key==="Enter")_t();else if(r.key==="ArrowUp"){if(r.preventDefault(),O.length>0){let l=R===-1?O.length-1:Math.max(0,R-1);B(l),k(O[l])}}else if(r.key==="ArrowDown"&&(r.preventDefault(),R>=0)){let l=R+1;l>=O.length?(B(-1),k("")):(B(l),k(O[l]))}};if(!e)return p(Et,{onLogin:st});let V=a[c]||a[0],ut=h[V.name]||[],ft=b[V.name]||[],dt=m[V.name]||"";return p("div",{class:"irc-app"},p("div",{class:"tab-bar"},p("div",{class:"tabs"},a.map((r,l)=>p("div",{class:`tab ${l===c?"active":""} ${C[r.name]>0&&l!==c?"has-unread":""}`,onClick:()=>_(l),key:r.name},p("span",{class:"tab-label"},(r.type==="dm",r.name)),C[r.name]>0&&l!==c&&p("span",{class:"unread-count"},"(",C[r.name],")"),r.type!=="server"&&p("span",{class:"tab-close",onClick:d=>{d.stopPropagation(),it(l)}},"\xD7")))),p("div",{class:"status-area"},!g&&p("span",{class:"status-warn"},"\u25CF Reconnecting"),p("span",{class:"status-nick"},n))),V.type==="channel"&&p("div",{class:"topic-bar"},p("span",{class:"topic-label"},"Topic:")," ",p("span",{class:"topic-text"},dt||"(no topic set)")),p("div",{class:"main-area"},p("div",{class:"messages-panel"},p("div",{class:"messages-scroll"},ut.map(r=>p(Mt,{msg:r,myNick:n,key:r.id})),p("div",{ref:xe}))),V.type==="channel"&&p(Ot,{members:ft,onNickClick:Pe})),p("div",{class:"input-line"},p("span",{class:"input-prompt"},"[",n,"]",V.type!=="server"?` ${V.name}`:""," >"),p("input",{ref:Ie,type:"text",value:I,onInput:r=>k(r.target.value),onKeyDown:lt,placeholder:V.type==="server"?"Type /help for commands":"",spellCheck:!1,autoComplete:"off"})))}qe(p($t,null),document.getElementById("root")); diff --git a/web/dist/style.css b/web/dist/style.css index ecf3ebb..0fcd97f 100644 --- a/web/dist/style.css +++ b/web/dist/style.css @@ -1,317 +1,466 @@ -* { margin: 0; padding: 0; box-sizing: border-box; } +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} :root { - --bg: #1a1a2e; - --bg-secondary: #16213e; - --bg-input: #0f3460; - --text: #e0e0e0; - --text-muted: #888; - --accent: #e94560; - --accent2: #0f3460; - --border: #2a2a4a; - --nick: #53a8b6; - --timestamp: #666; - --tab-active: #e94560; - --tab-bg: #16213e; - --tab-hover: #1a1a3e; - --topic-bg: #121a30; - --unread-bg: #e94560; - --warn: #f0ad4e; + --bg: #0a0e14; + --bg-panel: #0d1117; + --bg-input: #0d1117; + --bg-tab: #161b22; + --bg-tab-active: #0d1117; + --bg-topic: #0d1117; + --text: #c9d1d9; + --text-dim: #6e7681; + --text-bright: #e6edf3; + --accent: #58a6ff; + --accent-dim: #1f6feb; + --border: #21262d; + --system: #7d8590; + --action: #d2a8ff; + --warn: #d29922; + --error: #f85149; + --unread: #f0883e; + --nick-brackets: #6e7681; + --timestamp: #484f58; + --input-bg: #161b22; + --prompt: #3fb950; + --tab-indicator: #58a6ff; + --user-list-bg: #0d1117; + --user-list-header: #484f58; } -html, body, #root { +html, +body, +#root { height: 100%; - font-family: 'Courier New', Courier, monospace; - font-size: 14px; + font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono", + "Consolas", "Liberation Mono", "Courier New", monospace; + font-size: 13px; background: var(--bg); color: var(--text); + overflow: hidden; } -/* Login screen */ +/* ============================================ + Login Screen + ============================================ */ + .login-screen { display: flex; - flex-direction: column; align-items: center; justify-content: center; height: 100%; - gap: 16px; + background: var(--bg); } -.login-screen h1 { - color: var(--accent); - font-size: 2em; -} - -.login-screen input { - padding: 10px 16px; - font-size: 16px; - font-family: inherit; - background: var(--bg-input); - border: 1px solid var(--border); - color: var(--text); - border-radius: 4px; - width: 280px; -} - -.login-screen button { - padding: 10px 24px; - font-size: 16px; - font-family: inherit; - background: var(--accent); - border: none; - color: white; - border-radius: 4px; - cursor: pointer; -} - -.login-screen .error { - color: var(--accent); -} - -.login-screen .motd { - color: var(--text-muted); - max-width: 400px; +.login-box { text-align: center; - white-space: pre-wrap; + max-width: 360px; + width: 100%; + padding: 32px; } -/* Main layout */ -.app { +.login-box h1 { + color: var(--accent); + font-size: 1.8em; + margin-bottom: 16px; + font-weight: 400; +} + +.login-box .motd { + color: var(--text-dim); + font-size: 12px; + margin-bottom: 20px; + text-align: left; + white-space: pre-wrap; + font-family: inherit; + border-left: 2px solid var(--border); + padding-left: 12px; +} + +.login-box form { + display: flex; + flex-direction: column; + gap: 8px; + align-items: stretch; +} + +.login-box label { + color: var(--text-dim); + text-align: left; + font-size: 12px; +} + +.login-box input { + padding: 8px 12px; + font-family: inherit; + font-size: 14px; + background: var(--input-bg); + border: 1px solid var(--border); + color: var(--text-bright); + border-radius: 3px; + outline: none; +} + +.login-box input:focus { + border-color: var(--accent-dim); +} + +.login-box button { + padding: 8px 16px; + font-family: inherit; + font-size: 14px; + background: var(--accent-dim); + border: none; + color: var(--text-bright); + border-radius: 3px; + cursor: pointer; + margin-top: 4px; +} + +.login-box button:hover { + background: var(--accent); +} + +.login-box .error { + color: var(--error); + font-size: 12px; + margin-top: 8px; +} + +/* ============================================ + IRC App Layout + ============================================ */ + +.irc-app { display: flex; flex-direction: column; height: 100%; + overflow: hidden; } -/* Tab bar */ +/* ============================================ + Tab Bar + ============================================ */ + .tab-bar { display: flex; - background: var(--bg-secondary); + background: var(--bg-tab); border-bottom: 1px solid var(--border); - overflow-x: auto; flex-shrink: 0; - align-items: center; + height: 32px; + align-items: stretch; +} + +.tabs { + display: flex; + overflow-x: auto; + flex: 1; + scrollbar-width: none; +} + +.tabs::-webkit-scrollbar { + display: none; } .tab { - padding: 8px 16px; + display: flex; + align-items: center; + padding: 0 12px; cursor: pointer; - border-bottom: 2px solid transparent; + color: var(--text-dim); white-space: nowrap; - color: var(--text-muted); user-select: none; + border-right: 1px solid var(--border); + font-size: 12px; + gap: 4px; position: relative; } .tab:hover { - background: var(--tab-hover); + color: var(--text); + background: rgba(255, 255, 255, 0.03); } .tab.active { - color: var(--text); - border-bottom-color: var(--tab-active); + color: var(--text-bright); + background: var(--bg-tab-active); + border-bottom: 2px solid var(--tab-indicator); + margin-bottom: -1px; } -.tab .close-btn { - margin-left: 8px; - color: var(--text-muted); - font-size: 12px; -} - -.tab .close-btn:hover { - color: var(--accent); -} - -.tab .unread-badge { - display: inline-block; - background: var(--unread-bg); - color: white; - font-size: 10px; +.tab.has-unread .tab-label { + color: var(--unread); font-weight: bold; - padding: 1px 5px; - border-radius: 8px; - margin-left: 6px; - min-width: 16px; - text-align: center; } -/* Connection status */ -.connection-status { - padding: 4px 12px; - background: var(--warn); - color: #1a1a2e; - font-size: 12px; +.tab .unread-count { + color: var(--unread); + font-size: 11px; font-weight: bold; - white-space: nowrap; +} + +.tab-close { + color: var(--text-dim); + font-size: 14px; + line-height: 1; + margin-left: 2px; +} + +.tab-close:hover { + color: var(--error); +} + +.status-area { + display: flex; + align-items: center; + gap: 10px; + padding: 0 12px; flex-shrink: 0; + font-size: 12px; } -/* Topic bar */ +.status-nick { + color: var(--accent); + font-weight: bold; +} + +.status-warn { + color: var(--warn); + animation: blink 1.5s ease-in-out infinite; +} + +@keyframes blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +/* ============================================ + Topic Bar + ============================================ */ + .topic-bar { - padding: 6px 12px; - background: var(--topic-bg); + padding: 4px 12px; + background: var(--bg-topic); border-bottom: 1px solid var(--border); - color: var(--text-muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; + line-height: 1.5; } -/* Content area */ -.content { +.topic-label { + color: var(--text-dim); +} + +.topic-text { + color: var(--text); +} + +/* ============================================ + Main Content Area + ============================================ */ + +.main-area { display: flex; flex: 1; overflow: hidden; + min-height: 0; } -/* Messages */ -.messages-pane { +/* ============================================ + Messages Panel + ============================================ */ + +.messages-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; + min-width: 0; } -.messages { +.messages-scroll { flex: 1; overflow-y: auto; - padding: 8px 12px; + padding: 4px 8px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; } +.messages-scroll::-webkit-scrollbar { + width: 8px; +} + +.messages-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.messages-scroll::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +/* ============================================ + Message Lines + ============================================ */ + .message { - padding: 2px 0; + padding: 1px 0; line-height: 1.4; + white-space: pre-wrap; word-wrap: break-word; + font-size: 13px; } .message .timestamp { color: var(--timestamp); font-size: 12px; - margin-right: 8px; } .message .nick { - color: var(--nick); font-weight: bold; - margin-right: 8px; } -.message .nick::before { content: '<'; } -.message .nick::after { content: '>'; } +.message .content { + color: var(--text); +} -.message.system { - color: var(--text-muted); +/* System messages (joins, parts, quits, etc.) */ +.system-message { + color: var(--system); +} + +.system-message .system-text { + color: var(--system); +} + +/* /me action messages */ +.action-message .action-text { + color: var(--action); +} + +/* ============================================ + User List (Right Panel) + ============================================ */ + +.user-list { + width: 160px; + background: var(--user-list-bg); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; +} + +.user-list-header { + padding: 6px 10px; + color: var(--user-list-header); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.user-list-entries { + overflow-y: auto; + padding: 4px 0; + flex: 1; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.nick-entry { + padding: 2px 10px; + font-size: 12px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.5; +} + +.nick-entry:hover { + background: rgba(255, 255, 255, 0.04); +} + +.nick-prefix { + color: var(--text-dim); + display: inline-block; + width: 1ch; + text-align: right; + margin-right: 1px; +} + +.nick-name { + font-weight: normal; +} + +/* ============================================ + Input Line (Bottom) + ============================================ */ + +.input-line { + display: flex; + align-items: center; + background: var(--input-bg); + border-top: 1px solid var(--border); + flex-shrink: 0; + height: 36px; + padding: 0 8px; + gap: 6px; +} + +.input-prompt { + color: var(--prompt); + font-size: 13px; + flex-shrink: 0; + white-space: nowrap; +} + +.input-line input { + flex: 1; + padding: 4px 0; + font-family: inherit; + font-size: 13px; + background: transparent; + border: none; + color: var(--text-bright); + outline: none; + caret-color: var(--accent); +} + +.input-line input::placeholder { + color: var(--text-dim); font-style: italic; } -.message.system .nick { - color: var(--text-muted); -} +/* ============================================ + Responsive + ============================================ */ -.message.system .nick::before, -.message.system .nick::after { content: ''; } - -/* Input */ -.input-bar { - display: flex; - border-top: 1px solid var(--border); - background: var(--bg-secondary); - flex-shrink: 0; -} - -.input-bar input { - flex: 1; - padding: 10px 12px; - font-family: inherit; - font-size: 14px; - background: var(--bg-input); - border: none; - color: var(--text); - outline: none; -} - -.input-bar button { - padding: 10px 16px; - font-family: inherit; - background: var(--accent); - border: none; - color: white; - cursor: pointer; -} - -/* User list */ -.user-list { - width: 160px; - background: var(--bg-secondary); - border-left: 1px solid var(--border); - overflow-y: auto; - padding: 8px; - flex-shrink: 0; -} - -.user-list h3 { - color: var(--text-muted); - font-size: 11px; - text-transform: uppercase; - margin-bottom: 8px; - letter-spacing: 1px; -} - -.user-list .user { - padding: 3px 4px; - color: var(--nick); - font-size: 13px; - cursor: pointer; -} - -.user-list .user:hover { - background: var(--tab-hover); -} - -/* Server tab */ -.server-messages { - color: var(--text-muted); - padding: 12px; - white-space: pre-wrap; - overflow-y: auto; - flex: 1; -} - -/* Channel join dialog */ -.join-dialog { - padding: 12px; - display: flex; - gap: 8px; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border); - margin-left: auto; -} - -.join-dialog input { - padding: 6px 10px; - font-family: inherit; - font-size: 13px; - background: var(--bg-input); - border: 1px solid var(--border); - color: var(--text); - border-radius: 3px; - width: 200px; -} - -.join-dialog button { - padding: 6px 14px; - font-family: inherit; - font-size: 13px; - background: var(--accent2); - border: none; - color: var(--text); - border-radius: 3px; - cursor: pointer; -} - -/* Responsive */ @media (max-width: 600px) { - .user-list { display: none; } - .tab { padding: 6px 10px; font-size: 13px; } + .user-list { + display: none; + } + + .tab { + padding: 0 8px; + font-size: 11px; + } + + .input-prompt { + font-size: 12px; + } } diff --git a/web/src/app.jsx b/web/src/app.jsx index 47ef442..9086bf4 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -1,120 +1,221 @@ -import { h, render } from 'preact'; -import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; +import { h, render } from "preact"; +import { useState, useEffect, useRef, useCallback } from "preact/hooks"; -const API = '/api/v1'; +const API = "/api/v1"; const POLL_TIMEOUT = 15; const RECONNECT_DELAY = 3000; const MEMBER_REFRESH_INTERVAL = 10000; +const ACTION_PREFIX = "\x01ACTION "; +const ACTION_SUFFIX = "\x01"; function api(path, opts = {}) { - const token = localStorage.getItem('neoirc_token'); - const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; - if (token) headers['Authorization'] = `Bearer ${token}`; + const token = localStorage.getItem("neoirc_token"); + const headers = { + "Content-Type": "application/json", + ...(opts.headers || {}), + }; + if (token) headers["Authorization"] = `Bearer ${token}`; const { signal, ...rest } = opts; - return fetch(API + path, { ...rest, headers, signal }).then(async r => { + + return fetch(API + path, { ...rest, headers, signal }).then(async (r) => { const data = await r.json().catch(() => null); if (!r.ok) throw { status: r.status, data }; + return data; }); } function formatTime(ts) { const d = new Date(ts); - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + + return d.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); } function nickColor(nick) { let h = 0; - for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h); + for (let i = 0; i < nick.length; i++) + h = nick.charCodeAt(i) + ((h << 5) - h); const hue = Math.abs(h) % 360; - return `hsl(${hue}, 70%, 65%)`; + + return `hsl(${hue}, 60%, 65%)`; } +function isAction(text) { + return text.startsWith(ACTION_PREFIX) && text.endsWith(ACTION_SUFFIX); +} + +function parseAction(text) { + return text.slice(ACTION_PREFIX.length, -ACTION_SUFFIX.length); +} + +// LoginScreen renders the initial nick selection form. function LoginScreen({ onLogin }) { - const [nick, setNick] = useState(''); - const [error, setError] = useState(''); - const [motd, setMotd] = useState(''); - const [serverName, setServerName] = useState('NeoIRC'); + const [nick, setNick] = useState(""); + const [error, setError] = useState(""); + const [motd, setMotd] = useState(""); + const [serverName, setServerName] = useState("NeoIRC"); const inputRef = useRef(); useEffect(() => { - api('/server').then(s => { - if (s.name) setServerName(s.name); - if (s.motd) setMotd(s.motd); - }).catch(() => {}); - const saved = localStorage.getItem('neoirc_token'); + api("/server") + .then((s) => { + if (s.name) setServerName(s.name); + if (s.motd) setMotd(s.motd); + }) + .catch(() => {}); + const saved = localStorage.getItem("neoirc_token"); if (saved) { - api('/state').then(u => onLogin(u.nick)).catch(() => localStorage.removeItem('neoirc_token')); + api("/state") + .then((u) => onLogin(u.nick)) + .catch(() => localStorage.removeItem("neoirc_token")); } inputRef.current?.focus(); }, []); const submit = async (e) => { e.preventDefault(); - setError(''); + setError(""); try { - const res = await api('/session', { - method: 'POST', - body: JSON.stringify({ nick: nick.trim() }) + const res = await api("/session", { + method: "POST", + body: JSON.stringify({ nick: nick.trim() }), }); - localStorage.setItem('neoirc_token', res.token); + localStorage.setItem("neoirc_token", res.token); onLogin(res.nick); } catch (err) { - setError(err.data?.error || 'Connection failed'); + setError(err.data?.error || "Connection failed"); } }; return ( ); } -function Message({ msg }) { +// Message renders a single chat line in IRC format. +function Message({ msg, myNick }) { + const time = formatTime(msg.ts); + if (msg.system) { return ( -
- {formatTime(msg.ts)} - {msg.text} +
+ [{time}] + * {msg.text}
); } + + if (msg.isAction) { + return ( +
+ [{time}] + + {" "} + * {msg.from}{" "} + {msg.text} + +
+ ); + } + return (
- {formatTime(msg.ts)} - {msg.from} + [{time}]{" "} + + <{msg.from}> + {" "} {msg.text}
); } +// UserList renders the right-side nick list for channels. +function UserList({ members, onNickClick }) { + const ops = []; + const voiced = []; + const regular = []; + + for (const m of members) { + const mode = m.mode || ""; + if (mode === "o") { + ops.push(m); + } else if (mode === "v") { + voiced.push(m); + } else { + regular.push(m); + } + } + + const sortNicks = (a, b) => + a.nick.toLowerCase().localeCompare(b.nick.toLowerCase()); + ops.sort(sortNicks); + voiced.sort(sortNicks); + regular.sort(sortNicks); + + const renderNick = (m, prefix) => ( +
onNickClick(m.nick)} + title={m.nick} + > + {prefix} + + {m.nick} + +
+ ); + + return ( +
+
+ {members.length} user{members.length !== 1 ? "s" : ""} +
+
+ {ops.map((m) => renderNick(m, "@"))} + {voiced.map((m) => renderNick(m, "+"))} + {regular.map((m) => renderNick(m, ""))} +
+
+ ); +} + function App() { const [loggedIn, setLoggedIn] = useState(false); - const [nick, setNick] = useState(''); - const [tabs, setTabs] = useState([{ type: 'server', name: 'Server' }]); + const [nick, setNick] = useState(""); + const [tabs, setTabs] = useState([{ type: "server", name: "Server" }]); const [activeTab, setActiveTab] = useState(0); const [messages, setMessages] = useState({ Server: [] }); const [members, setMembers] = useState({}); const [topics, setTopics] = useState({}); const [unread, setUnread] = useState({}); - const [input, setInput] = useState(''); - const [joinInput, setJoinInput] = useState(''); + const [input, setInput] = useState(""); const [connected, setConnected] = useState(true); + const [inputHistory, setInputHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); const lastIdRef = useRef(0); const seenIdsRef = useRef(new Set()); @@ -125,134 +226,215 @@ function App() { const messagesEndRef = useRef(); const inputRef = useRef(); - useEffect(() => { tabsRef.current = tabs; }, [tabs]); - useEffect(() => { activeTabRef.current = activeTab; }, [activeTab]); - useEffect(() => { nickRef.current = nick; }, [nick]); - - // Persist joined channels useEffect(() => { - const channels = tabs.filter(t => t.type === 'channel').map(t => t.name); - localStorage.setItem('neoirc_channels', JSON.stringify(channels)); + tabsRef.current = tabs; + }, [tabs]); + useEffect(() => { + activeTabRef.current = activeTab; + }, [activeTab]); + useEffect(() => { + nickRef.current = nick; + }, [nick]); + + // Persist joined channels. + useEffect(() => { + const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name); + localStorage.setItem("neoirc_channels", JSON.stringify(channels)); }, [tabs]); - // Clear unread on tab switch + // Clear unread on tab switch. useEffect(() => { const tab = tabs[activeTab]; - if (tab) setUnread(prev => ({ ...prev, [tab.name]: 0 })); + if (tab) setUnread((prev) => ({ ...prev, [tab.name]: 0 })); }, [activeTab, tabs]); const addMessage = useCallback((tabName, msg) => { if (msg.id && seenIdsRef.current.has(msg.id)) return; if (msg.id) seenIdsRef.current.add(msg.id); - setMessages(prev => ({ + setMessages((prev) => ({ ...prev, - [tabName]: [...(prev[tabName] || []), msg] + [tabName]: [...(prev[tabName] || []), msg], })); const currentTab = tabsRef.current[activeTabRef.current]; if (!currentTab || currentTab.name !== tabName) { - setUnread(prev => ({ ...prev, [tabName]: (prev[tabName] || 0) + 1 })); + setUnread((prev) => ({ + ...prev, + [tabName]: (prev[tabName] || 0) + 1, + })); } }, []); const addSystemMessage = useCallback((tabName, text) => { - setMessages(prev => ({ + setMessages((prev) => ({ ...prev, - [tabName]: [...(prev[tabName] || []), { - id: 'sys-' + Date.now() + '-' + Math.random(), - ts: new Date().toISOString(), - text, - system: true - }] + [tabName]: [ + ...(prev[tabName] || []), + { + id: "sys-" + Date.now() + "-" + Math.random(), + ts: new Date().toISOString(), + text, + system: true, + }, + ], })); }, []); const refreshMembers = useCallback((channel) => { - const chName = channel.replace('#', ''); - api(`/channels/${chName}/members`).then(m => { - setMembers(prev => ({ ...prev, [channel]: m })); - }).catch(() => {}); + const chName = channel.replace("#", ""); + api(`/channels/${chName}/members`) + .then((m) => { + setMembers((prev) => ({ ...prev, [channel]: m })); + }) + .catch(() => {}); }, []); - const processMessage = useCallback((msg) => { - const body = Array.isArray(msg.body) ? msg.body.join('\n') : ''; - const base = { id: msg.id, ts: msg.ts, from: msg.from, to: msg.to, command: msg.command }; + const processMessage = useCallback( + (msg) => { + const body = Array.isArray(msg.body) ? msg.body.join("\n") : ""; + const base = { + id: msg.id, + ts: msg.ts, + from: msg.from, + to: msg.to, + command: msg.command, + }; - switch (msg.command) { - case 'PRIVMSG': - case 'NOTICE': { - const parsed = { ...base, text: body, system: false }; - const target = msg.to; - if (target && target.startsWith('#')) { - addMessage(target, parsed); - } else { - const dmPeer = msg.from === nickRef.current ? msg.to : msg.from; - setTabs(prev => { - if (!prev.find(t => t.type === 'dm' && t.name === dmPeer)) { - return [...prev, { type: 'dm', name: dmPeer }]; + switch (msg.command) { + case "PRIVMSG": + case "NOTICE": { + let text = body; + let actionMsg = false; + + if (isAction(text)) { + text = parseAction(text); + actionMsg = true; + } + + const parsed = { ...base, text, system: false, isAction: actionMsg }; + const target = msg.to; + + if (target && target.startsWith("#")) { + addMessage(target, parsed); + } else { + const dmPeer = + msg.from === nickRef.current ? msg.to : msg.from; + setTabs((prev) => { + if (!prev.find((t) => t.type === "dm" && t.name === dmPeer)) { + return [...prev, { type: "dm", name: dmPeer }]; + } + + return prev; + }); + addMessage(dmPeer, parsed); + } + + break; + } + case "JOIN": { + const text = `${msg.from} has joined ${msg.to}`; + if (msg.to) addMessage(msg.to, { ...base, text, system: true }); + if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to); + + break; + } + case "PART": { + const reason = body ? " (" + body + ")" : ""; + const text = `${msg.from} has parted ${msg.to}${reason}`; + if (msg.to) addMessage(msg.to, { ...base, text, system: true }); + if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to); + + break; + } + case "QUIT": { + const reason = body ? " (" + body + ")" : ""; + const text = `${msg.from} has quit${reason}`; + tabsRef.current.forEach((tab) => { + if (tab.type === "channel") { + addMessage(tab.name, { ...base, text, system: true }); } - return prev; }); - addMessage(dmPeer, parsed); - } - break; - } - case 'JOIN': { - const text = `${msg.from} has joined ${msg.to}`; - if (msg.to) addMessage(msg.to, { ...base, text, system: true }); - if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to); - break; - } - case 'PART': { - const reason = body ? ': ' + body : ''; - const text = `${msg.from} has left ${msg.to}${reason}`; - if (msg.to) addMessage(msg.to, { ...base, text, system: true }); - if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to); - break; - } - case 'QUIT': { - const reason = body ? ': ' + body : ''; - const text = `${msg.from} has quit${reason}`; - tabsRef.current.forEach(tab => { - if (tab.type === 'channel') { - addMessage(tab.name, { ...base, text, system: true }); - } - }); - break; - } - case 'NICK': { - const newNick = Array.isArray(msg.body) ? msg.body[0] : body; - const text = `${msg.from} is now known as ${newNick}`; - tabsRef.current.forEach(tab => { - if (tab.type === 'channel') { - addMessage(tab.name, { ...base, text, system: true }); - } - }); - if (msg.from === nickRef.current && newNick) setNick(newNick); - // Refresh members in all channels - tabsRef.current.forEach(tab => { - if (tab.type === 'channel') refreshMembers(tab.name); - }); - break; - } - case 'TOPIC': { - const text = `${msg.from} set the topic: ${body}`; - if (msg.to) { - addMessage(msg.to, { ...base, text, system: true }); - setTopics(prev => ({ ...prev, [msg.to]: body })); - } - break; - } - case '375': - case '372': - case '376': - addMessage('Server', { ...base, text: body, system: true }); - break; - default: - addMessage('Server', { ...base, text: body || msg.command, system: true }); - } - }, [addMessage, refreshMembers]); - // Long-poll loop + break; + } + case "NICK": { + const newNick = Array.isArray(msg.body) ? msg.body[0] : body; + const text = `${msg.from} is now known as ${newNick}`; + tabsRef.current.forEach((tab) => { + if (tab.type === "channel") { + addMessage(tab.name, { ...base, text, system: true }); + } + }); + if (msg.from === nickRef.current && newNick) setNick(newNick); + tabsRef.current.forEach((tab) => { + if (tab.type === "channel") refreshMembers(tab.name); + }); + + break; + } + case "TOPIC": { + const text = `${msg.from} has changed the topic to: ${body}`; + if (msg.to) { + addMessage(msg.to, { ...base, text, system: true }); + setTopics((prev) => ({ ...prev, [msg.to]: body })); + } + + break; + } + case "353": { + // RPL_NAMREPLY — parse mode prefixes from names list. + if ( + Array.isArray(msg.params) && + msg.params.length >= 2 && + msg.body + ) { + const channel = msg.params[1]; + const namesStr = Array.isArray(msg.body) + ? msg.body[0] + : String(msg.body); + const names = namesStr.split(/\s+/).filter(Boolean); + const parsed = names.map((n) => { + if (n.startsWith("@")) { + return { nick: n.slice(1), mode: "o" }; + } + if (n.startsWith("+")) { + return { nick: n.slice(1), mode: "v" }; + } + + return { nick: n, mode: "" }; + }); + setMembers((prev) => ({ ...prev, [channel]: parsed })); + } + + break; + } + case "332": { + // RPL_TOPIC — set topic from server. + if (Array.isArray(msg.params) && msg.params.length >= 1) { + const channel = msg.params[0]; + const topicText = Array.isArray(msg.body) ? msg.body[0] : body; + if (topicText) { + setTopics((prev) => ({ ...prev, [channel]: topicText })); + } + } + + break; + } + case "375": + case "372": + case "376": + addMessage("Server", { ...base, text: body, system: true }); + + break; + default: + if (body) { + addMessage("Server", { ...base, text: body, system: true }); + } + } + }, + [addMessage, refreshMembers], + ); + + // Long-poll loop. useEffect(() => { if (!loggedIn) return; let alive = true; @@ -264,7 +446,7 @@ function App() { pollAbortRef.current = controller; const result = await api( `/messages?after=${lastIdRef.current}&timeout=${POLL_TIMEOUT}`, - { signal: controller.signal } + { signal: controller.signal }, ); if (!alive) break; setConnected(true); @@ -276,168 +458,437 @@ function App() { } } catch (err) { if (!alive) break; - if (err.name === 'AbortError') continue; + if (err.name === "AbortError") continue; setConnected(false); - await new Promise(r => setTimeout(r, RECONNECT_DELAY)); + await new Promise((r) => setTimeout(r, RECONNECT_DELAY)); } } }; poll(); - return () => { alive = false; pollAbortRef.current?.abort(); }; + + return () => { + alive = false; + pollAbortRef.current?.abort(); + }; }, [loggedIn, processMessage]); - // Refresh members for active channel + // Refresh members for active channel. useEffect(() => { if (!loggedIn) return; const tab = tabs[activeTab]; - if (!tab || tab.type !== 'channel') return; + if (!tab || tab.type !== "channel") return; refreshMembers(tab.name); - const iv = setInterval(() => refreshMembers(tab.name), MEMBER_REFRESH_INTERVAL); + const iv = setInterval( + () => refreshMembers(tab.name), + MEMBER_REFRESH_INTERVAL, + ); + return () => clearInterval(iv); }, [loggedIn, activeTab, tabs, refreshMembers]); - // Auto-scroll + // Auto-scroll. useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, activeTab]); - // Focus input on tab change - useEffect(() => { inputRef.current?.focus(); }, [activeTab]); + // Focus input on tab change. + useEffect(() => { + inputRef.current?.focus(); + }, [activeTab]); - // Fetch topic for active channel + // Fetch topic for active channel. useEffect(() => { if (!loggedIn) return; const tab = tabs[activeTab]; - if (!tab || tab.type !== 'channel') return; - api('/channels').then(channels => { - const ch = channels.find(c => c.name === tab.name); - if (ch && ch.topic) setTopics(prev => ({ ...prev, [tab.name]: ch.topic })); - }).catch(() => {}); + if (!tab || tab.type !== "channel") return; + api("/channels") + .then((channels) => { + const ch = channels.find((c) => c.name === tab.name); + if (ch && ch.topic) + setTopics((prev) => ({ ...prev, [tab.name]: ch.topic })); + }) + .catch(() => {}); }, [loggedIn, activeTab, tabs]); - const onLogin = useCallback(async (userNick) => { - setNick(userNick); - setLoggedIn(true); - addSystemMessage('Server', `Connected as ${userNick}`); - // Auto-rejoin saved channels - const saved = JSON.parse(localStorage.getItem('neoirc_channels') || '[]'); - for (const ch of saved) { - try { - await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: ch }) }); - setTabs(prev => { - if (prev.find(t => t.type === 'channel' && t.name === ch)) return prev; - return [...prev, { type: 'channel', name: ch }]; - }); - } catch (e) { - // Channel may not exist anymore + const onLogin = useCallback( + async (userNick) => { + setNick(userNick); + setLoggedIn(true); + addSystemMessage("Server", `Connected as ${userNick}`); + const saved = JSON.parse( + localStorage.getItem("neoirc_channels") || "[]", + ); + for (const ch of saved) { + try { + await api("/messages", { + method: "POST", + body: JSON.stringify({ command: "JOIN", to: ch }), + }); + setTabs((prev) => { + if (prev.find((t) => t.type === "channel" && t.name === ch)) + return prev; + + return [...prev, { type: "channel", name: ch }]; + }); + } catch (e) { + // Channel may not exist anymore. + } } - } - }, [addSystemMessage]); + }, + [addSystemMessage], + ); const joinChannel = async (name) => { if (!name) return; name = name.trim(); - if (!name.startsWith('#')) name = '#' + name; + if (!name.startsWith("#")) name = "#" + name; try { - await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: name }) }); - setTabs(prev => { - if (prev.find(t => t.type === 'channel' && t.name === name)) return prev; - return [...prev, { type: 'channel', name }]; + await api("/messages", { + method: "POST", + body: JSON.stringify({ command: "JOIN", to: name }), }); - setActiveTab(tabs.length); - // Load history + setTabs((prev) => { + if (prev.find((t) => t.type === "channel" && t.name === name)) + return prev; + + return [...prev, { type: "channel", name }]; + }); + const newIdx = tabsRef.current.length; + setActiveTab(newIdx); try { - const hist = await api(`/history?target=${encodeURIComponent(name)}&limit=50`); + const hist = await api( + `/history?target=${encodeURIComponent(name)}&limit=50`, + ); if (Array.isArray(hist)) { for (const m of hist) processMessage(m); } } catch (e) { - // History may be empty + // History may be empty. } - setJoinInput(''); } catch (err) { - addSystemMessage('Server', `Failed to join ${name}: ${err.data?.error || 'error'}`); + addSystemMessage( + "Server", + `Failed to join ${name}: ${err.data?.error || "error"}`, + ); } }; - const partChannel = async (name) => { + const partChannel = async (name, reason) => { try { - await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PART', to: name }) }); + const body = reason ? { command: "PART", to: name, body: [reason] } : { command: "PART", to: name }; + await api("/messages", { + method: "POST", + body: JSON.stringify(body), + }); } catch (e) { - // Ignore + // Ignore. } - setTabs(prev => prev.filter(t => !(t.type === 'channel' && t.name === name))); + setTabs((prev) => + prev.filter((t) => !(t.type === "channel" && t.name === name)), + ); setActiveTab(0); }; const closeTab = (idx) => { const tab = tabs[idx]; - if (tab.type === 'channel') { + if (tab.type === "channel") { partChannel(tab.name); - } else if (tab.type === 'dm') { - setTabs(prev => prev.filter((_, i) => i !== idx)); + } else if (tab.type === "dm") { + setTabs((prev) => prev.filter((_, i) => i !== idx)); if (activeTab >= idx) setActiveTab(Math.max(0, activeTab - 1)); } }; const openDM = (targetNick) => { - setTabs(prev => { - if (prev.find(t => t.type === 'dm' && t.name === targetNick)) return prev; - return [...prev, { type: 'dm', name: targetNick }]; + if (targetNick === nickRef.current) return; + setTabs((prev) => { + if (prev.find((t) => t.type === "dm" && t.name === targetNick)) + return prev; + + return [...prev, { type: "dm", name: targetNick }]; }); - const idx = tabs.findIndex(t => t.type === 'dm' && t.name === targetNick); + const idx = tabs.findIndex( + (t) => t.type === "dm" && t.name === targetNick, + ); setActiveTab(idx >= 0 ? idx : tabs.length); }; + const handleCommand = async (text) => { + const parts = text.split(" "); + const cmd = parts[0].toLowerCase(); + const tab = tabs[activeTab]; + + switch (cmd) { + case "/join": { + if (parts[1]) joinChannel(parts[1]); + else addSystemMessage("Server", "Usage: /join #channel"); + + break; + } + case "/part": { + if (tab.type === "channel") { + const reason = parts.slice(1).join(" ") || undefined; + partChannel(tab.name, reason); + } else { + addSystemMessage("Server", "You are not in a channel"); + } + + break; + } + case "/msg": { + if (parts[1] && parts.slice(2).join(" ")) { + const target = parts[1]; + const body = parts.slice(2).join(" "); + try { + await api("/messages", { + method: "POST", + body: JSON.stringify({ + command: "PRIVMSG", + to: target, + body: [body], + }), + }); + openDM(target); + } catch (err) { + addSystemMessage( + "Server", + `Message failed: ${err.data?.error || "error"}`, + ); + } + } else { + addSystemMessage("Server", "Usage: /msg "); + } + + break; + } + case "/me": { + if (tab.type === "server") { + addSystemMessage("Server", "Cannot use /me in server window"); + + break; + } + const actionText = parts.slice(1).join(" "); + if (actionText) { + try { + await api("/messages", { + method: "POST", + body: JSON.stringify({ + command: "PRIVMSG", + to: tab.name, + body: [ACTION_PREFIX + actionText + ACTION_SUFFIX], + }), + }); + } catch (err) { + addSystemMessage( + tab.name, + `Action failed: ${err.data?.error || "error"}`, + ); + } + } else { + addSystemMessage("Server", "Usage: /me "); + } + + break; + } + case "/nick": { + if (parts[1]) { + try { + await api("/messages", { + method: "POST", + body: JSON.stringify({ command: "NICK", body: [parts[1]] }), + }); + } catch (err) { + addSystemMessage( + "Server", + `Nick change failed: ${err.data?.error || "error"}`, + ); + } + } else { + addSystemMessage("Server", "Usage: /nick "); + } + + break; + } + case "/topic": { + if (tab.type !== "channel") { + addSystemMessage("Server", "You are not in a channel"); + + break; + } + const topicText = parts.slice(1).join(" "); + if (topicText) { + try { + await api("/messages", { + method: "POST", + body: JSON.stringify({ + command: "TOPIC", + to: tab.name, + body: [topicText], + }), + }); + } catch (err) { + addSystemMessage( + "Server", + `Topic change failed: ${err.data?.error || "error"}`, + ); + } + } else { + addSystemMessage( + "Server", + `Current topic for ${tab.name}: ${topics[tab.name] || "(none)"}`, + ); + } + + break; + } + case "/mode": { + if (tab.type !== "channel") { + addSystemMessage("Server", "You are not in a channel"); + + break; + } + const modeArgs = parts.slice(1); + if (modeArgs.length > 0) { + try { + await api("/messages", { + method: "POST", + body: JSON.stringify({ + command: "MODE", + to: tab.name, + params: modeArgs, + }), + }); + } catch (err) { + addSystemMessage( + "Server", + `Mode change failed: ${err.data?.error || "error"}`, + ); + } + } else { + addSystemMessage( + "Server", + "Usage: /mode <+/-mode> [params]", + ); + } + + break; + } + case "/quit": { + const reason = parts.slice(1).join(" ") || undefined; + try { + const body = reason ? { command: "QUIT", body: [reason] } : { command: "QUIT" }; + await api("/messages", { + method: "POST", + body: JSON.stringify(body), + }); + } catch (e) { + // Ignore. + } + localStorage.removeItem("neoirc_token"); + localStorage.removeItem("neoirc_channels"); + window.location.reload(); + + break; + } + case "/help": { + const helpLines = [ + "Available commands:", + " /join #channel — Join a channel", + " /part [reason] — Part the current channel", + " /msg nick message — Send a private message", + " /me action — Send an action", + " /nick newnick — Change your nickname", + " /topic [text] — View or set channel topic", + " /mode +/-flags — Set channel modes", + " /quit [reason] — Disconnect from server", + " /help — Show this help", + ]; + for (const line of helpLines) { + addSystemMessage("Server", line); + } + + break; + } + default: + addSystemMessage("Server", `Unknown command: ${cmd}`); + } + }; + const sendMessage = async () => { const text = input.trim(); if (!text) return; - setInput(''); - const tab = tabs[activeTab]; - if (!tab || tab.type === 'server') return; - if (text.startsWith('/')) { - const parts = text.split(' '); - const cmd = parts[0].toLowerCase(); - if (cmd === '/join' && parts[1]) { joinChannel(parts[1]); return; } - if (cmd === '/part') { if (tab.type === 'channel') partChannel(tab.name); return; } - if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) { - const target = parts[1]; - const body = parts.slice(2).join(' '); - try { - await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: target, body: [body] }) }); - openDM(target); - } catch (err) { - addSystemMessage('Server', `DM failed: ${err.data?.error || 'error'}`); - } - return; - } - if (cmd === '/nick' && parts[1]) { - try { - await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'NICK', body: [parts[1]] }) }); - } catch (err) { - addSystemMessage('Server', `Nick change failed: ${err.data?.error || 'error'}`); - } - return; - } - if (cmd === '/topic' && tab.type === 'channel') { - const topicText = parts.slice(1).join(' '); - try { - await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'TOPIC', to: tab.name, body: [topicText] }) }); - } catch (err) { - addSystemMessage('Server', `Topic failed: ${err.data?.error || 'error'}`); - } - return; - } - addSystemMessage('Server', `Unknown command: ${cmd}`); + // Save to input history. + setInputHistory((prev) => { + const next = [...prev, text]; + if (next.length > 100) next.shift(); + + return next; + }); + setHistoryIndex(-1); + setInput(""); + + const tab = tabs[activeTab]; + if (!tab) return; + + if (text.startsWith("/")) { + await handleCommand(text); + + return; + } + + if (tab.type === "server") { + addSystemMessage( + "Server", + "Cannot send messages to the server window. Use /join #channel first.", + ); + return; } try { - await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: tab.name, body: [text] }) }); + await api("/messages", { + method: "POST", + body: JSON.stringify({ + command: "PRIVMSG", + to: tab.name, + body: [text], + }), + }); } catch (err) { - addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`); + addSystemMessage( + tab.name, + `Send failed: ${err.data?.error || "error"}`, + ); + } + }; + + const handleInputKeyDown = (e) => { + if (e.key === "Enter") { + sendMessage(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (inputHistory.length > 0) { + const newIdx = + historyIndex === -1 + ? inputHistory.length - 1 + : Math.max(0, historyIndex - 1); + setHistoryIndex(newIdx); + setInput(inputHistory[newIdx]); + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + if (historyIndex >= 0) { + const newIdx = historyIndex + 1; + if (newIdx >= inputHistory.length) { + setHistoryIndex(-1); + setInput(""); + } else { + setHistoryIndex(newIdx); + setInput(inputHistory[newIdx]); + } + } } }; @@ -446,74 +897,102 @@ function App() { const currentTab = tabs[activeTab] || tabs[0]; const currentMessages = messages[currentTab.name] || []; const currentMembers = members[currentTab.name] || []; - const currentTopic = topics[currentTab.name] || ''; + const currentTopic = topics[currentTab.name] || ""; return ( -
+
+ {/* Tab bar */}
- {!connected &&
⚠ Reconnecting...
} - {tabs.map((tab, i) => ( -
setActiveTab(i)} - > - {tab.type === 'dm' ? `→${tab.name}` : tab.name} - {unread[tab.name] > 0 && i !== activeTab && ( - {unread[tab.name]} - )} - {tab.type !== 'server' && ( - { e.stopPropagation(); closeTab(i); }}>× - )} -
- ))} -
- setJoinInput(e.target.value)} - onKeyDown={e => e.key === 'Enter' && joinChannel(joinInput)} - /> - +
+ {tabs.map((tab, i) => ( +
0 && i !== activeTab ? "has-unread" : ""}`} + onClick={() => setActiveTab(i)} + key={tab.name} + > + + {tab.type === "dm" ? tab.name : tab.name} + + {unread[tab.name] > 0 && i !== activeTab && ( + ({unread[tab.name]}) + )} + {tab.type !== "server" && ( + { + e.stopPropagation(); + closeTab(i); + }} + > + × + + )} +
+ ))} +
+
+ {!connected && ● Reconnecting} + {nick}
- {currentTab.type === 'channel' && currentTopic && ( -
{currentTopic}
+ {/* Topic bar — channels only */} + {currentTab.type === "channel" && ( +
+ Topic:{" "} + + {currentTopic || "(no topic set)"} + +
)} -
-
-
- {currentMessages.map(m => )} + {/* Main content area */} +
+ {/* Messages */} +
+
+ {currentMessages.map((m) => ( + + ))}
- {currentTab.type !== 'server' && ( -
- setInput(e.target.value)} - onKeyDown={e => e.key === 'Enter' && sendMessage()} - /> - -
- )}
- {currentTab.type === 'channel' && ( -
-

Users ({currentMembers.length})

- {currentMembers.map(u => ( -
openDM(u.nick)} style={{ color: nickColor(u.nick) }}> - {u.nick} -
- ))} -
+ {/* User list — channels only */} + {currentTab.type === "channel" && ( + )}
+ + {/* Persistent input line */} +
+ + [{nick}] + {currentTab.type !== "server" + ? ` ${currentTab.name}` + : ""} + {" >"} + + setInput(e.target.value)} + onKeyDown={handleInputKeyDown} + placeholder={ + currentTab.type === "server" + ? "Type /help for commands" + : "" + } + spellCheck={false} + autoComplete="off" + /> +
); } -render(, document.getElementById('root')); +render(, document.getElementById("root")); diff --git a/web/src/style.css b/web/src/style.css index ecf3ebb..0fcd97f 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1,317 +1,466 @@ -* { margin: 0; padding: 0; box-sizing: border-box; } +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} :root { - --bg: #1a1a2e; - --bg-secondary: #16213e; - --bg-input: #0f3460; - --text: #e0e0e0; - --text-muted: #888; - --accent: #e94560; - --accent2: #0f3460; - --border: #2a2a4a; - --nick: #53a8b6; - --timestamp: #666; - --tab-active: #e94560; - --tab-bg: #16213e; - --tab-hover: #1a1a3e; - --topic-bg: #121a30; - --unread-bg: #e94560; - --warn: #f0ad4e; + --bg: #0a0e14; + --bg-panel: #0d1117; + --bg-input: #0d1117; + --bg-tab: #161b22; + --bg-tab-active: #0d1117; + --bg-topic: #0d1117; + --text: #c9d1d9; + --text-dim: #6e7681; + --text-bright: #e6edf3; + --accent: #58a6ff; + --accent-dim: #1f6feb; + --border: #21262d; + --system: #7d8590; + --action: #d2a8ff; + --warn: #d29922; + --error: #f85149; + --unread: #f0883e; + --nick-brackets: #6e7681; + --timestamp: #484f58; + --input-bg: #161b22; + --prompt: #3fb950; + --tab-indicator: #58a6ff; + --user-list-bg: #0d1117; + --user-list-header: #484f58; } -html, body, #root { +html, +body, +#root { height: 100%; - font-family: 'Courier New', Courier, monospace; - font-size: 14px; + font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono", + "Consolas", "Liberation Mono", "Courier New", monospace; + font-size: 13px; background: var(--bg); color: var(--text); + overflow: hidden; } -/* Login screen */ +/* ============================================ + Login Screen + ============================================ */ + .login-screen { display: flex; - flex-direction: column; align-items: center; justify-content: center; height: 100%; - gap: 16px; + background: var(--bg); } -.login-screen h1 { - color: var(--accent); - font-size: 2em; -} - -.login-screen input { - padding: 10px 16px; - font-size: 16px; - font-family: inherit; - background: var(--bg-input); - border: 1px solid var(--border); - color: var(--text); - border-radius: 4px; - width: 280px; -} - -.login-screen button { - padding: 10px 24px; - font-size: 16px; - font-family: inherit; - background: var(--accent); - border: none; - color: white; - border-radius: 4px; - cursor: pointer; -} - -.login-screen .error { - color: var(--accent); -} - -.login-screen .motd { - color: var(--text-muted); - max-width: 400px; +.login-box { text-align: center; - white-space: pre-wrap; + max-width: 360px; + width: 100%; + padding: 32px; } -/* Main layout */ -.app { +.login-box h1 { + color: var(--accent); + font-size: 1.8em; + margin-bottom: 16px; + font-weight: 400; +} + +.login-box .motd { + color: var(--text-dim); + font-size: 12px; + margin-bottom: 20px; + text-align: left; + white-space: pre-wrap; + font-family: inherit; + border-left: 2px solid var(--border); + padding-left: 12px; +} + +.login-box form { + display: flex; + flex-direction: column; + gap: 8px; + align-items: stretch; +} + +.login-box label { + color: var(--text-dim); + text-align: left; + font-size: 12px; +} + +.login-box input { + padding: 8px 12px; + font-family: inherit; + font-size: 14px; + background: var(--input-bg); + border: 1px solid var(--border); + color: var(--text-bright); + border-radius: 3px; + outline: none; +} + +.login-box input:focus { + border-color: var(--accent-dim); +} + +.login-box button { + padding: 8px 16px; + font-family: inherit; + font-size: 14px; + background: var(--accent-dim); + border: none; + color: var(--text-bright); + border-radius: 3px; + cursor: pointer; + margin-top: 4px; +} + +.login-box button:hover { + background: var(--accent); +} + +.login-box .error { + color: var(--error); + font-size: 12px; + margin-top: 8px; +} + +/* ============================================ + IRC App Layout + ============================================ */ + +.irc-app { display: flex; flex-direction: column; height: 100%; + overflow: hidden; } -/* Tab bar */ +/* ============================================ + Tab Bar + ============================================ */ + .tab-bar { display: flex; - background: var(--bg-secondary); + background: var(--bg-tab); border-bottom: 1px solid var(--border); - overflow-x: auto; flex-shrink: 0; - align-items: center; + height: 32px; + align-items: stretch; +} + +.tabs { + display: flex; + overflow-x: auto; + flex: 1; + scrollbar-width: none; +} + +.tabs::-webkit-scrollbar { + display: none; } .tab { - padding: 8px 16px; + display: flex; + align-items: center; + padding: 0 12px; cursor: pointer; - border-bottom: 2px solid transparent; + color: var(--text-dim); white-space: nowrap; - color: var(--text-muted); user-select: none; + border-right: 1px solid var(--border); + font-size: 12px; + gap: 4px; position: relative; } .tab:hover { - background: var(--tab-hover); + color: var(--text); + background: rgba(255, 255, 255, 0.03); } .tab.active { - color: var(--text); - border-bottom-color: var(--tab-active); + color: var(--text-bright); + background: var(--bg-tab-active); + border-bottom: 2px solid var(--tab-indicator); + margin-bottom: -1px; } -.tab .close-btn { - margin-left: 8px; - color: var(--text-muted); - font-size: 12px; -} - -.tab .close-btn:hover { - color: var(--accent); -} - -.tab .unread-badge { - display: inline-block; - background: var(--unread-bg); - color: white; - font-size: 10px; +.tab.has-unread .tab-label { + color: var(--unread); font-weight: bold; - padding: 1px 5px; - border-radius: 8px; - margin-left: 6px; - min-width: 16px; - text-align: center; } -/* Connection status */ -.connection-status { - padding: 4px 12px; - background: var(--warn); - color: #1a1a2e; - font-size: 12px; +.tab .unread-count { + color: var(--unread); + font-size: 11px; font-weight: bold; - white-space: nowrap; +} + +.tab-close { + color: var(--text-dim); + font-size: 14px; + line-height: 1; + margin-left: 2px; +} + +.tab-close:hover { + color: var(--error); +} + +.status-area { + display: flex; + align-items: center; + gap: 10px; + padding: 0 12px; flex-shrink: 0; + font-size: 12px; } -/* Topic bar */ +.status-nick { + color: var(--accent); + font-weight: bold; +} + +.status-warn { + color: var(--warn); + animation: blink 1.5s ease-in-out infinite; +} + +@keyframes blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +/* ============================================ + Topic Bar + ============================================ */ + .topic-bar { - padding: 6px 12px; - background: var(--topic-bg); + padding: 4px 12px; + background: var(--bg-topic); border-bottom: 1px solid var(--border); - color: var(--text-muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; + line-height: 1.5; } -/* Content area */ -.content { +.topic-label { + color: var(--text-dim); +} + +.topic-text { + color: var(--text); +} + +/* ============================================ + Main Content Area + ============================================ */ + +.main-area { display: flex; flex: 1; overflow: hidden; + min-height: 0; } -/* Messages */ -.messages-pane { +/* ============================================ + Messages Panel + ============================================ */ + +.messages-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; + min-width: 0; } -.messages { +.messages-scroll { flex: 1; overflow-y: auto; - padding: 8px 12px; + padding: 4px 8px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; } +.messages-scroll::-webkit-scrollbar { + width: 8px; +} + +.messages-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.messages-scroll::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +/* ============================================ + Message Lines + ============================================ */ + .message { - padding: 2px 0; + padding: 1px 0; line-height: 1.4; + white-space: pre-wrap; word-wrap: break-word; + font-size: 13px; } .message .timestamp { color: var(--timestamp); font-size: 12px; - margin-right: 8px; } .message .nick { - color: var(--nick); font-weight: bold; - margin-right: 8px; } -.message .nick::before { content: '<'; } -.message .nick::after { content: '>'; } +.message .content { + color: var(--text); +} -.message.system { - color: var(--text-muted); +/* System messages (joins, parts, quits, etc.) */ +.system-message { + color: var(--system); +} + +.system-message .system-text { + color: var(--system); +} + +/* /me action messages */ +.action-message .action-text { + color: var(--action); +} + +/* ============================================ + User List (Right Panel) + ============================================ */ + +.user-list { + width: 160px; + background: var(--user-list-bg); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; +} + +.user-list-header { + padding: 6px 10px; + color: var(--user-list-header); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.user-list-entries { + overflow-y: auto; + padding: 4px 0; + flex: 1; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.nick-entry { + padding: 2px 10px; + font-size: 12px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.5; +} + +.nick-entry:hover { + background: rgba(255, 255, 255, 0.04); +} + +.nick-prefix { + color: var(--text-dim); + display: inline-block; + width: 1ch; + text-align: right; + margin-right: 1px; +} + +.nick-name { + font-weight: normal; +} + +/* ============================================ + Input Line (Bottom) + ============================================ */ + +.input-line { + display: flex; + align-items: center; + background: var(--input-bg); + border-top: 1px solid var(--border); + flex-shrink: 0; + height: 36px; + padding: 0 8px; + gap: 6px; +} + +.input-prompt { + color: var(--prompt); + font-size: 13px; + flex-shrink: 0; + white-space: nowrap; +} + +.input-line input { + flex: 1; + padding: 4px 0; + font-family: inherit; + font-size: 13px; + background: transparent; + border: none; + color: var(--text-bright); + outline: none; + caret-color: var(--accent); +} + +.input-line input::placeholder { + color: var(--text-dim); font-style: italic; } -.message.system .nick { - color: var(--text-muted); -} +/* ============================================ + Responsive + ============================================ */ -.message.system .nick::before, -.message.system .nick::after { content: ''; } - -/* Input */ -.input-bar { - display: flex; - border-top: 1px solid var(--border); - background: var(--bg-secondary); - flex-shrink: 0; -} - -.input-bar input { - flex: 1; - padding: 10px 12px; - font-family: inherit; - font-size: 14px; - background: var(--bg-input); - border: none; - color: var(--text); - outline: none; -} - -.input-bar button { - padding: 10px 16px; - font-family: inherit; - background: var(--accent); - border: none; - color: white; - cursor: pointer; -} - -/* User list */ -.user-list { - width: 160px; - background: var(--bg-secondary); - border-left: 1px solid var(--border); - overflow-y: auto; - padding: 8px; - flex-shrink: 0; -} - -.user-list h3 { - color: var(--text-muted); - font-size: 11px; - text-transform: uppercase; - margin-bottom: 8px; - letter-spacing: 1px; -} - -.user-list .user { - padding: 3px 4px; - color: var(--nick); - font-size: 13px; - cursor: pointer; -} - -.user-list .user:hover { - background: var(--tab-hover); -} - -/* Server tab */ -.server-messages { - color: var(--text-muted); - padding: 12px; - white-space: pre-wrap; - overflow-y: auto; - flex: 1; -} - -/* Channel join dialog */ -.join-dialog { - padding: 12px; - display: flex; - gap: 8px; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border); - margin-left: auto; -} - -.join-dialog input { - padding: 6px 10px; - font-family: inherit; - font-size: 13px; - background: var(--bg-input); - border: 1px solid var(--border); - color: var(--text); - border-radius: 3px; - width: 200px; -} - -.join-dialog button { - padding: 6px 14px; - font-family: inherit; - font-size: 13px; - background: var(--accent2); - border: none; - color: var(--text); - border-radius: 3px; - cursor: pointer; -} - -/* Responsive */ @media (max-width: 600px) { - .user-list { display: none; } - .tab { padding: 6px 10px; font-size: 13px; } + .user-list { + display: none; + } + + .tab { + padding: 0 8px; + font-size: 11px; + } + + .input-prompt { + font-size: 12px; + } } -- 2.49.1