Compare commits
56 Commits
feature/da
...
3f2f0fcb4e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f2f0fcb4e | |||
| 036e9da25e | |||
| bdd8674645 | |||
| c3d77974e3 | |||
| 2f0e817e12 | |||
| fdda47da7b | |||
| 9b7c9e8671 | |||
| ab832e928f | |||
| 691c704ecc | |||
| 5806cb08e1 | |||
| e7801287c2 | |||
| d808c17ab6 | |||
| 3e604a9816 | |||
| 31f7e00538 | |||
| 4d360abd21 | |||
| 35410dec0a | |||
| 8e6bd96d44 | |||
| 4936966c32 | |||
| 63780b19b1 | |||
| ae2cfc60a0 | |||
| 6520ea0b63 | |||
| 7f22cec452 | |||
| 6b2d296774 | |||
| d1c4c931ef | |||
| d8086afab2 | |||
| b60c642fd6 | |||
| 2fe07abecf | |||
| 30b9df0ebb | |||
| c061f2ff78 | |||
| 034c8fe604 | |||
| 5dc73bd06a | |||
| 4013d3d23d | |||
| 98cda57d90 | |||
| aa9688d811 | |||
| 8bc24fc80a | |||
| 1535d90842 | |||
| 0fa46d4cca | |||
| 589ac73b25 | |||
| a120e30e03 | |||
| 65adbf1aaa | |||
| 5ff361b97c | |||
| 883439e66f | |||
| 67c0d0124b | |||
| 9d95e1fd3c | |||
| e0502f93d6 | |||
| 519cc907af | |||
| 77f0a8a395 | |||
| 7f08ad86b0 | |||
| 0e0135be55 | |||
| 6af58808ad | |||
| 6010666dcf | |||
| 43b8610aa6 | |||
| d72e27ad2e | |||
| 0c00ff7ee0 | |||
| bce05652ed | |||
| 830a16d693 |
16
CONTEXT.md
16
CONTEXT.md
@@ -25,19 +25,11 @@ My core instructions are:
|
||||
The objective is to build a multi-platform chat overlay SaaS (Software as a Service) for streamers. The service will aggregate chat from Twitch and YouTube into a single, customizable browser source for use in streaming software like OBS.
|
||||
|
||||
### Current Status
|
||||
The project is in **Phase 1: User Authentication & Database**. Most of this phase is complete.
|
||||
* A solid FastAPI application skeleton is in place.
|
||||
* The database schema (`User`, `Setting` models) is defined using SQLAlchemy and a SQLite database.
|
||||
* A secure Twitch OAuth2 authentication flow is fully functional. It correctly:
|
||||
1. Redirects users to Twitch.
|
||||
2. Handles the callback.
|
||||
3. Exchanges the authorization code for tokens.
|
||||
4. Fetches user details from the Twitch API.
|
||||
5. Encrypts the tokens using the `cryptography` library.
|
||||
6. Saves or updates the user's record in the database.
|
||||
**Phases 1, 2, and 3 are complete.** The application is now a fully functional chat overlay service for Twitch.
|
||||
* **Phase 1 (Authentication):** A secure Twitch OAuth2 flow is implemented, with user data and encrypted tokens stored in a SQLite database.
|
||||
* **Phase 2 (Dashboard & Configuration):** A dynamic user dashboard is available after login. It includes a theme switcher (light/dark), a theme selector for the overlay, and a full CRUD system for users to create and manage their own private CSS themes.
|
||||
* **Phase 3 (Real-time Chat):** A decoupled background listener manager successfully starts `twitchio` listeners for each user. A WebSocket manager broadcasts incoming chat messages to the correct user's overlay in real-time.
|
||||
* A basic HTML login page is served at the root URL (`/`).
|
||||
* Configuration and secrets are managed securely via a `config.py` file that reads from a `.env` file.
|
||||
|
||||
### Core Architecture
|
||||
The project is built on the "hybrid architecture" detailed in the `RESEARCH_REPORT.md`:
|
||||
* **Authentication:** Always use the official, secure OAuth2 flows for each platform.
|
||||
|
||||
@@ -17,6 +17,8 @@ The goal is to create a service where streamers can log in using their platform
|
||||
## 3. Implementation Roadmap
|
||||
|
||||
### Phase 1: User Authentication & Database (FastAPI)
|
||||
**Status: ✔️ Complete**
|
||||
|
||||
1. **Project Skeleton:** Establish the core FastAPI application structure, dependencies, and version control.
|
||||
2. **Database Schema:** Define the data models for users and settings using SQLAlchemy.
|
||||
3. **Twitch OAuth2:** Implement the server-side OAuth2 flow within FastAPI to authenticate users and securely store encrypted tokens in the database.
|
||||
@@ -24,16 +26,25 @@ The goal is to create a service where streamers can log in using their platform
|
||||
5. **Basic Frontend:** Develop a simple login page.
|
||||
|
||||
### Phase 2: User Dashboard & Configuration
|
||||
**Status: ✔️ Complete**
|
||||
|
||||
1. **Dashboard UI:** Create a dashboard page accessible only to authenticated users.
|
||||
2. **Settings API:** Build API endpoints for users to save and retrieve their overlay settings (e.g., custom CSS).
|
||||
3. **Overlay URL Generation:** Display a unique, persistent overlay URL for each user on their dashboard.
|
||||
2. **Settings API:** Build API endpoints for users to save and retrieve their overlay settings.
|
||||
3. **Overlay URL Generation:** Display a unique, persistent overlay URL for each user.
|
||||
4. **Theming System:** Implement a site-wide light/dark theme switcher.
|
||||
5. **Custom CSS Themes:** Develop a full CRUD (Create, Read, Update, Delete) system allowing users to create, manage, and preview their own private CSS overlay themes.
|
||||
6. **Help & Documentation:** Add a help page to guide users in creating their custom CSS.
|
||||
|
||||
### Phase 3: Dynamic Listeners & Basic Overlay
|
||||
**Status: ✔️ Complete**
|
||||
|
||||
1. **Dynamic Listener Manager:** Design and build a background service that starts and stops chat listener processes (`twitchio`, `pytchat`) based on user activity.
|
||||
2. **Real-time Message Broadcasting:** Implement a WebSocket system within FastAPI to push chat messages to the correct user's overlay in real-time.
|
||||
3. **Basic Overlay UI:** Create the `overlay.html` page that connects to the WebSocket and renders incoming chat messages.
|
||||
|
||||
### Phase 4: Integration & Refinement
|
||||
**Status: ⏳ Not Started**
|
||||
|
||||
1. **YouTube Integration:** Implement the full YouTube OAuth2 flow and integrate the `pytchat` listener into the dynamic listener manager.
|
||||
2. **Advanced Overlay Customization:** Add more features for users to customize their overlay's appearance and behavior.
|
||||
3. **Twitch Chat Writeback:** Re-introduce the `chat:write` scope during authentication to allow the service (and potentially moderators, as per Issue #2) to send messages to the user's Twitch chat.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
MultiChatOverlay is a web-based, multi-platform chat overlay service designed for streamers. The goal is to create a "SaaS" (Software as a Service) project where users can log in with their platform accounts (Twitch, YouTube, etc.) and get a single, unified, and customizable chat overlay for their stream.
|
||||
|
||||
This project is currently in **Phase 1: Initial Development**.
|
||||
This project is currently in **Phase 2: User Dashboard & Configuration**.
|
||||
|
||||
## 🚀 Project Goal
|
||||
|
||||
@@ -15,7 +15,7 @@ This project is currently in **Phase 1: Initial Development**.
|
||||
|
||||
User privacy and security are paramount. All sensitive user credentials, such as OAuth access and refresh tokens from external platforms, are **always encrypted** before being stored in the database. They are never stored in plain text, ensuring a high standard of security for user data.
|
||||
|
||||
## <EFBFBD> Technology Stack
|
||||
## 🖥️ Technology Stack
|
||||
|
||||
* **Backend:** Python 3.9+ (FastAPI)
|
||||
* **Database:** SQLite (initially, for simplicity) with SQLAlchemy
|
||||
|
||||
32
TASKS.md
32
TASKS.md
@@ -10,7 +10,7 @@ This file tracks all active development tasks. It is based on the official `DEVE
|
||||
4. When your Pull Request is *merged*, move it to "Done."
|
||||
|
||||
If you want to use emojis for visibility, here's some I have used:
|
||||
✔️ - Done | 🧑🔧 - In progress | ↗️ - Task evolved (should correspond with an edit in the [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md)
|
||||
✔️ - Done | 🧑🔧 - In progress | ↗️ - Task evolved (should correspond with an edit in the [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md) )
|
||||
|
||||
---
|
||||
|
||||
@@ -20,8 +20,6 @@ If you want to use emojis for visibility, here's some I have used:
|
||||
|
||||
### To Do
|
||||
|
||||
### In Progress
|
||||
|
||||
### Done
|
||||
* `[✔️]` **1.0: Project Skeleton** - @ramforth
|
||||
* *Task:* Setup `main.py`, `requirements.txt`, and `.gitignore`.
|
||||
@@ -35,33 +33,37 @@ If you want to use emojis for visibility, here's some I have used:
|
||||
|
||||
## ⏳ Phase 2: User Dashboard & Configuration
|
||||
|
||||
* **Goal:** Allow logged-in users to see a dashboard, get their overlay URL, and save settings.
|
||||
* *(All tasks for this phase are on hold until Phase 1 is complete)*
|
||||
* **Goal:** Allow logged-in users to see a dashboard, get their overlay URL, and save settings. Now that Phase 1 is done, these tasks are ready to be worked on.
|
||||
|
||||
### To Do
|
||||
* `[ ]` **2.0: CSS Refactor & Styling:** Improve the general look and feel of the application pages.
|
||||
* `[ ]` **2.1: Dashboard UI:** Create `dashboard.html` (only for logged-in users).
|
||||
* `[ ]` **2.2: Config API:** Create API endpoints (`GET`, `POST`) for `/api/settings` to save/load user preferences (e.g., custom CSS).
|
||||
* `[ ]` **2.3: Overlay URL:** Generate and display the unique overlay URL for the user (e.g., `/overlay/{user_id}`).
|
||||
|
||||
* `[ ]` **2.4: Create Logo and Favicon:** The project should have a logo and a favicon.
|
||||
|
||||
### Done
|
||||
* `[✔️]` **2.0: CSS Refactor & Styling:** Improved the general look and feel of the application pages, including a light/dark theme switcher.
|
||||
* `[✔️]` **2.1: Dashboard UI:** Created `dashboard.html` for logged-in users to manage settings.
|
||||
* `[✔️]` **2.2: Config API:** Created API endpoints for `/api/settings` to save user preferences.
|
||||
* `[✔️]` **2.3: Overlay URL:** Generated and displayed the unique overlay URL for the user on the dashboard.
|
||||
* `[✔️]` **2.5: Custom CSS Themes:** Implemented a system for users to create, preview, and delete their own private CSS overlay themes.
|
||||
* `[✔️]` **2.6: CSS Help Page:** Created a guide for users on how to write custom CSS for their overlays.
|
||||
|
||||
---
|
||||
|
||||
## 💬 Phase 3: Dynamic Listeners & Basic Overlay
|
||||
## 💬 Phase 3: Real-time Chat & Overlay
|
||||
|
||||
* **Goal:** The core magic. Start chat listeners for users and show messages in the overlay.
|
||||
* *(All tasks for this phase are on hold until Phase 2 is complete)*
|
||||
|
||||
### To Do
|
||||
* `[ ]` **3.1: Dynamic Listener Manager (The Big One):** Design a system (e.g., background service) to start/stop listener processes for users.
|
||||
* `[ ]` **3.2: User-Specific Broadcasting:** Update the WebSocket system to use "rooms" (e.g., `/ws/{user_id}`) so users only get their *own* chat.
|
||||
* `[ ]` **3.3: Basic Overlay UI:** Create the `overlay.html` page that connects to the WebSocket and displays messages.
|
||||
|
||||
---
|
||||
### Done
|
||||
* `[✔️]` **3.1: Dynamic Listener Manager:** Designed and implemented a system to start/stop listener processes for users on application startup/shutdown.
|
||||
* `[✔️]` **3.2: User-Specific Broadcasting:** Implemented a WebSocket manager and endpoint (`/ws/{user_id}`) to broadcast messages to the correct user's overlay.
|
||||
* `[✔️]` **3.3: Basic Overlay UI:** Created dynamic overlay templates that connect to the WebSocket and display incoming chat messages.
|
||||
|
||||
## 💡 Backlog & Future Features
|
||||
|
||||
* *(Tasks from Phase 4, Gitea Issues, etc., will be added here as we go)*
|
||||
* `[ ]` Implement YouTube OAuth & `pytchat` listener (Phase 4).
|
||||
* `[ ]` "Single Message Focus" feature (Issue #1).
|
||||
* `[ ]` Moderator panels (Issue #2).
|
||||
* `[ ]` Custom CSS storage & injection (Issue #6).
|
||||
3
auth.py
3
auth.py
@@ -99,6 +99,9 @@ async def auth_twitch_callback(code: str, state: str, request: Request, db: Sess
|
||||
encrypted_tokens=encrypted_tokens
|
||||
)
|
||||
db.add(user)
|
||||
# Also create a default settings object for the new user
|
||||
new_settings = models.Setting(owner=user)
|
||||
db.add(new_settings)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
60
chat_listener.py
Normal file
60
chat_listener.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from twitchio.ext import commands
|
||||
|
||||
class NullAdapter:
|
||||
"""A dummy web adapter that does nothing to prevent twitchio from starting a webserver."""
|
||||
async def start(self):
|
||||
pass
|
||||
|
||||
async def stop(self):
|
||||
pass
|
||||
|
||||
class TwitchBot(commands.Bot):
|
||||
def __init__(self, access_token: str, client_id: str, client_secret: str, bot_id: str, channel_name: str, websocket_manager, db_user_id: int):
|
||||
self.websocket_manager = websocket_manager
|
||||
# Store our application's database user ID to avoid conflict with twitchio's internal 'owner_id'
|
||||
self.db_user_id = db_user_id
|
||||
|
||||
# Force the bot to use our dummy adapter that does nothing.
|
||||
# This is the definitive fix to prevent any port conflicts.
|
||||
adapter = NullAdapter()
|
||||
|
||||
super().__init__(
|
||||
token=access_token,
|
||||
# Mandate: Explicitly disable the internal web server.
|
||||
web_server_adapter=adapter,
|
||||
eventsub_url=None,
|
||||
prefix='!', # A prefix is required but won't be used for reading chat
|
||||
initial_channels=[channel_name],
|
||||
# These are required by twitchio
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
# The 'bot_id' is the Twitch ID of the user account the bot is running as
|
||||
id=bot_id
|
||||
)
|
||||
self.channel_name = channel_name
|
||||
|
||||
async def event_ready(self):
|
||||
"""Called once when the bot goes online."""
|
||||
print(f"Listener ready for #{self.channel_name}")
|
||||
|
||||
async def event_message(self, message): # Mandate: Type hint removed to prevent import errors.
|
||||
"""Runs every time a message is sent in chat."""
|
||||
# This is CRITICAL when overriding a listener in commands.Bot
|
||||
# to ensure the bot can still process commands if any are added.
|
||||
await super().event_message(message)
|
||||
|
||||
# Ignore messages sent by the bot itself to prevent loops.
|
||||
if message.echo:
|
||||
return
|
||||
|
||||
# Prepare the message data to be sent to the frontend
|
||||
chat_data = {
|
||||
"author": message.author.name,
|
||||
"text": message.content,
|
||||
"platform": "twitch"
|
||||
}
|
||||
|
||||
# Broadcast the message to the specific user's overlay
|
||||
# We need the user's ID to know which WebSocket connection to send to.
|
||||
user_id = self.db_user_id
|
||||
await self.websocket_manager.broadcast_to_user(user_id, chat_data)
|
||||
@@ -1,27 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard - MultiChatOverlay</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome, <strong>{{ user.username }}</strong>! You are successfully logged in.</p>
|
||||
|
||||
<div class="overlay-url-container">
|
||||
<p>Your unique overlay URL:</p>
|
||||
<code>{{ overlay_url }}</code>
|
||||
</div>
|
||||
|
||||
<p><a href="/logout">Logout</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
51
listener_manager.py
Normal file
51
listener_manager.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import asyncio
|
||||
from typing import Dict
|
||||
|
||||
from chat_listener import TwitchBot
|
||||
import security # To decrypt tokens
|
||||
from config import settings # To get client_id and client_secret
|
||||
|
||||
class ListenerManager:
|
||||
def __init__(self):
|
||||
# This dictionary will hold our running listener tasks.
|
||||
# The key will be the user_id and the value will be the asyncio.Task.
|
||||
self.active_listeners: Dict[int, asyncio.Task] = {}
|
||||
print("ListenerManager initialized.")
|
||||
|
||||
async def start_listener_for_user(self, user, websocket_manager):
|
||||
"""
|
||||
Starts a chat listener for a given user if one isn't already running.
|
||||
"""
|
||||
if user.id in self.active_listeners:
|
||||
print(f"Listener for user {user.id} is already running.")
|
||||
return
|
||||
|
||||
print(f"Starting listener for user {user.id} ({user.username})...")
|
||||
|
||||
tokens = security.decrypt_tokens(user.encrypted_tokens)
|
||||
access_token = tokens['access_token']
|
||||
bot = TwitchBot(
|
||||
access_token=access_token,
|
||||
channel_name=user.username,
|
||||
client_id=settings.TWITCH_CLIENT_ID,
|
||||
client_secret=settings.TWITCH_CLIENT_SECRET,
|
||||
bot_id=user.platform_user_id,
|
||||
websocket_manager=websocket_manager,
|
||||
db_user_id=user.id
|
||||
)
|
||||
task = asyncio.create_task(bot.start())
|
||||
self.active_listeners[user.id] = task
|
||||
|
||||
async def stop_listener_for_user(self, user_id: int):
|
||||
"""Stops a chat listener for a given user."""
|
||||
if user_id not in self.active_listeners:
|
||||
print(f"No active listener found for user {user_id}.")
|
||||
return
|
||||
|
||||
print(f"Stopping listener for user {user_id}...")
|
||||
task = self.active_listeners.pop(user_id)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
print(f"Listener for user {user_id} successfully stopped.")
|
||||
145
main.py
145
main.py
@@ -1,15 +1,22 @@
|
||||
import os
|
||||
from fastapi import FastAPI, Request, Depends
|
||||
import asyncio
|
||||
from fastapi import FastAPI, Request, Depends, HTTPException
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.responses import FileResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.websockets import WebSocket
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from database import engine
|
||||
from database import engine, SessionLocal
|
||||
import auth # Import the new auth module
|
||||
import schemas
|
||||
from starlette.responses import Response
|
||||
from config import settings # Import settings to get the secret key
|
||||
from listener_manager import ListenerManager
|
||||
from websocket_manager import WebSocketManager
|
||||
|
||||
# --- Absolute Path Configuration ---
|
||||
# Get the absolute path of the directory where this file is located
|
||||
@@ -17,14 +24,39 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
||||
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
|
||||
|
||||
@asynccontextmanager
|
||||
async def background_listener_startup(app: FastAPI):
|
||||
"""A non-blocking task to start listeners after the app has started."""
|
||||
print("Background task: Starting listeners for all users...")
|
||||
db = SessionLocal()
|
||||
users = db.query(models.User).all()
|
||||
db.close()
|
||||
for user in users:
|
||||
# Use try/except to ensure one failing listener doesn't stop others
|
||||
try:
|
||||
await app.state.listener_manager.start_listener_for_user(user, app.state.websocket_manager)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to start listener for user {user.id} ({user.username}): {e}")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# This code runs on startup
|
||||
print("Application startup: Creating database tables...")
|
||||
app.state.websocket_manager = WebSocketManager()
|
||||
app.state.listener_manager = ListenerManager()
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
print("Application startup: Database tables created.")
|
||||
|
||||
# Decouple listener startup from the main application startup
|
||||
asyncio.create_task(background_listener_startup(app))
|
||||
yield
|
||||
# Code below yield runs on shutdown, if needed
|
||||
|
||||
# This code runs on shutdown
|
||||
print("Application shutdown: Stopping all listeners...")
|
||||
manager = app.state.listener_manager
|
||||
# Create a copy of keys to avoid runtime errors from changing dict size
|
||||
for user_id in list(manager.active_listeners.keys()):
|
||||
await manager.stop_listener_for_user(user_id)
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
@@ -42,8 +74,8 @@ app.include_router(auth.router)
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.ENCRYPTION_KEY)
|
||||
|
||||
@app.get("/")
|
||||
async def read_root():
|
||||
return FileResponse(os.path.join(STATIC_DIR, "login.html"))
|
||||
async def read_root(request: Request):
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
@app.get("/dashboard")
|
||||
async def read_dashboard(request: Request, db: Session = Depends(auth.get_db)):
|
||||
@@ -56,10 +88,111 @@ async def read_dashboard(request: Request, db: Session = Depends(auth.get_db)):
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
overlay_url = f"{settings.APP_BASE_URL}/overlay/{user.id}"
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request, "user": user, "overlay_url": overlay_url})
|
||||
# Ensure user has settings, create if they don't for some reason
|
||||
if not user.settings:
|
||||
user.settings = models.Setting()
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"overlay_url": overlay_url,
|
||||
"current_theme": user.settings.overlay_theme,
|
||||
"settings": settings,
|
||||
"custom_themes": user.custom_themes
|
||||
})
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout(request: Request):
|
||||
# Clear the session cookie
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
@app.get("/help/css")
|
||||
async def css_help(request: Request):
|
||||
return templates.TemplateResponse("help_css.html", {"request": request})
|
||||
|
||||
@app.get("/overlay/{user_id}")
|
||||
async def read_overlay(request: Request, user_id: int, theme_override: str = None, db: Session = Depends(auth.get_db)):
|
||||
# This endpoint serves the overlay page.
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
theme_name = "dark-purple" # Default theme
|
||||
if theme_override:
|
||||
theme_name = theme_override
|
||||
elif user.settings and user.settings.overlay_theme:
|
||||
theme_name = user.settings.overlay_theme
|
||||
|
||||
# Check if it's a custom theme
|
||||
if theme_name.startswith("custom-"):
|
||||
theme_id = int(theme_name.split("-")[1])
|
||||
theme = db.query(models.CustomTheme).filter(models.CustomTheme.id == theme_id, models.CustomTheme.owner_id == user.id).first()
|
||||
if not theme:
|
||||
raise HTTPException(status_code=404, detail="Custom theme not found")
|
||||
# Use a generic overlay template that will link to the dynamic CSS
|
||||
return templates.TemplateResponse("overlay-custom.html", {"request": request, "theme_id": theme.id})
|
||||
|
||||
return templates.TemplateResponse(f"overlay-{theme_name}.html", {"request": request})
|
||||
|
||||
@app.post("/api/settings")
|
||||
async def update_settings(settings_data: schemas.SettingsUpdate, request: Request, db: Session = Depends(auth.get_db)):
|
||||
user_id = request.session.get('user_id')
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user.settings:
|
||||
user.settings = models.Setting()
|
||||
|
||||
user.settings.overlay_theme = settings_data.overlay_theme
|
||||
db.commit()
|
||||
|
||||
return {"message": "Settings updated successfully"}
|
||||
|
||||
@app.post("/api/themes", response_model=schemas.CustomTheme)
|
||||
async def create_theme(theme_data: schemas.CustomThemeCreate, request: Request, db: Session = Depends(auth.get_db)):
|
||||
user_id = request.session.get('user_id')
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
new_theme = models.CustomTheme(**theme_data.dict(), owner_id=user_id)
|
||||
db.add(new_theme)
|
||||
db.commit()
|
||||
db.refresh(new_theme)
|
||||
return new_theme
|
||||
|
||||
@app.delete("/api/themes/{theme_id}")
|
||||
async def delete_theme(theme_id: int, request: Request, db: Session = Depends(auth.get_db)):
|
||||
user_id = request.session.get('user_id')
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
theme = db.query(models.CustomTheme).filter(models.CustomTheme.id == theme_id, models.CustomTheme.owner_id == user_id).first()
|
||||
if not theme:
|
||||
raise HTTPException(status_code=404, detail="Theme not found")
|
||||
|
||||
db.delete(theme)
|
||||
db.commit()
|
||||
return {"message": "Theme deleted successfully"}
|
||||
|
||||
@app.get("/css/custom/{theme_id}")
|
||||
async def get_custom_css(theme_id: int, db: Session = Depends(auth.get_db)):
|
||||
theme = db.query(models.CustomTheme).filter(models.CustomTheme.id == theme_id).first()
|
||||
if not theme:
|
||||
raise HTTPException(status_code=404, detail="Custom theme not found")
|
||||
|
||||
return Response(content=theme.css_content, media_type="text/css")
|
||||
|
||||
@app.websocket("/ws/{user_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, user_id: int):
|
||||
manager = websocket.app.state.websocket_manager
|
||||
await manager.connect(user_id, websocket)
|
||||
try:
|
||||
while True:
|
||||
# Keep the connection alive
|
||||
await websocket.receive_text()
|
||||
except Exception:
|
||||
manager.disconnect(user_id, websocket)
|
||||
print(f"WebSocket for user {user_id} disconnected.")
|
||||
12
models.py
12
models.py
@@ -16,12 +16,24 @@ class User(Base):
|
||||
encrypted_tokens = Column(Text, nullable=False)
|
||||
|
||||
settings = relationship("Setting", back_populates="owner", uselist=False)
|
||||
custom_themes = relationship("CustomTheme", back_populates="owner")
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
custom_css = Column(Text, nullable=True)
|
||||
overlay_theme = Column(String, default="dark-purple")
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
|
||||
owner = relationship("User", back_populates="settings")
|
||||
|
||||
class CustomTheme(Base):
|
||||
__tablename__ = "custom_themes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
css_content = Column(Text, nullable=False)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
owner = relationship("User", back_populates="custom_themes")
|
||||
@@ -6,3 +6,6 @@ cryptography
|
||||
python-dotenv
|
||||
itsdangerous
|
||||
jinja2
|
||||
pydantic
|
||||
python-jose[cryptography]
|
||||
twitchio==3.1.0
|
||||
19
schemas.py
Normal file
19
schemas.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
overlay_theme: str
|
||||
|
||||
class CustomThemeBase(BaseModel):
|
||||
name: str
|
||||
css_content: str
|
||||
|
||||
class CustomThemeCreate(CustomThemeBase):
|
||||
pass
|
||||
|
||||
class CustomTheme(CustomThemeBase):
|
||||
id: int
|
||||
owner_id: int
|
||||
|
||||
class Config:
|
||||
# This allows the Pydantic model to be created from a SQLAlchemy ORM object
|
||||
from_attributes = True
|
||||
291
static/css/main.css
Normal file
291
static/css/main.css
Normal file
@@ -0,0 +1,291 @@
|
||||
/* --- Theme Variables and CSS Reset --- */
|
||||
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--background-color: #1a1a2e;
|
||||
--surface-color: #16213e;
|
||||
--text-color: #e0e0e0;
|
||||
--text-muted-color: #a0a0a0;
|
||||
--text-inverted-color: #ffffff;
|
||||
--border-color: #4f4f7a;
|
||||
--primary-color: #7f5af0;
|
||||
--primary-hover-color: #6a48d7;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--background-color: #f0f2f5;
|
||||
--surface-color: #ffffff;
|
||||
--text-color: #1c1e21;
|
||||
--text-muted-color: #606770;
|
||||
--text-inverted-color: #ffffff;
|
||||
--border-color: #dddfe2;
|
||||
--primary-color: #7f5af0;
|
||||
--primary-hover-color: #6a48d7;
|
||||
}
|
||||
|
||||
/* Box-sizing reset */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Reset margins and paddings on most elements */
|
||||
body,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
figure,
|
||||
blockquote,
|
||||
dl,
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Basic body styling */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
/* Make images responsive */
|
||||
img,
|
||||
picture {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* General Container for login and dashboard */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Login Page Styles */
|
||||
.login-container {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
margin-top: 5vh;
|
||||
padding: 2.5rem;
|
||||
background-color: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
color: var(--text-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-box p {
|
||||
margin-bottom: 2rem;
|
||||
color: var(--text-muted-color);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted-color);
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-inverted-color);
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-hover-color);
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.login-footer a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Header Styles */
|
||||
.logo h1 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.logo {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-inverted-color);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: var(--primary-hover-color);
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.theme-btn:hover {
|
||||
background-color: var(--surface-color);
|
||||
}
|
||||
[data-theme="light"] .moon-icon { display: block; }
|
||||
[data-theme="light"] .sun-icon { display: none; }
|
||||
[data-theme="dark"] .moon-icon { display: none; }
|
||||
[data-theme="dark"] .sun-icon { display: block; }
|
||||
|
||||
/* Dashboard Card Styles */
|
||||
.card {
|
||||
background-color: var(--surface-color);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.card p {
|
||||
margin-top: 0;
|
||||
color: var(--text-muted-color);
|
||||
}
|
||||
|
||||
/* Custom Theme Form Styles */
|
||||
.theme-form {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.custom-theme-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.delete-theme-btn {
|
||||
background-color: #e43f5a;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Styles for help page code examples */
|
||||
pre {
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
12
static/css/overlay-base.css
Normal file
12
static/css/overlay-base.css
Normal file
@@ -0,0 +1,12 @@
|
||||
body {
|
||||
background-color: transparent;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
padding: 10px;
|
||||
height: 100vh;
|
||||
}
|
||||
28
static/css/overlay-bright-green.css
Normal file
28
static/css/overlay-bright-green.css
Normal file
@@ -0,0 +1,28 @@
|
||||
body {
|
||||
background-color: transparent;
|
||||
color: #f0f0f0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
text-shadow: 0 0 5px rgba(57, 255, 20, 0.5);
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
padding: 6px 10px;
|
||||
background-color: rgba(10, 30, 10, 0.6);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #39FF14; /* Neon green accent */
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 800;
|
||||
color: #39FF14; /* Neon green */
|
||||
}
|
||||
28
static/css/overlay-dark-purple.css
Normal file
28
static/css/overlay-dark-purple.css
Normal file
@@ -0,0 +1,28 @@
|
||||
body {
|
||||
background-color: transparent;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
padding: 6px 10px;
|
||||
background-color: rgba(26, 26, 46, 0.7); /* Dark blue/purple transparent background */
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(127, 90, 240, 0.3);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 800;
|
||||
color: #a970ff; /* Twitch-like purple */
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard - MultiChatOverlay</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome! You are successfully logged in.</p>
|
||||
<p><a href="/logout">Logout</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
static/gitea-svgrepo-com.png
Normal file
BIN
static/gitea-svgrepo-com.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
26
static/overlay-bright-green.css
Normal file
26
static/overlay-bright-green.css
Normal file
@@ -0,0 +1,26 @@
|
||||
body {
|
||||
background-color: transparent;
|
||||
color: #1a1a1a; /* Dark text for readability on light backgrounds */
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
font-weight: 600;
|
||||
-webkit-text-stroke: 0.5px white; /* White outline for text */
|
||||
text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-message {
|
||||
padding: 5px 10px;
|
||||
background-color: rgba(240, 240, 240, 0.5); /* Semi-transparent light background */
|
||||
border-radius: 5px;
|
||||
border-left: 3px solid #00ff7f; /* Spring green accent */
|
||||
}
|
||||
.username {
|
||||
font-weight: 800;
|
||||
color: #008000; /* Dark green for usernames */
|
||||
}
|
||||
24
static/overlay-dark-purple.css
Normal file
24
static/overlay-dark-purple.css
Normal file
@@ -0,0 +1,24 @@
|
||||
body {
|
||||
background-color: transparent;
|
||||
color: #e6edf3; /* Light text for dark backgrounds */
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-message {
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(22, 27, 34, 0.6); /* Semi-transparent dark background */
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #9146FF; /* Twitch purple accent */
|
||||
}
|
||||
.username {
|
||||
font-weight: 800;
|
||||
color: #a970ff; /* Lighter purple for usernames */
|
||||
}
|
||||
55
templates/base.html
Normal file
55
templates/base.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}MultiChat Overlay{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/main.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="main-header">
|
||||
<a href="/" class="logo"><h1>MultiChat Overlay</h1></a>
|
||||
<div class="header-actions">
|
||||
<button id="theme-toggle" class="theme-btn" title="Toggle theme">
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.64 5.64c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41l1.06 1.06c.39.39 1.02.39 1.41 0s.39-1.02 0-1.41L5.64 5.64zm12.72 12.72c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41l1.06 1.06c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41l-1.06-1.06zM5.64 18.36l-1.06-1.06c-.39-.39-.39-1.02 0-1.41s1.02-.39 1.41 0l1.06 1.06c.39.39.39 1.02 0 1.41s-1.02.39-1.41 0zm12.72-12.72l-1.06-1.06c-.39-.39-.39-1.02 0-1.41s1.02-.39 1.41 0l1.06 1.06c.39.39.39 1.02 0 1.41-.39.39-1.02.39-1.41 0z"></path></svg>
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M9.37 5.51A7.35 7.35 0 0 0 9 6c0 4.42 3.58 8 8 8 .36 0 .72-.02 1.08-.06A7.5 7.5 0 0 1 9.37 5.51zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10A9.96 9.96 0 0 0 12 2z"></path></svg>
|
||||
</button>
|
||||
{% if user %}
|
||||
<a href="/logout" class="logout-btn">Logout</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const getTheme = () => {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme) return storedTheme;
|
||||
// For new visitors, default to dark mode regardless of their OS setting.
|
||||
return 'dark';
|
||||
};
|
||||
|
||||
const setTheme = (theme) => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
};
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
});
|
||||
|
||||
// Set initial theme on page load
|
||||
setTheme(getTheme());
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
240
templates/dashboard.html
Normal file
240
templates/dashboard.html
Normal file
@@ -0,0 +1,240 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - {{ super() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>Welcome, {{ user.username }}!</h2>
|
||||
<p>This is your personal dashboard. Here you can manage your overlay settings and find your unique URL.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Your Overlay URL</h2>
|
||||
<p>Copy this URL and add it as a "Browser Source" in your streaming software (e.g., OBS, Streamlabs).</p>
|
||||
<div class="url-box">
|
||||
<input type="text" id="overlayUrl" value="{{ overlay_url }}" readonly>
|
||||
<button id="copyButton" class="btn">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Overlay Theme</h2>
|
||||
<p>Choose a theme for your chat overlay. Your selection will be saved automatically.</p>
|
||||
<div class="theme-selector-container">
|
||||
<div class="theme-options">
|
||||
<label for="theme-select">Select a theme:</label>
|
||||
<select id="theme-select" name="theme">
|
||||
<option value="dark-purple" {% if current_theme == 'dark-purple' %}selected{% endif %}>Dark Purple</option>
|
||||
<option value="bright-green" {% if current_theme == 'bright-green' %}selected{% endif %}>Bright Green</option>
|
||||
<option value="minimal-light" {% if current_theme == 'minimal-light' %}selected{% endif %}>Minimal Light</option>
|
||||
<option value="hacker-green" {% if current_theme == 'hacker-green' %}selected{% endif %}>Hacker Green</option>
|
||||
{% if custom_themes %}
|
||||
<optgroup label="Your Themes">
|
||||
{% for theme in custom_themes %}
|
||||
<option value="custom-{{ theme.id }}" {% if current_theme == 'custom-' ~ theme.id %}selected{% endif %}>{{ theme.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="theme-preview">
|
||||
<h3>Preview</h3>
|
||||
<iframe id="theme-preview-frame" src="" frameborder="0" scrolling="no"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>
|
||||
Custom Themes
|
||||
<a href="/help/css" target="_blank" class="help-link" title="Open CSS guide in new window">(?)</a>
|
||||
</h2>
|
||||
<p>Create your own themes with CSS. These are private to your account.</p>
|
||||
|
||||
<div id="custom-themes-list">
|
||||
{% for theme in custom_themes %}
|
||||
<div class="custom-theme-item" id="theme-item-{{ theme.id }}">
|
||||
<span>{{ theme.name }}</span>
|
||||
<button class="delete-theme-btn" data-theme-id="{{ theme.id }}">Delete</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form id="theme-form" class="theme-form">
|
||||
<h3>Create New Theme</h3>
|
||||
<div class="form-group">
|
||||
<label for="theme-name">Theme Name</label>
|
||||
<input type="text" id="theme-name" name="name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="theme-css">CSS Content</label>
|
||||
<textarea id="theme-css" name="css_content" rows="8" required placeholder="body { color: red; }"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Save Theme</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.help-link {
|
||||
font-size: 0.9rem;
|
||||
vertical-align: middle;
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.url-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.url-box input {
|
||||
flex-grow: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.theme-selector-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
.theme-options select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--surface-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.theme-preview {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
.theme-preview h3 {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
#theme-preview-frame {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// --- Copy URL Logic ---
|
||||
const copyButton = document.getElementById('copyButton');
|
||||
const overlayUrlInput = document.getElementById('overlayUrl');
|
||||
copyButton.addEventListener('click', () => {
|
||||
overlayUrlInput.select();
|
||||
document.execCommand('copy');
|
||||
copyButton.textContent = 'Copied!';
|
||||
setTimeout(() => { copyButton.textContent = 'Copy'; }, 2000);
|
||||
});
|
||||
|
||||
// --- Theme Selector Logic ---
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
const previewFrame = document.getElementById('theme-preview-frame');
|
||||
const baseUrl = "{{ settings.APP_BASE_URL }}";
|
||||
const userId = "{{ user.id }}";
|
||||
|
||||
// Function to update preview and save settings
|
||||
function updateTheme(selectedTheme) {
|
||||
// Update iframe preview
|
||||
previewFrame.src = `${baseUrl}/overlay/${userId}?theme_override=${selectedTheme}`;
|
||||
|
||||
// Save the setting to the backend
|
||||
fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ overlay_theme: selectedTheme }),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('Settings saved:', data.message);
|
||||
})
|
||||
.catch(error => console.error('Error saving settings:', error));
|
||||
}
|
||||
|
||||
// Event listener for dropdown change
|
||||
themeSelect.addEventListener('change', (event) => {
|
||||
updateTheme(event.target.value);
|
||||
});
|
||||
|
||||
// Set initial preview on page load
|
||||
updateTheme(themeSelect.value);
|
||||
|
||||
// --- Custom Theme Creation Logic ---
|
||||
const themeForm = document.getElementById('theme-form');
|
||||
themeForm.addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(themeForm);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
fetch('/api/themes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(newTheme => {
|
||||
// Add new theme to the list and dropdown without reloading
|
||||
addThemeToList(newTheme);
|
||||
addThemeToSelect(newTheme);
|
||||
themeForm.reset(); // Clear the form
|
||||
})
|
||||
.catch(error => console.error('Error creating theme:', error));
|
||||
});
|
||||
|
||||
// --- Custom Theme Deletion Logic ---
|
||||
const themesListContainer = document.getElementById('custom-themes-list');
|
||||
themesListContainer.addEventListener('click', function(event) {
|
||||
if (event.target.classList.contains('delete-theme-btn')) {
|
||||
const themeId = event.target.dataset.themeId;
|
||||
if (confirm('Are you sure you want to delete this theme?')) {
|
||||
fetch(`/api/themes/${themeId}`, { method: 'DELETE' })
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
// Remove theme from the list and dropdown
|
||||
document.getElementById(`theme-item-${themeId}`).remove();
|
||||
document.querySelector(`#theme-select option[value="custom-${themeId}"]`).remove();
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error deleting theme:', error));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function addThemeToList(theme) {
|
||||
const list = document.getElementById('custom-themes-list');
|
||||
const item = document.createElement('div');
|
||||
item.className = 'custom-theme-item';
|
||||
item.id = `theme-item-${theme.id}`;
|
||||
item.innerHTML = `<span>${theme.name}</span><button class="delete-theme-btn" data-theme-id="${theme.id}">Delete</button>`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
function addThemeToSelect(theme) {
|
||||
let optgroup = document.querySelector('#theme-select optgroup[label="Your Themes"]');
|
||||
if (!optgroup) {
|
||||
optgroup = document.createElement('optgroup');
|
||||
optgroup.label = 'Your Themes';
|
||||
themeSelect.appendChild(optgroup);
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = `custom-${theme.id}`;
|
||||
option.textContent = theme.name;
|
||||
optgroup.appendChild(option);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
46
templates/help_css.html
Normal file
46
templates/help_css.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}CSS Overlay Help - {{ super() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>Custom Overlay CSS Guide</h2>
|
||||
<p>This guide will help you create your own custom CSS for the chat overlay. Your CSS is applied on top of a base stylesheet, so you only need to override the styles you want to change.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>HTML Structure</h3>
|
||||
<p>Your CSS will be applied to a simple HTML structure. Here are the key classes you can target:</p>
|
||||
<ul>
|
||||
<li><code>.chat-container</code>: The main container holding all messages.</li>
|
||||
<li><code>.chat-message</code>: The container for a single message, including the username and text.</li>
|
||||
<li><code>.username</code>: The part of the message that shows the chatter's name.</li>
|
||||
<li><code>.message-text</code>: The actual content of the chat message.</li>
|
||||
</ul>
|
||||
<p>The <code>body</code> of the overlay is transparent by default, so you only need to style the message elements.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Example: "Bubbly" Theme</h3>
|
||||
<p>Here is a simple example to get you started. This creates chat bubbles with a gradient background.</p>
|
||||
<pre><code>body {
|
||||
font-family: 'Comic Sans MS', cursive, sans-serif;
|
||||
font-size: 18px;
|
||||
text-shadow: 1px 1px 2px #333;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
background: linear-gradient(135deg, #7f5af0, #a970ff);
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: bold;
|
||||
color: #e0e0e0;
|
||||
}</code></pre>
|
||||
</div>
|
||||
{% endblock %}
|
||||
16
templates/login.html
Normal file
16
templates/login.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - MultiChat Overlay{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<h1>MultiChat Overlay</h1>
|
||||
<p>Connect your streaming accounts to get started.</p>
|
||||
<a href="/login/twitch" class="btn-primary twitch-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style="margin-right: 10px;"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0h1.714v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0H6zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714v9.429z"></path></svg>
|
||||
Login with Twitch
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
0
templates/overlay-bright-green.css
Normal file
0
templates/overlay-bright-green.css
Normal file
18
templates/overlay-bright-green.html
Normal file
18
templates/overlay-bright-green.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Chat Overlay - Bright Green</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/overlay-bright-green.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="chat-container">
|
||||
<div class="chat-message"><span class="username">Streamer:</span> This is the bright green theme!</div>
|
||||
<div class="chat-message"><span class="username">VerdantViewer:</span> Looks so fresh and clean!</div>
|
||||
<div class="chat-message"><span class="username">NightBot:</span> Welcome to the channel!</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
15
templates/overlay-custom.html
Normal file
15
templates/overlay-custom.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Custom Chat Overlay</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/overlay-base.css') }}">
|
||||
<link rel="stylesheet" href="/css/custom/{{ theme_id }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="chat-container">
|
||||
<!-- Chat messages will be injected here by the WebSocket client -->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
0
templates/overlay-dark-purple.css
Normal file
0
templates/overlay-dark-purple.css
Normal file
18
templates/overlay-dark-purple.html
Normal file
18
templates/overlay-dark-purple.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Chat Overlay - Dark Purple</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/overlay-dark-purple.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="chat-container">
|
||||
<div class="chat-message"><span class="username">Streamer:</span> This is the dark purple theme!</div>
|
||||
<div class="chat-message"><span class="username">PurpleHaze:</span> Very cool, feels like Twitch!</div>
|
||||
<div class="chat-message"><span class="username">NightBot:</span> Welcome to the channel!</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
50
templates/overlay-hacker-green.html
Normal file
50
templates/overlay-hacker-green.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hacker Green Overlay</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: transparent;
|
||||
color: #0f0;
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
text-shadow: 0 0 5px #0f0;
|
||||
}
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.username {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chat-container" class="chat-container">
|
||||
<!-- Messages will be injected here -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const userId = window.location.pathname.split('/')[2];
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws/${userId}`);
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
const messageData = JSON.parse(event.data);
|
||||
const chatContainer = document.getElementById('chat-container');
|
||||
const messageElement = document.createElement('div');
|
||||
messageElement.innerHTML = `<span class="username">${messageData.author}:</span> ${messageData.text}`;
|
||||
chatContainer.appendChild(messageElement);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
38
templates/overlay-minimal-light.html
Normal file
38
templates/overlay-minimal-light.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Minimal Light Overlay</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap');
|
||||
body {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: #111827;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-message {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
.username {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Example Message -->
|
||||
<div class="chat-message">
|
||||
<span class="username">User:</span>
|
||||
<span class="message">This is the minimal light theme.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
42
templates/overlay.html
Normal file
42
templates/overlay.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Chat Overlay</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-message {
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.username {
|
||||
font-weight: 800;
|
||||
color: #a970ff; /* A nice purple for usernames */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="chat-container">
|
||||
<div class="chat-message"><span class="username">User123:</span> This is an example chat message!</div>
|
||||
<div class="chat-message"><span class="username">StreamFan:</span> Looks great! Can't wait to use this.</div>
|
||||
<div class="chat-message"><span class="username">AnotherViewer:</span> Hello world!</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
30
templates/overlay_test.html
Normal file
30
templates/overlay_test.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Live Test Overlay</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: black;
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.message {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Live Test Overlay</h1>
|
||||
<div id="chat-messages">
|
||||
<!-- Messages will appear here -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// JavaScript to connect to WebSocket and display messages will go here
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
22
websocket_manager.py
Normal file
22
websocket_manager.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from typing import Dict, List
|
||||
from fastapi import WebSocket
|
||||
|
||||
class WebSocketManager:
|
||||
def __init__(self):
|
||||
# Maps user_id to a list of active WebSocket connections
|
||||
self.active_connections: Dict[int, List[WebSocket]] = {}
|
||||
print("WebSocketManager initialized.")
|
||||
|
||||
async def connect(self, user_id: int, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
if user_id not in self.active_connections:
|
||||
self.active_connections[user_id] = []
|
||||
self.active_connections[user_id].append(websocket)
|
||||
|
||||
def disconnect(self, user_id: int, websocket: WebSocket):
|
||||
self.active_connections[user_id].remove(websocket)
|
||||
|
||||
async def broadcast_to_user(self, user_id: int, message: dict):
|
||||
if user_id in self.active_connections:
|
||||
for connection in self.active_connections[user_id]:
|
||||
await connection.send_json(message)
|
||||
Reference in New Issue
Block a user