Compare commits
3 Commits
feature/ir
...
fix/irc-nu
| Author | SHA1 | Date | |
|---|---|---|---|
| 38d222f6a7 | |||
| 8e3166969e | |||
|
|
8d91ad852c |
@@ -28,6 +28,18 @@ const (
|
||||
defaultHistLimit = 50
|
||||
maxHistLimit = 500
|
||||
cmdPrivmsg = "PRIVMSG"
|
||||
|
||||
// IRC numeric reply codes per RFC 1459/2812.
|
||||
ircErrNoSuchNick = "401"
|
||||
ircErrNoSuchChannel = "403"
|
||||
ircErrNoRecipient = "411"
|
||||
ircErrNoTextToSend = "412"
|
||||
ircErrUnknownCommand = "421"
|
||||
ircErrNoNicknameGiven = "431"
|
||||
ircErrErroneusNickname = "432"
|
||||
ircErrNicknameInUse = "433"
|
||||
ircErrNotOnChannel = "442"
|
||||
ircErrNeedMoreParams = "461"
|
||||
)
|
||||
|
||||
func (hdlr *Handlers) maxBodySize() int64 {
|
||||
@@ -63,6 +75,43 @@ func (hdlr *Handlers) authSession(
|
||||
return sessionID, clientID, nick, nil
|
||||
}
|
||||
|
||||
// serverName returns the configured server name or the
|
||||
// default "neoirc".
|
||||
func (hdlr *Handlers) serverName() string {
|
||||
if hdlr.params.Config.ServerName != "" {
|
||||
return hdlr.params.Config.ServerName
|
||||
}
|
||||
|
||||
return "neoirc"
|
||||
}
|
||||
|
||||
// respondIRCError sends an IRC numeric error reply as a
|
||||
// JSON response with HTTP 200 OK. This is used for errors
|
||||
// in IRC command processing (as opposed to HTTP transport
|
||||
// errors). The params slice contains IRC-style positional
|
||||
// parameters (e.g. the target nick or channel).
|
||||
func (hdlr *Handlers) respondIRCError(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
nick, numeric, msg string,
|
||||
params ...string,
|
||||
) {
|
||||
resp := map[string]any{
|
||||
"command": numeric,
|
||||
"from": hdlr.serverName(),
|
||||
"to": nick,
|
||||
"body": []string{msg},
|
||||
}
|
||||
|
||||
if len(params) > 0 {
|
||||
resp["params"] = params
|
||||
}
|
||||
|
||||
hdlr.respondJSON(
|
||||
writer, request, resp, http.StatusOK,
|
||||
)
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) requireAuth(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
@@ -226,11 +275,7 @@ func (hdlr *Handlers) deliverMOTD(
|
||||
clientID, sessionID int64,
|
||||
) {
|
||||
motd := hdlr.params.Config.MOTD
|
||||
serverName := hdlr.params.Config.ServerName
|
||||
|
||||
if serverName == "" {
|
||||
serverName = "neoirc"
|
||||
}
|
||||
serverName := hdlr.serverName()
|
||||
|
||||
if motd == "" {
|
||||
return
|
||||
@@ -557,10 +602,10 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
|
||||
payload.To = strings.TrimSpace(payload.To)
|
||||
|
||||
if payload.Command == "" {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"command required",
|
||||
http.StatusBadRequest,
|
||||
nick, ircErrUnknownCommand,
|
||||
"No command given",
|
||||
)
|
||||
|
||||
return
|
||||
@@ -631,10 +676,11 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
},
|
||||
http.StatusOK)
|
||||
default:
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"unknown command: "+command,
|
||||
http.StatusBadRequest,
|
||||
nick, ircErrUnknownCommand,
|
||||
command+" :Unknown command",
|
||||
command,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -648,10 +694,10 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
if target == "" {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"to field required",
|
||||
http.StatusBadRequest,
|
||||
nick, ircErrNoRecipient,
|
||||
"No recipient given ("+command+")",
|
||||
)
|
||||
|
||||
return
|
||||
@@ -659,10 +705,10 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
|
||||
lines := bodyLines()
|
||||
if len(lines) == 0 {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"body required",
|
||||
http.StatusBadRequest,
|
||||
nick, ircErrNoTextToSend,
|
||||
"No text to send",
|
||||
)
|
||||
|
||||
return
|
||||
@@ -694,10 +740,11 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
request.Context(), target,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"channel not found",
|
||||
http.StatusNotFound,
|
||||
nick, ircErrNoSuchChannel,
|
||||
"No such channel",
|
||||
target,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -720,10 +767,11 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
}
|
||||
|
||||
if !isMember {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"not a member of this channel",
|
||||
http.StatusForbidden,
|
||||
nick, ircErrNotOnChannel,
|
||||
"You're not on that channel",
|
||||
target,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -761,7 +809,7 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"id": msgUUID, "status": "sent"},
|
||||
http.StatusCreated)
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) handleDirectMsg(
|
||||
@@ -775,10 +823,11 @@ func (hdlr *Handlers) handleDirectMsg(
|
||||
request.Context(), target,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"user not found",
|
||||
http.StatusNotFound,
|
||||
nick, ircErrNoSuchNick,
|
||||
"No such nick/channel",
|
||||
target,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -805,7 +854,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"id": msgUUID, "status": "sent"},
|
||||
http.StatusCreated)
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) handleJoin(
|
||||
@@ -815,10 +864,11 @@ func (hdlr *Handlers) handleJoin(
|
||||
nick, target string,
|
||||
) {
|
||||
if target == "" {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"to field required",
|
||||
http.StatusBadRequest,
|
||||
nick, ircErrNeedMoreParams,
|
||||
"Not enough parameters",
|
||||
"JOIN",
|
||||
)
|
||||
|
||||
return
|
||||
@@ -830,10 +880,11 @@ func (hdlr *Handlers) handleJoin(
|
||||
}
|
||||
|
||||
if !validChannelRe.MatchString(channel) {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"invalid channel name",
|
||||
http.StatusBadRequest,
|
||||
nick, ircErrNoSuchChannel,
|
||||
"No such channel",
|
||||
channel,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -895,10 +946,11 @@ func (hdlr *Handlers) handlePart(
|
||||
body json.RawMessage,
|
||||
) {
|
||||
if target == "" {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"to field required",
|
||||
http.StatusBadRequest,
|
||||
nick, ircErrNeedMoreParams,
|
||||
"Not enough parameters",
|
||||
"PART",
|
||||
)
|
||||
|
||||
return
|
||||
@@ -913,10 +965,11 @@ func (hdlr *Handlers) handlePart(
|
||||
request.Context(), channel,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"channel not found",
|
||||
http.StatusNotFound,
|
||||
nick, ircErrNoSuchChannel,
|
||||
"No such channel",
|
||||
channel,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -967,10 +1020,10 @@ func (hdlr *Handlers) handleNick(
|
||||
) {
|
||||
lines := bodyLines()
|
||||
if len(lines) == 0 {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"body required (new nick)",
|
||||
http.StatusBadRequest,
|
||||
nick, ircErrNoNicknameGiven,
|
||||
"No nickname given",
|
||||
)
|
||||
|
||||
return
|
||||
@@ -979,10 +1032,11 @@ func (hdlr *Handlers) handleNick(
|
||||
newNick := strings.TrimSpace(lines[0])
|
||||
|
||||
if !validNickRe.MatchString(newNick) {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"invalid nick",
|
||||
http.StatusBadRequest,
|
||||
nick, ircErrErroneusNickname,
|
||||
"Erroneous nickname",
|
||||
newNick,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -1003,10 +1057,11 @@ func (hdlr *Handlers) handleNick(
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"nick already in use",
|
||||
http.StatusConflict,
|
||||
nick, ircErrNicknameInUse,
|
||||
"Nickname is already in use",
|
||||
newNick,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -1093,10 +1148,11 @@ func (hdlr *Handlers) handleTopic(
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
if target == "" {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"to field required",
|
||||
http.StatusBadRequest,
|
||||
nick, ircErrNeedMoreParams,
|
||||
"Not enough parameters",
|
||||
"TOPIC",
|
||||
)
|
||||
|
||||
return
|
||||
@@ -1104,10 +1160,11 @@ func (hdlr *Handlers) handleTopic(
|
||||
|
||||
lines := bodyLines()
|
||||
if len(lines) == 0 {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"body required (topic text)",
|
||||
http.StatusBadRequest,
|
||||
nick, ircErrNeedMoreParams,
|
||||
"Not enough parameters",
|
||||
"TOPIC",
|
||||
)
|
||||
|
||||
return
|
||||
@@ -1140,10 +1197,11 @@ func (hdlr *Handlers) handleTopic(
|
||||
request.Context(), channel,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.respondError(
|
||||
hdlr.respondIRCError(
|
||||
writer, request,
|
||||
"channel not found",
|
||||
http.StatusNotFound,
|
||||
nick, ircErrNoSuchChannel,
|
||||
"No such channel",
|
||||
channel,
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
@@ -668,11 +668,18 @@ func TestJoinMissingTo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("joiner3")
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
status, result := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: joinCmd},
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if result[commandKey] != "461" {
|
||||
t.Fatalf(
|
||||
"expected IRC 461, got %v",
|
||||
result[commandKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,9 +706,9 @@ func TestChannelMessage(t *testing.T) {
|
||||
bodyKey: []string{"hello world"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusCreated {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 201, got %d: %v", status, result,
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -728,11 +735,18 @@ func TestMessageMissingBody(t *testing.T) {
|
||||
commandKey: joinCmd, toKey: "#test",
|
||||
})
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
status, result := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd, toKey: "#test",
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if result[commandKey] != "412" {
|
||||
t.Fatalf(
|
||||
"expected IRC 412, got %v",
|
||||
result[commandKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,12 +754,19 @@ func TestMessageMissingTo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("noto")
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
status, result := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
bodyKey: []string{"hello"},
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if result[commandKey] != "411" {
|
||||
t.Fatalf(
|
||||
"expected IRC 411, got %v",
|
||||
result[commandKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,7 +781,7 @@ func TestNonMemberCannotSend(t *testing.T) {
|
||||
})
|
||||
|
||||
// Alice tries to send without joining.
|
||||
status, _ := tserver.sendCommand(
|
||||
status, result := tserver.sendCommand(
|
||||
aliceToken,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
@@ -768,8 +789,15 @@ func TestNonMemberCannotSend(t *testing.T) {
|
||||
bodyKey: []string{"sneaky"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if result[commandKey] != "442" {
|
||||
t.Fatalf(
|
||||
"expected IRC 442, got %v",
|
||||
result[commandKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,9 +814,9 @@ func TestDirectMessage(t *testing.T) {
|
||||
bodyKey: []string{"hey bob"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusCreated {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 201, got %d: %v", status, result,
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -818,13 +846,20 @@ func TestDMToNonexistentUser(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("dmsender")
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
status, result := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "nobody",
|
||||
bodyKey: []string{"hello?"},
|
||||
})
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if result[commandKey] != "401" {
|
||||
t.Fatalf(
|
||||
"expected IRC 401, got %v",
|
||||
result[commandKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -871,12 +906,19 @@ func TestNickCollision(t *testing.T) {
|
||||
|
||||
tserver.createSession("taken_nick")
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
status, result := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "NICK",
|
||||
bodyKey: []string{"taken_nick"},
|
||||
})
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if result[commandKey] != "433" {
|
||||
t.Fatalf(
|
||||
"expected IRC 433, got %v",
|
||||
result[commandKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,12 +926,19 @@ func TestNickInvalid(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("nickval")
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
status, result := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "NICK",
|
||||
bodyKey: []string{"bad nick!"},
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if result[commandKey] != "432" {
|
||||
t.Fatalf(
|
||||
"expected IRC 432, got %v",
|
||||
result[commandKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,11 +946,18 @@ func TestNickEmptyBody(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("nicknobody")
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
status, result := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: "NICK"},
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if result[commandKey] != "431" {
|
||||
t.Fatalf(
|
||||
"expected IRC 431, got %v",
|
||||
result[commandKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,12 +994,19 @@ func TestTopicMissingTo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("topicnoto")
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
status, result := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "TOPIC",
|
||||
bodyKey: []string{"topic"},
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if result[commandKey] != "461" {
|
||||
t.Fatalf(
|
||||
"expected IRC 461, got %v",
|
||||
result[commandKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,11 +1018,18 @@ func TestTopicMissingBody(t *testing.T) {
|
||||
commandKey: joinCmd, toKey: "#topictest",
|
||||
})
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
status, result := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "TOPIC", toKey: "#topictest",
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if result[commandKey] != "461" {
|
||||
t.Fatalf(
|
||||
"expected IRC 461, got %v",
|
||||
result[commandKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1027,11 +1097,18 @@ func TestUnknownCommand(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("cmdtest")
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
status, result := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: "BOGUS"},
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if result[commandKey] != "421" {
|
||||
t.Fatalf(
|
||||
"expected IRC 421, got %v",
|
||||
result[commandKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1039,11 +1116,18 @@ func TestEmptyCommand(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("emptycmd")
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
status, result := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: ""},
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if result[commandKey] != "421" {
|
||||
t.Fatalf(
|
||||
"expected IRC 421, got %v",
|
||||
result[commandKey],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
web/dist/app.js
vendored
4
web/dist/app.js
vendored
File diff suppressed because one or more lines are too long
593
web/dist/style.css
vendored
593
web/dist/style.css
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
1075
web/src/app.jsx
1075
web/src/app.jsx
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user