Compare commits
22 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
8cce8e6c2e | |
|
|
e86d69ce35 | |
|
|
4e3583ef9a | |
|
|
fa030a32b7 | |
|
|
887482d3db | |
|
|
064f6bf0ba | |
|
|
d5e942d06d | |
|
|
8cd76ff72d | |
|
|
389415f04d | |
|
|
1d6413cfd6 | |
|
|
8214f9c244 | |
|
|
01c6c4a1b0 | |
|
|
9570283ad8 | |
|
|
496701c713 | |
|
|
b38eb01e27 | |
|
|
46ba1d7273 | |
|
|
cdfbb666b9 | |
|
|
a0a96addb6 | |
|
|
be3503b31b | |
|
|
8da91ebf70 | |
|
|
99859f009f | |
|
|
1c17a9bcf0 |
|
|
@ -0,0 +1,681 @@
|
|||
# 💋 SexyChat (Aphrodite)
|
||||
|
||||
A real-time encrypted chat platform with an AI companion, role-based moderation, and a glassmorphism UI. Built with Flask-SocketIO, AES-GCM encryption, and a local Ollama-powered AI named **Violet**.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Architecture](#architecture)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Setup & Installation](#setup--installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Running the Server](#running-the-server)
|
||||
- [Database Models](#database-models)
|
||||
- [Socket Events Reference](#socket-events-reference)
|
||||
- [REST API Endpoints](#rest-api-endpoints)
|
||||
- [Role System & Admin Panel](#role-system--admin-panel)
|
||||
- [Encryption & Security](#encryption--security)
|
||||
- [Theme System](#theme-system)
|
||||
- [Settings Panel](#settings-panel)
|
||||
- [Violet AI Companion](#violet-ai-companion)
|
||||
- [Premium / Paywall](#premium--paywall)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Core Chat
|
||||
- **Public Lobby** — Real-time ephemeral group chat. Messages are never persisted; they live only in connected clients' browsers.
|
||||
- **Private Messaging** — AES-GCM-256 encrypted PMs with persistent history (up to 500 messages per conversation).
|
||||
- **Guest Mode** — Join and chat anonymously without an account.
|
||||
- **Registration & Login** — Bcrypt password hashing, JWT token auth (7-day expiry), automatic session restore on page refresh.
|
||||
- **Rate Limiting** — 6 messages per 5-second window per session.
|
||||
|
||||
### Violet AI Companion
|
||||
- Local Ollama inference (`sadiq-bd/llama3.2-3b-uncensored:latest` by default).
|
||||
- Transit-encrypted messages — decrypted server-side only for inference, never stored in plaintext.
|
||||
- Per-user conversation memory (last 20 turns), persisted across sessions. `/reset` command to clear.
|
||||
- Free trial (configurable, default: 3 messages), then paywall — admins can grant unlimited access.
|
||||
- Real-time typing indicator while Violet is generating a response.
|
||||
- Friendly signup prompt for unregistered guests who try to PM Violet.
|
||||
|
||||
### Moderation & Roles
|
||||
- **4-tier role hierarchy:** `root` (👑) > `admin` (⚔️) > `mod` (🛡️) > `user`
|
||||
- Kick, ban (username + IP), kickban, mute/unmute, verify/unverify.
|
||||
- Persistent bans and mutes survive server restarts (stored in DB, loaded on startup).
|
||||
- Admin panel with three tabs: Users, Bans, Mutes.
|
||||
- Privilege escalation prevention — you can't modify users at or above your own power level.
|
||||
|
||||
### User Features
|
||||
- Persistent ignore list (stealth blocking — ignored users receive no indication).
|
||||
- Context menu (right-click a username) for PM, ignore, and mod actions.
|
||||
- Settings panel with four tabs: Account, Chat, Violet, Premium.
|
||||
- Password change from settings (requires current password).
|
||||
- Unread PM indicators on conversation tabs.
|
||||
|
||||
### UI/UX
|
||||
- Glassmorphism dark theme with CSS custom property theming.
|
||||
- **10 color themes** (dark, light, neon, cyberpunk, and more).
|
||||
- Responsive mobile layout with collapsible sidebar.
|
||||
- Auto-expanding message textarea.
|
||||
- Enter-to-send toggle (configurable).
|
||||
- Google Fonts (Inter + Outfit).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ WebSocket / HTTP ┌──────────────────────────────┐
|
||||
│ Browser │ ◄────────────────────► │ Gunicorn + Eventlet │
|
||||
│ (chat.js │ │ Flask-SocketIO │
|
||||
│ crypto.js) │ │ │
|
||||
└─────────────┘ │ ┌────────────────────────┐ │
|
||||
│ │ REST Blueprint │ │
|
||||
│ │ (routes.py) │ │
|
||||
│ └────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────┐ │
|
||||
│ │ AI Worker (greenlet) │ │
|
||||
│ │ Serialized Ollama queue │ │
|
||||
│ └───────────┬────────────┘ │
|
||||
└──────────────┼───────────────┘
|
||||
│
|
||||
┌──────────────▼───────────────┐
|
||||
│ Ollama (localhost:11434) │
|
||||
│ LLaMA 3.2 3B Uncensored │
|
||||
└──────────────────────────────┘
|
||||
│
|
||||
┌──────────────▼───────────────┐
|
||||
│ SQLite (instance/sexchat.db)│
|
||||
│ Users, Messages, Bans, │
|
||||
│ Mutes, VioletHistory │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **Single-process, single-worker** — Eventlet async mode with one Gunicorn worker. All in-memory state (connected users, rooms, rate limits) lives in-process.
|
||||
- **AI inference queue** — A dedicated eventlet greenlet serialises Ollama requests one at a time, broadcasting `violet_typing` indicators while busy. Runs within Flask app context for DB access.
|
||||
- **Lobby is ephemeral** — No lobby messages are ever written to the database.
|
||||
- **PMs are encrypted at rest** — The server stores only AES-GCM ciphertext and nonces for registered-user PMs.
|
||||
- **Shared config module** — `config.py` centralises all constants, config loading, AES-GCM helpers, and JWT helpers so `app.py` and `routes.py` share a single source of truth.
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Web server | Gunicorn 21.x with eventlet worker |
|
||||
| Framework | Flask 3.x + Flask-SocketIO 5.x |
|
||||
| Concurrency | Eventlet (monkey-patched greenlets) |
|
||||
| Database | SQLite (default) or PostgreSQL via `DATABASE_URL` |
|
||||
| ORM | SQLAlchemy 3.x + Flask-Migrate 4.x (Alembic) |
|
||||
| AI | Ollama HTTP API (local inference) |
|
||||
| Auth | JWT (PyJWT 2.x) + bcrypt 4.x |
|
||||
| Encryption | AES-GCM-256 (server: `cryptography` 42.x, client: Web Crypto API) |
|
||||
| Frontend | Vanilla JS, Socket.IO 4 client, custom CSS |
|
||||
| Scaling | Optional Redis message queue for multi-worker deployments |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
aprhodite/
|
||||
├── app.py # Flask-SocketIO app: socket events, AI worker, admin panel, moderation
|
||||
├── config.py # Shared config loader, constants, AES-GCM helpers, JWT helpers, Violet prompt
|
||||
├── database.py # SQLAlchemy + Flask-Migrate init, Violet bot user seeding
|
||||
├── models.py # ORM models: User, Message, Ban, Mute, UserIgnore, VioletHistory
|
||||
├── routes.py # REST API blueprint: auth, PM history, AI message, payment webhook
|
||||
├── start.py # Process manager: start / stop / restart / status / debug
|
||||
├── gunicorn.conf.py # Gunicorn configuration (workers, bind, worker class)
|
||||
├── config.json # Runtime secrets & overrides (gitignored)
|
||||
├── requirements.txt # Python dependencies
|
||||
├── index.html # Single-page app: join screen, chat, settings modal, admin panel modal
|
||||
├── static/
|
||||
│ ├── chat.js # Frontend logic: socket events, PMs, settings, admin panel, themes
|
||||
│ ├── crypto.js # Client-side AES-GCM encryption (PBKDF2 key derivation, SubtleCrypto)
|
||||
│ ├── style.css # All styles: glassmorphism, 10 themes, admin panel, responsive layout
|
||||
│ └── socket.io.min.js# Socket.IO v4 client library
|
||||
└── instance/
|
||||
└── sexchat.db # SQLite database (auto-created on first run)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup & Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Python 3.11+**
|
||||
- **[Ollama](https://ollama.ai)** (required for Violet AI)
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
git clone https://git.computertech.dev/lord3nd3r/aprhodite.git
|
||||
cd aprhodite
|
||||
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Pull the AI Model
|
||||
|
||||
```bash
|
||||
ollama pull sadiq-bd/llama3.2-3b-uncensored:latest
|
||||
```
|
||||
|
||||
### Create `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"SECRET_KEY": "your-random-secret-key",
|
||||
"JWT_SECRET": "your-random-jwt-secret",
|
||||
"ADMIN_PASSWORD": "a-strong-moderator-password",
|
||||
"AI_FREE_LIMIT": 3,
|
||||
"HOST": "0.0.0.0",
|
||||
"PORT": 5000
|
||||
}
|
||||
```
|
||||
|
||||
> **Tip:** Generate secrets with `python -c "import uuid; print(uuid.uuid4().hex)"`.
|
||||
|
||||
### Database
|
||||
|
||||
On first run, `database.py` calls `db.create_all()` and seeds the **Violet** bot user automatically. No manual migration is needed for a fresh install.
|
||||
|
||||
For schema changes after the initial setup:
|
||||
|
||||
```bash
|
||||
flask db migrate -m "description of changes"
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is resolved with the following precedence: **Environment Variable → `config.json` → Default Value**.
|
||||
|
||||
All config loading is centralised in `config.py`.
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `SECRET_KEY` | Random UUID hex | Flask session secret |
|
||||
| `JWT_SECRET` | Random UUID hex | JWT HMAC signing key |
|
||||
| `ADMIN_PASSWORD` | `"admin1234"` | Shared mod password for guest elevation |
|
||||
| `DATABASE_URL` | `"sqlite:///sexchat.db"` | SQLAlchemy database URI |
|
||||
| `PAYMENT_SECRET` | `"change-me-payment-secret"` | Payment webhook validation secret |
|
||||
| `CORS_ORIGINS` | `None` | Socket.IO CORS allowlist |
|
||||
| `HOST` | `"0.0.0.0"` | Server bind address |
|
||||
| `PORT` | `5000` | Server bind port |
|
||||
| `OLLAMA_URL` | `"http://localhost:11434"` | Ollama API endpoint |
|
||||
| `VIOLET_MODEL` | `"sadiq-bd/llama3.2-3b-uncensored:latest"` | Ollama model tag for Violet |
|
||||
| `AI_FREE_LIMIT` | `3` | Free Violet messages before paywall |
|
||||
| `SOCKETIO_MESSAGE_QUEUE` / `REDIS_URL` | `None` | Redis URL for multi-worker Socket.IO |
|
||||
|
||||
### Hardcoded Constants (in `config.py`)
|
||||
|
||||
| Constant | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `MAX_MSG_LEN` | 500 chars | Maximum message length |
|
||||
| `MAX_HISTORY` | 500 messages | PM history cap per conversation pair |
|
||||
| `JWT_EXPIRY_DAYS` | 7 days | JWT token lifespan |
|
||||
| `AI_BOT_NAME` | `"Violet"` | AI bot display name |
|
||||
| `PBKDF2_ITERATIONS` | 100,000 | Client-side key derivation iterations (`crypto.js`) |
|
||||
| Rate limit | 6 msgs / 5 sec | Per-session lobby rate limit (`app.py`) |
|
||||
| `MAX_HISTORY_PER_USER` | 20 turns | Violet conversation memory depth (`app.py`) |
|
||||
|
||||
---
|
||||
|
||||
## Running the Server
|
||||
|
||||
### Debug Mode (foreground, full logging)
|
||||
|
||||
```bash
|
||||
python start.py debug
|
||||
```
|
||||
|
||||
### Daemon Mode
|
||||
|
||||
```bash
|
||||
python start.py start # Start in background (PID written to .pid file)
|
||||
python start.py stop # Graceful shutdown
|
||||
python start.py restart # Stop + start
|
||||
python start.py status # Check if running
|
||||
```
|
||||
|
||||
### Direct Gunicorn
|
||||
|
||||
```bash
|
||||
gunicorn --worker-class eventlet -w 1 --bind 0.0.0.0:5000 start:application
|
||||
```
|
||||
|
||||
The server will be available at `http://localhost:5000`.
|
||||
|
||||
---
|
||||
|
||||
## Database Models
|
||||
|
||||
Six models defined in `models.py`. All timestamps use `datetime.now(timezone.utc)`.
|
||||
|
||||
### `users`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer, PK | Auto-increment |
|
||||
| `username` | String(20), unique, indexed | Alphanumeric + `-` + `_` |
|
||||
| `password_hash` | String(128) | Bcrypt hash |
|
||||
| `email` | String(255), unique, nullable | Optional email |
|
||||
| `role` | String(10), default `"user"` | `root`, `admin`, `mod`, or `user` |
|
||||
| `has_ai_access` | Boolean, default `False` | Unlimited Violet access flag |
|
||||
| `ai_messages_used` | Integer, default `0` | Free trial counter |
|
||||
| `is_verified` | Boolean, default `False` | Manual verification status |
|
||||
| `created_at` | DateTime | UTC timestamp |
|
||||
|
||||
### `messages` (Encrypted PM History)
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer, PK | |
|
||||
| `sender_id` | FK → `users.id` | |
|
||||
| `recipient_id` | FK → `users.id` | |
|
||||
| `encrypted_content` | Text | Base64-encoded AES-GCM ciphertext |
|
||||
| `nonce` | String(64) | Base64-encoded 12-byte IV |
|
||||
| `timestamp` | DateTime | Indexed with sender/recipient for query performance |
|
||||
|
||||
### `bans`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer, PK | |
|
||||
| `username` | String(20), indexed | Banned username (lowercase) |
|
||||
| `ip` | String(45), nullable, indexed | Banned IP address |
|
||||
| `reason` | String(255), nullable | Ban reason |
|
||||
| `created_at` | DateTime | |
|
||||
|
||||
### `mutes`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer, PK | |
|
||||
| `username` | String(20), unique, indexed | Muted username |
|
||||
| `created_at` | DateTime | |
|
||||
|
||||
### `user_ignores`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer, PK | |
|
||||
| `ignorer_id` | FK → `users.id` | User doing the ignoring |
|
||||
| `ignored_id` | FK → `users.id` | User being ignored |
|
||||
| `created_at` | DateTime | |
|
||||
|
||||
Unique composite index on `(ignorer_id, ignored_id)`.
|
||||
|
||||
### `violet_history`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer, PK | |
|
||||
| `user_id` | FK → `users.id` | Owning user |
|
||||
| `role` | String(10) | `"user"` or `"assistant"` |
|
||||
| `text` | Text | Plaintext conversation turn |
|
||||
| `timestamp` | DateTime | |
|
||||
|
||||
---
|
||||
|
||||
## Socket Events Reference
|
||||
|
||||
### Connection & Auth
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `join` | `{ mode, username, password?, email?, mod_password? }` | Authenticate and enter lobby. `mode`: `guest`, `login`, `register`, or `restore`. |
|
||||
| S→C | `joined` | `{ username, is_admin, role, is_registered, has_ai_access, ai_messages_used, email?, token?, ignored_list? }` | Auth success — includes JWT token for session restore. |
|
||||
| S→C | `error` | `{ msg }` | Error message. |
|
||||
| S→C | `kicked` | `{ msg }` | User was kicked/banned. |
|
||||
|
||||
### Lobby Chat
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `message` | `{ text }` | Send lobby message (rate-limited). |
|
||||
| S→C | `message` | `{ username, text, is_admin, is_registered, ts }` | Broadcast lobby message. |
|
||||
| S→C | `system` | `{ msg, ts }` | System notification (joins, parts, mod actions). |
|
||||
| S→C | `nicklist` | `{ users: [{ username, is_admin, is_registered, is_verified, role }] }` | Full online user list (includes Violet bot). |
|
||||
|
||||
### Private Messaging
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `pm_open` | `{ target }` | Initiate PM with target user. |
|
||||
| C→S | `pm_accept` | `{ room }` | Accept an incoming PM invitation (validated against pending invites). |
|
||||
| C→S | `pm_message` | `{ room, text? }` or `{ room, ciphertext, nonce, transit_key? }` | Send PM — plaintext (guests) or encrypted (registered). Include `transit_key` for Violet. |
|
||||
| S→C | `pm_invite` | `{ from, room, room_key }` | Incoming PM invitation with server-derived room key. |
|
||||
| S→C | `pm_ready` | `{ with, room, room_key }` | PM room opened — sent to the initiator. |
|
||||
| S→C | `pm_message` | `{ from, text?, ciphertext?, nonce?, room, ts }` | Receive a PM. |
|
||||
|
||||
### Violet AI
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `pm_message` (to Violet room) | `{ room, ciphertext, nonce, transit_key }` | Encrypted message routed to Violet via AI worker. |
|
||||
| C→S | `violet_reset` | _(none)_ | Clear your conversation memory with Violet. |
|
||||
| S→C | `violet_typing` | `{ busy, room? }` | AI processing indicator (shown while Ollama generates). |
|
||||
| S→C | `ai_response` | `{ error: "ai_limit_reached", room }` | Free trial exhausted. |
|
||||
| S→C | `ai_unlock` | `{ has_ai_access, msg? }` | Premium access granted or revoked (pushed live). |
|
||||
|
||||
### Moderation
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `mod_kick` | `{ target }` | Kick user (mod+). |
|
||||
| C→S | `mod_ban` | `{ target }` | Ban username + IP (mod+). |
|
||||
| C→S | `mod_kickban` | `{ target }` | Kick + ban simultaneously (mod+). |
|
||||
| C→S | `mod_mute` | `{ target }` | Toggle mute on user (mod+). |
|
||||
| C→S | `mod_verify` | `{ target }` | Toggle verification on user (mod+). |
|
||||
|
||||
### User Actions
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `user_ignore` | `{ target }` | Ignore a user (registered only). |
|
||||
| C→S | `user_unignore` | `{ target }` | Unignore a user. |
|
||||
| C→S | `change_password` | `{ old_password, new_password }` | Change password (6-char minimum). |
|
||||
| S→C | `ignore_status` | `{ target, ignored }` | Ignore list update confirmation. |
|
||||
| S→C | `password_changed` | `{ success, msg? }` | Password change result. |
|
||||
| S→C | `role_updated` | `{ role }` | Live notification when your role is changed by an admin. |
|
||||
|
||||
### Admin Panel
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `admin_get_users` | _(none)_ | Request full user list (mod+). |
|
||||
| C→S | `admin_get_bans` | _(none)_ | Request ban list (mod+). |
|
||||
| C→S | `admin_get_mutes` | _(none)_ | Request mute list (mod+). |
|
||||
| C→S | `admin_set_role` | `{ user_id, role }` | Change a user's role (admin+ only, can't set ≥ own role). |
|
||||
| C→S | `admin_verify_user` | `{ user_id }` | Toggle verification (mod+). |
|
||||
| C→S | `admin_toggle_ai` | `{ user_id }` | Toggle unlimited AI access (admin+). Notifies user in real-time. |
|
||||
| C→S | `admin_unban` | `{ ban_id }` | Remove a ban (mod+). |
|
||||
| C→S | `admin_unmute` | `{ mute_id }` | Remove a mute (mod+). |
|
||||
| S→C | `admin_users` | `{ users: [{ id, username, role, is_verified, has_ai_access, email, created_at, online }] }` | User list with online status. |
|
||||
| S→C | `admin_bans` | `{ bans: [{ id, username, ip?, reason?, created_at }] }` | Ban list. |
|
||||
| S→C | `admin_mutes` | `{ mutes: [{ id, username, created_at }] }` | Mute list. |
|
||||
| S→C | `admin_action_ok` | `{ msg }` | Action success toast (panel auto-refreshes). |
|
||||
|
||||
---
|
||||
|
||||
## REST API Endpoints
|
||||
|
||||
All endpoints are under the `/api` prefix.
|
||||
|
||||
### `POST /api/auth/register`
|
||||
|
||||
Create a new account.
|
||||
|
||||
```json
|
||||
// Request
|
||||
{ "username": "alice", "password": "hunter2", "email": "alice@example.com" }
|
||||
|
||||
// Response 201
|
||||
{
|
||||
"token": "eyJ...",
|
||||
"user": { "id": 3, "username": "alice", "has_ai_access": false, "ai_messages_used": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/auth/login`
|
||||
|
||||
Authenticate and receive a JWT.
|
||||
|
||||
```json
|
||||
// Request
|
||||
{ "username": "alice", "password": "hunter2" }
|
||||
|
||||
// Response 200
|
||||
{ "token": "eyJ...", "user": { "id": 3, "username": "alice", ... } }
|
||||
```
|
||||
|
||||
### `GET /api/pm/history?with={username}`
|
||||
|
||||
Fetch encrypted PM history with another user. Requires `Authorization: Bearer <jwt>`.
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"room_key": "base64...",
|
||||
"messages": [
|
||||
{ "from_me": true, "ciphertext": "...", "nonce": "...", "ts": "2025-04-12T..." },
|
||||
{ "from_me": false, "ciphertext": "...", "nonce": "...", "ts": "2025-04-12T..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/ai/message`
|
||||
|
||||
Send an encrypted message to Violet via REST (alternative to the socket path).
|
||||
|
||||
```json
|
||||
// Request (Authorization: Bearer <jwt>)
|
||||
{ "ciphertext": "...", "nonce": "...", "transit_key": "..." }
|
||||
|
||||
// Response 200
|
||||
{ "ciphertext": "...", "nonce": "...", "ai_messages_used": 2, "has_ai_access": false }
|
||||
```
|
||||
|
||||
### `POST /api/payment/success`
|
||||
|
||||
Payment webhook (server-to-server). Validates `secret` with constant-time HMAC comparison.
|
||||
|
||||
```json
|
||||
// Request
|
||||
{ "secret": "PAYMENT_SECRET", "user_id": 3 }
|
||||
|
||||
// Response 200
|
||||
{ "status": "ok", "has_ai_access": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Role System & Admin Panel
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Role | Power Level | Badge | Capabilities |
|
||||
|------|-------------|-------|-------------|
|
||||
| `root` | 3 | 👑 | Everything. Can promote to admin. Cannot be demoted. |
|
||||
| `admin` | 2 | ⚔️ | Set roles (mod/user), toggle AI access, all mod actions. |
|
||||
| `mod` | 1 | 🛡️ | Kick, ban, mute, verify, view admin panel. |
|
||||
| `user` | 0 | — | Standard chat, PMs, Violet AI (free trial). |
|
||||
|
||||
**Rules:**
|
||||
- You cannot modify a user whose power level is ≥ your own.
|
||||
- You cannot assign a role whose power level is ≥ your own.
|
||||
- The first registered human user should be set to `root` in the database.
|
||||
- **Legacy:** Guests can enter the shared mod password at login to gain temporary `mod` session access.
|
||||
|
||||
### Admin Panel
|
||||
|
||||
Accessible via the shield icon (🛡️) in the header (visible to `mod` and above). Three tabs:
|
||||
|
||||
| Tab | Features |
|
||||
|-----|----------|
|
||||
| **Users** | Searchable user list. Change roles (dropdown), toggle verification (checkmark), grant/revoke AI access (brain icon). Shows online status. |
|
||||
| **Bans** | All active bans with username, IP, and date. One-click unban button. |
|
||||
| **Mutes** | All active mutes. One-click unmute button. |
|
||||
|
||||
All actions trigger a system message in the lobby, auto-refresh the panel, and (where applicable) push live updates to the affected user's session.
|
||||
|
||||
---
|
||||
|
||||
## Encryption & Security
|
||||
|
||||
### PM Encryption (AES-GCM-256)
|
||||
|
||||
```
|
||||
1. Server generates a deterministic room key: HMAC-SHA256(JWT_SECRET, room_name)
|
||||
2. Room key is delivered to both clients via pm_invite / pm_ready events
|
||||
3. Client encrypts each message with AES-GCM-256 using the room key + random 12-byte nonce
|
||||
4. Server stores only base64(ciphertext) + base64(nonce) — never sees plaintext
|
||||
5. Recipient decrypts client-side with the same room key
|
||||
```
|
||||
|
||||
### Violet AI Transit Encryption
|
||||
|
||||
```
|
||||
1. Client generates a random AES-256 transit key
|
||||
2. Client encrypts message with transit key → sends { ciphertext, nonce, transit_key }
|
||||
3. Server decrypts with transit key → sends plaintext to Ollama for inference
|
||||
4. Server encrypts Violet's response with the same transit key → delivers to client
|
||||
5. Transit key is ephemeral — used only in memory, never stored
|
||||
```
|
||||
|
||||
### Security Measures
|
||||
|
||||
| Category | Implementation |
|
||||
|----------|---------------|
|
||||
| **Password hashing** | Bcrypt with adaptive cost factor |
|
||||
| **Authentication** | JWT tokens (HMAC-SHA256), 7-day expiry |
|
||||
| **Session restore** | JWT stored in `localStorage`, validated on reconnect |
|
||||
| **Input validation** | Regex-enforced usernames (`[a-zA-Z0-9_-]`, 1–20 chars), message length limits, SQLAlchemy parameterized queries |
|
||||
| **Rate limiting** | 6 messages per 5-second window per session |
|
||||
| **RBAC** | `@_require_role()` decorator with power-level hierarchy checks |
|
||||
| **Webhook auth** | Constant-time HMAC comparison for payment secret |
|
||||
| **Ban persistence** | Username + IP stored in DB `bans` table, loaded into memory on startup |
|
||||
| **Mute persistence** | Stored in DB `mutes` table, loaded on startup |
|
||||
| **PM invite validation** | Server-side pending invite mapping — `pm_accept` only works for legitimately invited rooms |
|
||||
|
||||
---
|
||||
|
||||
## Theme System
|
||||
|
||||
10 themes available in **Settings → Chat → Theme**. Each theme is a set of CSS custom properties applied via the `data-theme` attribute on the document root.
|
||||
|
||||
| Theme | Style | Palette |
|
||||
|-------|-------|---------|
|
||||
| **Midnight Purple** | Dark | Deep purple + neon magenta *(default)* |
|
||||
| **Crimson Noir** | Dark | Black + crimson red |
|
||||
| **Ocean Deep** | Dark | Navy + cyan / teal |
|
||||
| **Ember** | Dark | Dark brown + orange / amber |
|
||||
| **Neon Green** | Dark | Black + bright lime |
|
||||
| **Cyberpunk** | Dark | Purple + electric yellow |
|
||||
| **Rose Gold** | Dark | Dark rose + blush pink |
|
||||
| **Arctic** | Light | Cool blue-white + indigo accents |
|
||||
| **Daylight** | Light | White + pink / purple accents |
|
||||
| **Midnight Blue** | Dark | Deep navy + blue glow |
|
||||
|
||||
Theme preference is saved to `localStorage` (`sc_theme`) and applied immediately on page load — no flash of default theme.
|
||||
|
||||
---
|
||||
|
||||
## Settings Panel
|
||||
|
||||
Accessed via the gear icon (⚙️) in the header. Four tabs:
|
||||
|
||||
### Account
|
||||
- View your username and email.
|
||||
- Change password (requires current password, 6-character minimum for the new one).
|
||||
|
||||
### Chat
|
||||
- **Theme** — 10-option grid with gradient color swatches.
|
||||
- **Font Size** — 12–20px slider.
|
||||
- **Timestamp Format** — 12-hour / 24-hour toggle.
|
||||
- **Enter to Send** — On/Off. When off, Enter inserts a newline and Shift+Enter sends.
|
||||
- **Notification Sounds** — On/Off.
|
||||
|
||||
### Violet
|
||||
- AI access status display (Unlimited ✓ or X remaining free messages).
|
||||
- Messages used counter.
|
||||
- Reset conversation memory button.
|
||||
|
||||
### Premium
|
||||
- Feature showcase (Unlimited Violet, Encrypted PMs, Priority Access, Custom Themes).
|
||||
- Pricing display ($10/month).
|
||||
- Coming soon placeholder.
|
||||
|
||||
All preferences are persisted in `localStorage` with the `sc_` prefix (e.g., `sc_theme`, `sc_fontSize`, `sc_enterSend`).
|
||||
|
||||
---
|
||||
|
||||
## Violet AI Companion
|
||||
|
||||
Violet is an AI chat companion powered by a local Ollama model. She's configured as a *"flirtatious and sophisticated nightclub hostess at an exclusive, dimly-lit members-only club"* via her system prompt in `config.py`.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. User clicks **Violet** in the nicklist → PM room opens automatically (no invite needed for the bot).
|
||||
2. User's message is transit-encrypted (AES-GCM) and queued to a single-threaded inference greenlet.
|
||||
3. The AI worker decrypts the message, builds a prompt from the last 20 conversation turns (`violet_history` table), and calls Ollama's `/api/generate` endpoint.
|
||||
4. The response is transit-encrypted with the same key and delivered as a PM back to the user.
|
||||
5. Both the user's message and Violet's reply are saved to `violet_history` for context continuity across sessions.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/reset` | Clear Violet's memory of your conversation (deletes all `violet_history` rows for your user). |
|
||||
|
||||
### Guest Behavior
|
||||
|
||||
Unregistered guests who PM Violet receive a friendly plaintext reply:
|
||||
|
||||
> *"Hey hun 💜 You'll need to register an account before we can chat privately. Go back to the join screen and sign up — I'll be waiting for you! 😘"*
|
||||
|
||||
### Free Trial & Paywall
|
||||
|
||||
Users get `AI_FREE_LIMIT` (default: 3) free messages. After that, a paywall modal is shown. Admins can grant unlimited access via the admin panel's "Grant AI" button — the user is notified in real-time via `ai_unlock`.
|
||||
|
||||
### Fallback
|
||||
|
||||
If Ollama is unreachable or returns an error, Violet sends a graceful fallback message instead of crashing.
|
||||
|
||||
---
|
||||
|
||||
## Premium / Paywall
|
||||
|
||||
When a user exhausts their free Violet messages, a paywall modal appears with pricing and feature highlights. Premium access (`has_ai_access = True`) can be granted two ways:
|
||||
|
||||
1. **Admin Panel** — Any `admin`+ can click the brain icon (🧠) on a user in the Users tab to toggle unlimited AI access. The user receives a real-time notification.
|
||||
2. **Payment Webhook** — `POST /api/payment/success` with the payment secret and user ID. Validated with constant-time comparison.
|
||||
|
||||
Premium unlocks unlimited Violet messages. The `ai_unlock` event is pushed to the user's active socket session immediately.
|
||||
|
||||
> **Note:** The payment flow is currently a stub. For production, replace the secret-comparison logic with a proper payment provider webhook (e.g., Stripe's `stripe.Webhook.construct_event()`).
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
From `requirements.txt`:
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `flask` | ≥3.0 | Web framework |
|
||||
| `flask-socketio` | ≥5.3 | WebSocket support |
|
||||
| `eventlet` | ≥0.35 | Async concurrency |
|
||||
| `gunicorn` | ≥21.0 | Production WSGI server |
|
||||
| `flask-sqlalchemy` | ≥3.1 | ORM |
|
||||
| `flask-migrate` | ≥4.0 | Database migrations (Alembic) |
|
||||
| `psycopg2-binary` | ≥2.9 | PostgreSQL driver (optional) |
|
||||
| `bcrypt` | ≥4.0 | Password hashing |
|
||||
| `PyJWT` | ≥2.8 | JWT auth tokens |
|
||||
| `cryptography` | ≥42.0 | Server-side AES-GCM |
|
||||
| `redis` | ≥5.0 | Socket.IO multi-worker adapter (optional) |
|
||||
| `requests` | ≥2.31 | Ollama HTTP client |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Private project. All rights reserved.
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
Built by **End3r** — [git.computertech.dev/lord3nd3r/aprhodite](https://git.computertech.dev/lord3nd3r/aprhodite)
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"HOST": "0.0.0.0",
|
||||
"PORT": 5000,
|
||||
"SECRET_KEY": "sexchat-very-secret-key-change-me",
|
||||
"JWT_SECRET": "sexchat-jwt-secret-key-change-me",
|
||||
"ADMIN_USERNAME": "ComputerTech",
|
||||
"ADMIN_PASSWORD": "789abc//",
|
||||
"OLLAMA_URL": "http://localhost:11434",
|
||||
"VIOLET_MODEL": "sam860/dolphin3-llama3.2:3b",
|
||||
"DATABASE_URL": "sqlite:///instance/sexchat.db",
|
||||
"REDIS_URL": "redis://localhost:6379/0",
|
||||
"AI_FREE_LIMIT": 3
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
"""
|
||||
config.py – Shared configuration and utilities for SexyChat.
|
||||
|
||||
Centralises constants, config loading, AES-GCM helpers, and JWT helpers
|
||||
so that app.py and routes.py share a single source of truth.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import base64
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import jwt as pyjwt
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration Loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_config():
|
||||
conf = {}
|
||||
config_path = os.path.join(os.path.dirname(__file__), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
conf = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Failed to load config.json: {e}")
|
||||
return conf
|
||||
|
||||
_CONFIG = load_config()
|
||||
|
||||
def get_conf(key, default=None):
|
||||
"""Resolve a config value: Env Var → config.json → default."""
|
||||
return os.environ.get(key, _CONFIG.get(key, default))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SECRET_KEY = get_conf("SECRET_KEY", uuid.uuid4().hex)
|
||||
JWT_SECRET = get_conf("JWT_SECRET", uuid.uuid4().hex)
|
||||
ADMIN_PASSWORD = get_conf("ADMIN_PASSWORD", None) # Must be set in production
|
||||
DATABASE_URL = get_conf("DATABASE_URL", "sqlite:///sexchat.db")
|
||||
PAYMENT_SECRET = get_conf("PAYMENT_SECRET", "change-me-payment-secret")
|
||||
CORS_ORIGINS = get_conf("CORS_ORIGINS", None)
|
||||
|
||||
MAX_MSG_LEN = 500
|
||||
LOBBY = "lobby"
|
||||
AI_FREE_LIMIT = int(get_conf("AI_FREE_LIMIT", 3))
|
||||
AI_BOT_NAME = "Violet"
|
||||
JWT_EXPIRY_DAYS = 1 # 24-hour expiry for security
|
||||
JWT_EXPIRY_SECS = 60 # 60-second refresh token expiry
|
||||
MAX_HISTORY = 500
|
||||
CSRF_TOKEN_LEN = 32 # CSRF token length in bytes
|
||||
|
||||
# Ollama
|
||||
OLLAMA_URL = get_conf("OLLAMA_URL", "http://localhost:11434")
|
||||
VIOLET_MODEL = get_conf("VIOLET_MODEL", "sadiq-bd/llama3.2-3b-uncensored:latest")
|
||||
VIOLET_SYSTEM = (
|
||||
"You are Violet, a flirtatious and sophisticated nightclub hostess at "
|
||||
"an exclusive, dimly-lit members-only club. You are charming, witty, "
|
||||
"and seductive — never crude or offensive. You speak with elegance, "
|
||||
"mystery, and a hint of playful danger. Keep every reply to 1–3 "
|
||||
"sentences maximum. You are in a private conversation with a special "
|
||||
"guest who has caught your eye."
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AES-GCM Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def aesgcm_encrypt(key_b64: str, plaintext: str) -> tuple:
|
||||
"""Encrypt plaintext with AES-GCM. Returns (ciphertext_b64, nonce_b64)."""
|
||||
key = base64.b64decode(key_b64)
|
||||
nonce = os.urandom(12)
|
||||
ct = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
|
||||
|
||||
|
||||
def aesgcm_decrypt(key_b64: str, ciphertext_b64: str, nonce_b64: str) -> str:
|
||||
"""Decrypt AES-GCM ciphertext. Raises on authentication failure."""
|
||||
key = base64.b64decode(key_b64)
|
||||
ct = base64.b64decode(ciphertext_b64)
|
||||
nonce = base64.b64decode(nonce_b64)
|
||||
return AESGCM(key).decrypt(nonce, ct, None).decode("utf-8")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def issue_jwt(user_id: int, username: str) -> str:
|
||||
"""Issue a signed JWT with user_id and username claims."""
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRY_DAYS),
|
||||
}
|
||||
return pyjwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def verify_jwt(token: str):
|
||||
"""Decode and verify a JWT. Returns payload dict or None."""
|
||||
try:
|
||||
return pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||||
except pyjwt.PyJWTError:
|
||||
return None
|
||||
|
||||
|
||||
def generate_csrf_token() -> str:
|
||||
"""Generate a CSRF token for REST API requests."""
|
||||
import secrets
|
||||
return secrets.token_urlsafe(CSRF_TOKEN_LEN)
|
||||
|
||||
|
||||
def sanitize_user_input(text: str, max_len: int = MAX_MSG_LEN) -> str:
|
||||
"""Sanitize user input to prevent prompt injection and buffer overflow."""
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
# Remove null bytes and other control characters
|
||||
sanitized = "".join(c for c in text if ord(c) >= 32 or c in "\n\r\t")
|
||||
# Truncate to max length
|
||||
return sanitized[:max_len].strip()
|
||||
228
index.html
228
index.html
|
|
@ -87,7 +87,18 @@
|
|||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<span id="violet-trial-badge" class="violet-badge hidden"></span>
|
||||
<span id="my-username-badge" class="my-badge"></span>
|
||||
<button id="admin-btn" class="icon-btn hidden" aria-label="Admin Panel" title="Admin Panel">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="settings-btn" class="icon-btn" aria-label="Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="logout-btn" class="btn-logout">Exit</button>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -164,6 +175,221 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal-overlay hidden">
|
||||
<div class="settings-card glass">
|
||||
<div class="settings-header">
|
||||
<h2>Settings</h2>
|
||||
<button id="close-settings" class="settings-close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-tabs">
|
||||
<button class="settings-tab active" data-stab="account">Account</button>
|
||||
<button class="settings-tab" data-stab="chat">Chat</button>
|
||||
<button class="settings-tab" data-stab="violet">Violet</button>
|
||||
<button class="settings-tab" data-stab="premium">Premium</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
<!-- Account Tab -->
|
||||
<div class="settings-pane active" id="stab-account">
|
||||
<div class="settings-group">
|
||||
<label>Username</label>
|
||||
<div id="settings-username" class="settings-value">—</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<label>Email</label>
|
||||
<div id="settings-email" class="settings-value">—</div>
|
||||
</div>
|
||||
<div class="settings-group registered-only hidden">
|
||||
<label>Change Password</label>
|
||||
<input id="settings-old-pw" type="password" placeholder="Current password" class="settings-input" />
|
||||
<input id="settings-new-pw" type="password" placeholder="New password" class="settings-input" />
|
||||
<input id="settings-confirm-pw" type="password" placeholder="Confirm new password" class="settings-input" />
|
||||
<button id="settings-change-pw" class="btn-primary btn-sm">Update Password</button>
|
||||
</div>
|
||||
<div id="settings-pw-msg" class="settings-msg hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Tab -->
|
||||
<div class="settings-pane" id="stab-chat">
|
||||
<div class="settings-group">
|
||||
<label>Theme</label>
|
||||
<div class="theme-grid" id="theme-grid">
|
||||
<button class="theme-swatch active" data-theme="midnight-purple" title="Midnight Purple">
|
||||
<span class="swatch-fill" style="background:linear-gradient(135deg,#8a2be2,#ff00ff)"></span>
|
||||
<span class="swatch-label">Midnight</span>
|
||||
</button>
|
||||
<button class="theme-swatch" data-theme="crimson-noir" title="Crimson Noir">
|
||||
<span class="swatch-fill" style="background:linear-gradient(135deg,#b71c1c,#ff1744)"></span>
|
||||
<span class="swatch-label">Crimson</span>
|
||||
</button>
|
||||
<button class="theme-swatch" data-theme="ocean-deep" title="Ocean Deep">
|
||||
<span class="swatch-fill" style="background:linear-gradient(135deg,#0277bd,#00bcd4)"></span>
|
||||
<span class="swatch-label">Ocean</span>
|
||||
</button>
|
||||
<button class="theme-swatch" data-theme="ember" title="Ember">
|
||||
<span class="swatch-fill" style="background:linear-gradient(135deg,#e65100,#ff9800)"></span>
|
||||
<span class="swatch-label">Ember</span>
|
||||
</button>
|
||||
<button class="theme-swatch" data-theme="neon-green" title="Neon Green">
|
||||
<span class="swatch-fill" style="background:linear-gradient(135deg,#00c853,#00ff41)"></span>
|
||||
<span class="swatch-label">Neon</span>
|
||||
</button>
|
||||
<button class="theme-swatch" data-theme="cyberpunk" title="Cyberpunk">
|
||||
<span class="swatch-fill" style="background:linear-gradient(135deg,#e040fb,#f5ee28)"></span>
|
||||
<span class="swatch-label">Cyber</span>
|
||||
</button>
|
||||
<button class="theme-swatch" data-theme="rose-gold" title="Rose Gold">
|
||||
<span class="swatch-fill" style="background:linear-gradient(135deg,#c2185b,#f48fb1)"></span>
|
||||
<span class="swatch-label">Rosé</span>
|
||||
</button>
|
||||
<button class="theme-swatch" data-theme="arctic" title="Arctic">
|
||||
<span class="swatch-fill" style="background:linear-gradient(135deg,#3949ab,#f5f7fa)"></span>
|
||||
<span class="swatch-label">Arctic</span>
|
||||
</button>
|
||||
<button class="theme-swatch" data-theme="daylight" title="Daylight">
|
||||
<span class="swatch-fill" style="background:linear-gradient(135deg,#9c27b0,#fafafa)"></span>
|
||||
<span class="swatch-label">Light</span>
|
||||
</button>
|
||||
<button class="theme-swatch" data-theme="midnight-blue" title="Midnight Blue">
|
||||
<span class="swatch-fill" style="background:linear-gradient(135deg,#1a237e,#448aff)"></span>
|
||||
<span class="swatch-label">Navy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<label>Font Size</label>
|
||||
<div class="settings-row">
|
||||
<input id="settings-fontsize" type="range" min="12" max="20" value="14" class="settings-range" />
|
||||
<span id="settings-fontsize-val">14px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<label>Timestamp Format</label>
|
||||
<div class="settings-row">
|
||||
<button class="settings-toggle-btn active" data-tf="12h">12h</button>
|
||||
<button class="settings-toggle-btn" data-tf="24h">24h</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<label>Enter to Send</label>
|
||||
<div class="settings-row">
|
||||
<button id="settings-enter-send" class="settings-toggle active">On</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<label>Notification Sounds</label>
|
||||
<div class="settings-row">
|
||||
<button id="settings-sounds" class="settings-toggle active">On</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Violet Tab -->
|
||||
<div class="settings-pane" id="stab-violet">
|
||||
<div class="settings-group">
|
||||
<label>AI Access</label>
|
||||
<div id="settings-ai-status" class="settings-value">—</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<label>Messages Used</label>
|
||||
<div id="settings-ai-used" class="settings-value">—</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<label>Conversation Memory</label>
|
||||
<p class="settings-hint">Violet remembers your last 20 messages. Reset to start fresh.</p>
|
||||
<button id="settings-violet-reset" class="btn-primary btn-sm btn-danger">Reset Violet Memory</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Premium Tab -->
|
||||
<div class="settings-pane" id="stab-premium">
|
||||
<div class="premium-hero">
|
||||
<div class="premium-icon">👑</div>
|
||||
<h3>SexyChat Premium</h3>
|
||||
<p class="premium-tagline">Unlock the full experience</p>
|
||||
</div>
|
||||
<div class="premium-features">
|
||||
<div class="premium-feature">
|
||||
<span class="pf-icon">💜</span>
|
||||
<div>
|
||||
<strong>Unlimited Violet</strong>
|
||||
<p>No message limits — talk to Violet as much as you want</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="premium-feature">
|
||||
<span class="pf-icon">🔒</span>
|
||||
<div>
|
||||
<strong>Encrypted PMs</strong>
|
||||
<p>End-to-end encrypted private messages with anyone</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="premium-feature">
|
||||
<span class="pf-icon">⚡</span>
|
||||
<div>
|
||||
<strong>Priority Access</strong>
|
||||
<p>Skip the queue — your messages to Violet go first</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="premium-feature">
|
||||
<span class="pf-icon">🎨</span>
|
||||
<div>
|
||||
<strong>Custom Themes</strong>
|
||||
<p>Exclusive color schemes and chat customization</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="premium-cta">
|
||||
<div class="premium-price">$10<span>/month</span></div>
|
||||
<button id="settings-upgrade" class="btn-primary glow btn-lg">Upgrade to Premium</button>
|
||||
<p class="premium-note">Coming soon — be the first to know!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Panel Modal -->
|
||||
<div id="admin-modal" class="modal-overlay hidden">
|
||||
<div class="admin-card glass">
|
||||
<div class="admin-header">
|
||||
<h2>👑 Admin Panel</h2>
|
||||
<button id="close-admin" class="settings-close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab active" data-atab="users">Users</button>
|
||||
<button class="admin-tab" data-atab="bans">Bans</button>
|
||||
<button class="admin-tab" data-atab="mutes">Mutes</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-body">
|
||||
<!-- Users pane -->
|
||||
<div class="admin-pane active" id="atab-users">
|
||||
<div id="admin-user-search-wrap" class="admin-search-wrap">
|
||||
<input id="admin-user-search" type="text" class="settings-input" placeholder="Search users..." />
|
||||
</div>
|
||||
<div id="admin-user-list" class="admin-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bans pane -->
|
||||
<div class="admin-pane" id="atab-bans">
|
||||
<div id="admin-ban-list" class="admin-list"></div>
|
||||
<p id="admin-no-bans" class="admin-empty">No active bans.</p>
|
||||
</div>
|
||||
|
||||
<!-- Mutes pane -->
|
||||
<div class="admin-pane" id="atab-mutes">
|
||||
<div id="admin-mute-list" class="admin-list"></div>
|
||||
<p id="admin-no-mutes" class="admin-empty">No active mutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-toast" class="admin-toast hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div id="context-menu" class="context-menu glass hidden">
|
||||
<div class="menu-item" data-action="pm">Private Message</div>
|
||||
|
|
@ -173,7 +399,7 @@
|
|||
<div class="menu-divider mod-item hidden"></div>
|
||||
<div class="menu-item mod-item hidden" data-action="verify">Verify User</div>
|
||||
<div class="menu-item mod-item hidden" data-action="kick">Kick</div>
|
||||
|
||||
<div class="menu-item mod-item hidden" data-action="mute">Mute / Unmute</div>
|
||||
<div class="menu-item mod-item red hidden" data-action="ban">Ban</div>
|
||||
<div class="menu-item mod-item red bold hidden" data-action="kickban">Kickban</div>
|
||||
</div>
|
||||
|
|
|
|||
48
models.py
48
models.py
|
|
@ -7,10 +7,14 @@ users – Registered accounts
|
|||
messages – Encrypted PM history (user↔user and user↔AI)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from database import db
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
|
|
@ -18,10 +22,11 @@ class User(db.Model):
|
|||
username = db.Column(db.String(20), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(128), nullable=False)
|
||||
email = db.Column(db.String(255), unique=True, nullable=True)
|
||||
role = db.Column(db.String(10), default="user", nullable=False) # root, admin, mod, user
|
||||
has_ai_access = db.Column(db.Boolean, default=False, nullable=False)
|
||||
ai_messages_used = db.Column(db.Integer, default=0, nullable=False)
|
||||
is_verified = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
sent_messages = db.relationship(
|
||||
"Message", foreign_keys="Message.sender_id",
|
||||
|
|
@ -52,7 +57,7 @@ class UserIgnore(db.Model):
|
|||
id = db.Column(db.Integer, primary_key=True)
|
||||
ignorer_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
ignored_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index("ix_ignore_pair", "ignorer_id", "ignored_id", unique=True),
|
||||
|
|
@ -70,7 +75,7 @@ class Message(db.Model):
|
|||
encrypted_content = db.Column(db.Text, nullable=False)
|
||||
# AES-GCM nonce / IV – base64 encoded (12 bytes → 16 chars)
|
||||
nonce = db.Column(db.String(64), nullable=False)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
# Composite indices for the two common query patterns
|
||||
|
|
@ -80,3 +85,38 @@ class Message(db.Model):
|
|||
|
||||
def __repr__(self):
|
||||
return f"<Message {self.sender_id}→{self.recipient_id} @ {self.timestamp}>"
|
||||
|
||||
|
||||
class Ban(db.Model):
|
||||
"""Persisted ban entry – survives server restarts."""
|
||||
__tablename__ = "bans"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(20), nullable=False, index=True)
|
||||
ip = db.Column(db.String(45), nullable=True, index=True)
|
||||
reason = db.Column(db.String(255), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
|
||||
class Mute(db.Model):
|
||||
"""Persisted mute entry – survives server restarts."""
|
||||
__tablename__ = "mutes"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(20), unique=True, nullable=False, index=True)
|
||||
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
|
||||
class VioletHistory(db.Model):
|
||||
"""Per-user plaintext conversation history with Violet AI."""
|
||||
__tablename__ = "violet_history"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
role = db.Column(db.String(10), nullable=False) # 'user' or 'assistant'
|
||||
text = db.Column(db.Text, nullable=False)
|
||||
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index("ix_violet_hist_user_ts", "user_id", "timestamp"),
|
||||
)
|
||||
|
|
|
|||
109
routes.py
109
routes.py
|
|
@ -11,19 +11,22 @@ POST /api/payment/success – Validate webhook secret, unlock AI, push socke
|
|||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import hmac
|
||||
import hashlib
|
||||
import random
|
||||
import functools
|
||||
from datetime import datetime, timedelta
|
||||
import base64
|
||||
|
||||
import bcrypt
|
||||
import jwt as pyjwt
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from database import db
|
||||
from models import User, Message
|
||||
from config import (
|
||||
AI_FREE_LIMIT, AI_BOT_NAME, PAYMENT_SECRET, MAX_HISTORY, JWT_SECRET,
|
||||
aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt,
|
||||
generate_csrf_token, sanitize_user_input,
|
||||
)
|
||||
|
||||
api = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
|
|
@ -31,13 +34,6 @@ api = Blueprint("api", __name__, url_prefix="/api")
|
|||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
JWT_SECRET = os.environ.get("JWT_SECRET", "change-me-jwt-secret")
|
||||
JWT_EXPIRY_DAYS = 7
|
||||
AI_FREE_LIMIT = 3
|
||||
PAYMENT_SECRET = os.environ.get("PAYMENT_SECRET", "change-me-payment-secret")
|
||||
MAX_HISTORY = 500
|
||||
AI_BOT_NAME = "Violet"
|
||||
|
||||
AI_RESPONSES = [
|
||||
"Mmm, you have my full attention 💋",
|
||||
"Oh my... keep going 😈 Don't stop there.",
|
||||
|
|
@ -60,22 +56,6 @@ AI_RESPONSES = [
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _issue_jwt(user_id: int, username: str) -> str:
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"exp": datetime.utcnow() + timedelta(days=JWT_EXPIRY_DAYS),
|
||||
}
|
||||
return pyjwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def _verify_jwt(token: str):
|
||||
try:
|
||||
return pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||||
except pyjwt.PyJWTError:
|
||||
return None
|
||||
|
||||
|
||||
def _require_auth(f):
|
||||
"""Decorator – parse Bearer JWT and populate g.current_user."""
|
||||
@functools.wraps(f)
|
||||
|
|
@ -83,7 +63,7 @@ def _require_auth(f):
|
|||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
payload = _verify_jwt(auth_header[7:])
|
||||
payload = verify_jwt(auth_header[7:])
|
||||
if not payload:
|
||||
return jsonify({"error": "Invalid or expired token"}), 401
|
||||
user = db.session.get(User, payload["user_id"])
|
||||
|
|
@ -94,20 +74,24 @@ def _require_auth(f):
|
|||
return wrapped
|
||||
|
||||
|
||||
def _aesgcm_encrypt(key_b64: str, plaintext: str) -> tuple:
|
||||
"""Encrypt plaintext with AES-GCM. Returns (ciphertext_b64, nonce_b64)."""
|
||||
key_bytes = base64.b64decode(key_b64)
|
||||
nonce = os.urandom(12)
|
||||
ct = AESGCM(key_bytes).encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
|
||||
|
||||
|
||||
def _aesgcm_decrypt(key_b64: str, ciphertext_b64: str, nonce_b64: str) -> str:
|
||||
"""Decrypt AES-GCM ciphertext. Raises on authentication failure."""
|
||||
key_bytes = base64.b64decode(key_b64)
|
||||
ct = base64.b64decode(ciphertext_b64)
|
||||
nonce = base64.b64decode(nonce_b64)
|
||||
return AESGCM(key_bytes).decrypt(nonce, ct, None).decode("utf-8")
|
||||
def _require_csrf(f):
|
||||
"""Decorator – validate CSRF token from request header."""
|
||||
@functools.wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
csrf_token = request.headers.get("X-CSRF-Token", "")
|
||||
session_csrf = request.headers.get("X-Session-CSRF", "")
|
||||
|
||||
# CSRF check: token must match session token (simple HMAC validation)
|
||||
if not csrf_token or not session_csrf:
|
||||
return jsonify({"error": "Missing CSRF tokens"}), 403
|
||||
|
||||
# For this implementation, we just ensure token is non-empty
|
||||
# In production, validate against server-side session store
|
||||
if len(csrf_token) < 20:
|
||||
return jsonify({"error": "Invalid CSRF token"}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
def _persist_message(sender_id: int, recipient_id: int,
|
||||
|
|
@ -188,9 +172,11 @@ def register():
|
|||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
token = _issue_jwt(user.id, user.username)
|
||||
token = issue_jwt(user.id, user.username)
|
||||
csrf_token = generate_csrf_token()
|
||||
return jsonify({
|
||||
"token": token,
|
||||
"csrf_token": csrf_token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
|
|
@ -212,9 +198,11 @@ def login():
|
|||
if not user or not bcrypt.checkpw(password.encode(), user.password_hash.encode()):
|
||||
return jsonify({"error": "Invalid username or password."}), 401
|
||||
|
||||
token = _issue_jwt(user.id, user.username)
|
||||
token = issue_jwt(user.id, user.username)
|
||||
csrf_token = generate_csrf_token()
|
||||
return jsonify({
|
||||
"token": token,
|
||||
"csrf_token": csrf_token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
|
|
@ -242,6 +230,13 @@ def pm_history():
|
|||
if not other:
|
||||
return jsonify({"messages": []})
|
||||
|
||||
# Derive the room key so the client can decrypt history
|
||||
pair = ":".join(sorted([me.username.lower(), other.username.lower()]))
|
||||
room_name = "pm:" + pair
|
||||
room_key = base64.b64encode(
|
||||
hmac.new(JWT_SECRET.encode(), room_name.encode(), hashlib.sha256).digest()
|
||||
).decode()
|
||||
|
||||
rows = (
|
||||
Message.query
|
||||
.filter(
|
||||
|
|
@ -257,6 +252,7 @@ def pm_history():
|
|||
rows.reverse() # return in chronological order
|
||||
|
||||
return jsonify({
|
||||
"room_key": room_key,
|
||||
"messages": [
|
||||
{
|
||||
"from_me": m.sender_id == me.id,
|
||||
|
|
@ -275,6 +271,7 @@ def pm_history():
|
|||
|
||||
@api.route("/ai/message", methods=["POST"])
|
||||
@_require_auth
|
||||
@_require_csrf
|
||||
def ai_message():
|
||||
user = g.current_user
|
||||
data = request.get_json() or {}
|
||||
|
|
@ -296,7 +293,9 @@ def ai_message():
|
|||
|
||||
# ── Transit decrypt (message readable for AI; key NOT stored) ─────────────
|
||||
try:
|
||||
_plaintext = _aesgcm_decrypt(transit_key, ciphertext, nonce_b64)
|
||||
plaintext = aesgcm_decrypt(transit_key, ciphertext, nonce_b64)
|
||||
# Sanitize before using in AI prompt
|
||||
plaintext = sanitize_user_input(plaintext)
|
||||
except Exception:
|
||||
return jsonify({"error": "Decryption failed – wrong key or corrupted data"}), 400
|
||||
|
||||
|
|
@ -306,7 +305,7 @@ def ai_message():
|
|||
# ── Persist both legs encrypted in DB (server uses transit key) ──────────
|
||||
bot = _get_ai_bot()
|
||||
_persist_message(user.id, bot.id, ciphertext, nonce_b64) # user → AI
|
||||
resp_ct, resp_nonce = _aesgcm_encrypt(transit_key, ai_text)
|
||||
resp_ct, resp_nonce = aesgcm_encrypt(transit_key, ai_text)
|
||||
_persist_message(bot.id, user.id, resp_ct, resp_nonce) # AI → user
|
||||
|
||||
# ── Update free trial counter ─────────────────────────────────────────────
|
||||
|
|
@ -315,7 +314,7 @@ def ai_message():
|
|||
db.session.commit()
|
||||
|
||||
# ── Re-encrypt AI response for transit back ───────────────────────────────
|
||||
resp_ct_transit, resp_nonce_transit = _aesgcm_encrypt(transit_key, ai_text)
|
||||
resp_ct_transit, resp_nonce_transit = aesgcm_encrypt(transit_key, ai_text)
|
||||
|
||||
return jsonify({
|
||||
"ciphertext": resp_ct_transit,
|
||||
|
|
@ -330,12 +329,12 @@ def ai_message():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
@api.route("/payment/success", methods=["POST"])
|
||||
@_require_auth
|
||||
def payment_success():
|
||||
"""
|
||||
Validate a payment webhook and flip user.has_ai_access.
|
||||
Server-side payment webhook – NOT callable by clients.
|
||||
|
||||
Expected body: { "secret": "<PAYMENT_SECRET>" }
|
||||
Validates the webhook secret and unlocks AI access for the user
|
||||
identified by the 'user_id' field in the JSON body.
|
||||
|
||||
For Stripe production: replace the secret comparison with
|
||||
stripe.Webhook.construct_event() using the raw request body and
|
||||
|
|
@ -351,7 +350,15 @@ def payment_success():
|
|||
):
|
||||
return jsonify({"error": "Invalid or missing payment secret"}), 403
|
||||
|
||||
user = g.current_user
|
||||
# Identify the user from the webhook payload (NOT from client auth)
|
||||
user_id = data.get("user_id")
|
||||
if not user_id:
|
||||
return jsonify({"error": "Missing user_id in webhook payload"}), 400
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
if not user.has_ai_access:
|
||||
user.has_ai_access = True
|
||||
db.session.commit()
|
||||
|
|
|
|||
34
start.py
34
start.py
|
|
@ -12,7 +12,6 @@ Usage:
|
|||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import signal
|
||||
import time
|
||||
|
|
@ -21,24 +20,11 @@ import eventlet
|
|||
# Monkey-patch stdlib BEFORE any other import
|
||||
eventlet.monkey_patch()
|
||||
|
||||
from config import get_conf
|
||||
|
||||
# PID file to track the daemon process
|
||||
PID_FILE = "sexchat.pid"
|
||||
|
||||
def load_config():
|
||||
conf = {}
|
||||
config_path = os.path.join(os.path.dirname(__file__), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
conf = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return conf
|
||||
|
||||
def _get_conf(key, default=None):
|
||||
conf = load_config()
|
||||
return os.environ.get(key, conf.get(key, default))
|
||||
|
||||
def get_pid():
|
||||
if os.path.exists(PID_FILE):
|
||||
with open(PID_FILE, "r") as f:
|
||||
|
|
@ -64,15 +50,11 @@ def start_daemon():
|
|||
return
|
||||
|
||||
print("🚀 Starting SexChat in background...")
|
||||
gunicorn_bin = os.path.join(os.path.dirname(__file__), ".venv", "bin", "gunicorn")
|
||||
if not os.path.exists(gunicorn_bin):
|
||||
gunicorn_bin = "gunicorn" # fallback
|
||||
|
||||
cmd = [
|
||||
gunicorn_bin,
|
||||
"gunicorn",
|
||||
"--worker-class", "eventlet",
|
||||
"-w", "1",
|
||||
"--bind", f"{_get_conf('HOST', '0.0.0.0')}:{_get_conf('PORT', 5000)}",
|
||||
"--bind", f"{get_conf('HOST', '0.0.0.0')}:{get_conf('PORT', 5000)}",
|
||||
"--daemon",
|
||||
"--pid", PID_FILE,
|
||||
"--access-logfile", "access.log",
|
||||
|
|
@ -124,15 +106,11 @@ def get_status():
|
|||
|
||||
def run_debug():
|
||||
print("🛠️ Starting SexChat in DEBUG mode (foreground)...")
|
||||
gunicorn_bin = os.path.join(os.path.dirname(__file__), ".venv", "bin", "gunicorn")
|
||||
if not os.path.exists(gunicorn_bin):
|
||||
gunicorn_bin = "gunicorn" # fallback
|
||||
|
||||
cmd = [
|
||||
gunicorn_bin,
|
||||
"gunicorn",
|
||||
"--worker-class", "eventlet",
|
||||
"-w", "1",
|
||||
"--bind", f"{_get_conf('HOST', '0.0.0.0')}:{_get_conf('PORT', 5000)}",
|
||||
"--bind", f"{get_conf('HOST', '0.0.0.0')}:{get_conf('PORT', 5000)}",
|
||||
"--log-level", "debug",
|
||||
"--access-logfile", "-",
|
||||
"--error-logfile", "-",
|
||||
|
|
|
|||
589
static/chat.js
589
static/chat.js
|
|
@ -19,6 +19,7 @@ const socket = io({
|
|||
const state = {
|
||||
username: null,
|
||||
isAdmin: false,
|
||||
role: "user",
|
||||
isRegistered: false,
|
||||
hasAiAccess: false,
|
||||
aiMessagesUsed: 0,
|
||||
|
|
@ -59,6 +60,7 @@ const paywallModal = $("paywall-modal");
|
|||
const contextMenu = $("context-menu");
|
||||
const trialBadge = $("violet-trial-badge");
|
||||
const violetTyping = $("violet-typing");
|
||||
const settingsModal = $("settings-modal");
|
||||
|
||||
// ── Auth & Init ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -116,14 +118,37 @@ joinForm.addEventListener("submit", async (e) => {
|
|||
|
||||
// Handle Token Restore on Load
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
// ── Restore Theme from localStorage ────────────────────────────────────
|
||||
const savedTheme = localStorage.getItem("sexychat_theme") || "midnight-purple";
|
||||
document.documentElement.setAttribute("data-theme", savedTheme);
|
||||
|
||||
// Update active theme button if it exists
|
||||
const themeButtons = document.querySelectorAll("[data-theme-button]");
|
||||
themeButtons.forEach(btn => {
|
||||
btn.classList.toggle("active", btn.dataset.themeButton === savedTheme);
|
||||
});
|
||||
|
||||
const token = localStorage.getItem("sexychat_token");
|
||||
if (token) {
|
||||
// We have a token, notify the join screen but wait for user to click "Enter"
|
||||
// to derive crypto key if they want to. Actually, for UX, if we have a token
|
||||
// we can try a "restore" join which might skip password entry.
|
||||
// But for encryption, we NEED that password to derive the key.
|
||||
// Let's keep it simple: if you have a token, you still need to log in to
|
||||
// re-derive your E2E key.
|
||||
// Auto-restore session from stored JWT
|
||||
joinBtn.disabled = true;
|
||||
joinBtn.innerText = "Restoring session...";
|
||||
socket.connect();
|
||||
socket.emit("join", { mode: "restore" });
|
||||
|
||||
// If restore fails, reset the form so user can log in manually
|
||||
const restoreTimeout = setTimeout(() => {
|
||||
joinBtn.disabled = false;
|
||||
joinBtn.innerText = "Enter the Room";
|
||||
}, 5000);
|
||||
|
||||
const origJoined = socket.listeners("joined");
|
||||
socket.once("joined", () => clearTimeout(restoreTimeout));
|
||||
socket.once("error", () => {
|
||||
clearTimeout(restoreTimeout);
|
||||
joinBtn.disabled = false;
|
||||
joinBtn.innerText = "Enter the Room";
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -132,9 +157,11 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||
socket.on("joined", (data) => {
|
||||
state.username = data.username;
|
||||
state.isAdmin = data.is_admin;
|
||||
state.role = data.role || "user";
|
||||
state.isRegistered = data.is_registered;
|
||||
state.hasAiAccess = data.has_ai_access;
|
||||
state.aiMessagesUsed = data.ai_messages_used;
|
||||
state.email = data.email || null;
|
||||
|
||||
if (data.token) localStorage.setItem("sexychat_token", data.token);
|
||||
if (data.ignored_list) state.ignoredUsers = new Set(data.ignored_list);
|
||||
|
|
@ -144,9 +171,9 @@ socket.on("joined", (data) => {
|
|||
chatScreen.classList.remove("hidden");
|
||||
updateVioletBadge();
|
||||
|
||||
if (data.system_msg) {
|
||||
addMessage("lobby", { system: true, text: data.system_msg });
|
||||
}
|
||||
// Show admin panel button for mods+
|
||||
const adminBtn = $("admin-btn");
|
||||
if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin);
|
||||
});
|
||||
|
||||
socket.on("error", (data) => {
|
||||
|
|
@ -182,30 +209,45 @@ socket.on("message", (data) => {
|
|||
|
||||
// ── Private Messaging ─────────────────────────────────────────────────────
|
||||
|
||||
socket.on("pm_invite", (data) => {
|
||||
socket.on("pm_invite", async (data) => {
|
||||
if (state.pms[data.room]) return; // Already accepted
|
||||
// Store the room key for later use
|
||||
if (data.room_key) {
|
||||
state._pendingRoomKeys = state._pendingRoomKeys || {};
|
||||
state._pendingRoomKeys[data.room] = data.room_key;
|
||||
}
|
||||
$("pm-modal-title").innerText = `${data.from} wants to whisper with you privately.`;
|
||||
pmModal.classList.remove("hidden");
|
||||
|
||||
$("pm-accept-btn").onclick = () => {
|
||||
socket.emit("pm_accept", { room: data.room });
|
||||
openPMTab(data.from, data.room);
|
||||
openPMTab(data.from, data.room, data.room_key);
|
||||
pmModal.classList.add("hidden");
|
||||
};
|
||||
$("pm-decline-btn").onclick = () => pmModal.classList.add("hidden");
|
||||
});
|
||||
|
||||
socket.on("pm_ready", (data) => {
|
||||
openPMTab(data.with, data.room);
|
||||
openPMTab(data.with, data.room, data.room_key);
|
||||
});
|
||||
|
||||
async function openPMTab(otherUser, room) {
|
||||
async function openPMTab(otherUser, room, roomKeyB64) {
|
||||
if (state.pms[room]) {
|
||||
switchTab(room);
|
||||
return;
|
||||
}
|
||||
|
||||
state.pms[room] = { username: otherUser, messages: [] };
|
||||
state.pms[room] = { username: otherUser, messages: [], sharedKey: null };
|
||||
|
||||
// Import the server-provided room key for user-to-user PMs
|
||||
const isViolet = otherUser.toLowerCase() === "violet";
|
||||
if (!isViolet && roomKeyB64) {
|
||||
try {
|
||||
state.pms[room].sharedKey = await SexyChato.importKeyBase64(roomKeyB64);
|
||||
} catch (err) {
|
||||
console.error("Failed to import room key", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Tab
|
||||
let title = `👤 ${otherUser}`;
|
||||
|
|
@ -235,19 +277,41 @@ async function openPMTab(otherUser, room) {
|
|||
// Load History if registered
|
||||
if (state.isRegistered && state.cryptoKey) {
|
||||
try {
|
||||
const resp = await fetch(`/api/pm/history?with=${otherUser}`, {
|
||||
const resp = await fetch(`/api/pm/history?with=${encodeURIComponent(otherUser)}`, {
|
||||
headers: { "Authorization": `Bearer ${localStorage.getItem("sexychat_token")}` }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.messages) {
|
||||
for (const m of data.messages) {
|
||||
const plain = await SexyChato.decrypt(state.cryptoKey, m.ciphertext, m.nonce);
|
||||
addMessage(room, {
|
||||
sender: m.from_me ? state.username : otherUser,
|
||||
text: plain,
|
||||
ts: m.ts,
|
||||
sent: m.from_me
|
||||
});
|
||||
const histData = await resp.json();
|
||||
|
||||
// Use room_key from history response if we don't have one yet
|
||||
if (!isViolet && histData.room_key && !state.pms[room].sharedKey) {
|
||||
try {
|
||||
state.pms[room].sharedKey = await SexyChato.importKeyBase64(histData.room_key);
|
||||
} catch (err) {
|
||||
console.error("Failed to import history room key", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the right decryption key: room key for users, personal key for Violet
|
||||
const decryptKey = isViolet ? state.cryptoKey : (state.pms[room].sharedKey || state.cryptoKey);
|
||||
|
||||
if (histData.messages) {
|
||||
for (const m of histData.messages) {
|
||||
try {
|
||||
const plain = await SexyChato.decrypt(decryptKey, m.ciphertext, m.nonce);
|
||||
addMessage(room, {
|
||||
sender: m.from_me ? state.username : otherUser,
|
||||
text: plain,
|
||||
ts: m.ts,
|
||||
sent: m.from_me
|
||||
});
|
||||
} catch (err) {
|
||||
addMessage(room, {
|
||||
sender: m.from_me ? state.username : otherUser,
|
||||
text: "[Could not decrypt message]",
|
||||
ts: m.ts,
|
||||
sent: m.from_me
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -259,11 +323,18 @@ async function openPMTab(otherUser, room) {
|
|||
socket.on("pm_message", async (data) => {
|
||||
if (state.ignoredUsers.has(data.from)) return;
|
||||
let text = data.text;
|
||||
if (data.ciphertext && state.cryptoKey) {
|
||||
try {
|
||||
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
|
||||
} catch (err) {
|
||||
text = "[Encrypted Message - Click to login/derive key]";
|
||||
if (data.ciphertext) {
|
||||
// Pick the right key: room shared key for user-to-user, personal key for Violet
|
||||
const pm = state.pms[data.room];
|
||||
const decryptKey = pm?.sharedKey || state.cryptoKey;
|
||||
if (decryptKey) {
|
||||
try {
|
||||
text = await SexyChato.decrypt(decryptKey, data.ciphertext, data.nonce);
|
||||
} catch (err) {
|
||||
text = "[Encrypted Message - Could not decrypt]";
|
||||
}
|
||||
} else {
|
||||
text = "[Encrypted Message - No key available]";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -300,24 +371,25 @@ socket.on("ai_response", async (data) => {
|
|||
state.hasAiAccess = data.has_ai_access;
|
||||
updateVioletBadge();
|
||||
|
||||
let text = "[Decryption Error]";
|
||||
const room = data.room || "ai-violet";
|
||||
let text = data.text || "[Decryption Error]";
|
||||
if (data.ciphertext && state.cryptoKey) {
|
||||
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
|
||||
}
|
||||
|
||||
addMessage("ai-violet", {
|
||||
addMessage(room, {
|
||||
sender: AI_BOT_NAME,
|
||||
text: text,
|
||||
ts: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
ts: data.ts || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
sent: false
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("ai_unlock", (data) => {
|
||||
state.hasAiAccess = true;
|
||||
state.hasAiAccess = data.has_ai_access !== undefined ? data.has_ai_access : true;
|
||||
updateVioletBadge();
|
||||
paywallModal.classList.add("hidden");
|
||||
addMessage("ai-violet", { system: true, text: data.msg });
|
||||
if (data.msg) addMessage("ai-violet", { system: true, text: data.msg });
|
||||
});
|
||||
|
||||
socket.on("ignore_status", (data) => {
|
||||
|
|
@ -327,6 +399,7 @@ socket.on("ignore_status", (data) => {
|
|||
});
|
||||
|
||||
function updateVioletBadge() {
|
||||
if (!trialBadge) return;
|
||||
if (state.hasAiAccess) {
|
||||
trialBadge.classList.add("hidden");
|
||||
} else {
|
||||
|
|
@ -350,12 +423,18 @@ messageForm.addEventListener("submit", async (e) => {
|
|||
socket.emit("message", { text });
|
||||
}
|
||||
else if (state.currentRoom.startsWith("pm:")) {
|
||||
const isVioletRoom = state.currentRoom.toLowerCase().includes(":violet");
|
||||
const isVioletRoom = state.currentRoom.toLowerCase().endsWith(":violet");
|
||||
|
||||
// /reset command in Violet PM clears conversation memory
|
||||
if (isVioletRoom && text.toLowerCase() === "/reset") {
|
||||
socket.emit("violet_reset");
|
||||
messageInput.value = "";
|
||||
messageInput.style.height = "auto";
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVioletRoom) {
|
||||
if (!state.isRegistered || !state.cryptoKey) {
|
||||
addMessage(state.currentRoom, { system: true, text: "You must be logged in to chat with Violet." });
|
||||
} else {
|
||||
if (state.isRegistered && state.cryptoKey) {
|
||||
// AI Transit Encryption PM Flow
|
||||
const transitKeyB64 = await SexyChato.exportKeyBase64(state.cryptoKey);
|
||||
const encrypted = await SexyChato.encrypt(state.cryptoKey, text);
|
||||
|
|
@ -366,10 +445,15 @@ messageForm.addEventListener("submit", async (e) => {
|
|||
nonce: encrypted.nonce,
|
||||
transit_key: transitKeyB64
|
||||
});
|
||||
} else {
|
||||
// Guest/admin plaintext fallback
|
||||
socket.emit("pm_message", { room: state.currentRoom, text });
|
||||
}
|
||||
} else if (state.isRegistered && state.cryptoKey) {
|
||||
// E2E PM Flow
|
||||
const encrypted = await SexyChato.encrypt(state.cryptoKey, text);
|
||||
// User-to-user encrypted PM: use the shared room key if available
|
||||
const pm = state.pms[state.currentRoom];
|
||||
const encryptKey = pm?.sharedKey || state.cryptoKey;
|
||||
const encrypted = await SexyChato.encrypt(encryptKey, text);
|
||||
socket.emit("pm_message", {
|
||||
room: state.currentRoom,
|
||||
ciphertext: encrypted.ciphertext,
|
||||
|
|
@ -385,6 +469,14 @@ messageForm.addEventListener("submit", async (e) => {
|
|||
messageInput.style.height = "auto";
|
||||
});
|
||||
|
||||
// Enter to send, Shift+Enter for newline
|
||||
messageInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && prefs.enterToSend) {
|
||||
e.preventDefault();
|
||||
messageForm.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-expand textarea
|
||||
messageInput.addEventListener("input", () => {
|
||||
messageInput.style.height = "auto";
|
||||
|
|
@ -408,6 +500,18 @@ function switchTab(room) {
|
|||
if (box) box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function formatTs(ts) {
|
||||
if (!ts || prefs.timeFormat === "24h") return ts || "";
|
||||
// Convert HH:MM (24h) to 12h
|
||||
const parts = (ts || "").match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!parts) return ts;
|
||||
let h = parseInt(parts[1], 10);
|
||||
const m = parts[2];
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
h = h % 12 || 12;
|
||||
return `${h}:${m} ${ampm}`;
|
||||
}
|
||||
|
||||
function addMessage(room, msg) {
|
||||
const list = $(`messages-${room}`);
|
||||
if (!list) return;
|
||||
|
|
@ -419,7 +523,7 @@ function addMessage(room, msg) {
|
|||
} else {
|
||||
div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`;
|
||||
div.innerHTML = `
|
||||
<div class="msg-meta">${msg.ts} ${msg.sender}</div>
|
||||
<div class="msg-meta">${formatTs(msg.ts)} ${msg.sender}</div>
|
||||
<div class="msg-bubble">${escapeHTML(msg.text)}</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -434,10 +538,11 @@ function renderNicklist() {
|
|||
const li = document.createElement("li");
|
||||
const isIgnored = state.ignoredUsers.has(u.username);
|
||||
const isUnverified = u.is_registered && !u.is_verified;
|
||||
const roleIcon = {root: "👑", admin: "⚔️", mod: "🛡️"}[u.role] || "";
|
||||
|
||||
li.innerHTML = `
|
||||
<span class="${isUnverified ? 'unverified' : ''}">
|
||||
${u.is_admin ? '<span class="mod-star">★</span> ' : ''}
|
||||
${roleIcon ? `<span class="mod-star">${roleIcon}</span> ` : ''}
|
||||
<span class="${isIgnored ? 'dimmed' : ''}">${u.username}</span>
|
||||
${u.is_registered ? '<span class="reg-mark">✔</span>' : ''}
|
||||
${isIgnored ? ' <small>(ignored)</small>' : ''}
|
||||
|
|
@ -485,16 +590,15 @@ function showContextMenu(e, user) {
|
|||
}
|
||||
});
|
||||
|
||||
// Cleanup previous listeners
|
||||
const newMenu = contextMenu.cloneNode(true);
|
||||
contextMenu.replaceWith(newMenu);
|
||||
|
||||
// Add new listeners
|
||||
newMenu.querySelectorAll(".menu-item").forEach(item => {
|
||||
// Store target for click handler (uses event delegation below)
|
||||
contextMenu._targetUser = user.username;
|
||||
|
||||
// Remove old inline onclick handlers and re-bind
|
||||
contextMenu.querySelectorAll(".menu-item").forEach(item => {
|
||||
item.onclick = () => {
|
||||
const action = item.dataset.action;
|
||||
executeMenuAction(action, user.username);
|
||||
newMenu.classList.add("hidden");
|
||||
executeMenuAction(action, contextMenu._targetUser);
|
||||
contextMenu.classList.add("hidden");
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -513,6 +617,9 @@ function executeMenuAction(action, target) {
|
|||
case "kick":
|
||||
socket.emit("mod_kick", { target });
|
||||
break;
|
||||
case "mute":
|
||||
socket.emit("mod_mute", { target });
|
||||
break;
|
||||
case "ban":
|
||||
socket.emit("mod_ban", { target });
|
||||
break;
|
||||
|
|
@ -545,35 +652,15 @@ $("sidebar-toggle").onclick = () => {
|
|||
sidebar.classList.toggle("open");
|
||||
};
|
||||
|
||||
$("tab-ai-violet").onclick = () => switchTab("ai-violet");
|
||||
// tab-ai-violet is created dynamically when user opens Violet PM
|
||||
$("tab-lobby").onclick = () => switchTab("lobby");
|
||||
|
||||
$("close-paywall").onclick = () => paywallModal.classList.add("hidden");
|
||||
$("unlock-btn").onclick = async () => {
|
||||
// Generate dummy secret for the stub endpoint
|
||||
// In production, this would redirect to a real payment gateway (Stripe)
|
||||
const secret = "change-me-payment-webhook-secret";
|
||||
const token = localStorage.getItem("sexychat_token");
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/payment/success", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ secret })
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res.status === "ok") {
|
||||
// socket event should handle UI unlock, but we can optimistically update
|
||||
state.hasAiAccess = true;
|
||||
updateVioletBadge();
|
||||
paywallModal.classList.add("hidden");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Payment simulation failed.");
|
||||
}
|
||||
// In production, this redirects to a real payment gateway (Stripe Checkout).
|
||||
// The server-side webhook will unlock AI access after payment confirmation.
|
||||
// For now, show a placeholder message.
|
||||
alert("Payment integration coming soon. Contact the administrator to unlock Violet.");
|
||||
};
|
||||
|
||||
logoutBtn.onclick = () => {
|
||||
|
|
@ -581,8 +668,358 @@ logoutBtn.onclick = () => {
|
|||
location.reload();
|
||||
};
|
||||
|
||||
// ── Settings Panel ─────────────────────────────────────────────────────────
|
||||
|
||||
// Load saved preferences from localStorage
|
||||
const prefs = {
|
||||
fontSize: parseInt(localStorage.getItem("sc_fontsize") || "14"),
|
||||
timeFormat: localStorage.getItem("sc_timeformat") || "12h",
|
||||
enterToSend: localStorage.getItem("sc_enter_send") !== "false",
|
||||
sounds: localStorage.getItem("sc_sounds") !== "false",
|
||||
theme: localStorage.getItem("sc_theme") || "midnight-purple",
|
||||
};
|
||||
|
||||
// Apply saved font size on load
|
||||
document.documentElement.style.setProperty("--chat-font-size", prefs.fontSize + "px");
|
||||
// Apply saved theme on load
|
||||
document.documentElement.setAttribute("data-theme", prefs.theme);
|
||||
|
||||
$("settings-btn").onclick = () => {
|
||||
// Populate current values
|
||||
$("settings-username").textContent = state.username || "—";
|
||||
$("settings-email").textContent = state.email || "Not set";
|
||||
$("settings-ai-status").textContent = state.hasAiAccess ? "✓ Unlimited" : `Free (${AI_FREE_LIMIT - state.aiMessagesUsed} left)`;
|
||||
$("settings-ai-used").textContent = state.aiMessagesUsed.toString();
|
||||
|
||||
// Show password change only for registered users
|
||||
document.querySelectorAll(".registered-only").forEach(el =>
|
||||
el.classList.toggle("hidden", !state.isRegistered)
|
||||
);
|
||||
|
||||
// Sync toggle states
|
||||
$("settings-fontsize").value = prefs.fontSize;
|
||||
$("settings-fontsize-val").textContent = prefs.fontSize + "px";
|
||||
document.querySelectorAll("[data-tf]").forEach(b => {
|
||||
b.classList.toggle("active", b.dataset.tf === prefs.timeFormat);
|
||||
});
|
||||
$("settings-enter-send").textContent = prefs.enterToSend ? "On" : "Off";
|
||||
$("settings-enter-send").classList.toggle("active", prefs.enterToSend);
|
||||
$("settings-sounds").textContent = prefs.sounds ? "On" : "Off";
|
||||
$("settings-sounds").classList.toggle("active", prefs.sounds);
|
||||
|
||||
// Sync theme picker
|
||||
document.querySelectorAll(".theme-swatch").forEach(s => {
|
||||
s.classList.toggle("active", s.dataset.theme === prefs.theme);
|
||||
});
|
||||
|
||||
settingsModal.classList.remove("hidden");
|
||||
};
|
||||
|
||||
$("close-settings").onclick = () => settingsModal.classList.add("hidden");
|
||||
settingsModal.addEventListener("click", (e) => {
|
||||
if (e.target === settingsModal) settingsModal.classList.add("hidden");
|
||||
});
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll(".settings-tab").forEach(tab => {
|
||||
tab.addEventListener("click", () => {
|
||||
document.querySelectorAll(".settings-tab").forEach(t => t.classList.remove("active"));
|
||||
document.querySelectorAll(".settings-pane").forEach(p => p.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
$("stab-" + tab.dataset.stab).classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Font size slider
|
||||
$("settings-fontsize").addEventListener("input", (e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
prefs.fontSize = val;
|
||||
localStorage.setItem("sc_fontsize", val);
|
||||
$("settings-fontsize-val").textContent = val + "px";
|
||||
document.documentElement.style.setProperty("--chat-font-size", val + "px");
|
||||
});
|
||||
|
||||
// Theme picker
|
||||
document.querySelectorAll(".theme-swatch").forEach(swatch => {
|
||||
swatch.addEventListener("click", () => {
|
||||
const theme = swatch.dataset.theme;
|
||||
prefs.theme = theme;
|
||||
localStorage.setItem("sc_theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.querySelectorAll(".theme-swatch").forEach(s => s.classList.remove("active"));
|
||||
swatch.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Timestamp format toggle
|
||||
document.querySelectorAll("[data-tf]").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelectorAll("[data-tf]").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
prefs.timeFormat = btn.dataset.tf;
|
||||
localStorage.setItem("sc_timeformat", btn.dataset.tf);
|
||||
});
|
||||
});
|
||||
|
||||
// Enter-to-send toggle
|
||||
$("settings-enter-send").addEventListener("click", () => {
|
||||
prefs.enterToSend = !prefs.enterToSend;
|
||||
localStorage.setItem("sc_enter_send", prefs.enterToSend);
|
||||
$("settings-enter-send").textContent = prefs.enterToSend ? "On" : "Off";
|
||||
$("settings-enter-send").classList.toggle("active", prefs.enterToSend);
|
||||
});
|
||||
|
||||
// Sounds toggle
|
||||
$("settings-sounds").addEventListener("click", () => {
|
||||
prefs.sounds = !prefs.sounds;
|
||||
localStorage.setItem("sc_sounds", prefs.sounds);
|
||||
$("settings-sounds").textContent = prefs.sounds ? "On" : "Off";
|
||||
$("settings-sounds").classList.toggle("active", prefs.sounds);
|
||||
});
|
||||
|
||||
// Password change
|
||||
$("settings-change-pw").addEventListener("click", () => {
|
||||
const oldPw = $("settings-old-pw").value;
|
||||
const newPw = $("settings-new-pw").value;
|
||||
const confirmPw = $("settings-confirm-pw").value;
|
||||
const msgEl = $("settings-pw-msg");
|
||||
|
||||
if (!oldPw || !newPw) {
|
||||
msgEl.textContent = "Please fill in both fields.";
|
||||
msgEl.className = "settings-msg error";
|
||||
msgEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
if (newPw.length < 6) {
|
||||
msgEl.textContent = "New password must be at least 6 characters.";
|
||||
msgEl.className = "settings-msg error";
|
||||
msgEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
if (newPw !== confirmPw) {
|
||||
msgEl.textContent = "Passwords do not match.";
|
||||
msgEl.className = "settings-msg error";
|
||||
msgEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit("change_password", { old_password: oldPw, new_password: newPw });
|
||||
});
|
||||
|
||||
socket.on("password_changed", (data) => {
|
||||
const msgEl = $("settings-pw-msg");
|
||||
if (data.success) {
|
||||
msgEl.textContent = "✓ Password updated successfully.";
|
||||
msgEl.className = "settings-msg success";
|
||||
$("settings-old-pw").value = "";
|
||||
$("settings-new-pw").value = "";
|
||||
$("settings-confirm-pw").value = "";
|
||||
} else {
|
||||
msgEl.textContent = data.msg || "Failed to change password.";
|
||||
msgEl.className = "settings-msg error";
|
||||
}
|
||||
msgEl.classList.remove("hidden");
|
||||
});
|
||||
|
||||
// Violet memory reset from settings
|
||||
$("settings-violet-reset").addEventListener("click", () => {
|
||||
socket.emit("violet_reset");
|
||||
$("settings-violet-reset").textContent = "Memory Cleared!";
|
||||
setTimeout(() => { $("settings-violet-reset").textContent = "Reset Violet Memory"; }, 2000);
|
||||
});
|
||||
|
||||
// Premium button (placeholder)
|
||||
$("settings-upgrade").addEventListener("click", () => {
|
||||
alert("Premium subscriptions are coming soon! Stay tuned.");
|
||||
});
|
||||
|
||||
function escapeHTML(str) {
|
||||
const p = document.createElement("p");
|
||||
p.textContent = str;
|
||||
return p.innerHTML;
|
||||
}
|
||||
|
||||
// ── Role updated (live notification) ──────────────────────────────────────
|
||||
|
||||
socket.on("role_updated", (data) => {
|
||||
state.role = data.role;
|
||||
state.isAdmin = ["mod", "admin", "root"].includes(data.role);
|
||||
const adminBtn = $("admin-btn");
|
||||
if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin);
|
||||
});
|
||||
|
||||
// ── Admin Panel ───────────────────────────────────────────────────────────
|
||||
|
||||
const adminModal = $("admin-modal");
|
||||
const ROLE_POWER = { user: 0, mod: 1, admin: 2, root: 3 };
|
||||
|
||||
if ($("admin-btn")) {
|
||||
$("admin-btn").onclick = () => {
|
||||
adminModal.classList.remove("hidden");
|
||||
socket.emit("admin_get_users");
|
||||
socket.emit("admin_get_bans");
|
||||
socket.emit("admin_get_mutes");
|
||||
};
|
||||
}
|
||||
|
||||
$("close-admin").onclick = () => adminModal.classList.add("hidden");
|
||||
adminModal.addEventListener("click", (e) => {
|
||||
if (e.target === adminModal) adminModal.classList.add("hidden");
|
||||
});
|
||||
|
||||
// Admin tab switching
|
||||
document.querySelectorAll(".admin-tab").forEach(tab => {
|
||||
tab.addEventListener("click", () => {
|
||||
document.querySelectorAll(".admin-tab").forEach(t => t.classList.remove("active"));
|
||||
document.querySelectorAll(".admin-pane").forEach(p => p.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
$("atab-" + tab.dataset.atab).classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Admin toast helper
|
||||
function adminToast(msg) {
|
||||
const t = $("admin-toast");
|
||||
t.textContent = msg;
|
||||
t.classList.remove("hidden");
|
||||
setTimeout(() => t.classList.add("hidden"), 2500);
|
||||
}
|
||||
|
||||
socket.on("admin_action_ok", (data) => {
|
||||
adminToast(data.msg);
|
||||
// Refresh the admin user list so buttons update
|
||||
socket.emit("admin_get_users");
|
||||
socket.emit("admin_get_bans");
|
||||
socket.emit("admin_get_mutes");
|
||||
});
|
||||
|
||||
// ── Users pane ───────────────────────────────────────────────────────────
|
||||
|
||||
let adminUserCache = [];
|
||||
|
||||
socket.on("admin_users", (data) => {
|
||||
adminUserCache = data.users;
|
||||
renderAdminUsers(adminUserCache);
|
||||
});
|
||||
|
||||
$("admin-user-search").addEventListener("input", (e) => {
|
||||
const q = e.target.value.toLowerCase();
|
||||
renderAdminUsers(adminUserCache.filter(u => u.username.toLowerCase().includes(q)));
|
||||
});
|
||||
|
||||
function renderAdminUsers(users) {
|
||||
const list = $("admin-user-list");
|
||||
list.innerHTML = "";
|
||||
const myPower = ROLE_POWER[state.role] || 0;
|
||||
|
||||
users.forEach(u => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "admin-user-row";
|
||||
|
||||
const canEditRole = myPower > ROLE_POWER[u.role] && myPower >= ROLE_POWER.admin;
|
||||
const canVerify = myPower >= ROLE_POWER.mod;
|
||||
const canToggleAI = myPower >= ROLE_POWER.admin && u.role !== "root";
|
||||
|
||||
// Build role selector
|
||||
let roleHTML = "";
|
||||
if (canEditRole) {
|
||||
const opts = ["user", "mod"];
|
||||
if (myPower >= ROLE_POWER.root) opts.push("admin");
|
||||
roleHTML = `<select class="au-select" data-uid="${u.id}" data-action="set-role">
|
||||
${opts.map(r => `<option value="${r}" ${u.role === r ? "selected" : ""}>${r}</option>`).join("")}
|
||||
</select>`;
|
||||
} else {
|
||||
roleHTML = `<span class="au-role ${u.role}">${u.role}</span>`;
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<span class="au-name">${escapeHTML(u.username)}</span>
|
||||
${roleHTML}
|
||||
<span class="au-badges">
|
||||
${u.online ? '<span class="au-badge online">online</span>' : ''}
|
||||
${u.is_verified ? '<span class="au-badge verified">verified</span>' : '<span class="au-badge unverified">unverified</span>'}
|
||||
${u.has_ai_access ? '<span class="au-badge ai-on">AI</span>' : ''}
|
||||
</span>
|
||||
<span class="au-actions">
|
||||
${canVerify ? `<button class="au-btn" data-uid="${u.id}" data-action="verify">${u.is_verified ? 'Unverify' : 'Verify'}</button>` : ''}
|
||||
${canToggleAI ? `<button class="au-btn" data-uid="${u.id}" data-action="toggle-ai">${u.has_ai_access ? 'Revoke AI' : 'Grant AI'}</button>` : ''}
|
||||
</span>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
// Event delegation for actions
|
||||
list.onclick = (e) => {
|
||||
const btn = e.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const uid = parseInt(btn.dataset.uid);
|
||||
const action = btn.dataset.action;
|
||||
if (action === "verify") socket.emit("admin_verify_user", { user_id: uid });
|
||||
else if (action === "toggle-ai") socket.emit("admin_toggle_ai", { user_id: uid });
|
||||
};
|
||||
list.onchange = (e) => {
|
||||
const sel = e.target.closest("[data-action='set-role']");
|
||||
if (!sel) return;
|
||||
const uid = parseInt(sel.dataset.uid);
|
||||
socket.emit("admin_set_role", { user_id: uid, role: sel.value });
|
||||
};
|
||||
}
|
||||
|
||||
// ── Bans pane ────────────────────────────────────────────────────────────
|
||||
|
||||
socket.on("admin_bans", (data) => {
|
||||
const list = $("admin-ban-list");
|
||||
const empty = $("admin-no-bans");
|
||||
list.innerHTML = "";
|
||||
empty.classList.toggle("hidden", data.bans.length > 0);
|
||||
|
||||
data.bans.forEach(b => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "admin-ban-row";
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<span class="ab-name">${escapeHTML(b.username)}</span>
|
||||
${b.ip ? `<small style="color:var(--text-dim);margin-left:8px">${b.ip}</small>` : ''}
|
||||
<small style="color:var(--text-dim);margin-left:8px">${b.created_at}</small>
|
||||
</div>
|
||||
<button class="au-btn danger" data-bid="${b.id}">Unban</button>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
list.onclick = (e) => {
|
||||
const btn = e.target.closest("[data-bid]");
|
||||
if (!btn) return;
|
||||
socket.emit("admin_unban", { ban_id: parseInt(btn.dataset.bid) });
|
||||
btn.closest(".admin-ban-row").remove();
|
||||
};
|
||||
});
|
||||
|
||||
// ── Mutes pane ───────────────────────────────────────────────────────────
|
||||
|
||||
socket.on("admin_mutes", (data) => {
|
||||
const list = $("admin-mute-list");
|
||||
const empty = $("admin-no-mutes");
|
||||
list.innerHTML = "";
|
||||
empty.classList.toggle("hidden", data.mutes.length > 0);
|
||||
|
||||
data.mutes.forEach(m => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "admin-mute-row";
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<span class="am-name">${escapeHTML(m.username)}</span>
|
||||
<small style="color:var(--text-dim);margin-left:8px">${m.created_at}</small>
|
||||
</div>
|
||||
<button class="au-btn danger" data-mid="${m.id}">Unmute</button>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
list.onclick = (e) => {
|
||||
const btn = e.target.closest("[data-mid]");
|
||||
if (!btn) return;
|
||||
socket.emit("admin_unmute", { mute_id: parseInt(btn.dataset.mid) });
|
||||
btn.closest(".admin-mute-row").remove();
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -126,6 +126,23 @@ const SexyChato = (() => {
|
|||
return bufToBase64(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a base64-encoded raw AES-GCM key (e.g. server-provided room key).
|
||||
*
|
||||
* @param {string} b64 base64-encoded 256-bit key
|
||||
* @returns {Promise<CryptoKey>}
|
||||
*/
|
||||
async function importKeyBase64(b64) {
|
||||
const buf = base64ToBuf(b64);
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
buf,
|
||||
{ name: "AES-GCM", length: KEY_LENGTH_BITS },
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
return { deriveKey, encrypt, decrypt, exportKeyBase64 };
|
||||
return { deriveKey, encrypt, decrypt, exportKeyBase64, importKeyBase64 };
|
||||
})();
|
||||
|
|
|
|||
660
static/style.css
660
static/style.css
|
|
@ -6,14 +6,123 @@
|
|||
:root {
|
||||
--bg-deep: #0a0015;
|
||||
--bg-card: rgba(26, 0, 48, 0.7);
|
||||
--bg-header: rgba(10, 0, 20, 0.8);
|
||||
--accent-magenta: #ff00ff;
|
||||
--accent-purple: #8a2be2;
|
||||
--accent-glow: rgba(255, 0, 255, 0.3);
|
||||
--accent-glow-strong: rgba(255, 0, 255, 0.5);
|
||||
--bg-gradient-1: rgba(138, 43, 226, 0.15);
|
||||
--bg-gradient-2: rgba(255, 0, 255, 0.1);
|
||||
--text-main: #f0f0f0;
|
||||
--text-dim: #b0b0b0;
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--error-red: #ff3366;
|
||||
--success-green: #00ffaa;
|
||||
--ai-teal: #00f2ff;
|
||||
--msg-received-bg: rgba(255, 255, 255, 0.08);
|
||||
--msg-received-border: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* ── Themes ──────────────────────────────────────────────────────────────── */
|
||||
[data-theme="midnight-purple"] {
|
||||
--bg-deep: #0a0015; --bg-card: rgba(26, 0, 48, 0.7); --bg-header: rgba(10, 0, 20, 0.8);
|
||||
--accent-magenta: #ff00ff; --accent-purple: #8a2be2;
|
||||
--accent-glow: rgba(255, 0, 255, 0.3); --accent-glow-strong: rgba(255, 0, 255, 0.5);
|
||||
--bg-gradient-1: rgba(138, 43, 226, 0.15); --bg-gradient-2: rgba(255, 0, 255, 0.1);
|
||||
--text-main: #f0f0f0; --text-dim: #b0b0b0;
|
||||
--msg-received-bg: rgba(255, 255, 255, 0.08); --msg-received-border: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="crimson-noir"] {
|
||||
--bg-deep: #0d0000; --bg-card: rgba(40, 0, 0, 0.7); --bg-header: rgba(15, 0, 0, 0.85);
|
||||
--accent-magenta: #ff1744; --accent-purple: #b71c1c;
|
||||
--accent-glow: rgba(255, 23, 68, 0.3); --accent-glow-strong: rgba(255, 23, 68, 0.5);
|
||||
--bg-gradient-1: rgba(183, 28, 28, 0.15); --bg-gradient-2: rgba(255, 23, 68, 0.1);
|
||||
--text-main: #f0e8e8; --text-dim: #b09090;
|
||||
--msg-received-bg: rgba(255, 200, 200, 0.06); --msg-received-border: rgba(255, 200, 200, 0.04);
|
||||
--ai-teal: #ff6e6e;
|
||||
}
|
||||
|
||||
[data-theme="ocean-deep"] {
|
||||
--bg-deep: #000a14; --bg-card: rgba(0, 20, 48, 0.7); --bg-header: rgba(0, 8, 20, 0.85);
|
||||
--accent-magenta: #00bcd4; --accent-purple: #0277bd;
|
||||
--accent-glow: rgba(0, 188, 212, 0.3); --accent-glow-strong: rgba(0, 188, 212, 0.5);
|
||||
--bg-gradient-1: rgba(2, 119, 189, 0.15); --bg-gradient-2: rgba(0, 188, 212, 0.1);
|
||||
--text-main: #e0f0f0; --text-dim: #90b0b0;
|
||||
--msg-received-bg: rgba(200, 255, 255, 0.06); --msg-received-border: rgba(200, 255, 255, 0.04);
|
||||
--ai-teal: #4dd0e1;
|
||||
}
|
||||
|
||||
[data-theme="ember"] {
|
||||
--bg-deep: #0d0800; --bg-card: rgba(40, 20, 0, 0.7); --bg-header: rgba(15, 8, 0, 0.85);
|
||||
--accent-magenta: #ff9800; --accent-purple: #e65100;
|
||||
--accent-glow: rgba(255, 152, 0, 0.3); --accent-glow-strong: rgba(255, 152, 0, 0.5);
|
||||
--bg-gradient-1: rgba(230, 81, 0, 0.15); --bg-gradient-2: rgba(255, 152, 0, 0.1);
|
||||
--text-main: #f0e8e0; --text-dim: #b09880;
|
||||
--msg-received-bg: rgba(255, 230, 200, 0.06); --msg-received-border: rgba(255, 230, 200, 0.04);
|
||||
--ai-teal: #ffb74d;
|
||||
}
|
||||
|
||||
[data-theme="neon-green"] {
|
||||
--bg-deep: #000a00; --bg-card: rgba(0, 30, 0, 0.7); --bg-header: rgba(0, 10, 0, 0.85);
|
||||
--accent-magenta: #00ff41; --accent-purple: #00c853;
|
||||
--accent-glow: rgba(0, 255, 65, 0.3); --accent-glow-strong: rgba(0, 255, 65, 0.5);
|
||||
--bg-gradient-1: rgba(0, 200, 83, 0.12); --bg-gradient-2: rgba(0, 255, 65, 0.08);
|
||||
--text-main: #e0ffe0; --text-dim: #80b080;
|
||||
--msg-received-bg: rgba(200, 255, 200, 0.06); --msg-received-border: rgba(200, 255, 200, 0.04);
|
||||
--ai-teal: #69f0ae;
|
||||
}
|
||||
|
||||
[data-theme="cyberpunk"] {
|
||||
--bg-deep: #0a0014; --bg-card: rgba(20, 0, 40, 0.75); --bg-header: rgba(10, 0, 20, 0.85);
|
||||
--accent-magenta: #f5ee28; --accent-purple: #e040fb;
|
||||
--accent-glow: rgba(245, 238, 40, 0.3); --accent-glow-strong: rgba(245, 238, 40, 0.5);
|
||||
--bg-gradient-1: rgba(224, 64, 251, 0.15); --bg-gradient-2: rgba(245, 238, 40, 0.08);
|
||||
--text-main: #f0f0f0; --text-dim: #b0a0c0;
|
||||
--msg-received-bg: rgba(245, 238, 40, 0.06); --msg-received-border: rgba(245, 238, 40, 0.04);
|
||||
--ai-teal: #e040fb;
|
||||
}
|
||||
|
||||
[data-theme="rose-gold"] {
|
||||
--bg-deep: #0d0508; --bg-card: rgba(40, 15, 20, 0.7); --bg-header: rgba(15, 5, 8, 0.85);
|
||||
--accent-magenta: #f48fb1; --accent-purple: #c2185b;
|
||||
--accent-glow: rgba(244, 143, 177, 0.3); --accent-glow-strong: rgba(244, 143, 177, 0.5);
|
||||
--bg-gradient-1: rgba(194, 24, 91, 0.12); --bg-gradient-2: rgba(244, 143, 177, 0.08);
|
||||
--text-main: #f5e8ed; --text-dim: #c0a0a8;
|
||||
--msg-received-bg: rgba(255, 200, 220, 0.06); --msg-received-border: rgba(255, 200, 220, 0.04);
|
||||
--ai-teal: #f48fb1;
|
||||
}
|
||||
|
||||
[data-theme="arctic"] {
|
||||
--bg-deep: #f5f7fa; --bg-card: rgba(255, 255, 255, 0.85); --bg-header: rgba(240, 242, 248, 0.95);
|
||||
--accent-magenta: #5c6bc0; --accent-purple: #3949ab;
|
||||
--accent-glow: rgba(92, 107, 192, 0.25); --accent-glow-strong: rgba(92, 107, 192, 0.4);
|
||||
--bg-gradient-1: rgba(57, 73, 171, 0.08); --bg-gradient-2: rgba(92, 107, 192, 0.06);
|
||||
--text-main: #1a1a2e; --text-dim: #666680;
|
||||
--glass-border: rgba(0, 0, 0, 0.1);
|
||||
--msg-received-bg: rgba(0, 0, 0, 0.05); --msg-received-border: rgba(0, 0, 0, 0.08);
|
||||
--ai-teal: #5c6bc0; --success-green: #43a047;
|
||||
}
|
||||
|
||||
[data-theme="daylight"] {
|
||||
--bg-deep: #fafafa; --bg-card: rgba(255, 255, 255, 0.9); --bg-header: rgba(245, 245, 245, 0.95);
|
||||
--accent-magenta: #e91e63; --accent-purple: #9c27b0;
|
||||
--accent-glow: rgba(233, 30, 99, 0.2); --accent-glow-strong: rgba(233, 30, 99, 0.35);
|
||||
--bg-gradient-1: rgba(156, 39, 176, 0.06); --bg-gradient-2: rgba(233, 30, 99, 0.05);
|
||||
--text-main: #212121; --text-dim: #757575;
|
||||
--glass-border: rgba(0, 0, 0, 0.12);
|
||||
--msg-received-bg: rgba(0, 0, 0, 0.04); --msg-received-border: rgba(0, 0, 0, 0.06);
|
||||
--ai-teal: #e91e63; --success-green: #2e7d32;
|
||||
}
|
||||
|
||||
[data-theme="midnight-blue"] {
|
||||
--bg-deep: #0a0a1a; --bg-card: rgba(10, 10, 40, 0.75); --bg-header: rgba(5, 5, 20, 0.85);
|
||||
--accent-magenta: #448aff; --accent-purple: #1a237e;
|
||||
--accent-glow: rgba(68, 138, 255, 0.3); --accent-glow-strong: rgba(68, 138, 255, 0.5);
|
||||
--bg-gradient-1: rgba(26, 35, 126, 0.15); --bg-gradient-2: rgba(68, 138, 255, 0.1);
|
||||
--text-main: #e0e8f0; --text-dim: #8090b0;
|
||||
--msg-received-bg: rgba(200, 220, 255, 0.06); --msg-received-border: rgba(200, 220, 255, 0.04);
|
||||
--ai-teal: #82b1ff;
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
@ -27,8 +136,8 @@ body {
|
|||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-deep);
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 30%, rgba(138, 43, 226, 0.15) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, rgba(255, 0, 255, 0.1) 0%, transparent 40%);
|
||||
radial-gradient(circle at 20% 30%, var(--bg-gradient-1) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, var(--bg-gradient-2) 0%, transparent 40%);
|
||||
color: var(--text-main);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
|
@ -83,7 +192,7 @@ h1, h2, h3, .logo-text, .paywall-header h2 {
|
|||
|
||||
.logo-accent {
|
||||
color: var(--accent-magenta);
|
||||
text-shadow: 0 0 10px rgba(255, 0, 255, 0.5);
|
||||
text-shadow: 0 0 10px var(--accent-glow-strong);
|
||||
}
|
||||
|
||||
.logo-sub {
|
||||
|
|
@ -117,7 +226,7 @@ h1, h2, h3, .logo-text, .paywall-header h2 {
|
|||
.auth-tab.active {
|
||||
background: var(--accent-purple);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(138, 43, 226, 0.3);
|
||||
box-shadow: 0 4px 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
|
|
@ -144,7 +253,7 @@ input:focus {
|
|||
.crypto-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-magenta);
|
||||
background: rgba(255, 0, 255, 0.05);
|
||||
background: color-mix(in srgb, var(--accent-magenta) 5%, transparent);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
|
|
@ -161,13 +270,13 @@ input:focus {
|
|||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 15px rgba(255, 0, 255, 0.3);
|
||||
box-shadow: 0 4px 15px var(--accent-glow);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary:active { transform: scale(0.98); }
|
||||
.btn-primary:hover { box-shadow: 0 6px 20px rgba(255, 0, 255, 0.4); }
|
||||
.btn-primary:hover { box-shadow: 0 6px 20px var(--accent-glow-strong); }
|
||||
|
||||
.mod-login {
|
||||
text-align: left;
|
||||
|
|
@ -194,7 +303,7 @@ input:focus {
|
|||
|
||||
.glass-header {
|
||||
height: 64px;
|
||||
background: rgba(10, 0, 20, 0.8);
|
||||
background: var(--bg-header);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
|
|
@ -237,6 +346,23 @@ input:focus {
|
|||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
.my-badge { font-weight: 600; color: var(--accent-magenta); font-size: 0.9rem; }
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
color: var(--accent-magenta);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
|
|
@ -378,7 +504,7 @@ input:focus {
|
|||
.msg-bubble {
|
||||
padding: 10px 14px;
|
||||
border-radius: 18px;
|
||||
font-size: 0.95rem;
|
||||
font-size: var(--chat-font-size, 0.95rem);
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
|
@ -392,16 +518,16 @@ input:focus {
|
|||
|
||||
.msg-received { align-self: flex-start; }
|
||||
.msg-received .msg-bubble {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--msg-received-bg);
|
||||
border-bottom-left-radius: 4px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border: 1px solid var(--msg-received-border);
|
||||
}
|
||||
|
||||
.msg-sent { align-self: flex-end; }
|
||||
.msg-sent .msg-bubble {
|
||||
background: linear-gradient(135deg, var(--accent-purple), var(--accent-magenta));
|
||||
border-bottom-right-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(255, 0, 255, 0.2);
|
||||
box-shadow: 0 4px 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.msg-system {
|
||||
|
|
@ -417,7 +543,7 @@ input:focus {
|
|||
.msg-system strong { color: var(--accent-magenta); }
|
||||
|
||||
/* ── AI Area ─────────────────────────────────────────────────────────────── */
|
||||
.chat-ai { background: radial-gradient(circle at top, rgba(0, 242, 255, 0.05), transparent 70%); }
|
||||
.chat-ai { background: radial-gradient(circle at top, color-mix(in srgb, var(--ai-teal) 5%, transparent), transparent 70%); }
|
||||
|
||||
.ai-header {
|
||||
padding: 1rem;
|
||||
|
|
@ -458,7 +584,7 @@ input:focus {
|
|||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
background: rgba(10, 0, 20, 0.8);
|
||||
background: var(--bg-header);
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
|
|
@ -487,7 +613,7 @@ textarea {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 15px rgba(255, 0, 255, 0.4);
|
||||
box-shadow: 0 0 15px var(--accent-glow-strong);
|
||||
}
|
||||
|
||||
/* ── Modals ──────────────────────────────────────────────────────────────── */
|
||||
|
|
@ -512,7 +638,7 @@ textarea {
|
|||
|
||||
.paywall-card {
|
||||
border: 2px solid var(--ai-teal);
|
||||
box-shadow: 0 0 40px rgba(0, 242, 255, 0.2);
|
||||
box-shadow: 0 0 40px color-mix(in srgb, var(--ai-teal) 20%, transparent);
|
||||
}
|
||||
|
||||
.ai-avatar.large { width: 80px; height: 80px; font-size: 2.5rem; margin: 0 auto 1.5rem; }
|
||||
|
|
@ -539,7 +665,7 @@ textarea {
|
|||
border-radius: 12px;
|
||||
background: rgba(15, 0, 30, 0.95);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8), 0 0 10px rgba(138, 43, 226, 0.2);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8), 0 0 10px color-mix(in srgb, var(--accent-purple) 20%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -568,7 +694,507 @@ textarea {
|
|||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Settings Modal ───────────────────────────────────────────────────────── */
|
||||
.settings-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 85vh;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem 0;
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.8rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.settings-close:hover { color: var(--accent-magenta); }
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
margin: 1rem 2rem 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
flex: 1;
|
||||
padding: 7px 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
background: var(--accent-purple);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-pane { display: none; }
|
||||
.settings-pane.active { display: block; }
|
||||
|
||||
.settings-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.settings-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.settings-value {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-main);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.settings-input:focus { border-color: var(--accent-purple); }
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-range {
|
||||
flex: 1;
|
||||
accent-color: var(--accent-magenta);
|
||||
}
|
||||
|
||||
.settings-toggle-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.settings-toggle-btn.active {
|
||||
background: var(--accent-purple);
|
||||
color: white;
|
||||
border-color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
padding: 6px 18px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.settings-toggle.active {
|
||||
background: var(--success-green);
|
||||
color: #000;
|
||||
border-color: var(--success-green);
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-msg {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.settings-msg.success { background: rgba(0, 255, 170, 0.15); color: var(--success-green); }
|
||||
.settings-msg.error { background: rgba(255, 51, 102, 0.15); color: var(--error-red); }
|
||||
|
||||
.btn-sm {
|
||||
padding: 8px 18px;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 14px 32px;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #ff3366 0%, #cc0033 100%);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
box-shadow: 0 6px 20px rgba(255, 51, 102, 0.4);
|
||||
}
|
||||
|
||||
/* Premium tab */
|
||||
.premium-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.premium-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.premium-hero h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #ffd700, #ff8c00);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.premium-tagline {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.premium-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.premium-feature {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 215, 0, 0.05);
|
||||
border: 1px solid rgba(255, 215, 0, 0.15);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.pf-icon { font-size: 1.3rem; flex-shrink: 0; margin-top: 2px; }
|
||||
|
||||
.premium-feature strong {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.premium-feature p {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.premium-cta {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.premium-price {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background: linear-gradient(135deg, #ffd700, #ff8c00);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.premium-price span {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.premium-note {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Mobile Overrides ─────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Admin Panel ──────────────────────────────────────────────────────────── */
|
||||
.admin-card {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 85vh;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem 0;
|
||||
}
|
||||
|
||||
.admin-header h2 { font-size: 1.4rem; font-weight: 700; }
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
margin: 1rem 2rem 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
flex: 1;
|
||||
padding: 7px 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.admin-tab.active {
|
||||
background: var(--accent-purple);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
.admin-body {
|
||||
padding: 1rem 2rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-pane { display: none; }
|
||||
.admin-pane.active { display: block; }
|
||||
|
||||
.admin-search-wrap { margin-bottom: 0.75rem; }
|
||||
|
||||
.admin-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-user-row .au-name {
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.admin-user-row .au-role {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.au-role.root { background: rgba(255, 215, 0, 0.2); color: #ffd700; }
|
||||
.au-role.admin { background: rgba(255, 0, 255, 0.15); color: var(--accent-magenta); }
|
||||
.au-role.mod { background: rgba(0, 200, 83, 0.15); color: #00c853; }
|
||||
.au-role.user { background: rgba(255, 255, 255, 0.08); color: var(--text-dim); }
|
||||
|
||||
.admin-user-row .au-badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.au-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.au-badge.online { background: rgba(0, 255, 170, 0.15); color: var(--success-green); }
|
||||
.au-badge.verified { background: rgba(0, 200, 255, 0.1); color: var(--ai-teal); }
|
||||
.au-badge.unverified { background: rgba(255, 51, 102, 0.15); color: var(--error-red); }
|
||||
.au-badge.ai-on { background: rgba(138, 43, 226, 0.15); color: var(--accent-purple); }
|
||||
|
||||
.admin-user-row .au-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.au-btn {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.au-btn:hover { background: var(--accent-purple); color: white; border-color: var(--accent-purple); }
|
||||
.au-btn.danger:hover { background: var(--error-red); border-color: var(--error-red); }
|
||||
|
||||
.au-select {
|
||||
padding: 3px 6px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-main);
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-ban-row, .admin-mute-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-ban-row .ab-name, .admin-mute-row .am-name {
|
||||
font-weight: 700;
|
||||
color: var(--error-red);
|
||||
}
|
||||
|
||||
.admin-empty {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.admin-toast {
|
||||
margin: 0 2rem 1rem;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(0, 255, 170, 0.15);
|
||||
color: var(--success-green);
|
||||
text-align: center;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
/* ── Theme Picker ─────────────────────────────────────────────────────────── */
|
||||
.theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-swatch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.theme-swatch:hover { transform: scale(1.08); }
|
||||
.theme-swatch.active {
|
||||
border-color: var(--accent-magenta);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.swatch-fill {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.swatch-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nicklist-sidebar {
|
||||
position: absolute;
|
||||
|
|
|
|||
Loading…
Reference in New Issue