feat: initial mIRCcloud.com scaffold

- Express + TypeScript backend with IRC connection manager
- React + Vite frontend with mIRC-inspired theme
- Drizzle ORM + PostgreSQL schema (users, connections, channels, messages)
- Redis pub/sub for real-time WebSocket delivery across replicas
- IRC color code parser (16 + 99 extended palette)
- Kubernetes manifests for homecloud deployment
- Multi-stage Dockerfile with non-root user
- docker-compose for local development
This commit is contained in:
Jason Preston
2026-08-08 23:50:08 -06:00
commit c59ac7ad0f
52 changed files with 5723 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>mIRCcloud</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "mirccloud-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"date-fns": "3.6.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.23.1",
"zustand": "4.5.2"
},
"devDependencies": {
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@vitejs/plugin-react": "4.3.1",
"typescript": "5.4.5",
"vite": "5.3.1"
}
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './stores/auth';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import MainLayout from './layouts/MainLayout';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
if (!token) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
export default function App() {
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
useEffect(() => {
loadFromStorage();
}, [loadFromStorage]);
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/*"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
/>
</Routes>
);
}
+173
View File
@@ -0,0 +1,173 @@
import { renderIrcText } from '../utils/irc-colors';
import { format } from 'date-fns';
import { IrcMessage } from '../stores/irc';
interface MessageProps {
message: IrcMessage;
}
export default function Message({ message }: MessageProps) {
const timestamp = format(new Date(message.timestamp), 'HH:mm');
switch (message.type) {
case 'action':
return (
<div style={styles.row}>
<span style={styles.timestamp}>[{timestamp}]</span>
<span style={styles.actionText}>
* {message.nick} {renderIrcText(message.text)}
</span>
</div>
);
case 'join':
return (
<div style={styles.row}>
<span style={styles.timestamp}>[{timestamp}]</span>
<span style={styles.joinText}>
{message.nick} has joined {message.text || message.buffer}
</span>
</div>
);
case 'part':
return (
<div style={styles.row}>
<span style={styles.timestamp}>[{timestamp}]</span>
<span style={styles.partText}>
{message.nick} has left {message.buffer}
{message.text ? ` (${message.text})` : ''}
</span>
</div>
);
case 'quit':
return (
<div style={styles.row}>
<span style={styles.timestamp}>[{timestamp}]</span>
<span style={styles.partText}>
{message.nick} has quit
{message.text ? ` (${message.text})` : ''}
</span>
</div>
);
case 'kick':
return (
<div style={styles.row}>
<span style={styles.timestamp}>[{timestamp}]</span>
<span style={styles.partText}>
{message.nick} was kicked {message.text ? `(${message.text})` : ''}
</span>
</div>
);
case 'nick':
return (
<div style={styles.row}>
<span style={styles.timestamp}>[{timestamp}]</span>
<span style={styles.systemText}>
{message.nick} is now known as {message.text}
</span>
</div>
);
case 'topic':
return (
<div style={styles.row}>
<span style={styles.timestamp}>[{timestamp}]</span>
<span style={styles.systemText}>
{message.nick} has set the topic: {renderIrcText(message.text)}
</span>
</div>
);
case 'mode':
return (
<div style={styles.row}>
<span style={styles.timestamp}>[{timestamp}]</span>
<span style={styles.systemText}>
{message.nick} sets mode {message.text}
</span>
</div>
);
case 'notice':
return (
<div style={styles.row}>
<span style={styles.timestamp}>[{timestamp}]</span>
<span style={styles.noticeText}>
-{message.nick}- {renderIrcText(message.text)}
</span>
</div>
);
case 'system':
return (
<div style={styles.row}>
<span style={styles.timestamp}>[{timestamp}]</span>
<span style={styles.systemText}>
{renderIrcText(message.text)}
</span>
</div>
);
case 'privmsg':
default:
return (
<div style={styles.row}>
<span style={styles.timestamp}>[{timestamp}]</span>
<span style={styles.nick}>&lt;{message.nick}&gt;</span>
<span style={styles.text}>{renderIrcText(message.text)}</span>
</div>
);
}
}
const styles: Record<string, React.CSSProperties> = {
row: {
display: 'flex',
padding: '1px 8px',
lineHeight: '1.5',
wordBreak: 'break-word',
gap: '6px',
},
timestamp: {
color: 'var(--text-muted)',
flexShrink: 0,
fontSize: '12px',
},
nick: {
color: 'var(--accent)',
fontWeight: 'bold',
flexShrink: 0,
whiteSpace: 'nowrap',
},
text: {
color: 'var(--text-primary)',
wordBreak: 'break-word',
flex: 1,
},
actionText: {
color: 'var(--action-color)',
fontStyle: 'italic',
flex: 1,
},
joinText: {
color: 'var(--join-color)',
flex: 1,
},
partText: {
color: 'var(--part-color)',
flex: 1,
},
noticeText: {
color: 'var(--notice-color)',
flex: 1,
},
systemText: {
color: 'var(--text-secondary)',
fontStyle: 'italic',
flex: 1,
},
};
+269
View File
@@ -0,0 +1,269 @@
import { useState, useRef, useCallback, KeyboardEvent } from 'react';
import { useWebSocketStore } from '../stores/websocket';
import { useIrcStore } from '../stores/irc';
export default function MessageInput() {
const [input, setInput] = useState('');
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [tabState, setTabState] = useState<{ prefix: string; matches: string[]; index: number } | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const send = useWebSocketStore((s) => s.send);
const activeConnectionId = useIrcStore((s) => s.activeConnectionId);
const activeBuffer = useIrcStore((s) => s.activeBuffer);
const users = useIrcStore((s) => s.users);
const connections = useIrcStore((s) => s.connections);
const activeConnection = connections.find((c) => c.id === activeConnectionId);
const nick = activeConnection?.nick || 'user';
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || !activeConnectionId || !activeBuffer) return;
// Add to history
setHistory((prev) => {
const newHist = [trimmed, ...prev.slice(0, 99)];
return newHist;
});
setHistoryIndex(-1);
setTabState(null);
// Parse commands
if (trimmed.startsWith('/')) {
const parts = trimmed.slice(1).split(' ');
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
switch (cmd) {
case 'join':
case 'j': {
const channel = args[0];
if (channel) {
send('join', { connectionId: activeConnectionId, channel: channel.startsWith('#') ? channel : `#${channel}` });
}
break;
}
case 'part':
case 'leave': {
const channel = args[0] || activeBuffer;
send('part', { connectionId: activeConnectionId, channel, message: args.slice(1).join(' ') || undefined });
break;
}
case 'msg':
case 'privmsg':
case 'query': {
const target = args[0];
const text = args.slice(1).join(' ');
if (target && text) {
send('privmsg', { connectionId: activeConnectionId, target, text });
}
break;
}
case 'nick': {
const newNick = args[0];
if (newNick) {
send('nick', { connectionId: activeConnectionId, nick: newNick });
}
break;
}
case 'quit': {
send('quit', { connectionId: activeConnectionId, message: args.join(' ') || undefined });
break;
}
case 'me': {
const actionText = args.join(' ');
if (actionText) {
send('action', { connectionId: activeConnectionId, target: activeBuffer, text: actionText });
}
break;
}
case 'topic': {
const topic = args.join(' ');
send('topic', { connectionId: activeConnectionId, channel: activeBuffer, topic });
break;
}
case 'kick': {
const kickTarget = args[0];
const reason = args.slice(1).join(' ');
if (kickTarget) {
send('kick', { connectionId: activeConnectionId, channel: activeBuffer, nick: kickTarget, reason });
}
break;
}
case 'mode': {
const modeStr = args.join(' ');
send('mode', { connectionId: activeConnectionId, target: activeBuffer, mode: modeStr });
break;
}
case 'notice': {
const target = args[0];
const text = args.slice(1).join(' ');
if (target && text) {
send('notice', { connectionId: activeConnectionId, target, text });
}
break;
}
case 'raw':
case 'quote': {
send('raw', { connectionId: activeConnectionId, command: args.join(' ') });
break;
}
case 'clear': {
useIrcStore.getState().setMessages(activeConnectionId, activeBuffer, []);
break;
}
default:
// Send unknown commands as raw
send('raw', { connectionId: activeConnectionId, command: trimmed.slice(1) });
break;
}
} else {
// Regular message
send('privmsg', { connectionId: activeConnectionId, target: activeBuffer, text: trimmed });
}
setInput('');
}, [input, activeConnectionId, activeBuffer, send]);
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'Enter':
e.preventDefault();
handleSend();
break;
case 'ArrowUp':
e.preventDefault();
if (history.length > 0) {
const newIndex = Math.min(historyIndex + 1, history.length - 1);
setHistoryIndex(newIndex);
setInput(history[newIndex]);
}
break;
case 'ArrowDown':
e.preventDefault();
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
setInput(history[newIndex]);
} else {
setHistoryIndex(-1);
setInput('');
}
break;
case 'Tab':
e.preventDefault();
handleTabComplete();
break;
default:
// Reset tab state on any other key
if (tabState && e.key !== 'Shift') {
setTabState(null);
}
break;
}
};
const handleTabComplete = () => {
if (!activeConnectionId || !activeBuffer) return;
const key = `${activeConnectionId}:${activeBuffer}`;
const channelUsers = users[key] || [];
if (channelUsers.length === 0) return;
if (tabState) {
// Cycle through matches
const nextIndex = (tabState.index + 1) % tabState.matches.length;
const match = tabState.matches[nextIndex];
const beforePrefix = input.slice(0, input.length - (tabState.matches[tabState.index].length + (input.startsWith(tabState.prefix) && tabState.prefix === '' ? 0 : 0)));
// Replace the current completion with the next
const cursorPos = input.lastIndexOf(tabState.matches[tabState.index]);
if (cursorPos >= 0) {
const before = input.slice(0, cursorPos);
const suffix = input.length === cursorPos + tabState.matches[tabState.index].length + 2
? ': '
: input.length === cursorPos + tabState.matches[tabState.index].length + 1
? ' '
: '';
setInput(before + match + (before === '' ? ': ' : ' '));
setTabState({ ...tabState, index: nextIndex });
}
} else {
// Start new tab completion
const words = input.split(' ');
const lastWord = words[words.length - 1].toLowerCase();
if (!lastWord) return;
const matches = channelUsers
.map((u) => u.nick)
.filter((n) => n.toLowerCase().startsWith(lastWord))
.sort();
if (matches.length === 0) return;
const match = matches[0];
const prefix = words.slice(0, -1).join(' ');
const separator = prefix ? ' ' : '';
const suffix = prefix === '' ? ': ' : ' ';
setInput((prefix ? prefix + ' ' : '') + match + suffix);
setTabState({ prefix: lastWord, matches, index: 0 });
}
};
return (
<div style={styles.container}>
<span style={styles.nickLabel}>[{nick}]</span>
<input
ref={inputRef}
style={styles.input}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={activeBuffer ? `Message ${activeBuffer}...` : 'Select a channel'}
disabled={!activeConnectionId || !activeBuffer}
spellCheck={false}
autoComplete="off"
/>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
background: 'var(--bg-input)',
borderTop: '2px solid',
borderColor: '#808080 transparent transparent #808080',
gap: '6px',
flexShrink: 0,
},
nickLabel: {
color: 'var(--accent)',
fontSize: '12px',
fontWeight: 'bold',
whiteSpace: 'nowrap',
flexShrink: 0,
},
input: {
flex: 1,
padding: '4px 8px',
background: 'var(--bg-primary)',
border: '2px solid',
borderColor: '#808080 #DFDFDF #DFDFDF #808080',
color: 'var(--text-primary)',
fontSize: '14px',
fontFamily: 'var(--font-mono)',
outline: 'none',
},
};
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useIrcStore } from '../stores/irc';
import { useWebSocketStore } from '../stores/websocket';
import Message from './Message';
import MessageInput from './MessageInput';
import { renderIrcText } from '../utils/irc-colors';
export default function MessagePanel() {
const activeConnectionId = useIrcStore((s) => s.activeConnectionId);
const activeBuffer = useIrcStore((s) => s.activeBuffer);
const messages = useIrcStore((s) => s.messages);
const connections = useIrcStore((s) => s.connections);
const send = useWebSocketStore((s) => s.send);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const [userScrolledUp, setUserScrolledUp] = useState(false);
const key = activeConnectionId && activeBuffer ? `${activeConnectionId}:${activeBuffer}` : '';
const currentMessages = messages[key] || [];
const activeConnection = connections.find((c) => c.id === activeConnectionId);
const activeChannel = activeConnection?.channels.find((ch) => ch.name === activeBuffer);
const topic = activeChannel?.topic || '';
// Request backlog when buffer changes
useEffect(() => {
if (activeConnectionId && activeBuffer) {
send('request_backlog', { connectionId: activeConnectionId, buffer: activeBuffer });
}
}, [activeConnectionId, activeBuffer, send]);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (autoScroll && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'instant' });
}
}, [currentMessages.length, autoScroll]);
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 30;
setAutoScroll(isAtBottom);
setUserScrolledUp(!isAtBottom);
}, []);
const scrollToBottom = () => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
setAutoScroll(true);
setUserScrolledUp(false);
}
};
if (!activeConnectionId || !activeBuffer) {
return (
<div style={styles.emptyContainer}>
<div style={styles.emptyContent}>
<div style={styles.emptyLogo}>mIRCcloud</div>
<div style={styles.emptyText}>Select a channel or conversation from the tree to begin.</div>
<div style={styles.emptyHint}>Right-click a connection to join a channel.</div>
</div>
</div>
);
}
return (
<div style={styles.container}>
{/* Topic bar */}
{topic && (
<div style={styles.topicBar}>
<span style={styles.topicLabel}>Topic:</span>
<span style={styles.topicText}>{renderIrcText(topic)}</span>
</div>
)}
{/* Message area */}
<div
ref={scrollContainerRef}
style={styles.messageArea}
onScroll={handleScroll}
>
{currentMessages.length === 0 && (
<div style={styles.noMessages}>
No messages yet in {activeBuffer}
</div>
)}
{currentMessages.map((msg) => (
<Message key={msg.id} message={msg} />
))}
<div ref={messagesEndRef} />
</div>
{/* Scroll-to-bottom indicator */}
{userScrolledUp && (
<button style={styles.scrollButton} onClick={scrollToBottom}>
New messages below
</button>
)}
{/* Input */}
<MessageInput />
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
flexDirection: 'column',
height: '100%',
position: 'relative',
},
emptyContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
background: 'var(--bg-primary)',
},
emptyContent: {
textAlign: 'center',
},
emptyLogo: {
color: 'var(--accent)',
fontSize: '32px',
fontWeight: 'bold',
marginBottom: '16px',
},
emptyText: {
color: 'var(--text-secondary)',
fontSize: '14px',
marginBottom: '8px',
},
emptyHint: {
color: 'var(--text-muted)',
fontSize: '12px',
},
topicBar: {
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
background: 'var(--bg-topic)',
borderBottom: '1px solid var(--border-light)',
fontSize: '12px',
overflow: 'hidden',
whiteSpace: 'nowrap',
flexShrink: 0,
minHeight: 'var(--topic-height)',
},
topicLabel: {
color: 'var(--text-muted)',
marginRight: '6px',
flexShrink: 0,
},
topicText: {
color: 'var(--text-secondary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
messageArea: {
flex: 1,
overflow: 'auto',
background: 'var(--bg-primary)',
padding: '4px 0',
},
noMessages: {
padding: '20px',
textAlign: 'center',
color: 'var(--text-muted)',
fontSize: '12px',
fontStyle: 'italic',
},
scrollButton: {
position: 'absolute',
bottom: '44px',
left: '50%',
transform: 'translateX(-50%)',
padding: '4px 12px',
background: 'var(--accent)',
color: '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '11px',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
zIndex: 10,
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
},
};
+433
View File
@@ -0,0 +1,433 @@
import { useState, useEffect, useCallback } from 'react';
import { useIrcStore, IrcConnection, IrcChannel } from '../stores/irc';
import { useWebSocketStore } from '../stores/websocket';
interface ContextMenu {
x: number;
y: number;
type: 'connection' | 'channel';
connectionId: string;
channel?: string;
}
export default function TreeBar() {
const connections = useIrcStore((s) => s.connections);
const activeConnectionId = useIrcStore((s) => s.activeConnectionId);
const activeBuffer = useIrcStore((s) => s.activeBuffer);
const setActiveBuffer = useIrcStore((s) => s.setActiveBuffer);
const send = useWebSocketStore((s) => s.send);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [contextMenu, setContextMenu] = useState<ContextMenu | null>(null);
const [joinDialogConn, setJoinDialogConn] = useState<string | null>(null);
const [joinChannel, setJoinChannel] = useState('');
// Auto-expand all connections initially
useEffect(() => {
const newExpanded: Record<string, boolean> = {};
connections.forEach((c) => {
if (expanded[c.id] === undefined) {
newExpanded[c.id] = true;
}
});
if (Object.keys(newExpanded).length > 0) {
setExpanded((prev) => ({ ...prev, ...newExpanded }));
}
}, [connections]);
const toggleExpanded = (id: string) => {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
};
const handleContextMenu = (
e: React.MouseEvent,
type: 'connection' | 'channel',
connectionId: string,
channel?: string
) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, type, connectionId, channel });
};
const closeContextMenu = useCallback(() => {
setContextMenu(null);
}, []);
useEffect(() => {
if (contextMenu) {
document.addEventListener('click', closeContextMenu);
return () => document.removeEventListener('click', closeContextMenu);
}
}, [contextMenu, closeContextMenu]);
const handleJoinChannel = () => {
if (joinDialogConn && joinChannel.trim()) {
const channel = joinChannel.startsWith('#') ? joinChannel : `#${joinChannel}`;
send('join', { connectionId: joinDialogConn, channel });
setJoinDialogConn(null);
setJoinChannel('');
}
};
const handleDisconnect = (connectionId: string) => {
send('disconnect', { connectionId });
};
const handlePartChannel = (connectionId: string, channel: string) => {
send('part', { connectionId, channel });
};
return (
<div style={styles.container}>
<div style={styles.header}>Connections</div>
<div style={styles.tree}>
{connections.length === 0 && (
<div style={styles.empty}>No connections</div>
)}
{connections.map((conn) => (
<ConnectionNode
key={conn.id}
connection={conn}
expanded={expanded[conn.id] ?? true}
isActiveConnection={conn.id === activeConnectionId}
activeBuffer={activeBuffer}
onToggle={() => toggleExpanded(conn.id)}
onSelect={(buffer) => setActiveBuffer(conn.id, buffer)}
onContextMenu={handleContextMenu}
/>
))}
</div>
{/* Context Menu */}
{contextMenu && (
<div style={{ ...styles.contextMenu, top: contextMenu.y, left: contextMenu.x }}>
{contextMenu.type === 'connection' && (
<>
<button
style={styles.contextMenuItem}
onClick={() => { setJoinDialogConn(contextMenu.connectionId); closeContextMenu(); }}
>
Join Channel...
</button>
<div style={styles.contextMenuSep} />
<button
style={styles.contextMenuItem}
onClick={() => { handleDisconnect(contextMenu.connectionId); closeContextMenu(); }}
>
Disconnect
</button>
</>
)}
{contextMenu.type === 'channel' && contextMenu.channel && (
<>
<button
style={styles.contextMenuItem}
onClick={() => { setActiveBuffer(contextMenu.connectionId, contextMenu.channel!); closeContextMenu(); }}
>
Switch to Channel
</button>
<div style={styles.contextMenuSep} />
<button
style={styles.contextMenuItem}
onClick={() => { handlePartChannel(contextMenu.connectionId, contextMenu.channel!); closeContextMenu(); }}
>
Leave Channel
</button>
</>
)}
</div>
)}
{/* Join Channel Dialog */}
{joinDialogConn && (
<div style={styles.dialogOverlay}>
<div style={styles.dialog}>
<div style={styles.dialogTitle}>Join Channel</div>
<input
style={styles.dialogInput}
value={joinChannel}
onChange={(e) => setJoinChannel(e.target.value)}
placeholder="#channel"
autoFocus
onKeyDown={(e) => { if (e.key === 'Enter') handleJoinChannel(); if (e.key === 'Escape') setJoinDialogConn(null); }}
/>
<div style={styles.dialogButtons}>
<button style={styles.dialogButton} onClick={handleJoinChannel}>Join</button>
<button style={styles.dialogButton} onClick={() => setJoinDialogConn(null)}>Cancel</button>
</div>
</div>
</div>
)}
</div>
);
}
function ConnectionNode({
connection,
expanded,
isActiveConnection,
activeBuffer,
onToggle,
onSelect,
onContextMenu,
}: {
connection: IrcConnection;
expanded: boolean;
isActiveConnection: boolean;
activeBuffer: string | null;
onToggle: () => void;
onSelect: (buffer: string) => void;
onContextMenu: (e: React.MouseEvent, type: 'connection' | 'channel', connId: string, channel?: string) => void;
}) {
return (
<div>
<div
style={styles.connectionRow}
onContextMenu={(e) => onContextMenu(e, 'connection', connection.id)}
>
<button style={styles.expandBtn} onClick={onToggle}>
{expanded ? '' : '+'}
</button>
<span
style={{
...styles.connectionLabel,
color: connection.connected ? 'var(--text-treebar)' : 'var(--text-muted)',
}}
onClick={() => onSelect(connection.name)}
>
{connection.connected ? '⚡' : '⊘'} {connection.name || connection.server}
</span>
</div>
{expanded && (
<div style={styles.children}>
{connection.channels.map((ch) => (
<ChannelNode
key={ch.name}
channel={ch}
isActive={isActiveConnection && activeBuffer === ch.name}
onSelect={() => onSelect(ch.name)}
onContextMenu={(e) => onContextMenu(e, 'channel', connection.id, ch.name)}
/>
))}
{connection.queries.map((query) => (
<div
key={query}
style={{
...styles.channelRow,
background: isActiveConnection && activeBuffer === query ? 'var(--accent)' : undefined,
color: isActiveConnection && activeBuffer === query ? '#fff' : 'var(--text-treebar)',
}}
onClick={() => onSelect(query)}
>
<span style={styles.queryIcon}>💬</span>
{query}
</div>
))}
</div>
)}
</div>
);
}
function ChannelNode({
channel,
isActive,
onSelect,
onContextMenu,
}: {
channel: IrcChannel;
isActive: boolean;
onSelect: () => void;
onContextMenu: (e: React.MouseEvent) => void;
}) {
return (
<div
style={{
...styles.channelRow,
background: isActive ? 'var(--accent)' : undefined,
color: isActive ? '#fff' : channel.mentioned ? 'var(--highlight)' : channel.unread > 0 ? 'var(--unread)' : 'var(--text-treebar)',
fontWeight: channel.unread > 0 ? 'bold' : 'normal',
}}
onClick={onSelect}
onContextMenu={onContextMenu}
>
<span style={styles.channelIcon}>#</span>
<span style={styles.channelName}>{channel.name.replace(/^#/, '')}</span>
{channel.unread > 0 && (
<span style={styles.unreadBadge}>{channel.unread}</span>
)}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'var(--bg-treebar)',
fontFamily: 'var(--font-mono)',
fontSize: '12px',
},
header: {
padding: '4px 8px',
background: '#A0A0A0',
color: '#000',
fontWeight: 'bold',
fontSize: '11px',
borderBottom: '1px solid #808080',
},
tree: {
flex: 1,
overflow: 'auto',
padding: '2px 0',
},
empty: {
padding: '12px',
textAlign: 'center',
color: 'var(--text-muted)',
fontSize: '11px',
},
connectionRow: {
display: 'flex',
alignItems: 'center',
padding: '2px 4px',
cursor: 'pointer',
userSelect: 'none',
},
expandBtn: {
width: '14px',
height: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid #808080',
background: '#FFFFFF',
color: '#000',
fontSize: '10px',
lineHeight: '1',
cursor: 'pointer',
marginRight: '4px',
fontFamily: 'var(--font-mono)',
flexShrink: 0,
},
connectionLabel: {
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
children: {
marginLeft: '12px',
borderLeft: '1px dotted #808080',
paddingLeft: '4px',
},
channelRow: {
display: 'flex',
alignItems: 'center',
padding: '1px 4px',
cursor: 'pointer',
userSelect: 'none',
whiteSpace: 'nowrap',
},
channelIcon: {
marginRight: '3px',
fontWeight: 'bold',
opacity: 0.6,
},
channelName: {
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
},
queryIcon: {
marginRight: '4px',
fontSize: '10px',
},
unreadBadge: {
marginLeft: 'auto',
padding: '0 4px',
background: 'var(--unread)',
color: '#fff',
borderRadius: '3px',
fontSize: '9px',
fontWeight: 'bold',
},
contextMenu: {
position: 'fixed',
background: 'var(--bg-panel)',
border: '1px solid var(--border-light)',
boxShadow: '2px 2px 4px rgba(0,0,0,0.5)',
zIndex: 1000,
minWidth: '150px',
padding: '2px 0',
},
contextMenuItem: {
display: 'block',
width: '100%',
padding: '4px 16px',
textAlign: 'left',
color: 'var(--text-primary)',
fontSize: '12px',
cursor: 'pointer',
border: 'none',
background: 'none',
fontFamily: 'var(--font-mono)',
whiteSpace: 'nowrap',
},
contextMenuSep: {
height: '1px',
background: 'var(--border-light)',
margin: '2px 0',
},
dialogOverlay: {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
},
dialog: {
background: 'var(--bg-panel)',
border: '2px solid',
borderColor: '#DFDFDF #808080 #808080 #DFDFDF',
padding: '16px',
minWidth: '250px',
},
dialogTitle: {
color: 'var(--text-primary)',
fontWeight: 'bold',
marginBottom: '12px',
fontSize: '13px',
},
dialogInput: {
width: '100%',
padding: '6px 8px',
background: '#0C0C0C',
border: '2px solid',
borderColor: '#808080 #DFDFDF #DFDFDF #808080',
color: '#FFFFFF',
fontSize: '13px',
fontFamily: 'var(--font-mono)',
outline: 'none',
marginBottom: '12px',
},
dialogButtons: {
display: 'flex',
gap: '8px',
justifyContent: 'flex-end',
},
dialogButton: {
padding: '4px 16px',
background: '#3C3C3C',
border: '2px solid',
borderColor: '#DFDFDF #808080 #808080 #DFDFDF',
color: 'var(--text-primary)',
fontSize: '12px',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
},
};
+164
View File
@@ -0,0 +1,164 @@
import { useMemo } from 'react';
import { useIrcStore, ChannelUser } from '../stores/irc';
export default function UserList() {
const activeConnectionId = useIrcStore((s) => s.activeConnectionId);
const activeBuffer = useIrcStore((s) => s.activeBuffer);
const users = useIrcStore((s) => s.users);
const key = activeConnectionId && activeBuffer ? `${activeConnectionId}:${activeBuffer}` : '';
const channelUsers = users[key] || [];
const grouped = useMemo(() => {
const ops: ChannelUser[] = [];
const halfops: ChannelUser[] = [];
const voiced: ChannelUser[] = [];
const regular: ChannelUser[] = [];
const sortNicks = (a: ChannelUser, b: ChannelUser) =>
a.nick.toLowerCase().localeCompare(b.nick.toLowerCase());
for (const user of channelUsers) {
const modes = user.modes || user.prefix || '';
if (modes.includes('~') || modes.includes('&') || modes.includes('@')) {
ops.push(user);
} else if (modes.includes('%')) {
halfops.push(user);
} else if (modes.includes('+')) {
voiced.push(user);
} else {
regular.push(user);
}
}
ops.sort(sortNicks);
halfops.sort(sortNicks);
voiced.sort(sortNicks);
regular.sort(sortNicks);
return { ops, halfops, voiced, regular };
}, [channelUsers]);
const getPrefix = (user: ChannelUser): string => {
const modes = user.modes || user.prefix || '';
if (modes.includes('~')) return '~';
if (modes.includes('&')) return '&';
if (modes.includes('@')) return '@';
if (modes.includes('%')) return '%';
if (modes.includes('+')) return '+';
return '';
};
const getPrefixColor = (prefix: string): string => {
switch (prefix) {
case '~': return '#FF5555';
case '&': return '#FF8800';
case '@': return '#00CC00';
case '%': return '#5555FF';
case '+': return '#AAAAAA';
default: return 'var(--text-primary)';
}
};
return (
<div style={styles.container}>
<div style={styles.header}>
Users ({channelUsers.length})
</div>
<div style={styles.list}>
{grouped.ops.length > 0 && (
<div style={styles.group}>
<div style={styles.groupLabel}>Operators ({grouped.ops.length})</div>
{grouped.ops.map((user) => (
<UserItem key={user.nick} user={user} prefix={getPrefix(user)} prefixColor={getPrefixColor(getPrefix(user))} />
))}
</div>
)}
{grouped.halfops.length > 0 && (
<div style={styles.group}>
<div style={styles.groupLabel}>Half-Ops ({grouped.halfops.length})</div>
{grouped.halfops.map((user) => (
<UserItem key={user.nick} user={user} prefix={getPrefix(user)} prefixColor={getPrefixColor(getPrefix(user))} />
))}
</div>
)}
{grouped.voiced.length > 0 && (
<div style={styles.group}>
<div style={styles.groupLabel}>Voiced ({grouped.voiced.length})</div>
{grouped.voiced.map((user) => (
<UserItem key={user.nick} user={user} prefix={getPrefix(user)} prefixColor={getPrefixColor(getPrefix(user))} />
))}
</div>
)}
{grouped.regular.length > 0 && (
<div style={styles.group}>
<div style={styles.groupLabel}>Users ({grouped.regular.length})</div>
{grouped.regular.map((user) => (
<UserItem key={user.nick} user={user} prefix="" prefixColor="var(--text-primary)" />
))}
</div>
)}
</div>
</div>
);
}
function UserItem({ user, prefix, prefixColor }: { user: ChannelUser; prefix: string; prefixColor: string }) {
return (
<div style={styles.userRow}>
{prefix && <span style={{ ...styles.prefix, color: prefixColor }}>{prefix}</span>}
<span style={styles.nick}>{user.nick}</span>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'var(--bg-secondary)',
},
header: {
padding: '4px 8px',
background: 'var(--bg-toolbar)',
color: 'var(--text-secondary)',
fontSize: '11px',
fontWeight: 'bold',
borderBottom: '1px solid var(--border-light)',
flexShrink: 0,
},
list: {
flex: 1,
overflow: 'auto',
padding: '4px 0',
},
group: {
marginBottom: '4px',
},
groupLabel: {
padding: '2px 8px',
color: 'var(--text-muted)',
fontSize: '10px',
fontWeight: 'bold',
textTransform: 'uppercase',
},
userRow: {
display: 'flex',
alignItems: 'center',
padding: '1px 8px',
cursor: 'pointer',
fontSize: '12px',
},
prefix: {
width: '12px',
fontWeight: 'bold',
flexShrink: 0,
},
nick: {
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
};
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useAuthStore } from '../stores/auth';
import { useIrcStore } from '../stores/irc';
import { useWebSocketStore } from '../stores/websocket';
import TreeBar from '../components/TreeBar';
import MessagePanel from '../components/MessagePanel';
import UserList from '../components/UserList';
export default function MainLayout() {
const token = useAuthStore((s) => s.token);
const logout = useAuthStore((s) => s.logout);
const { connected, connect, disconnect } = useWebSocketStore();
const activeConnectionId = useIrcStore((s) => s.activeConnectionId);
const activeBuffer = useIrcStore((s) => s.activeBuffer);
const connections = useIrcStore((s) => s.connections);
const [treeWidth, setTreeWidth] = useState(200);
const [userListWidth, setUserListWidth] = useState(160);
const [resizing, setResizing] = useState<'tree' | 'userlist' | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (token) {
connect(token);
}
return () => {
disconnect();
};
}, [token, connect, disconnect]);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!resizing || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
if (resizing === 'tree') {
const newWidth = Math.max(120, Math.min(400, e.clientX - rect.left));
setTreeWidth(newWidth);
} else if (resizing === 'userlist') {
const newWidth = Math.max(100, Math.min(300, rect.right - e.clientX));
setUserListWidth(newWidth);
}
},
[resizing]
);
const handleMouseUp = useCallback(() => {
setResizing(null);
}, []);
useEffect(() => {
if (resizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [resizing, handleMouseMove, handleMouseUp]);
const activeConnection = connections.find((c) => c.id === activeConnectionId);
const isChannel = activeBuffer?.startsWith('#') || activeBuffer?.startsWith('&');
const handleLogout = () => {
disconnect();
logout();
};
return (
<div ref={containerRef} style={styles.container}>
{/* Toolbar */}
<div style={styles.toolbar}>
<div style={styles.toolbarLeft}>
<span style={styles.brand}>mIRCcloud</span>
<span style={styles.statusDot(connected)} />
<span style={styles.statusText}>
{connected ? 'Connected' : 'Disconnected'}
</span>
{activeConnection && (
<span style={styles.connectionInfo}>
{activeConnection.nick}@{activeConnection.server}
</span>
)}
</div>
<div style={styles.toolbarRight}>
<button style={styles.toolbarButton} onClick={handleLogout}>
Logout
</button>
</div>
</div>
{/* Main panels */}
<div style={styles.panels}>
{/* TreeBar */}
<div style={{ ...styles.treePanel, width: treeWidth }}>
<TreeBar />
</div>
{/* Tree resize handle */}
<div
style={styles.resizeHandle}
onMouseDown={() => setResizing('tree')}
/>
{/* Message panel */}
<div style={styles.messagePanel}>
<MessagePanel />
</div>
{/* User list resize handle */}
{isChannel && (
<div
style={styles.resizeHandle}
onMouseDown={() => setResizing('userlist')}
/>
)}
{/* User list (only for channels) */}
{isChannel && (
<div style={{ ...styles.userListPanel, width: userListWidth }}>
<UserList />
</div>
)}
</div>
</div>
);
}
const styles = {
container: {
display: 'flex',
flexDirection: 'column' as const,
height: '100vh',
background: 'var(--bg-primary)',
color: 'var(--text-primary)',
fontFamily: 'var(--font-mono)',
},
toolbar: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: 'var(--toolbar-height)',
padding: '0 8px',
background: 'var(--bg-toolbar)',
borderBottom: '2px solid',
borderColor: '#808080',
borderImage: 'none',
borderBottomColor: '#1A1A1A',
flexShrink: 0,
} as React.CSSProperties,
toolbarLeft: {
display: 'flex',
alignItems: 'center',
gap: '8px',
} as React.CSSProperties,
toolbarRight: {
display: 'flex',
alignItems: 'center',
gap: '8px',
} as React.CSSProperties,
brand: {
fontWeight: 'bold',
color: 'var(--accent)',
fontSize: '13px',
} as React.CSSProperties,
statusDot: (connected: boolean): React.CSSProperties => ({
width: '8px',
height: '8px',
borderRadius: '50%',
background: connected ? '#00FF00' : '#FF4444',
}),
statusText: {
fontSize: '11px',
color: 'var(--text-secondary)',
} as React.CSSProperties,
connectionInfo: {
fontSize: '11px',
color: 'var(--text-muted)',
} as React.CSSProperties,
toolbarButton: {
padding: '2px 12px',
background: '#3C3C3C',
border: '2px solid',
borderColor: '#DFDFDF #808080 #808080 #DFDFDF',
color: 'var(--text-primary)',
fontSize: '11px',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
} as React.CSSProperties,
panels: {
display: 'flex',
flex: 1,
overflow: 'hidden',
} as React.CSSProperties,
treePanel: {
flexShrink: 0,
overflow: 'hidden',
borderRight: '1px solid var(--border-light)',
} as React.CSSProperties,
messagePanel: {
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column' as const,
} as React.CSSProperties,
userListPanel: {
flexShrink: 0,
overflow: 'hidden',
borderLeft: '1px solid var(--border-light)',
} as React.CSSProperties,
resizeHandle: {
width: '4px',
cursor: 'col-resize',
background: 'var(--border-light)',
flexShrink: 0,
} as React.CSSProperties,
};
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/global.css';
import './styles/theme.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+157
View File
@@ -0,0 +1,157 @@
import { useState, FormEvent } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth';
export default function LoginPage() {
const { token, login, isLoading, error, clearError } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
if (token) {
return <Navigate to="/" replace />;
}
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
login(email, password);
};
return (
<div style={styles.container}>
<div style={styles.card}>
<h1 style={styles.title}>mIRCcloud</h1>
<p style={styles.subtitle}>IRC in the cloud powered by nostalgia</p>
<form onSubmit={handleSubmit} style={styles.form}>
{error && (
<div style={styles.error} onClick={clearError}>
{error}
</div>
)}
<div style={styles.field}>
<label style={styles.label} htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={styles.input}
placeholder="user@example.com"
required
autoFocus
/>
</div>
<div style={styles.field}>
<label style={styles.label} htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={styles.input}
placeholder="••••••••"
required
/>
</div>
<button type="submit" style={styles.button} disabled={isLoading}>
{isLoading ? 'Connecting...' : 'Login'}
</button>
</form>
<p style={styles.footer}>
Don't have an account?{' '}
<Link to="/register" style={styles.link}>
Register
</Link>
</p>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
background: '#0C0C0C',
fontFamily: "Consolas, 'Courier New', monospace",
},
card: {
background: '#1A1A1A',
border: '2px solid',
borderColor: '#505050 #1A1A1A #1A1A1A #505050',
padding: '40px',
width: '100%',
maxWidth: '400px',
},
title: {
color: '#4A9EFF',
fontSize: '28px',
marginBottom: '4px',
textAlign: 'center' as const,
},
subtitle: {
color: '#707070',
fontSize: '12px',
marginBottom: '24px',
textAlign: 'center' as const,
},
form: {
display: 'flex',
flexDirection: 'column' as const,
gap: '16px',
},
field: {
display: 'flex',
flexDirection: 'column' as const,
gap: '4px',
},
label: {
color: '#B0B0B0',
fontSize: '12px',
},
input: {
padding: '8px 10px',
background: '#0C0C0C',
border: '2px solid',
borderColor: '#808080 #DFDFDF #DFDFDF #808080',
color: '#FFFFFF',
fontSize: '14px',
fontFamily: "Consolas, 'Courier New', monospace",
outline: 'none',
},
button: {
padding: '10px',
background: '#3C3C3C',
border: '2px solid',
borderColor: '#DFDFDF #808080 #808080 #DFDFDF',
color: '#FFFFFF',
fontSize: '14px',
fontFamily: "Consolas, 'Courier New', monospace",
cursor: 'pointer',
marginTop: '8px',
},
error: {
padding: '8px',
background: '#3C0000',
border: '1px solid #FF4444',
color: '#FF8888',
fontSize: '12px',
cursor: 'pointer',
},
footer: {
color: '#707070',
fontSize: '12px',
textAlign: 'center' as const,
marginTop: '20px',
},
link: {
color: '#4A9EFF',
textDecoration: 'underline',
},
};
+200
View File
@@ -0,0 +1,200 @@
import { useState, FormEvent } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth';
export default function RegisterPage() {
const { token, register, isLoading, error, clearError } = useAuthStore();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [localError, setLocalError] = useState('');
if (token) {
return <Navigate to="/" replace />;
}
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setLocalError('');
if (password !== confirmPassword) {
setLocalError('Passwords do not match');
return;
}
if (password.length < 6) {
setLocalError('Password must be at least 6 characters');
return;
}
register(username, email, password);
};
const displayError = localError || error;
return (
<div style={styles.container}>
<div style={styles.card}>
<h1 style={styles.title}>mIRCcloud</h1>
<p style={styles.subtitle}>Create your account</p>
<form onSubmit={handleSubmit} style={styles.form}>
{displayError && (
<div style={styles.error} onClick={() => { setLocalError(''); clearError(); }}>
{displayError}
</div>
)}
<div style={styles.field}>
<label style={styles.label} htmlFor="username">Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
style={styles.input}
placeholder="YourNick"
required
autoFocus
/>
</div>
<div style={styles.field}>
<label style={styles.label} htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={styles.input}
placeholder="user@example.com"
required
/>
</div>
<div style={styles.field}>
<label style={styles.label} htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={styles.input}
placeholder="••••••••"
required
/>
</div>
<div style={styles.field}>
<label style={styles.label} htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
style={styles.input}
placeholder="••••••••"
required
/>
</div>
<button type="submit" style={styles.button} disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Register'}
</button>
</form>
<p style={styles.footer}>
Already have an account?{' '}
<Link to="/login" style={styles.link}>
Login
</Link>
</p>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
background: '#0C0C0C',
fontFamily: "Consolas, 'Courier New', monospace",
},
card: {
background: '#1A1A1A',
border: '2px solid',
borderColor: '#505050 #1A1A1A #1A1A1A #505050',
padding: '40px',
width: '100%',
maxWidth: '400px',
},
title: {
color: '#4A9EFF',
fontSize: '28px',
marginBottom: '4px',
textAlign: 'center' as const,
},
subtitle: {
color: '#707070',
fontSize: '12px',
marginBottom: '24px',
textAlign: 'center' as const,
},
form: {
display: 'flex',
flexDirection: 'column' as const,
gap: '16px',
},
field: {
display: 'flex',
flexDirection: 'column' as const,
gap: '4px',
},
label: {
color: '#B0B0B0',
fontSize: '12px',
},
input: {
padding: '8px 10px',
background: '#0C0C0C',
border: '2px solid',
borderColor: '#808080 #DFDFDF #DFDFDF #808080',
color: '#FFFFFF',
fontSize: '14px',
fontFamily: "Consolas, 'Courier New', monospace",
outline: 'none',
},
button: {
padding: '10px',
background: '#3C3C3C',
border: '2px solid',
borderColor: '#DFDFDF #808080 #808080 #DFDFDF',
color: '#FFFFFF',
fontSize: '14px',
fontFamily: "Consolas, 'Courier New', monospace",
cursor: 'pointer',
marginTop: '8px',
},
error: {
padding: '8px',
background: '#3C0000',
border: '1px solid #FF4444',
color: '#FF8888',
fontSize: '12px',
cursor: 'pointer',
},
footer: {
color: '#707070',
fontSize: '12px',
textAlign: 'center' as const,
marginTop: '20px',
},
link: {
color: '#4A9EFF',
textDecoration: 'underline',
},
};
+94
View File
@@ -0,0 +1,94 @@
import { create } from 'zustand';
interface User {
id: string;
username: string;
email: string;
}
interface AuthState {
token: string | null;
user: User | null;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string) => Promise<void>;
logout: () => void;
loadFromStorage: () => void;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
token: null,
user: null,
isLoading: false,
error: null,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({ message: 'Login failed' }));
throw new Error(data.message || `Login failed (${res.status})`);
}
const data = await res.json();
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
set({ token: data.token, user: data.user, isLoading: false });
} catch (err: any) {
set({ error: err.message, isLoading: false });
}
},
register: async (username: string, email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({ message: 'Registration failed' }));
throw new Error(data.message || `Registration failed (${res.status})`);
}
const data = await res.json();
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
set({ token: data.token, user: data.user, isLoading: false });
} catch (err: any) {
set({ error: err.message, isLoading: false });
}
},
logout: () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
set({ token: null, user: null });
},
loadFromStorage: () => {
const token = localStorage.getItem('token');
const userStr = localStorage.getItem('user');
if (token && userStr) {
try {
const user = JSON.parse(userStr);
set({ token, user });
} catch {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
}
},
clearError: () => set({ error: null }),
}));
+235
View File
@@ -0,0 +1,235 @@
import { create } from 'zustand';
export interface IrcMessage {
id: string;
connectionId: string;
buffer: string;
type: 'privmsg' | 'notice' | 'action' | 'join' | 'part' | 'quit' | 'kick' | 'nick' | 'topic' | 'mode' | 'system';
nick: string;
text: string;
timestamp: number;
}
export interface IrcChannel {
name: string;
topic: string;
unread: number;
mentioned: boolean;
joined: boolean;
}
export interface IrcConnection {
id: string;
name: string;
server: string;
port: number;
nick: string;
connected: boolean;
channels: IrcChannel[];
queries: string[];
}
export interface ChannelUser {
nick: string;
modes: string; // e.g. '@', '+', '%', '~', '&'
prefix: string;
}
interface IrcState {
connections: IrcConnection[];
activeConnectionId: string | null;
activeBuffer: string | null; // channel name or nick for PM
messages: Record<string, IrcMessage[]>; // key: `${connectionId}:${buffer}`
users: Record<string, ChannelUser[]>; // key: `${connectionId}:${channel}`
setActiveBuffer: (connectionId: string, buffer: string) => void;
addMessage: (message: IrcMessage) => void;
setConnections: (connections: IrcConnection[]) => void;
setMessages: (connectionId: string, buffer: string, messages: IrcMessage[]) => void;
addConnection: (connection: IrcConnection) => void;
removeConnection: (connectionId: string) => void;
updateConnection: (connectionId: string, updates: Partial<IrcConnection>) => void;
setChannelUsers: (connectionId: string, channel: string, users: ChannelUser[]) => void;
addChannelUser: (connectionId: string, channel: string, user: ChannelUser) => void;
removeChannelUser: (connectionId: string, channel: string, nick: string) => void;
updateChannelUser: (connectionId: string, channel: string, oldNick: string, newNick: string) => void;
setTopic: (connectionId: string, channel: string, topic: string) => void;
incrementUnread: (connectionId: string, buffer: string, mentioned?: boolean) => void;
clearUnread: (connectionId: string, buffer: string) => void;
addChannel: (connectionId: string, channel: IrcChannel) => void;
removeChannel: (connectionId: string, channelName: string) => void;
getBufferKey: (connectionId: string, buffer: string) => string;
}
export const useIrcStore = create<IrcState>((set, get) => ({
connections: [],
activeConnectionId: null,
activeBuffer: null,
messages: {},
users: {},
getBufferKey: (connectionId: string, buffer: string) => `${connectionId}:${buffer}`,
setActiveBuffer: (connectionId: string, buffer: string) => {
set({ activeConnectionId: connectionId, activeBuffer: buffer });
// Clear unread when switching to buffer
get().clearUnread(connectionId, buffer);
},
addMessage: (message: IrcMessage) => {
const key = `${message.connectionId}:${message.buffer}`;
set((state) => ({
messages: {
...state.messages,
[key]: [...(state.messages[key] || []), message],
},
}));
// Increment unread if not active buffer
const { activeConnectionId, activeBuffer } = get();
if (message.connectionId !== activeConnectionId || message.buffer !== activeBuffer) {
if (message.type === 'privmsg' || message.type === 'action' || message.type === 'notice') {
get().incrementUnread(message.connectionId, message.buffer);
}
}
},
setConnections: (connections: IrcConnection[]) => {
set({ connections });
},
setMessages: (connectionId: string, buffer: string, messages: IrcMessage[]) => {
const key = `${connectionId}:${buffer}`;
set((state) => ({
messages: { ...state.messages, [key]: messages },
}));
},
addConnection: (connection: IrcConnection) => {
set((state) => ({
connections: [...state.connections, connection],
}));
},
removeConnection: (connectionId: string) => {
set((state) => ({
connections: state.connections.filter((c) => c.id !== connectionId),
activeConnectionId: state.activeConnectionId === connectionId ? null : state.activeConnectionId,
activeBuffer: state.activeConnectionId === connectionId ? null : state.activeBuffer,
}));
},
updateConnection: (connectionId: string, updates: Partial<IrcConnection>) => {
set((state) => ({
connections: state.connections.map((c) =>
c.id === connectionId ? { ...c, ...updates } : c
),
}));
},
setChannelUsers: (connectionId: string, channel: string, users: ChannelUser[]) => {
const key = `${connectionId}:${channel}`;
set((state) => ({
users: { ...state.users, [key]: users },
}));
},
addChannelUser: (connectionId: string, channel: string, user: ChannelUser) => {
const key = `${connectionId}:${channel}`;
set((state) => ({
users: {
...state.users,
[key]: [...(state.users[key] || []), user],
},
}));
},
removeChannelUser: (connectionId: string, channel: string, nick: string) => {
const key = `${connectionId}:${channel}`;
set((state) => ({
users: {
...state.users,
[key]: (state.users[key] || []).filter((u) => u.nick !== nick),
},
}));
},
updateChannelUser: (connectionId: string, channel: string, oldNick: string, newNick: string) => {
const key = `${connectionId}:${channel}`;
set((state) => ({
users: {
...state.users,
[key]: (state.users[key] || []).map((u) =>
u.nick === oldNick ? { ...u, nick: newNick } : u
),
},
}));
},
setTopic: (connectionId: string, channel: string, topic: string) => {
set((state) => ({
connections: state.connections.map((c) =>
c.id === connectionId
? {
...c,
channels: c.channels.map((ch) =>
ch.name === channel ? { ...ch, topic } : ch
),
}
: c
),
}));
},
incrementUnread: (connectionId: string, buffer: string, mentioned = false) => {
set((state) => ({
connections: state.connections.map((c) =>
c.id === connectionId
? {
...c,
channels: c.channels.map((ch) =>
ch.name === buffer
? { ...ch, unread: ch.unread + 1, mentioned: mentioned || ch.mentioned }
: ch
),
}
: c
),
}));
},
clearUnread: (connectionId: string, buffer: string) => {
set((state) => ({
connections: state.connections.map((c) =>
c.id === connectionId
? {
...c,
channels: c.channels.map((ch) =>
ch.name === buffer ? { ...ch, unread: 0, mentioned: false } : ch
),
}
: c
),
}));
},
addChannel: (connectionId: string, channel: IrcChannel) => {
set((state) => ({
connections: state.connections.map((c) =>
c.id === connectionId
? { ...c, channels: [...c.channels, channel] }
: c
),
}));
},
removeChannel: (connectionId: string, channelName: string) => {
set((state) => ({
connections: state.connections.map((c) =>
c.id === connectionId
? { ...c, channels: c.channels.filter((ch) => ch.name !== channelName) }
: c
),
}));
},
}));
+188
View File
@@ -0,0 +1,188 @@
import { create } from 'zustand';
import { useIrcStore, IrcMessage, IrcConnection, ChannelUser, IrcChannel } from './irc';
interface WebSocketState {
connected: boolean;
ws: WebSocket | null;
reconnectAttempts: number;
maxReconnectAttempts: number;
reconnectTimer: ReturnType<typeof setTimeout> | null;
connect: (token: string) => void;
disconnect: () => void;
send: (type: string, payload: any) => void;
}
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
connected: false,
ws: null,
reconnectAttempts: 0,
maxReconnectAttempts: 10,
reconnectTimer: null,
connect: (token: string) => {
const { ws: existingWs, reconnectTimer } = get();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
set({ reconnectTimer: null });
}
if (existingWs) {
existingWs.close();
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
set({ connected: true, reconnectAttempts: 0 });
};
ws.onclose = (event) => {
set({ connected: false, ws: null });
// Auto-reconnect unless intentionally closed
if (event.code !== 1000) {
const { reconnectAttempts, maxReconnectAttempts } = get();
if (reconnectAttempts < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
const timer = setTimeout(() => {
set({ reconnectAttempts: reconnectAttempts + 1 });
get().connect(token);
}, delay);
set({ reconnectTimer: timer });
}
}
};
ws.onerror = () => {
// Error handling is done in onclose
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleMessage(data);
} catch (err) {
console.error('Failed to parse WebSocket message:', err);
}
};
set({ ws });
},
disconnect: () => {
const { ws, reconnectTimer } = get();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
if (ws) {
ws.close(1000, 'Client disconnect');
}
set({ ws: null, connected: false, reconnectTimer: null, reconnectAttempts: 0 });
},
send: (type: string, payload: any) => {
const { ws, connected } = get();
if (ws && connected) {
ws.send(JSON.stringify({ type, ...payload }));
}
},
}));
function handleMessage(data: any) {
const irc = useIrcStore.getState();
switch (data.type) {
case 'connections':
irc.setConnections(data.connections as IrcConnection[]);
break;
case 'connection_added':
irc.addConnection(data.connection as IrcConnection);
break;
case 'connection_removed':
irc.removeConnection(data.connectionId);
break;
case 'connection_update':
irc.updateConnection(data.connectionId, data.updates);
break;
case 'message': {
const msg: IrcMessage = {
id: data.id || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
connectionId: data.connectionId,
buffer: data.buffer,
type: data.messageType || 'privmsg',
nick: data.nick,
text: data.text,
timestamp: data.timestamp || Date.now(),
};
irc.addMessage(msg);
break;
}
case 'messages': {
const messages: IrcMessage[] = data.messages.map((m: any) => ({
id: m.id || `${m.timestamp}-${Math.random().toString(36).substr(2, 9)}`,
connectionId: data.connectionId,
buffer: data.buffer,
type: m.messageType || m.type || 'privmsg',
nick: m.nick,
text: m.text,
timestamp: m.timestamp || Date.now(),
}));
irc.setMessages(data.connectionId, data.buffer, messages);
break;
}
case 'channel_users':
irc.setChannelUsers(data.connectionId, data.channel, data.users as ChannelUser[]);
break;
case 'user_join': {
const user: ChannelUser = { nick: data.nick, modes: '', prefix: '' };
irc.addChannelUser(data.connectionId, data.channel, user);
break;
}
case 'user_part':
case 'user_quit':
irc.removeChannelUser(data.connectionId, data.channel, data.nick);
break;
case 'user_nick':
irc.updateChannelUser(data.connectionId, data.channel, data.oldNick, data.newNick);
break;
case 'topic':
irc.setTopic(data.connectionId, data.channel, data.topic);
break;
case 'channel_joined': {
const channel: IrcChannel = {
name: data.channel,
topic: data.topic || '',
unread: 0,
mentioned: false,
joined: true,
};
irc.addChannel(data.connectionId, channel);
break;
}
case 'channel_parted':
irc.removeChannel(data.connectionId, data.channel);
break;
case 'nick_changed':
irc.updateConnection(data.connectionId, { nick: data.newNick });
break;
default:
console.log('Unhandled WebSocket message type:', data.type, data);
}
}
+43
View File
@@ -0,0 +1,43 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: Consolas, 'Courier New', monospace;
font-size: 14px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
input, button, textarea, select {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
ul, ol {
list-style: none;
}
button {
cursor: pointer;
border: none;
background: none;
}
:focus-visible {
outline: 1px dotted #fff;
outline-offset: 1px;
}
+136
View File
@@ -0,0 +1,136 @@
:root {
/* mIRC 16-color palette */
--irc-white: #FFFFFF;
--irc-black: #000000;
--irc-navy: #00007F;
--irc-green: #009300;
--irc-red: #FF0000;
--irc-maroon: #7F0000;
--irc-purple: #9C009C;
--irc-orange: #FC7F00;
--irc-yellow: #FFFF00;
--irc-lime: #00FC00;
--irc-teal: #009393;
--irc-cyan: #00FFFF;
--irc-blue: #0000FC;
--irc-pink: #FF00FF;
--irc-gray: #7F7F7F;
--irc-silver: #D2D2D2;
/* App theme colors */
--bg-primary: #0C0C0C;
--bg-secondary: #1A1A1A;
--bg-panel: #2D2D2D;
--bg-treebar: #C0C0C0;
--bg-input: #1A1A1A;
--bg-toolbar: #3C3C3C;
--bg-topic: #1E1E2E;
--text-primary: #FFFFFF;
--text-secondary: #B0B0B0;
--text-muted: #707070;
--text-treebar: #000000;
--border-light: #505050;
--border-dark: #1A1A1A;
--border-outset-light: #DFDFDF;
--border-outset-dark: #808080;
--border-inset-light: #808080;
--border-inset-dark: #DFDFDF;
--accent: #4A9EFF;
--accent-hover: #6BB3FF;
--highlight: #FFD700;
--unread: #FF4444;
--action-color: #CC44CC;
--join-color: #00AA00;
--part-color: #AA0000;
--notice-color: #FF8800;
--nick-colors: #FF5555, #55FF55, #5555FF, #FFFF55, #FF55FF, #55FFFF, #FF8800, #88FF00;
--font-mono: Consolas, 'Courier New', monospace;
--font-size: 14px;
--font-size-small: 12px;
--panel-border-width: 2px;
--toolbar-height: 32px;
--topic-height: 28px;
--input-height: 32px;
--treebar-width: 200px;
--userlist-width: 160px;
}
/* Win32-style inset border */
.border-inset {
border: var(--panel-border-width) solid;
border-color: var(--border-inset-light) var(--border-inset-dark) var(--border-inset-dark) var(--border-inset-light);
}
/* Win32-style outset border */
.border-outset {
border: var(--panel-border-width) solid;
border-color: var(--border-outset-light) var(--border-outset-dark) var(--border-outset-dark) var(--border-outset-light);
}
/* Classic scrollbar styling */
::-webkit-scrollbar {
width: 16px;
height: 16px;
}
::-webkit-scrollbar-track {
background: #2A2A2A;
border: 1px solid #1A1A1A;
}
::-webkit-scrollbar-thumb {
background: #555555;
border: 1px solid;
border-color: #707070 #3A3A3A #3A3A3A #707070;
}
::-webkit-scrollbar-thumb:hover {
background: #666666;
}
::-webkit-scrollbar-button {
background: #3C3C3C;
border: 1px solid;
border-color: #606060 #2A2A2A #2A2A2A #606060;
display: block;
height: 16px;
width: 16px;
}
/* Context menu */
.context-menu {
position: fixed;
background: var(--bg-panel);
border: 1px solid var(--border-light);
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
z-index: 1000;
min-width: 150px;
padding: 2px 0;
}
.context-menu-item {
display: block;
width: 100%;
padding: 4px 20px;
text-align: left;
color: var(--text-primary);
font-size: var(--font-size-small);
cursor: pointer;
white-space: nowrap;
}
.context-menu-item:hover {
background: var(--accent);
color: white;
}
.context-menu-separator {
height: 1px;
background: var(--border-light);
margin: 2px 0;
}
+220
View File
@@ -0,0 +1,220 @@
import React from 'react';
// mIRC 16 standard colors
const IRC_COLORS_16: string[] = [
'#FFFFFF', // 0 - white
'#000000', // 1 - black
'#00007F', // 2 - navy
'#009300', // 3 - green
'#FF0000', // 4 - red
'#7F0000', // 5 - maroon
'#9C009C', // 6 - purple
'#FC7F00', // 7 - orange
'#FFFF00', // 8 - yellow
'#00FC00', // 9 - lime
'#009393', // 10 - teal
'#00FFFF', // 11 - cyan
'#0000FC', // 12 - blue
'#FF00FF', // 13 - pink
'#7F7F7F', // 14 - gray
'#D2D2D2', // 15 - silver/light gray
];
// Extended 99-color palette (mIRC extended colors 16-98)
const IRC_COLORS_EXTENDED: string[] = [
'#470000', '#472100', '#474700', '#324700', '#004700', '#00472C', '#004747', '#002747', '#000047', '#2E0047', '#470047', '#47002A',
'#740000', '#743A00', '#747400', '#517400', '#007400', '#007449', '#007474', '#004074', '#000074', '#4B0074', '#740074', '#740045',
'#B50000', '#B56300', '#B5B500', '#7DB500', '#00B500', '#00B571', '#00B5B5', '#0063B5', '#0000B5', '#7500B5', '#B500B5', '#B5006B',
'#FF0000', '#FF8C00', '#FFFF00', '#B2FF00', '#00FF00', '#00FFA0', '#00FFFF', '#008CFF', '#0000FF', '#A500FF', '#FF00FF', '#FF0098',
'#FF5959', '#FFB459', '#FFFF71', '#CFFF60', '#6FFF6F', '#65FFC9', '#6DFFFF', '#59B4FF', '#5959FF', '#C459FF', '#FF66FF', '#FF59BC',
'#FF9C9C', '#FFD39C', '#FFFF9C', '#E2FF9C', '#9CFF9C', '#9CFFDB', '#9CFFFF', '#9CD3FF', '#9C9CFF', '#DC9CFF', '#FF9CFF', '#FF94D3',
'#000000', '#131313', '#282828', '#363636', '#4D4D4D', '#656565', '#818181', '#9F9F9F', '#BCBCBC', '#E2E2E2', '#FFFFFF',
];
function getIrcColor(code: number): string | undefined {
if (code >= 0 && code <= 15) return IRC_COLORS_16[code];
if (code >= 16 && code <= 98) return IRC_COLORS_EXTENDED[code - 16];
return undefined;
}
interface TextState {
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
reverse: boolean;
fg: string | undefined;
bg: string | undefined;
}
function defaultState(): TextState {
return {
bold: false,
italic: false,
underline: false,
strikethrough: false,
reverse: false,
fg: undefined,
bg: undefined,
};
}
interface StyledSegment {
text: string;
style: TextState;
}
export function parseIrcFormatting(input: string): StyledSegment[] {
const segments: StyledSegment[] = [];
let state = defaultState();
let currentText = '';
let i = 0;
while (i < input.length) {
const charCode = input.charCodeAt(i);
switch (charCode) {
case 0x02: // Bold
if (currentText) {
segments.push({ text: currentText, style: { ...state } });
currentText = '';
}
state.bold = !state.bold;
i++;
break;
case 0x1D: // Italic
if (currentText) {
segments.push({ text: currentText, style: { ...state } });
currentText = '';
}
state.italic = !state.italic;
i++;
break;
case 0x1F: // Underline
if (currentText) {
segments.push({ text: currentText, style: { ...state } });
currentText = '';
}
state.underline = !state.underline;
i++;
break;
case 0x1E: // Strikethrough
if (currentText) {
segments.push({ text: currentText, style: { ...state } });
currentText = '';
}
state.strikethrough = !state.strikethrough;
i++;
break;
case 0x16: // Reverse
if (currentText) {
segments.push({ text: currentText, style: { ...state } });
currentText = '';
}
state.reverse = !state.reverse;
i++;
break;
case 0x0F: // Reset
if (currentText) {
segments.push({ text: currentText, style: { ...state } });
currentText = '';
}
state = defaultState();
i++;
break;
case 0x03: // Color
if (currentText) {
segments.push({ text: currentText, style: { ...state } });
currentText = '';
}
i++;
// Parse foreground color (1-2 digits)
const fgMatch = input.slice(i).match(/^(\d{1,2})/);
if (fgMatch) {
const fgCode = parseInt(fgMatch[1], 10);
state.fg = getIrcColor(fgCode);
i += fgMatch[1].length;
// Check for background color
if (input[i] === ',') {
const bgMatch = input.slice(i + 1).match(/^(\d{1,2})/);
if (bgMatch) {
const bgCode = parseInt(bgMatch[1], 10);
state.bg = getIrcColor(bgCode);
i += 1 + bgMatch[1].length;
}
}
} else {
// No digits after \x03 = reset colors
state.fg = undefined;
state.bg = undefined;
}
break;
default:
currentText += input[i];
i++;
break;
}
}
if (currentText) {
segments.push({ text: currentText, style: { ...state } });
}
return segments;
}
export function renderIrcText(input: string): React.ReactNode[] {
const segments = parseIrcFormatting(input);
return segments.map((segment, idx) => {
const style: React.CSSProperties = {};
if (segment.style.reverse) {
style.backgroundColor = segment.style.fg || 'var(--text-primary)';
style.color = segment.style.bg || 'var(--bg-primary)';
} else {
if (segment.style.fg) style.color = segment.style.fg;
if (segment.style.bg) style.backgroundColor = segment.style.bg;
}
if (segment.style.bold) style.fontWeight = 'bold';
if (segment.style.italic) style.fontStyle = 'italic';
const decorations: string[] = [];
if (segment.style.underline) decorations.push('underline');
if (segment.style.strikethrough) decorations.push('line-through');
if (decorations.length) style.textDecoration = decorations.join(' ');
// Linkify URLs in the text
const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/g;
const parts = segment.text.split(urlRegex);
const content = parts.map((part, partIdx) => {
if (urlRegex.test(part) || part.match(/^https?:\/\//)) {
return React.createElement('a', {
key: `${idx}-${partIdx}`,
href: part,
target: '_blank',
rel: 'noopener noreferrer',
style: { ...style, color: style.color || 'var(--accent)', textDecoration: 'underline' },
}, part);
}
return part;
});
return React.createElement('span', { key: idx, style }, ...content);
});
}
// Strip all IRC formatting codes from text (for plain text display)
export function stripIrcFormatting(input: string): string {
return input
.replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, '')
.replace(/[\x02\x1D\x1F\x1E\x16\x0F]/g, '');
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3000',
ws: true,
},
},
},
});