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:
@@ -0,0 +1,19 @@
|
|||||||
|
node_modules
|
||||||
|
client/node_modules
|
||||||
|
server/node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
docker-compose.yml
|
||||||
|
kubernetes/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
dist
|
||||||
|
client/dist
|
||||||
|
server/dist
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
server/dist/
|
||||||
|
client/dist/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
kubernetes/secrets.yaml
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.docker/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.tgz
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
# Stage 1: Build client
|
||||||
|
FROM node:20-alpine AS client-build
|
||||||
|
WORKDIR /app/client
|
||||||
|
COPY client/package.json client/package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY client/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build server
|
||||||
|
FROM node:20-alpine AS server-build
|
||||||
|
WORKDIR /app/server
|
||||||
|
COPY server/package.json server/package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY server/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 3: Production
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
RUN apk add --no-cache tini curl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S appgroup && \
|
||||||
|
adduser -S appuser -u 1001 -G appgroup
|
||||||
|
|
||||||
|
# Copy server production dependencies
|
||||||
|
COPY server/package.json server/package-lock.json* ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
# Copy server build output
|
||||||
|
COPY --from=server-build /app/server/dist ./dist
|
||||||
|
|
||||||
|
# Copy client build output to be served as static files
|
||||||
|
COPY --from=client-build /app/client/dist ./public
|
||||||
|
|
||||||
|
# Create data directory for uploads
|
||||||
|
RUN mkdir -p /data/uploads && chown -R appuser:appgroup /data/uploads
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# mIRCcloud.com
|
||||||
|
|
||||||
|
A modern web-based IRC client inspired by the classic mIRC desktop application. Connect to IRC networks from your browser with a nostalgic Win32 aesthetic, persistent message history, and real-time WebSocket communication.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Internet │
|
||||||
|
│ mirccloud.com (DNS → 24.144.124.133) │
|
||||||
|
└─────────────────┬───────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────▼───────────────────────────────────────┐
|
||||||
|
│ DigitalOcean Edge Node │
|
||||||
|
│ nginx-public ingress controller │
|
||||||
|
└─────────────────┬───────────────────────────────────────┘
|
||||||
|
│ WireGuard tunnel
|
||||||
|
┌─────────────────▼───────────────────────────────────────┐
|
||||||
|
│ k3s Homecloud Cluster │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────┐ │
|
||||||
|
│ │ mirccloud namespace │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||||||
|
│ │ │ App │ │ Postgres │ │ Redis │ │ │
|
||||||
|
│ │ │ (x2) │──│ (16) │ │ (7-alp) │ │ │
|
||||||
|
│ │ │ :3000 │ │ :5432 │ │ :6379 │ │ │
|
||||||
|
│ │ └─────────┘ └──────────┘ └──────────┘ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ ┌────▼────┐ │ │
|
||||||
|
│ │ │ NFS PVC │ (uploads, postgres, redis) │ │
|
||||||
|
│ │ │Synology │ │ │
|
||||||
|
│ │ └─────────┘ │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- **Client** — React + Vite SPA with mIRC-inspired dark theme
|
||||||
|
- **Server** — Express + WebSocket server handling IRC connections
|
||||||
|
- **PostgreSQL** — Message persistence, user accounts, connection configs
|
||||||
|
- **Redis** — Pub/sub for real-time event distribution across app replicas
|
||||||
|
- **NFS (Synology)** — Persistent storage for all stateful data
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- Docker & Docker Compose
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start infrastructure (postgres + redis)
|
||||||
|
docker compose up postgres redis -d
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
cd server && npx drizzle-kit push
|
||||||
|
|
||||||
|
# Start the server (watches for changes)
|
||||||
|
npm run dev --workspace=server
|
||||||
|
|
||||||
|
# In another terminal, start the client
|
||||||
|
npm run dev --workspace=client
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Docker Compose (full stack)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
The app will be available at http://localhost:3000 (API) and http://localhost:5173 (client dev server).
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Build & Push Container Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login to Gitea registry
|
||||||
|
docker login git.lab.fairings.org
|
||||||
|
|
||||||
|
# Build and push
|
||||||
|
docker build -t git.lab.fairings.org/jpreston/mirccloud/app:latest .
|
||||||
|
docker push git.lab.fairings.org/jpreston/mirccloud/app:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy to Kubernetes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create namespace
|
||||||
|
kubectl apply -f kubernetes/namespace.yaml
|
||||||
|
|
||||||
|
# Create secrets (copy from example first)
|
||||||
|
cp kubernetes/secrets.yaml.example kubernetes/secrets.yaml
|
||||||
|
# Edit kubernetes/secrets.yaml with real base64-encoded values
|
||||||
|
kubectl apply -f kubernetes/secrets.yaml
|
||||||
|
|
||||||
|
# Deploy everything
|
||||||
|
kubectl apply -f kubernetes/configmap.yaml
|
||||||
|
kubectl apply -f kubernetes/pvc.yaml
|
||||||
|
kubectl apply -f kubernetes/postgres.yaml
|
||||||
|
kubectl apply -f kubernetes/redis.yaml
|
||||||
|
kubectl apply -f kubernetes/deployment.yaml
|
||||||
|
kubectl apply -f kubernetes/service.yaml
|
||||||
|
kubectl apply -f kubernetes/ingress.yaml
|
||||||
|
kubectl apply -f kubernetes/networkpolicy.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### DNS Setup
|
||||||
|
|
||||||
|
Point these records to the edge node:
|
||||||
|
- `mirccloud.com` → A record → `24.144.124.133`
|
||||||
|
- `www.mirccloud.com` → A record → `24.144.124.133`
|
||||||
|
|
||||||
|
TLS certificates are automatically provisioned by cert-manager using the `letsencrypt-prod` ClusterIssuer.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `PORT` | Server listen port | `3000` |
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection string | — |
|
||||||
|
| `REDIS_URL` | Redis connection string | — |
|
||||||
|
| `JWT_SECRET` | Secret for signing JWT tokens | — |
|
||||||
|
| `CORS_ORIGIN` | Allowed CORS origin | `http://localhost:5173` |
|
||||||
|
| `NODE_ENV` | Environment (development/production) | `development` |
|
||||||
|
| `POSTGRES_PASSWORD` | PostgreSQL password (used in k8s) | — |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
mirccloud.com/
|
||||||
|
├── client/ # React frontend (Vite)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # UI components (TreeBar, MessagePanel, etc.)
|
||||||
|
│ │ ├── pages/ # Login, Register pages
|
||||||
|
│ │ ├── stores/ # Zustand state (auth, irc, websocket)
|
||||||
|
│ │ ├── styles/ # mIRC theme CSS
|
||||||
|
│ │ └── utils/ # IRC color parser, helpers
|
||||||
|
│ └── index.html
|
||||||
|
├── server/ # Express + WebSocket backend
|
||||||
|
│ └── src/
|
||||||
|
│ ├── auth/ # JWT + bcrypt authentication
|
||||||
|
│ ├── db/ # Drizzle ORM schema + connection
|
||||||
|
│ ├── irc/ # IRC connection manager + event handlers
|
||||||
|
│ ├── routes/ # REST API routes
|
||||||
|
│ └── websocket/ # WebSocket server
|
||||||
|
├── kubernetes/ # Production deployment manifests
|
||||||
|
├── Dockerfile # Multi-stage production build
|
||||||
|
├── docker-compose.yml # Local development stack
|
||||||
|
└── package.json # Workspace root
|
||||||
|
```
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}><{message.nick}></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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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)',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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)',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -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
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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, '');
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: server-build
|
||||||
|
command: npx tsx watch src/index.ts
|
||||||
|
working_dir: /app/server
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
- "5173:5173"
|
||||||
|
volumes:
|
||||||
|
- ./server/src:/app/server/src
|
||||||
|
- ./client/src:/app/client/src
|
||||||
|
- ./client/index.html:/app/client/index.html
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- PORT=3000
|
||||||
|
- DATABASE_URL=postgresql://mirccloud:mirccloud@postgres:5432/mirccloud
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- JWT_SECRET=dev-secret-change-me
|
||||||
|
- CORS_ORIGIN=http://localhost:5173
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=mirccloud
|
||||||
|
- POSTGRES_PASSWORD=mirccloud
|
||||||
|
- POSTGRES_DB=mirccloud
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U mirccloud"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-config
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: app
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
data:
|
||||||
|
PORT: "3000"
|
||||||
|
DATABASE_URL: "postgresql://mirccloud:$(POSTGRES_PASSWORD)@mirccloud-postgres:5432/mirccloud"
|
||||||
|
REDIS_URL: "redis://mirccloud-redis:6379"
|
||||||
|
CORS_ORIGIN: "https://mirccloud.com"
|
||||||
|
NODE_ENV: "production"
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-app
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: app
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: app
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 1
|
||||||
|
maxUnavailable: 0
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: app
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
serviceAccountName: default
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
fsGroup: 1001
|
||||||
|
containers:
|
||||||
|
- name: app
|
||||||
|
image: git.lab.fairings.org/jpreston/mirccloud/app:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 3000
|
||||||
|
protocol: TCP
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: mirccloud-secrets
|
||||||
|
- configMapRef:
|
||||||
|
name: mirccloud-config
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 512Mi
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 20
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
volumeMounts:
|
||||||
|
- name: uploads
|
||||||
|
mountPath: /data/uploads
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
volumes:
|
||||||
|
- name: uploads
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: mirccloud-uploads
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-ingress
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: ingress
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
|
||||||
|
nginx.ingress.kubernetes.io/websocket-services: mirccloud-app
|
||||||
|
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
|
||||||
|
nginx.ingress.kubernetes.io/upstream-hash-by: "$remote_addr"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx-public
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- mirccloud.com
|
||||||
|
- www.mirccloud.com
|
||||||
|
secretName: mirccloud-com-tls
|
||||||
|
rules:
|
||||||
|
- host: mirccloud.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: mirccloud-app
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
- host: www.mirccloud.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: mirccloud-app
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-app-ingress
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: app
|
||||||
|
policyTypes:
|
||||||
|
- Ingress
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: ingress-nginx
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 3000
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-postgres-ingress
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
policyTypes:
|
||||||
|
- Ingress
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 5432
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-redis-ingress
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: redis
|
||||||
|
policyTypes:
|
||||||
|
- Ingress
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 6379
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-postgres
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
serviceName: mirccloud-postgres
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
fsGroup: 1001
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: bitnami/postgresql:16
|
||||||
|
ports:
|
||||||
|
- name: tcp-postgres
|
||||||
|
containerPort: 5432
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: POSTGRESQL_USERNAME
|
||||||
|
value: mirccloud
|
||||||
|
- name: POSTGRESQL_DATABASE
|
||||||
|
value: mirccloud
|
||||||
|
- name: POSTGRESQL_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: mirccloud-secrets
|
||||||
|
key: POSTGRES_PASSWORD
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
|
volumeMounts:
|
||||||
|
- name: postgres-data
|
||||||
|
mountPath: /bitnami/postgresql
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- pg_isready -U mirccloud -d mirccloud
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 6
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- pg_isready -U mirccloud -d mirccloud
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 6
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: postgres-data
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: nfs-synology
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-postgres
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
ports:
|
||||||
|
- name: tcp-postgres
|
||||||
|
port: 5432
|
||||||
|
targetPort: 5432
|
||||||
|
protocol: TCP
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-uploads
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: app
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteMany
|
||||||
|
storageClassName: nfs-synology
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-redis
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: redis
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: redis
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: redis
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 999
|
||||||
|
runAsGroup: 999
|
||||||
|
fsGroup: 999
|
||||||
|
containers:
|
||||||
|
- name: redis
|
||||||
|
image: redis:7-alpine
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
- --appendonly
|
||||||
|
- "yes"
|
||||||
|
- --maxmemory
|
||||||
|
- 100mb
|
||||||
|
- --maxmemory-policy
|
||||||
|
- allkeys-lru
|
||||||
|
ports:
|
||||||
|
- name: tcp-redis
|
||||||
|
containerPort: 6379
|
||||||
|
protocol: TCP
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 128Mi
|
||||||
|
volumeMounts:
|
||||||
|
- name: redis-data
|
||||||
|
mountPath: /data
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- redis-cli
|
||||||
|
- ping
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- redis-cli
|
||||||
|
- ping
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
volumes:
|
||||||
|
- name: redis-data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: mirccloud-redis
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-redis
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: redis
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: redis
|
||||||
|
ports:
|
||||||
|
- name: tcp-redis
|
||||||
|
port: 6379
|
||||||
|
targetPort: 6379
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-redis
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: redis
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: nfs-synology
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# DO NOT commit real secrets to git!
|
||||||
|
# Copy this file to secrets.yaml and fill in real base64-encoded values.
|
||||||
|
#
|
||||||
|
# To encode a value: echo -n 'your-value' | base64
|
||||||
|
#
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-secrets
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
JWT_SECRET: Y2hhbmdlLW1lLXRvLWEtcmVhbC1zZWNyZXQ=
|
||||||
|
POSTGRES_PASSWORD: Y2hhbmdlLW1lLXRvLWEtcmVhbC1wYXNzd29yZA==
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: mirccloud-app
|
||||||
|
namespace: mirccloud
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: app
|
||||||
|
app.kubernetes.io/part-of: mirccloud
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: mirccloud
|
||||||
|
app.kubernetes.io/component: app
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 3000
|
||||||
|
protocol: TCP
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "mirccloud",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Self-hosted always-connected web IRC client with mIRC aesthetics",
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"client"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run dev --workspace=server",
|
||||||
|
"dev:client": "npm run dev --workspace=client",
|
||||||
|
"build": "npm run build --workspace=client && npm run build --workspace=server",
|
||||||
|
"start": "npm run start --workspace=server",
|
||||||
|
"db:generate": "npm run db:generate --workspace=server",
|
||||||
|
"db:migrate": "npm run db:migrate --workspace=server"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Database (PostgreSQL)
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/mirccloud
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
|
JWT_EXPIRY=7d
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "mirccloud-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"typescript": "5.5.4",
|
||||||
|
"tsx": "4.16.2",
|
||||||
|
"@types/node": "20.14.12",
|
||||||
|
"express": "4.19.2",
|
||||||
|
"@types/express": "4.17.21",
|
||||||
|
"cors": "2.8.5",
|
||||||
|
"ws": "8.18.0",
|
||||||
|
"@types/ws": "8.5.11",
|
||||||
|
"irc-framework": "4.13.1",
|
||||||
|
"drizzle-orm": "0.32.1",
|
||||||
|
"postgres": "3.4.4",
|
||||||
|
"ioredis": "5.4.1",
|
||||||
|
"jsonwebtoken": "9.0.2",
|
||||||
|
"@types/jsonwebtoken": "9.0.6",
|
||||||
|
"bcrypt": "5.1.1",
|
||||||
|
"@types/bcrypt": "5.0.2",
|
||||||
|
"zod": "3.23.8",
|
||||||
|
"dotenv": "16.4.5",
|
||||||
|
"@types/cors": "2.8.17"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"drizzle-kit": "0.23.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { IncomingMessage } from 'http';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
const BCRYPT_ROUNDS = 12;
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest extends Request {
|
||||||
|
user?: JwtPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateToken(payload: JwtPayload): string {
|
||||||
|
return jwt.sign(payload, config.JWT_SECRET, {
|
||||||
|
expiresIn: config.JWT_EXPIRY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyToken(token: string): JwtPayload {
|
||||||
|
return jwt.verify(token, config.JWT_SECRET) as JwtPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticateHttp(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): void {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
res.status(401).json({ error: 'Missing or invalid authorization header' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
req.user = payload;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticateWs(request: IncomingMessage): JwtPayload | null {
|
||||||
|
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return verifyToken(token);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
PORT: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL || 'postgresql://localhost:5432/mirccloud',
|
||||||
|
REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET || 'change-me-in-production',
|
||||||
|
JWT_EXPIRY: process.env.JWT_EXPIRY || '7d',
|
||||||
|
CORS_ORIGIN: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import * as schema from './schema.js';
|
||||||
|
|
||||||
|
const client = postgres(config.DATABASE_URL, {
|
||||||
|
max: 20,
|
||||||
|
idle_timeout: 20,
|
||||||
|
connect_timeout: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
|
|
||||||
|
export async function closeDb(): Promise<void> {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
serial,
|
||||||
|
bigserial,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
boolean,
|
||||||
|
integer,
|
||||||
|
bigint,
|
||||||
|
timestamp,
|
||||||
|
pgEnum,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const bufferTypeEnum = pgEnum('buffer_type', [
|
||||||
|
'console',
|
||||||
|
'channel',
|
||||||
|
'conversation',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const messageTypeEnum = pgEnum('message_type', [
|
||||||
|
'message',
|
||||||
|
'notice',
|
||||||
|
'action',
|
||||||
|
'join',
|
||||||
|
'part',
|
||||||
|
'quit',
|
||||||
|
'kick',
|
||||||
|
'mode',
|
||||||
|
'topic',
|
||||||
|
'nick',
|
||||||
|
'error',
|
||||||
|
'system',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
username: varchar('username', { length: 64 }).notNull().unique(),
|
||||||
|
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||||
|
passwordHash: text('password_hash').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const connections = pgTable('connections', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
name: varchar('name', { length: 128 }).notNull(),
|
||||||
|
hostname: varchar('hostname', { length: 255 }).notNull(),
|
||||||
|
port: integer('port').notNull().default(6667),
|
||||||
|
tls: boolean('tls').notNull().default(false),
|
||||||
|
nick: varchar('nick', { length: 64 }).notNull(),
|
||||||
|
username: varchar('username', { length: 64 }),
|
||||||
|
realname: varchar('realname', { length: 255 }),
|
||||||
|
password: text('password'),
|
||||||
|
saslUsername: varchar('sasl_username', { length: 128 }),
|
||||||
|
saslPassword: text('sasl_password'),
|
||||||
|
autoConnect: boolean('auto_connect').notNull().default(false),
|
||||||
|
connected: boolean('connected').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const channels = pgTable('channels', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
connectionId: integer('connection_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => connections.id, { onDelete: 'cascade' }),
|
||||||
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
|
key: varchar('key', { length: 255 }),
|
||||||
|
autoJoin: boolean('auto_join').notNull().default(false),
|
||||||
|
joined: boolean('joined').notNull().default(false),
|
||||||
|
topic: text('topic'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const messages = pgTable(
|
||||||
|
'messages',
|
||||||
|
{
|
||||||
|
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
||||||
|
connectionId: integer('connection_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => connections.id, { onDelete: 'cascade' }),
|
||||||
|
channelName: varchar('channel_name', { length: 255 }),
|
||||||
|
bufferType: bufferTypeEnum('buffer_type').notNull(),
|
||||||
|
fromNick: varchar('from_nick', { length: 64 }),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
type: messageTypeEnum('type').notNull(),
|
||||||
|
timestamp: timestamp('timestamp', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
isSelf: boolean('is_self').notNull().default(false),
|
||||||
|
isHighlight: boolean('is_highlight').notNull().default(false),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
connectionChannelTimestampIdx: index('idx_messages_conn_channel_ts').on(
|
||||||
|
table.connectionId,
|
||||||
|
table.channelName,
|
||||||
|
table.timestamp
|
||||||
|
),
|
||||||
|
connectionTimestampIdx: index('idx_messages_conn_ts').on(
|
||||||
|
table.connectionId,
|
||||||
|
table.timestamp
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const readMarkers = pgTable(
|
||||||
|
'read_markers',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
connectionId: integer('connection_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => connections.id, { onDelete: 'cascade' }),
|
||||||
|
channelName: varchar('channel_name', { length: 255 }),
|
||||||
|
lastReadId: bigint('last_read_id', { mode: 'bigint' }),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userConnectionChannelIdx: uniqueIndex('idx_read_markers_user_conn_channel').on(
|
||||||
|
table.userId,
|
||||||
|
table.connectionId,
|
||||||
|
table.channelName
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
|
connections: many(connections),
|
||||||
|
readMarkers: many(readMarkers),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const connectionsRelations = relations(connections, ({ one, many }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [connections.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
channels: many(channels),
|
||||||
|
messages: many(messages),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const channelsRelations = relations(channels, ({ one }) => ({
|
||||||
|
connection: one(connections, {
|
||||||
|
fields: [channels.connectionId],
|
||||||
|
references: [connections.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const messagesRelations = relations(messages, ({ one }) => ({
|
||||||
|
connection: one(connections, {
|
||||||
|
fields: [messages.connectionId],
|
||||||
|
references: [connections.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const readMarkersRelations = relations(readMarkers, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [readMarkers.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
connection: one(connections, {
|
||||||
|
fields: [readMarkers.connectionId],
|
||||||
|
references: [connections.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { config } from './config.js';
|
||||||
|
import { closeDb } from './db/index.js';
|
||||||
|
import { connectionManager } from './irc/connection-manager.js';
|
||||||
|
import { createWebSocketServer, closeAllSockets } from './websocket/index.js';
|
||||||
|
import { closePublisher } from './irc/event-handlers.js';
|
||||||
|
import authRoutes from './routes/auth.js';
|
||||||
|
import connectionsRoutes from './routes/connections.js';
|
||||||
|
import messagesRoutes from './routes/messages.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/connections', connectionsRoutes);
|
||||||
|
app.use('/api/messages', messagesRoutes);
|
||||||
|
|
||||||
|
const server = createServer(app);
|
||||||
|
|
||||||
|
createWebSocketServer(server);
|
||||||
|
|
||||||
|
async function start(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await connectionManager.loadPersistentConnections();
|
||||||
|
console.log('Loaded persistent IRC connections');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load persistent connections:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.listen(config.PORT, () => {
|
||||||
|
console.log(`mIRCcloud server running on port ${config.PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shutdown(): Promise<void> {
|
||||||
|
console.log('Shutting down gracefully...');
|
||||||
|
|
||||||
|
closeAllSockets();
|
||||||
|
|
||||||
|
await connectionManager.shutdown();
|
||||||
|
await closePublisher();
|
||||||
|
await closeDb();
|
||||||
|
|
||||||
|
server.close(() => {
|
||||||
|
console.log('HTTP server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error('Forced shutdown after timeout');
|
||||||
|
process.exit(1);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
|
||||||
|
start();
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import IrcFramework from 'irc-framework';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { connections, channels } from '../db/schema.js';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
handleRegistered,
|
||||||
|
handleMessage,
|
||||||
|
handleNotice,
|
||||||
|
handleJoin,
|
||||||
|
handlePart,
|
||||||
|
handleQuit,
|
||||||
|
handleKick,
|
||||||
|
handleNick,
|
||||||
|
handleTopic,
|
||||||
|
handleMode,
|
||||||
|
handleError,
|
||||||
|
} from './event-handlers.js';
|
||||||
|
|
||||||
|
interface ConnectionConfig {
|
||||||
|
id: number;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
tls: boolean;
|
||||||
|
nick: string;
|
||||||
|
username: string | null;
|
||||||
|
realname: string | null;
|
||||||
|
password: string | null;
|
||||||
|
saslUsername: string | null;
|
||||||
|
saslPassword: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReconnectState {
|
||||||
|
attempts: number;
|
||||||
|
timeout: ReturnType<typeof setTimeout> | null;
|
||||||
|
maxAttempts: number;
|
||||||
|
baseDelay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
||||||
|
const BASE_RECONNECT_DELAY = 1000;
|
||||||
|
|
||||||
|
class ConnectionManager {
|
||||||
|
private static instance: ConnectionManager;
|
||||||
|
private userConnections: Map<number, Map<number, IrcFramework.Client>> = new Map();
|
||||||
|
private reconnectState: Map<string, ReconnectState> = new Map();
|
||||||
|
private shutdownRequested = false;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): ConnectionManager {
|
||||||
|
if (!ConnectionManager.instance) {
|
||||||
|
ConnectionManager.instance = new ConnectionManager();
|
||||||
|
}
|
||||||
|
return ConnectionManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPersistentConnections(): Promise<void> {
|
||||||
|
const autoConnectList = await db.query.connections.findMany({
|
||||||
|
where: eq(connections.autoConnect, true),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const conn of autoConnectList) {
|
||||||
|
await this.connect(conn.userId, {
|
||||||
|
id: conn.id,
|
||||||
|
hostname: conn.hostname,
|
||||||
|
port: conn.port,
|
||||||
|
tls: conn.tls,
|
||||||
|
nick: conn.nick,
|
||||||
|
username: conn.username,
|
||||||
|
realname: conn.realname,
|
||||||
|
password: conn.password,
|
||||||
|
saslUsername: conn.saslUsername,
|
||||||
|
saslPassword: conn.saslPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(userId: number, connConfig: ConnectionConfig): Promise<IrcFramework.Client> {
|
||||||
|
const existing = this.getConnection(userId, connConfig.id);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new IrcFramework.Client();
|
||||||
|
|
||||||
|
const connectOptions: Record<string, unknown> = {
|
||||||
|
host: connConfig.hostname,
|
||||||
|
port: connConfig.port,
|
||||||
|
tls: connConfig.tls,
|
||||||
|
nick: connConfig.nick,
|
||||||
|
username: connConfig.username || connConfig.nick,
|
||||||
|
gecos: connConfig.realname || connConfig.nick,
|
||||||
|
auto_reconnect: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (connConfig.password) {
|
||||||
|
connectOptions.password = connConfig.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connConfig.saslUsername && connConfig.saslPassword) {
|
||||||
|
connectOptions.account = {
|
||||||
|
account: connConfig.saslUsername,
|
||||||
|
password: connConfig.saslPassword,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
userId,
|
||||||
|
connectionId: connConfig.id,
|
||||||
|
nick: connConfig.nick,
|
||||||
|
};
|
||||||
|
|
||||||
|
client.on('registered', handleRegistered(ctx));
|
||||||
|
client.on('privmsg', handleMessage(ctx));
|
||||||
|
client.on('action', handleMessage(ctx));
|
||||||
|
client.on('notice', handleNotice(ctx));
|
||||||
|
client.on('join', handleJoin(ctx));
|
||||||
|
client.on('part', handlePart(ctx));
|
||||||
|
client.on('quit', handleQuit(ctx));
|
||||||
|
client.on('kick', handleKick(ctx));
|
||||||
|
client.on('nick', handleNick(ctx));
|
||||||
|
client.on('topic', handleTopic(ctx));
|
||||||
|
client.on('mode', handleMode(ctx));
|
||||||
|
|
||||||
|
client.on('registered', async () => {
|
||||||
|
await db
|
||||||
|
.update(connections)
|
||||||
|
.set({ connected: true })
|
||||||
|
.where(eq(connections.id, connConfig.id));
|
||||||
|
|
||||||
|
this.resetReconnectState(userId, connConfig.id);
|
||||||
|
|
||||||
|
const autoJoinChannels = await db.query.channels.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(channels.connectionId, connConfig.id),
|
||||||
|
eq(channels.autoJoin, true)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ch of autoJoinChannels) {
|
||||||
|
if (ch.key) {
|
||||||
|
client.join(ch.name, ch.key);
|
||||||
|
} else {
|
||||||
|
client.join(ch.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('close', async () => {
|
||||||
|
await db
|
||||||
|
.update(connections)
|
||||||
|
.set({ connected: false })
|
||||||
|
.where(eq(connections.id, connConfig.id));
|
||||||
|
|
||||||
|
if (!this.shutdownRequested) {
|
||||||
|
this.scheduleReconnect(userId, connConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('socket close', async () => {
|
||||||
|
await db
|
||||||
|
.update(connections)
|
||||||
|
.set({ connected: false })
|
||||||
|
.where(eq(connections.id, connConfig.id));
|
||||||
|
|
||||||
|
if (!this.shutdownRequested) {
|
||||||
|
this.scheduleReconnect(userId, connConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('irc error', handleError(ctx));
|
||||||
|
|
||||||
|
client.connect(connectOptions);
|
||||||
|
|
||||||
|
let userMap = this.userConnections.get(userId);
|
||||||
|
if (!userMap) {
|
||||||
|
userMap = new Map();
|
||||||
|
this.userConnections.set(userId, userMap);
|
||||||
|
}
|
||||||
|
userMap.set(connConfig.id, client);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(userId: number, connectionId: number): Promise<void> {
|
||||||
|
const client = this.getConnection(userId, connectionId);
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
this.cancelReconnect(userId, connectionId);
|
||||||
|
|
||||||
|
client.quit('Disconnecting');
|
||||||
|
|
||||||
|
const userMap = this.userConnections.get(userId);
|
||||||
|
if (userMap) {
|
||||||
|
userMap.delete(connectionId);
|
||||||
|
if (userMap.size === 0) {
|
||||||
|
this.userConnections.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(connections)
|
||||||
|
.set({ connected: false })
|
||||||
|
.where(eq(connections.id, connectionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconnect(userId: number, connectionId: number): Promise<void> {
|
||||||
|
await this.disconnect(userId, connectionId);
|
||||||
|
|
||||||
|
const conn = await db.query.connections.findFirst({
|
||||||
|
where: eq(connections.id, connectionId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conn && conn.userId === userId) {
|
||||||
|
await this.connect(userId, {
|
||||||
|
id: conn.id,
|
||||||
|
hostname: conn.hostname,
|
||||||
|
port: conn.port,
|
||||||
|
tls: conn.tls,
|
||||||
|
nick: conn.nick,
|
||||||
|
username: conn.username,
|
||||||
|
realname: conn.realname,
|
||||||
|
password: conn.password,
|
||||||
|
saslUsername: conn.saslUsername,
|
||||||
|
saslPassword: conn.saslPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnection(userId: number, connectionId: number): IrcFramework.Client | undefined {
|
||||||
|
const userMap = this.userConnections.get(userId);
|
||||||
|
if (!userMap) return undefined;
|
||||||
|
return userMap.get(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getUserConnections(userId: number): Map<number, IrcFramework.Client> {
|
||||||
|
return this.userConnections.get(userId) || new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(userId: number, connConfig: ConnectionConfig): void {
|
||||||
|
const key = `${userId}:${connConfig.id}`;
|
||||||
|
let state = this.reconnectState.get(key);
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
state = {
|
||||||
|
attempts: 0,
|
||||||
|
timeout: null,
|
||||||
|
maxAttempts: MAX_RECONNECT_ATTEMPTS,
|
||||||
|
baseDelay: BASE_RECONNECT_DELAY,
|
||||||
|
};
|
||||||
|
this.reconnectState.set(key, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.attempts >= state.maxAttempts) {
|
||||||
|
this.reconnectState.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = Math.min(
|
||||||
|
state.baseDelay * Math.pow(2, state.attempts),
|
||||||
|
30000
|
||||||
|
);
|
||||||
|
|
||||||
|
state.attempts++;
|
||||||
|
|
||||||
|
state.timeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const userMap = this.userConnections.get(userId);
|
||||||
|
if (userMap) {
|
||||||
|
userMap.delete(connConfig.id);
|
||||||
|
}
|
||||||
|
await this.connect(userId, connConfig);
|
||||||
|
} catch {
|
||||||
|
this.scheduleReconnect(userId, connConfig);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetReconnectState(userId: number, connectionId: number): void {
|
||||||
|
const key = `${userId}:${connectionId}`;
|
||||||
|
const state = this.reconnectState.get(key);
|
||||||
|
if (state) {
|
||||||
|
if (state.timeout) clearTimeout(state.timeout);
|
||||||
|
this.reconnectState.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelReconnect(userId: number, connectionId: number): void {
|
||||||
|
this.resetReconnectState(userId, connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
this.shutdownRequested = true;
|
||||||
|
|
||||||
|
for (const [, state] of this.reconnectState) {
|
||||||
|
if (state.timeout) clearTimeout(state.timeout);
|
||||||
|
}
|
||||||
|
this.reconnectState.clear();
|
||||||
|
|
||||||
|
const disconnectPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (const [userId, userMap] of this.userConnections) {
|
||||||
|
for (const [connectionId] of userMap) {
|
||||||
|
disconnectPromises.push(this.disconnect(userId, connectionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(disconnectPromises);
|
||||||
|
this.userConnections.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const connectionManager = ConnectionManager.getInstance();
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { messages, channels } from '../db/schema.js';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
const publisher = new Redis(config.REDIS_URL);
|
||||||
|
|
||||||
|
interface EventContext {
|
||||||
|
userId: number;
|
||||||
|
connectionId: number;
|
||||||
|
nick: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IrcMessage {
|
||||||
|
nick: string;
|
||||||
|
ident: string;
|
||||||
|
hostname: string;
|
||||||
|
target: string;
|
||||||
|
message: string;
|
||||||
|
tags: Record<string, string>;
|
||||||
|
from_server: boolean;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IrcJoinEvent {
|
||||||
|
nick: string;
|
||||||
|
ident: string;
|
||||||
|
hostname: string;
|
||||||
|
channel: string;
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IrcPartEvent {
|
||||||
|
nick: string;
|
||||||
|
ident: string;
|
||||||
|
hostname: string;
|
||||||
|
channel: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IrcQuitEvent {
|
||||||
|
nick: string;
|
||||||
|
ident: string;
|
||||||
|
hostname: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IrcKickEvent {
|
||||||
|
nick: string;
|
||||||
|
ident: string;
|
||||||
|
hostname: string;
|
||||||
|
channel: string;
|
||||||
|
kicked: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IrcNickEvent {
|
||||||
|
nick: string;
|
||||||
|
ident: string;
|
||||||
|
hostname: string;
|
||||||
|
new_nick: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IrcTopicEvent {
|
||||||
|
nick: string;
|
||||||
|
channel: string;
|
||||||
|
topic: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IrcModeEvent {
|
||||||
|
nick: string;
|
||||||
|
target: string;
|
||||||
|
modes: Array<{ mode: string; param?: string }>;
|
||||||
|
raw_modes: string;
|
||||||
|
raw_params: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistAndPublish(
|
||||||
|
ctx: EventContext,
|
||||||
|
data: {
|
||||||
|
channelName: string | null;
|
||||||
|
bufferType: 'console' | 'channel' | 'conversation';
|
||||||
|
fromNick: string | null;
|
||||||
|
content: string;
|
||||||
|
type: 'message' | 'notice' | 'action' | 'join' | 'part' | 'quit' | 'kick' | 'mode' | 'topic' | 'nick' | 'error' | 'system';
|
||||||
|
isSelf: boolean;
|
||||||
|
isHighlight: boolean;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const [msg] = await db
|
||||||
|
.insert(messages)
|
||||||
|
.values({
|
||||||
|
connectionId: ctx.connectionId,
|
||||||
|
channelName: data.channelName,
|
||||||
|
bufferType: data.bufferType,
|
||||||
|
fromNick: data.fromNick,
|
||||||
|
content: data.content,
|
||||||
|
type: data.type,
|
||||||
|
isSelf: data.isSelf,
|
||||||
|
isHighlight: data.isHighlight,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
type: 'irc_event',
|
||||||
|
eventType: data.type,
|
||||||
|
connectionId: ctx.connectionId,
|
||||||
|
message: msg,
|
||||||
|
};
|
||||||
|
|
||||||
|
await publisher.publish(`irc:events:${ctx.userId}`, JSON.stringify(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleRegistered(ctx: EventContext) {
|
||||||
|
return async () => {
|
||||||
|
await persistAndPublish(ctx, {
|
||||||
|
channelName: null,
|
||||||
|
bufferType: 'console',
|
||||||
|
fromNick: null,
|
||||||
|
content: `Connected to server as ${ctx.nick}`,
|
||||||
|
type: 'system',
|
||||||
|
isSelf: false,
|
||||||
|
isHighlight: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleMessage(ctx: EventContext) {
|
||||||
|
return async (event: IrcMessage) => {
|
||||||
|
const isChannel = event.target.startsWith('#') || event.target.startsWith('&');
|
||||||
|
const isSelf = event.nick === ctx.nick;
|
||||||
|
const channelName = isChannel ? event.target : event.nick;
|
||||||
|
const bufferType = isChannel ? 'channel' : 'conversation';
|
||||||
|
|
||||||
|
const contentLower = event.message.toLowerCase();
|
||||||
|
const nickLower = ctx.nick.toLowerCase();
|
||||||
|
const isHighlight = !isSelf && contentLower.includes(nickLower);
|
||||||
|
|
||||||
|
let messageType: 'message' | 'action' = 'message';
|
||||||
|
let content = event.message;
|
||||||
|
|
||||||
|
if (event.type === 'action') {
|
||||||
|
messageType = 'action';
|
||||||
|
content = event.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistAndPublish(ctx, {
|
||||||
|
channelName,
|
||||||
|
bufferType,
|
||||||
|
fromNick: event.nick,
|
||||||
|
content,
|
||||||
|
type: messageType,
|
||||||
|
isSelf,
|
||||||
|
isHighlight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleNotice(ctx: EventContext) {
|
||||||
|
return async (event: IrcMessage) => {
|
||||||
|
const isChannel = event.target.startsWith('#') || event.target.startsWith('&');
|
||||||
|
const channelName = isChannel ? event.target : event.from_server ? null : event.nick;
|
||||||
|
const bufferType = isChannel ? 'channel' : event.from_server ? 'console' : 'conversation';
|
||||||
|
const isSelf = event.nick === ctx.nick;
|
||||||
|
|
||||||
|
await persistAndPublish(ctx, {
|
||||||
|
channelName,
|
||||||
|
bufferType: bufferType as 'console' | 'channel' | 'conversation',
|
||||||
|
fromNick: event.nick || null,
|
||||||
|
content: event.message,
|
||||||
|
type: 'notice',
|
||||||
|
isSelf,
|
||||||
|
isHighlight: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleJoin(ctx: EventContext) {
|
||||||
|
return async (event: IrcJoinEvent) => {
|
||||||
|
const isSelf = event.nick === ctx.nick;
|
||||||
|
|
||||||
|
if (isSelf) {
|
||||||
|
const existing = await db.query.channels.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(channels.connectionId, ctx.connectionId),
|
||||||
|
eq(channels.name, event.channel)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(channels)
|
||||||
|
.set({ joined: true })
|
||||||
|
.where(eq(channels.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(channels).values({
|
||||||
|
connectionId: ctx.connectionId,
|
||||||
|
name: event.channel,
|
||||||
|
autoJoin: false,
|
||||||
|
joined: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistAndPublish(ctx, {
|
||||||
|
channelName: event.channel,
|
||||||
|
bufferType: 'channel',
|
||||||
|
fromNick: event.nick,
|
||||||
|
content: `${event.nick} (${event.ident}@${event.hostname}) has joined ${event.channel}`,
|
||||||
|
type: 'join',
|
||||||
|
isSelf,
|
||||||
|
isHighlight: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePart(ctx: EventContext) {
|
||||||
|
return async (event: IrcPartEvent) => {
|
||||||
|
const isSelf = event.nick === ctx.nick;
|
||||||
|
|
||||||
|
if (isSelf) {
|
||||||
|
await db
|
||||||
|
.update(channels)
|
||||||
|
.set({ joined: false })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(channels.connectionId, ctx.connectionId),
|
||||||
|
eq(channels.name, event.channel)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = event.message
|
||||||
|
? `${event.nick} (${event.ident}@${event.hostname}) has left ${event.channel} (${event.message})`
|
||||||
|
: `${event.nick} (${event.ident}@${event.hostname}) has left ${event.channel}`;
|
||||||
|
|
||||||
|
await persistAndPublish(ctx, {
|
||||||
|
channelName: event.channel,
|
||||||
|
bufferType: 'channel',
|
||||||
|
fromNick: event.nick,
|
||||||
|
content,
|
||||||
|
type: 'part',
|
||||||
|
isSelf,
|
||||||
|
isHighlight: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleQuit(ctx: EventContext) {
|
||||||
|
return async (event: IrcQuitEvent) => {
|
||||||
|
const isSelf = event.nick === ctx.nick;
|
||||||
|
const content = event.message
|
||||||
|
? `${event.nick} (${event.ident}@${event.hostname}) has quit (${event.message})`
|
||||||
|
: `${event.nick} (${event.ident}@${event.hostname}) has quit`;
|
||||||
|
|
||||||
|
await persistAndPublish(ctx, {
|
||||||
|
channelName: null,
|
||||||
|
bufferType: 'console',
|
||||||
|
fromNick: event.nick,
|
||||||
|
content,
|
||||||
|
type: 'quit',
|
||||||
|
isSelf,
|
||||||
|
isHighlight: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleKick(ctx: EventContext) {
|
||||||
|
return async (event: IrcKickEvent) => {
|
||||||
|
const isSelf = event.kicked === ctx.nick;
|
||||||
|
|
||||||
|
if (isSelf) {
|
||||||
|
await db
|
||||||
|
.update(channels)
|
||||||
|
.set({ joined: false })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(channels.connectionId, ctx.connectionId),
|
||||||
|
eq(channels.name, event.channel)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = event.message
|
||||||
|
? `${event.kicked} was kicked from ${event.channel} by ${event.nick} (${event.message})`
|
||||||
|
: `${event.kicked} was kicked from ${event.channel} by ${event.nick}`;
|
||||||
|
|
||||||
|
await persistAndPublish(ctx, {
|
||||||
|
channelName: event.channel,
|
||||||
|
bufferType: 'channel',
|
||||||
|
fromNick: event.nick,
|
||||||
|
content,
|
||||||
|
type: 'kick',
|
||||||
|
isSelf,
|
||||||
|
isHighlight: isSelf,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleNick(ctx: EventContext) {
|
||||||
|
return async (event: IrcNickEvent) => {
|
||||||
|
const isSelf = event.nick === ctx.nick;
|
||||||
|
|
||||||
|
if (isSelf) {
|
||||||
|
ctx.nick = event.new_nick;
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistAndPublish(ctx, {
|
||||||
|
channelName: null,
|
||||||
|
bufferType: 'console',
|
||||||
|
fromNick: event.nick,
|
||||||
|
content: `${event.nick} is now known as ${event.new_nick}`,
|
||||||
|
type: 'nick',
|
||||||
|
isSelf,
|
||||||
|
isHighlight: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleTopic(ctx: EventContext) {
|
||||||
|
return async (event: IrcTopicEvent) => {
|
||||||
|
await db
|
||||||
|
.update(channels)
|
||||||
|
.set({ topic: event.topic })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(channels.connectionId, ctx.connectionId),
|
||||||
|
eq(channels.name, event.channel)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSelf = event.nick === ctx.nick;
|
||||||
|
|
||||||
|
await persistAndPublish(ctx, {
|
||||||
|
channelName: event.channel,
|
||||||
|
bufferType: 'channel',
|
||||||
|
fromNick: event.nick,
|
||||||
|
content: event.nick
|
||||||
|
? `${event.nick} has changed the topic to: ${event.topic}`
|
||||||
|
: `Topic: ${event.topic}`,
|
||||||
|
type: 'topic',
|
||||||
|
isSelf,
|
||||||
|
isHighlight: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleMode(ctx: EventContext) {
|
||||||
|
return async (event: IrcModeEvent) => {
|
||||||
|
const isChannel = event.target.startsWith('#') || event.target.startsWith('&');
|
||||||
|
const isSelf = event.nick === ctx.nick;
|
||||||
|
|
||||||
|
const modeStr = event.raw_params.length > 0
|
||||||
|
? `${event.raw_modes} ${event.raw_params.join(' ')}`
|
||||||
|
: event.raw_modes;
|
||||||
|
|
||||||
|
await persistAndPublish(ctx, {
|
||||||
|
channelName: isChannel ? event.target : null,
|
||||||
|
bufferType: isChannel ? 'channel' : 'console',
|
||||||
|
fromNick: event.nick,
|
||||||
|
content: `${event.nick} sets mode ${modeStr} on ${event.target}`,
|
||||||
|
type: 'mode',
|
||||||
|
isSelf,
|
||||||
|
isHighlight: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleError(ctx: EventContext) {
|
||||||
|
return async (event: { message: string; error?: Error }) => {
|
||||||
|
await persistAndPublish(ctx, {
|
||||||
|
channelName: null,
|
||||||
|
bufferType: 'console',
|
||||||
|
fromNick: null,
|
||||||
|
content: event.message || 'Unknown error',
|
||||||
|
type: 'error',
|
||||||
|
isSelf: false,
|
||||||
|
isHighlight: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closePublisher(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
publisher.quit().then(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { users } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
generateToken,
|
||||||
|
authenticateHttp,
|
||||||
|
AuthenticatedRequest,
|
||||||
|
} from '../auth/index.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.min(2)
|
||||||
|
.max(64)
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Username must be alphanumeric with dashes/underscores'),
|
||||||
|
email: z.string().email().max(255),
|
||||||
|
password: z.string().min(8).max(128),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = registerSchema.parse(req.body);
|
||||||
|
|
||||||
|
const existingUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, body.email),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
res.status(409).json({ error: 'Email already registered' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUsername = await db.query.users.findFirst({
|
||||||
|
where: eq(users.username, body.username),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUsername) {
|
||||||
|
res.status(409).json({ error: 'Username already taken' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(body.password);
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
username: body.username,
|
||||||
|
email: body.email,
|
||||||
|
passwordHash,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: users.id,
|
||||||
|
username: users.username,
|
||||||
|
email: users.email,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = generateToken({ userId: user!.id, username: user!.username });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
user: {
|
||||||
|
id: user!.id,
|
||||||
|
username: user!.username,
|
||||||
|
email: user!.email,
|
||||||
|
createdAt: user!.createdAt,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({ error: 'Validation failed', details: err.errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Registration error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = loginSchema.parse(req.body);
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, body.email),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(body.password, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateToken({ userId: user.id, username: user.username });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({ error: 'Validation failed', details: err.errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Login error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/me', authenticateHttp, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const userId = authReq.user!.userId;
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: 'User not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ user });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get user error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { connections } from '../db/schema.js';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { authenticateHttp, AuthenticatedRequest } from '../auth/index.js';
|
||||||
|
import { connectionManager } from '../irc/connection-manager.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticateHttp);
|
||||||
|
|
||||||
|
const createConnectionSchema = z.object({
|
||||||
|
name: z.string().min(1).max(128),
|
||||||
|
hostname: z.string().min(1).max(255),
|
||||||
|
port: z.number().int().min(1).max(65535).default(6667),
|
||||||
|
tls: z.boolean().default(false),
|
||||||
|
nick: z.string().min(1).max(64),
|
||||||
|
username: z.string().max(64).nullable().optional(),
|
||||||
|
realname: z.string().max(255).nullable().optional(),
|
||||||
|
password: z.string().nullable().optional(),
|
||||||
|
saslUsername: z.string().max(128).nullable().optional(),
|
||||||
|
saslPassword: z.string().nullable().optional(),
|
||||||
|
autoConnect: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateConnectionSchema = createConnectionSchema.partial();
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const userId = authReq.user!.userId;
|
||||||
|
|
||||||
|
const userConnections = await db.query.connections.findMany({
|
||||||
|
where: eq(connections.userId, userId),
|
||||||
|
with: { channels: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ connections: userConnections });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('List connections error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const userId = authReq.user!.userId;
|
||||||
|
const body = createConnectionSchema.parse(req.body);
|
||||||
|
|
||||||
|
const [conn] = await db
|
||||||
|
.insert(connections)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
name: body.name,
|
||||||
|
hostname: body.hostname,
|
||||||
|
port: body.port,
|
||||||
|
tls: body.tls,
|
||||||
|
nick: body.nick,
|
||||||
|
username: body.username || null,
|
||||||
|
realname: body.realname || null,
|
||||||
|
password: body.password || null,
|
||||||
|
saslUsername: body.saslUsername || null,
|
||||||
|
saslPassword: body.saslPassword || null,
|
||||||
|
autoConnect: body.autoConnect,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
res.status(201).json({ connection: conn });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({ error: 'Validation failed', details: err.errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Create connection error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const userId = authReq.user!.userId;
|
||||||
|
const connectionId = parseInt(req.params.id!, 10);
|
||||||
|
const body = updateConnectionSchema.parse(req.body);
|
||||||
|
|
||||||
|
const existing = await db.query.connections.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(connections.id, connectionId),
|
||||||
|
eq(connections.userId, userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: 'Connection not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(connections)
|
||||||
|
.set({
|
||||||
|
...body,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(connections.id, connectionId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
res.json({ connection: updated });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
res.status(400).json({ error: 'Validation failed', details: err.errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Update connection error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const userId = authReq.user!.userId;
|
||||||
|
const connectionId = parseInt(req.params.id!, 10);
|
||||||
|
|
||||||
|
const existing = await db.query.connections.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(connections.id, connectionId),
|
||||||
|
eq(connections.userId, userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: 'Connection not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectionManager.disconnect(userId, connectionId);
|
||||||
|
await db.delete(connections).where(eq(connections.id, connectionId));
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete connection error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/connect', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const userId = authReq.user!.userId;
|
||||||
|
const connectionId = parseInt(req.params.id!, 10);
|
||||||
|
|
||||||
|
const conn = await db.query.connections.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(connections.id, connectionId),
|
||||||
|
eq(connections.userId, userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conn) {
|
||||||
|
res.status(404).json({ error: 'Connection not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectionManager.connect(userId, {
|
||||||
|
id: conn.id,
|
||||||
|
hostname: conn.hostname,
|
||||||
|
port: conn.port,
|
||||||
|
tls: conn.tls,
|
||||||
|
nick: conn.nick,
|
||||||
|
username: conn.username,
|
||||||
|
realname: conn.realname,
|
||||||
|
password: conn.password,
|
||||||
|
saslUsername: conn.saslUsername,
|
||||||
|
saslPassword: conn.saslPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ status: 'connecting', connectionId });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Connect error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/disconnect', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const userId = authReq.user!.userId;
|
||||||
|
const connectionId = parseInt(req.params.id!, 10);
|
||||||
|
|
||||||
|
const conn = await db.query.connections.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(connections.id, connectionId),
|
||||||
|
eq(connections.userId, userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conn) {
|
||||||
|
res.status(404).json({ error: 'Connection not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectionManager.disconnect(userId, connectionId);
|
||||||
|
|
||||||
|
res.json({ status: 'disconnected', connectionId });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Disconnect error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { messages, connections } from '../db/schema.js';
|
||||||
|
import { eq, and, lt, desc, like, sql } from 'drizzle-orm';
|
||||||
|
import { authenticateHttp, AuthenticatedRequest } from '../auth/index.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticateHttp);
|
||||||
|
|
||||||
|
router.get('/:connectionId/:buffer', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const userId = authReq.user!.userId;
|
||||||
|
const connectionId = parseInt(req.params.connectionId!, 10);
|
||||||
|
const buffer = req.params.buffer === '_console' ? null : req.params.buffer!;
|
||||||
|
const before = req.query.before as string | undefined;
|
||||||
|
const limit = Math.min(parseInt((req.query.limit as string) || '100', 10), 500);
|
||||||
|
|
||||||
|
const conn = await db.query.connections.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(connections.id, connectionId),
|
||||||
|
eq(connections.userId, userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conn) {
|
||||||
|
res.status(404).json({ error: 'Connection not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions = [eq(messages.connectionId, connectionId)];
|
||||||
|
|
||||||
|
if (buffer) {
|
||||||
|
conditions.push(eq(messages.channelName, buffer));
|
||||||
|
} else {
|
||||||
|
conditions.push(eq(messages.bufferType, 'console'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (before) {
|
||||||
|
conditions.push(lt(messages.id, BigInt(before)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query.messages.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
orderBy: [desc(messages.timestamp)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ messages: result.reverse() });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get messages error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:connectionId/:buffer/search', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const authReq = req as AuthenticatedRequest;
|
||||||
|
const userId = authReq.user!.userId;
|
||||||
|
const connectionId = parseInt(req.params.connectionId!, 10);
|
||||||
|
const buffer = req.params.buffer === '_console' ? null : req.params.buffer!;
|
||||||
|
const query = req.query.q as string;
|
||||||
|
const limit = Math.min(parseInt((req.query.limit as string) || '50', 10), 200);
|
||||||
|
const offset = parseInt((req.query.offset as string) || '0', 10);
|
||||||
|
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
res.status(400).json({ error: 'Search query must be at least 2 characters' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = await db.query.connections.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(connections.id, connectionId),
|
||||||
|
eq(connections.userId, userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conn) {
|
||||||
|
res.status(404).json({ error: 'Connection not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
eq(messages.connectionId, connectionId),
|
||||||
|
like(messages.content, `%${query}%`),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (buffer) {
|
||||||
|
conditions.push(eq(messages.channelName, buffer));
|
||||||
|
} else {
|
||||||
|
conditions.push(eq(messages.bufferType, 'console'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query.messages.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
orderBy: [desc(messages.timestamp)],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [countResult] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(messages)
|
||||||
|
.where(and(...conditions));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
messages: result.reverse(),
|
||||||
|
total: countResult?.count || 0,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Search messages error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import { Server as HttpServer } from 'http';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { authenticateWs, JwtPayload } from '../auth/index.js';
|
||||||
|
import { connectionManager } from '../irc/connection-manager.js';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { connections, messages, readMarkers } from '../db/schema.js';
|
||||||
|
import { eq, and, lt, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
interface AuthenticatedSocket {
|
||||||
|
ws: WebSocket;
|
||||||
|
user: JwtPayload;
|
||||||
|
subscriber: Redis;
|
||||||
|
alive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSockets: Map<number, Set<AuthenticatedSocket>> = new Map();
|
||||||
|
|
||||||
|
export function createWebSocketServer(server: HttpServer): WebSocketServer {
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
for (const [, sockets] of activeSockets) {
|
||||||
|
for (const socket of sockets) {
|
||||||
|
if (!socket.alive) {
|
||||||
|
socket.ws.terminate();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
socket.alive = false;
|
||||||
|
socket.ws.ping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
wss.on('close', () => {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on('connection', async (ws, req) => {
|
||||||
|
const user = authenticateWs(req);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
ws.close(4001, 'Authentication failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriber = new Redis(config.REDIS_URL);
|
||||||
|
|
||||||
|
const socket: AuthenticatedSocket = {
|
||||||
|
ws,
|
||||||
|
user,
|
||||||
|
subscriber,
|
||||||
|
alive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let userSockets = activeSockets.get(user.userId);
|
||||||
|
if (!userSockets) {
|
||||||
|
userSockets = new Set();
|
||||||
|
activeSockets.set(user.userId, userSockets);
|
||||||
|
}
|
||||||
|
userSockets.add(socket);
|
||||||
|
|
||||||
|
await subscriber.subscribe(`irc:events:${user.userId}`);
|
||||||
|
|
||||||
|
subscriber.on('message', (_channel: string, message: string) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('pong', () => {
|
||||||
|
socket.alive = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', async (data) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
await handleClientCommand(socket, msg);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Invalid message';
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: errorMsg }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', async () => {
|
||||||
|
await subscriber.unsubscribe(`irc:events:${user.userId}`);
|
||||||
|
await subscriber.quit();
|
||||||
|
|
||||||
|
const sockets = activeSockets.get(user.userId);
|
||||||
|
if (sockets) {
|
||||||
|
sockets.delete(socket);
|
||||||
|
if (sockets.size === 0) {
|
||||||
|
activeSockets.delete(user.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', () => {
|
||||||
|
ws.terminate();
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendInitialState(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
return wss;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendInitialState(socket: AuthenticatedSocket): Promise<void> {
|
||||||
|
const { user, ws } = socket;
|
||||||
|
|
||||||
|
const userConnections = await db.query.connections.findMany({
|
||||||
|
where: eq(connections.userId, user.userId),
|
||||||
|
with: {
|
||||||
|
channels: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectionsState = userConnections.map((conn) => ({
|
||||||
|
id: conn.id,
|
||||||
|
name: conn.name,
|
||||||
|
hostname: conn.hostname,
|
||||||
|
port: conn.port,
|
||||||
|
tls: conn.tls,
|
||||||
|
nick: conn.nick,
|
||||||
|
connected: conn.connected,
|
||||||
|
channels: conn.channels.map((ch) => ({
|
||||||
|
id: ch.id,
|
||||||
|
name: ch.name,
|
||||||
|
joined: ch.joined,
|
||||||
|
topic: ch.topic,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'initial_state',
|
||||||
|
connections: connectionsState,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const conn of userConnections) {
|
||||||
|
for (const ch of conn.channels) {
|
||||||
|
if (ch.joined) {
|
||||||
|
const recentMessages = await db.query.messages.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(messages.connectionId, conn.id),
|
||||||
|
eq(messages.channelName, ch.name)
|
||||||
|
),
|
||||||
|
orderBy: [desc(messages.timestamp)],
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'backlog',
|
||||||
|
connectionId: conn.id,
|
||||||
|
buffer: ch.name,
|
||||||
|
messages: recentMessages.reverse(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleMessages = await db.query.messages.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(messages.connectionId, conn.id),
|
||||||
|
eq(messages.bufferType, 'console')
|
||||||
|
),
|
||||||
|
orderBy: [desc(messages.timestamp)],
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (consoleMessages.length > 0) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'backlog',
|
||||||
|
connectionId: conn.id,
|
||||||
|
buffer: null,
|
||||||
|
messages: consoleMessages.reverse(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClientCommand(
|
||||||
|
socket: AuthenticatedSocket,
|
||||||
|
msg: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const { user, ws } = socket;
|
||||||
|
|
||||||
|
switch (msg.command) {
|
||||||
|
case 'send_message': {
|
||||||
|
const connectionId = msg.connectionId as number;
|
||||||
|
const target = msg.target as string;
|
||||||
|
const text = msg.text as string;
|
||||||
|
|
||||||
|
const client = connectionManager.getConnection(user.userId, connectionId);
|
||||||
|
if (!client) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: 'Connection not found or not active' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.say(target, text);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'join_channel': {
|
||||||
|
const connectionId = msg.connectionId as number;
|
||||||
|
const channel = msg.channel as string;
|
||||||
|
const key = msg.key as string | undefined;
|
||||||
|
|
||||||
|
const client = connectionManager.getConnection(user.userId, connectionId);
|
||||||
|
if (!client) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: 'Connection not found or not active' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
client.join(channel, key);
|
||||||
|
} else {
|
||||||
|
client.join(channel);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'part_channel': {
|
||||||
|
const connectionId = msg.connectionId as number;
|
||||||
|
const channel = msg.channel as string;
|
||||||
|
const reason = msg.reason as string | undefined;
|
||||||
|
|
||||||
|
const client = connectionManager.getConnection(user.userId, connectionId);
|
||||||
|
if (!client) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: 'Connection not found or not active' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.part(channel, reason || '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'get_backlog': {
|
||||||
|
const connectionId = msg.connectionId as number;
|
||||||
|
const buffer = msg.buffer as string | null;
|
||||||
|
const before = msg.before as string | undefined;
|
||||||
|
const limit = Math.min((msg.limit as number) || 100, 500);
|
||||||
|
|
||||||
|
const conditions = [eq(messages.connectionId, connectionId)];
|
||||||
|
|
||||||
|
if (buffer) {
|
||||||
|
conditions.push(eq(messages.channelName, buffer));
|
||||||
|
} else {
|
||||||
|
conditions.push(eq(messages.bufferType, 'console'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (before) {
|
||||||
|
conditions.push(lt(messages.id, BigInt(before)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query.messages.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
orderBy: [desc(messages.timestamp)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'backlog',
|
||||||
|
connectionId,
|
||||||
|
buffer,
|
||||||
|
messages: result.reverse(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'mark_read': {
|
||||||
|
const connectionId = msg.connectionId as number;
|
||||||
|
const channelName = (msg.channel as string) || null;
|
||||||
|
const lastReadId = msg.lastReadId as string;
|
||||||
|
|
||||||
|
const existing = await db.query.readMarkers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(readMarkers.userId, user.userId),
|
||||||
|
eq(readMarkers.connectionId, connectionId),
|
||||||
|
channelName
|
||||||
|
? eq(readMarkers.channelName, channelName)
|
||||||
|
: eq(readMarkers.channelName, '')
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(readMarkers)
|
||||||
|
.set({ lastReadId: BigInt(lastReadId), updatedAt: new Date() })
|
||||||
|
.where(eq(readMarkers.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(readMarkers).values({
|
||||||
|
userId: user.userId,
|
||||||
|
connectionId,
|
||||||
|
channelName,
|
||||||
|
lastReadId: BigInt(lastReadId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'mark_read_ack', connectionId, channel: channelName }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'add_connection': {
|
||||||
|
const connData = msg.data as Record<string, unknown>;
|
||||||
|
|
||||||
|
const [newConn] = await db
|
||||||
|
.insert(connections)
|
||||||
|
.values({
|
||||||
|
userId: user.userId,
|
||||||
|
name: connData.name as string,
|
||||||
|
hostname: connData.hostname as string,
|
||||||
|
port: (connData.port as number) || 6667,
|
||||||
|
tls: (connData.tls as boolean) || false,
|
||||||
|
nick: connData.nick as string,
|
||||||
|
username: (connData.username as string) || null,
|
||||||
|
realname: (connData.realname as string) || null,
|
||||||
|
password: (connData.password as string) || null,
|
||||||
|
saslUsername: (connData.saslUsername as string) || null,
|
||||||
|
saslPassword: (connData.saslPassword as string) || null,
|
||||||
|
autoConnect: (connData.autoConnect as boolean) || false,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'connection_added', connection: newConn }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'remove_connection': {
|
||||||
|
const connectionId = msg.connectionId as number;
|
||||||
|
|
||||||
|
const conn = await db.query.connections.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(connections.id, connectionId),
|
||||||
|
eq(connections.userId, user.userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conn) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: 'Connection not found' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectionManager.disconnect(user.userId, connectionId);
|
||||||
|
await db.delete(connections).where(eq(connections.id, connectionId));
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'connection_removed', connectionId }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list_connections': {
|
||||||
|
const userConnections = await db.query.connections.findMany({
|
||||||
|
where: eq(connections.userId, user.userId),
|
||||||
|
with: { channels: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'connections_list',
|
||||||
|
connections: userConnections.map((conn) => ({
|
||||||
|
id: conn.id,
|
||||||
|
name: conn.name,
|
||||||
|
hostname: conn.hostname,
|
||||||
|
port: conn.port,
|
||||||
|
tls: conn.tls,
|
||||||
|
nick: conn.nick,
|
||||||
|
connected: conn.connected,
|
||||||
|
channels: conn.channels.map((ch) => ({
|
||||||
|
id: ch.id,
|
||||||
|
name: ch.name,
|
||||||
|
joined: ch.joined,
|
||||||
|
topic: ch.topic,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: `Unknown command: ${msg.command}` }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeAllSockets(): void {
|
||||||
|
for (const [, sockets] of activeSockets) {
|
||||||
|
for (const socket of sockets) {
|
||||||
|
socket.subscriber.quit();
|
||||||
|
socket.ws.close(1001, 'Server shutting down');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeSockets.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user