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.
|
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
|
### Current Status
|
||||||
The project is in **Phase 1: User Authentication & Database**. Most of this phase is complete.
|
**Phases 1, 2, and 3 are complete.** The application is now a fully functional chat overlay service for Twitch.
|
||||||
* A solid FastAPI application skeleton is in place.
|
* **Phase 1 (Authentication):** A secure Twitch OAuth2 flow is implemented, with user data and encrypted tokens stored in a SQLite database.
|
||||||
* The database schema (`User`, `Setting` models) is defined using SQLAlchemy and 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.
|
||||||
* A secure Twitch OAuth2 authentication flow is fully functional. It correctly:
|
* **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.
|
||||||
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.
|
|
||||||
* A basic HTML login page is served at the root URL (`/`).
|
* 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
|
### Core Architecture
|
||||||
The project is built on the "hybrid architecture" detailed in the `RESEARCH_REPORT.md`:
|
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.
|
* **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
|
## 3. Implementation Roadmap
|
||||||
|
|
||||||
### Phase 1: User Authentication & Database (FastAPI)
|
### Phase 1: User Authentication & Database (FastAPI)
|
||||||
|
**Status: ✔️ Complete**
|
||||||
|
|
||||||
1. **Project Skeleton:** Establish the core FastAPI application structure, dependencies, and version control.
|
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.
|
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.
|
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.
|
5. **Basic Frontend:** Develop a simple login page.
|
||||||
|
|
||||||
### Phase 2: User Dashboard & Configuration
|
### Phase 2: User Dashboard & Configuration
|
||||||
|
**Status: ✔️ Complete**
|
||||||
|
|
||||||
1. **Dashboard UI:** Create a dashboard page accessible only to authenticated users.
|
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).
|
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 on their dashboard.
|
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
|
### 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.
|
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.
|
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.
|
3. **Basic Overlay UI:** Create the `overlay.html` page that connects to the WebSocket and renders incoming chat messages.
|
||||||
|
|
||||||
### Phase 4: Integration & Refinement
|
### 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.
|
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.
|
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.
|
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.
|
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
|
## 🚀 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.
|
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)
|
* **Backend:** Python 3.9+ (FastAPI)
|
||||||
* **Database:** SQLite (initially, for simplicity) with SQLAlchemy
|
* **Database:** SQLite (initially, for simplicity) with SQLAlchemy
|
||||||
|
|||||||
34
TASKS.md
34
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."
|
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:
|
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
|
### To Do
|
||||||
|
|
||||||
### In Progress
|
|
||||||
|
|
||||||
### Done
|
### Done
|
||||||
* `[✔️]` **1.0: Project Skeleton** - @ramforth
|
* `[✔️]` **1.0: Project Skeleton** - @ramforth
|
||||||
* *Task:* Setup `main.py`, `requirements.txt`, and `.gitignore`.
|
* *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
|
## ⏳ Phase 2: User Dashboard & Configuration
|
||||||
|
|
||||||
* **Goal:** Allow logged-in users to see a dashboard, get their overlay URL, and save settings.
|
* **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.
|
||||||
* *(All tasks for this phase are on hold until Phase 1 is complete)*
|
|
||||||
|
|
||||||
### To Do
|
### 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.4: Create Logo and Favicon:** The project should have a logo and a favicon.
|
||||||
* `[ ]` **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}`).
|
### 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.
|
* **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)*
|
* *(All tasks for this phase are on hold until Phase 2 is complete)*
|
||||||
|
|
||||||
### To Do
|
### 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
|
## 💡 Backlog & Future Features
|
||||||
|
|
||||||
* *(Tasks from Phase 4, Gitea Issues, etc., will be added here as we go)*
|
|
||||||
* `[ ]` Implement YouTube OAuth & `pytchat` listener (Phase 4).
|
* `[ ]` Implement YouTube OAuth & `pytchat` listener (Phase 4).
|
||||||
* `[ ]` "Single Message Focus" feature (Issue #1).
|
* `[ ]` "Single Message Focus" feature (Issue #1).
|
||||||
* `[ ]` Moderator panels (Issue #2).
|
* `[ ]` 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
|
encrypted_tokens=encrypted_tokens
|
||||||
)
|
)
|
||||||
db.add(user)
|
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()
|
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.")
|
||||||
151
main.py
151
main.py
@@ -1,15 +1,22 @@
|
|||||||
import os
|
import os
|
||||||
from fastapi import FastAPI, Request, Depends
|
import asyncio
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from fastapi import FastAPI, Request, Depends, HTTPException
|
||||||
from starlette.staticfiles import StaticFiles
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
from starlette.responses import FileResponse, RedirectResponse
|
from starlette.responses import FileResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.websockets import WebSocket
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
import models
|
import models
|
||||||
from database import engine
|
from database import engine, SessionLocal
|
||||||
import auth # Import the new auth module
|
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 config import settings # Import settings to get the secret key
|
||||||
|
from listener_manager import ListenerManager
|
||||||
|
from websocket_manager import WebSocketManager
|
||||||
|
|
||||||
# --- Absolute Path Configuration ---
|
# --- Absolute Path Configuration ---
|
||||||
# Get the absolute path of the directory where this file is located
|
# 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")
|
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
||||||
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# This code runs on startup
|
# This code runs on startup
|
||||||
print("Application startup: Creating database tables...")
|
print("Application startup: Creating database tables...")
|
||||||
|
app.state.websocket_manager = WebSocketManager()
|
||||||
|
app.state.listener_manager = ListenerManager()
|
||||||
models.Base.metadata.create_all(bind=engine)
|
models.Base.metadata.create_all(bind=engine)
|
||||||
print("Application startup: Database tables created.")
|
print("Application startup: Database tables created.")
|
||||||
|
|
||||||
|
# Decouple listener startup from the main application startup
|
||||||
|
asyncio.create_task(background_listener_startup(app))
|
||||||
yield
|
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)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
@@ -42,8 +74,8 @@ app.include_router(auth.router)
|
|||||||
app.add_middleware(SessionMiddleware, secret_key=settings.ENCRYPTION_KEY)
|
app.add_middleware(SessionMiddleware, secret_key=settings.ENCRYPTION_KEY)
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def read_root():
|
async def read_root(request: Request):
|
||||||
return FileResponse(os.path.join(STATIC_DIR, "login.html"))
|
return templates.TemplateResponse("login.html", {"request": request})
|
||||||
|
|
||||||
@app.get("/dashboard")
|
@app.get("/dashboard")
|
||||||
async def read_dashboard(request: Request, db: Session = Depends(auth.get_db)):
|
async def read_dashboard(request: Request, db: Session = Depends(auth.get_db)):
|
||||||
@@ -55,11 +87,112 @@ async def read_dashboard(request: Request, db: Session = Depends(auth.get_db)):
|
|||||||
|
|
||||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
overlay_url = f"{settings.APP_BASE_URL}/overlay/{user.id}"
|
overlay_url = f"{settings.APP_BASE_URL}/overlay/{user.id}"
|
||||||
|
|
||||||
|
# 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})
|
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")
|
@app.get("/logout")
|
||||||
async def logout(request: Request):
|
async def logout(request: Request):
|
||||||
# Clear the session cookie
|
# Clear the session cookie
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
return RedirectResponse(url="/")
|
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.")
|
||||||
14
models.py
14
models.py
@@ -16,12 +16,24 @@ class User(Base):
|
|||||||
encrypted_tokens = Column(Text, nullable=False)
|
encrypted_tokens = Column(Text, nullable=False)
|
||||||
|
|
||||||
settings = relationship("Setting", back_populates="owner", uselist=False)
|
settings = relationship("Setting", back_populates="owner", uselist=False)
|
||||||
|
custom_themes = relationship("CustomTheme", back_populates="owner")
|
||||||
|
|
||||||
class Setting(Base):
|
class Setting(Base):
|
||||||
__tablename__ = "settings"
|
__tablename__ = "settings"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
custom_css = Column(Text, nullable=True)
|
custom_css = Column(Text, nullable=True)
|
||||||
|
overlay_theme = Column(String, default="dark-purple")
|
||||||
user_id = Column(Integer, ForeignKey("users.id"))
|
user_id = Column(Integer, ForeignKey("users.id"))
|
||||||
|
|
||||||
owner = relationship("User", back_populates="settings")
|
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")
|
||||||
@@ -5,4 +5,7 @@ httpx
|
|||||||
cryptography
|
cryptography
|
||||||
python-dotenv
|
python-dotenv
|
||||||
itsdangerous
|
itsdangerous
|
||||||
jinja2
|
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