This commit is contained in:
nvrl committed 2026-04-11 10:37:49 +02:00
1 parent 5e3474dd59
commit e4d59a13ba
119 files changed
+15490 -9975

No files matched your search

+12 -1
View File
@@ -2,15 +2,26 @@
# Your primary domain (e.g., example.com). Used to derive ALLOWED_HOSTS, CORS, and CSRF settings. # Your primary domain (e.g., example.com). Used to derive ALLOWED_HOSTS, CORS, and CSRF settings.
DOMAIN="localhost" DOMAIN="localhost"
# Set to a long, random string in production (e.g. 50+ chars) # Set to a long, random string in production (e.g. 50+ chars). REQUIRED in prod.
SECRET_KEY="django-insecure-replace-this-in-production" SECRET_KEY="django-insecure-replace-this-in-production"
# Which settings module to load. Use holy_backend.settings.dev locally,
# holy_backend.settings.prod in production (Docker compose sets this).
DJANGO_SETTINGS_MODULE=holy_backend.settings.dev
# Set to "false" in production for security and performance # Set to "false" in production for security and performance
DEBUG=true DEBUG=true
# Custom URL for the Django Admin interface (e.g., my-secret-admin/) # Custom URL for the Django Admin interface (e.g., my-secret-admin/)
ADMIN_URL="admin" ADMIN_URL="admin"
# --- Celery / Redis ---
# Broker and result backend for background jobs. Docker compose defaults to the bundled redis service.
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
# Run tasks synchronously (useful for local dev or CI). Unset in production.
# CELERY_TASK_ALWAYS_EAGER=true
# --- Advanced Networking (Optional, derived from DOMAIN if unset) --- # --- Advanced Networking (Optional, derived from DOMAIN if unset) ---
# ALLOWED_HOSTS=example.com,another-domain.com # ALLOWED_HOSTS=example.com,another-domain.com
# CSRF_TRUSTED_ORIGINS=https://example.com # CSRF_TRUSTED_ORIGINS=https://example.com
+42 -17
View File
@@ -8,23 +8,40 @@ on:
jobs: jobs:
frontend-build: frontend-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '25' node-version: '25'
cache: 'npm' cache: 'npm'
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Install Dependencies - name: Install Dependencies
run: npm install run: npm install
working-directory: frontend working-directory: frontend
- name: Build Frontend - name: Typecheck
run: npm run typecheck
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
- name: Format Check
run: npm run format:check
working-directory: frontend
- name: Test
run: npm test -- --run
working-directory: frontend
- name: Build
run: npm run build run: npm run build
working-directory: frontend working-directory: frontend
@@ -32,6 +49,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
env:
SECRET_KEY: "ci-test-key"
DEBUG: "false"
ALLOWED_HOSTS: "localhost"
DJANGO_SETTINGS_MODULE: holy_backend.settings.dev
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -40,24 +62,27 @@ jobs:
with: with:
python-version: '3.14.3' python-version: '3.14.3'
cache: 'pip' cache: 'pip'
cache-dependency-path: backend/requirements.txt cache-dependency-path: backend/requirements-dev.txt
- name: Install Dependencies - name: Install Dependencies
run: pip install -r requirements.txt run: pip install -r requirements-dev.txt
working-directory: backend
- name: Ruff Lint
run: ruff check .
working-directory: backend
- name: Ruff Format Check
run: ruff format --check .
working-directory: backend working-directory: backend
- name: Django System Check - name: Django System Check
run: python manage.py check run: python manage.py check
working-directory: backend
env:
SECRET_KEY: "ci-test-key"
DEBUG: "false"
ALLOWED_HOSTS: "localhost"
- name: Check Migrations - name: Check Migrations
run: python manage.py makemigrations --check --dry-run run: python manage.py makemigrations --check --dry-run
working-directory: backend working-directory: backend
env:
SECRET_KEY: "ci-test-key" - name: Run Tests
DEBUG: "false" run: pytest
ALLOWED_HOSTS: "localhost" working-directory: backend
+6 -7
View File
@@ -1,14 +1,13 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from .models import User, Category, Flavor, Rating, Banner
from .models import Banner, Category, Flavor, Rating, User
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(UserAdmin):
fieldsets = UserAdmin.fieldsets + ( fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("theme",)}),)
(None, {'fields': ('theme',)}), add_fieldsets = UserAdmin.add_fieldsets + ((None, {"fields": ("theme",)}),)
)
add_fieldsets = UserAdmin.add_fieldsets + (
(None, {'fields': ('theme',)}),
)
admin.site.register(User, CustomUserAdmin) admin.site.register(User, CustomUserAdmin)
admin.site.register(Category) admin.site.register(Category)
+1 -1
View File
@@ -2,4 +2,4 @@ from django.apps import AppConfig
class ApiConfig(AppConfig): class ApiConfig(AppConfig):
name = 'api' name = "api"
+5 -2
View File
@@ -1,6 +1,8 @@
import ssl import ssl
from django.core.mail.backends.smtp import EmailBackend
from django.conf import settings from django.conf import settings
from django.core.mail.backends.smtp import EmailBackend
class InsecureEmailBackend(EmailBackend): class InsecureEmailBackend(EmailBackend):
""" """
@@ -8,9 +10,10 @@ class InsecureEmailBackend(EmailBackend):
Useful for local development or internal SMTP servers with self-signed certificates. Useful for local development or internal SMTP servers with self-signed certificates.
Set EMAIL_SKIP_CERT_VERIFICATION = True in settings to use. Set EMAIL_SKIP_CERT_VERIFICATION = True in settings to use.
""" """
@property @property
def ssl_context(self): def ssl_context(self):
if getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False): if getattr(settings, "EMAIL_SKIP_CERT_VERIFICATION", False):
# The most robust way to bypass verification for self-signed certs # The most robust way to bypass verification for self-signed certs
context = ssl._create_unverified_context() context = ssl._create_unverified_context()
return context return context
+36 -26
View File
@@ -1,38 +1,43 @@
import sqlite3
import os import os
import shutil import sqlite3
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
class Command(BaseCommand): class Command(BaseCommand):
help = 'Creates a safe, consistent backup of the SQLite database and manages rotation.' help = "Creates a safe, consistent backup of the SQLite database and manages rotation."
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('--output', type=str, help='Specific output path for the backup file') parser.add_argument("--output", type=str, help="Specific output path for the backup file")
parser.add_argument('--retention', type=int, default=7, help='Number of days to keep old backups') parser.add_argument(
parser.add_argument('--full', action='store_true', help='Create a full archive including media') "--retention", type=int, default=7, help="Number of days to keep old backups"
)
parser.add_argument(
"--full", action="store_true", help="Create a full archive including media"
)
def handle(self, *args, **options): def handle(self, *args, **options):
db_conf = settings.DATABASES['default'] db_conf = settings.DATABASES["default"]
if db_conf['ENGINE'] != 'django.db.backends.sqlite3': if db_conf["ENGINE"] != "django.db.backends.sqlite3":
self.stdout.write(self.style.ERROR("This command only supports SQLite.")) self.stdout.write(self.style.ERROR("This command only supports SQLite."))
return return
db_path = str(db_conf['NAME']) db_path = str(db_conf["NAME"])
backup_dir = os.path.join(settings.BASE_DIR, 'backups') backup_dir = os.path.join(settings.BASE_DIR, "backups")
os.makedirs(backup_dir, exist_ok=True) os.makedirs(backup_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# 1. Database Backup # 1. Database Backup
if options['output']: if options["output"]:
db_backup_path = options['output'] db_backup_path = options["output"]
else: else:
db_backup_path = os.path.join(backup_dir, f'db_backup_{timestamp}.sqlite3') db_backup_path = os.path.join(backup_dir, f"db_backup_{timestamp}.sqlite3")
self.stdout.write(f"Cloning database {db_path}...") self.stdout.write(f"Cloning database {db_path}...")
try: try:
src = sqlite3.connect(db_path) src = sqlite3.connect(db_path)
dst = sqlite3.connect(db_backup_path) dst = sqlite3.connect(db_backup_path)
@@ -46,12 +51,13 @@ class Command(BaseCommand):
return return
# 2. Full Archive (Optional) # 2. Full Archive (Optional)
if options['full']: if options["full"]:
import tarfile import tarfile
archive_name = f"full_backup_{timestamp}.tar.gz" archive_name = f"full_backup_{timestamp}.tar.gz"
archive_path = os.path.join(backup_dir, archive_name) archive_path = os.path.join(backup_dir, archive_name)
self.stdout.write(f"Creating full archive at {archive_path}...") self.stdout.write(f"Creating full archive at {archive_path}...")
try: try:
with tarfile.open(archive_path, "w:gz") as tar: with tarfile.open(archive_path, "w:gz") as tar:
# Add database # Add database
@@ -60,19 +66,19 @@ class Command(BaseCommand):
media_dir = str(settings.MEDIA_ROOT) media_dir = str(settings.MEDIA_ROOT)
if os.path.exists(media_dir): if os.path.exists(media_dir):
tar.add(media_dir, arcname="media") tar.add(media_dir, arcname="media")
# Remove the raw sqlite backup after bundling it into the archive # Remove the raw sqlite backup after bundling it into the archive
if not options['output']: if not options["output"]:
os.remove(db_backup_path) os.remove(db_backup_path)
self.stdout.write(self.style.SUCCESS(f"Full archive created successfully.")) self.stdout.write(self.style.SUCCESS("Full archive created successfully."))
except Exception as e: except Exception as e:
self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}")) self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}"))
# 3. Cleanup Old Backups # 3. Cleanup Old Backups
retention_days = options['retention'] retention_days = options["retention"]
cutoff = datetime.now() - timedelta(days=retention_days) cutoff = datetime.now() - timedelta(days=retention_days)
self.stdout.write(f"Cleaning up backups older than {retention_days} days...") self.stdout.write(f"Cleaning up backups older than {retention_days} days...")
for filename in os.listdir(backup_dir): for filename in os.listdir(backup_dir):
file_path = os.path.join(backup_dir, filename) file_path = os.path.join(backup_dir, filename)
@@ -81,6 +87,10 @@ class Command(BaseCommand):
if file_time < cutoff: if file_time < cutoff:
try: try:
os.remove(file_path) os.remove(file_path)
self.stdout.write(self.style.SUCCESS(f" -> Deleted old backup: {filename}")) self.stdout.write(
self.style.SUCCESS(f" -> Deleted old backup: {filename}")
)
except Exception as e: except Exception as e:
self.stdout.write(self.style.WARNING(f" -> Failed to delete {filename}: {e}")) self.stdout.write(
self.style.WARNING(f" -> Failed to delete {filename}: {e}")
)
@@ -1,34 +1,39 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from api.models import Flavor, Rating
from django.db.models import Count from django.db.models import Count
from api.models import Flavor, Rating
class Command(BaseCommand): class Command(BaseCommand):
help = 'Identifies and merges duplicate flavors (same name and category)' help = "Identifies and merges duplicate flavors (same name and category)"
def handle(self, *args, **options): def handle(self, *args, **options):
# Find duplicates based on name and category # Find duplicates based on name and category
duplicates = Flavor.objects.values('name', 'category').annotate( duplicates = (
name_count=Count('id') Flavor.objects.values("name", "category")
).filter(name_count__gt=1) .annotate(name_count=Count("id"))
.filter(name_count__gt=1)
)
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.") self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
for dup in duplicates: for dup in duplicates:
name = dup['name'] name = dup["name"]
category_id = dup['category'] category_id = dup["category"]
# Get all flavors in this set, ordered by having an external_id first, then by date # Get all flavors in this set, ordered by having an external_id first, then by date
flavor_set = Flavor.objects.filter( flavor_set = Flavor.objects.filter(name=name, category_id=category_id).order_by(
name=name, "-external_id", "created_at"
category_id=category_id )
).order_by('-external_id', 'created_at')
# Keep the first one, merge others into it # Keep the first one, merge others into it
keep = flavor_set[0] keep = flavor_set[0]
others = flavor_set[1:] others = flavor_set[1:]
self.stdout.write(f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})") self.stdout.write(
f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})"
)
for other in others: for other in others:
# Re-assign ratings # Re-assign ratings
for rating in Rating.objects.filter(flavor=other): for rating in Rating.objects.filter(flavor=other):
@@ -39,7 +44,7 @@ class Command(BaseCommand):
# User already has a rating for the 'keep' flavor # User already has a rating for the 'keep' flavor
# Delete the duplicate rating instead # Delete the duplicate rating instead
rating.delete() rating.delete()
# Delete the duplicate flavor # Delete the duplicate flavor
other.delete() other.delete()
+33 -22
View File
@@ -1,64 +1,75 @@
from django.core.management.base import BaseCommand
from api.models import Banner
import os
import json import json
import os
from django.core.management.base import BaseCommand
from api.models import Banner
class Command(BaseCommand): class Command(BaseCommand):
help = 'Seeds banner models and missing settings from JSON files in backend/banners/' help = "Seeds banner models and missing settings from JSON files in backend/banners/"
def handle(self, *args, **options): def handle(self, *args, **options):
# Determine the banners directory relative to the project root # Determine the banners directory relative to the project root
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
banners_dir = os.path.join(project_root, 'banners') banners_dir = os.path.join(project_root, "banners")
if not os.path.exists(banners_dir): if not os.path.exists(banners_dir):
self.stdout.write(self.style.WARNING(f"Banners directory not found at {banners_dir}")) self.stdout.write(self.style.WARNING(f"Banners directory not found at {banners_dir}"))
return return
for filename in os.listdir(banners_dir): for filename in os.listdir(banners_dir):
if filename.endswith('.json'): if filename.endswith(".json"):
filepath = os.path.join(banners_dir, filename) filepath = os.path.join(banners_dir, filename)
try: try:
with open(filepath, 'r') as f: with open(filepath) as f:
data = json.load(f) data = json.load(f)
slug = data.get('slug') slug = data.get("slug")
if not slug: if not slug:
continue continue
banner, created = Banner.objects.get_or_create( banner, created = Banner.objects.get_or_create(
slug=slug, slug=slug,
defaults={ defaults={
'name': data.get('name', slug), "name": data.get("name", slug),
'description': data.get('description', ''), "description": data.get("description", ""),
'settings': data.get('settings', {}) "settings": data.get("settings", {}),
} },
) )
if not created: if not created:
# Sync missing settings from JSON to DB without overwriting existing keys # Sync missing settings from JSON to DB without overwriting existing keys
db_settings = banner.settings or {} db_settings = banner.settings or {}
json_settings = data.get('settings', {}) json_settings = data.get("settings", {})
json_schema = data.get('schema', []) json_schema = data.get("schema", [])
updated = False updated = False
# Sync missing settings # Sync missing settings
for key, value in json_settings.items(): for key, value in json_settings.items():
if key not in db_settings: if key not in db_settings:
db_settings[key] = value db_settings[key] = value
updated = True updated = True
# Always sync schema as it represents the UI structure # Always sync schema as it represents the UI structure
if banner.schema != json_schema: if banner.schema != json_schema:
banner.schema = json_schema banner.schema = json_schema
updated = True updated = True
if updated: if updated:
banner.settings = db_settings banner.settings = db_settings
banner.save() banner.save()
self.stdout.write(self.style.SUCCESS(f"Synced new settings for banner '{slug}' from {filename}")) self.stdout.write(
self.style.SUCCESS(
f"Synced new settings for banner '{slug}' from {filename}"
)
)
else: else:
self.stdout.write(self.style.SUCCESS(f"Created new banner '{slug}' from {filename}")) self.stdout.write(
self.style.SUCCESS(f"Created new banner '{slug}' from {filename}")
)
except Exception as e: except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to sync banner from {filename}: {e}")) self.stdout.write(
self.style.ERROR(f"Failed to sync banner from {filename}: {e}")
)
@@ -1,41 +1,45 @@
import json import json
import os import os
import requests import requests
from django.core.management.base import BaseCommand
from django.core.files import File
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.text import slugify from django.utils.text import slugify
from api.models import Category, Flavor from api.models import Category, Flavor
class Command(BaseCommand): class Command(BaseCommand):
help = 'Adds legacy flavors from JSON files in the legacy/ folder' help = "Adds legacy flavors from JSON files in the legacy/ folder"
def download_image(self, url, flavor_name): def download_image(self, url, flavor_name):
if not url: if not url:
return None return None
try: try:
safe_name = slugify(flavor_name) safe_name = slugify(flavor_name)
ext = url.split('.')[-1].split('?')[0].lower() ext = url.split(".")[-1].split("?")[0].lower()
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']: if ext not in ["jpg", "jpeg", "png", "webp", "gif"]:
ext = 'png' ext = "png"
filename = f"legacy_{safe_name}.{ext}" filename = f"legacy_{safe_name}.{ext}"
filepath = os.path.join(settings.MEDIA_ROOT, 'flavors', filename) filepath = os.path.join(settings.MEDIA_ROOT, "flavors", filename)
os.makedirs(os.path.dirname(filepath), exist_ok=True) os.makedirs(os.path.dirname(filepath), exist_ok=True)
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10) response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
response.raise_for_status() response.raise_for_status()
if os.path.exists(filepath): if os.path.exists(filepath):
os.remove(filepath) os.remove(filepath)
with open(filepath, 'wb') as f: with open(filepath, "wb") as f:
f.write(response.content) f.write(response.content)
return f"flavors/{filename}" return f"flavors/{filename}"
except Exception as e: except Exception as e:
self.stdout.write(self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")) self.stdout.write(
self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")
)
return None return None
def handle(self, *args, **options): def handle(self, *args, **options):
@@ -43,88 +47,103 @@ class Command(BaseCommand):
# Instead, we update existing entries. # Instead, we update existing entries.
cat_map = { cat_map = {
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0], "energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[0], "hydration": Category.objects.get_or_create(
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0], name="Hydration", defaults={"slug": "hydration"}
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0], )[0],
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[0], "iced-tea": Category.objects.get_or_create(
name="Iced Tea", defaults={"slug": "iced-tea"}
)[0],
"milkshake": Category.objects.get_or_create(
name="Milkshake", defaults={"slug": "milkshake"}
)[0],
"packs": Category.objects.get_or_create(
name="Packs and other", defaults={"slug": "packs-and-other"}
)[0],
} }
legacy_dir = os.path.join(settings.BASE_DIR, 'legacy') legacy_dir = os.path.join(settings.BASE_DIR, "legacy")
if not os.path.exists(legacy_dir): if not os.path.exists(legacy_dir):
legacy_dir = os.path.join(settings.BASE_DIR, '..', 'legacy') legacy_dir = os.path.join(settings.BASE_DIR, "..", "legacy")
if not os.path.exists(legacy_dir): if not os.path.exists(legacy_dir):
self.stdout.write(self.style.ERROR(f"Legacy directory not found.")) self.stdout.write(self.style.ERROR("Legacy directory not found."))
return return
json_files = [f for f in os.listdir(legacy_dir) if f.endswith('.json')] json_files = [f for f in os.listdir(legacy_dir) if f.endswith(".json")]
processed_count = 0 processed_count = 0
for json_file in json_files: for json_file in json_files:
file_path = os.path.join(legacy_dir, json_file) file_path = os.path.join(legacy_dir, json_file)
file_lower = json_file.lower() file_lower = json_file.lower()
# Determine category from filename # Determine category from filename
if 'energy' in file_lower: if "energy" in file_lower:
file_cat = cat_map['energy'] file_cat = cat_map["energy"]
elif 'iced_tea' in file_lower or 'iced-tea' in file_lower: elif "iced_tea" in file_lower or "iced-tea" in file_lower:
file_cat = cat_map['iced-tea'] file_cat = cat_map["iced-tea"]
elif 'hydration' in file_lower: elif "hydration" in file_lower:
file_cat = cat_map['hydration'] file_cat = cat_map["hydration"]
elif 'milkshake' in file_lower: elif "milkshake" in file_lower:
file_cat = cat_map['milkshake'] file_cat = cat_map["milkshake"]
else: else:
file_cat = None file_cat = None
with open(file_path, 'r', encoding='utf-8') as f: with open(file_path, encoding="utf-8") as f:
data_list = json.load(f) data_list = json.load(f)
for data in data_list: for data in data_list:
name = data.get('Name') name = data.get("Name")
desc = data.get('Beschreibung') desc = data.get("Beschreibung")
taste = data.get('Geschmack') taste = data.get("Geschmack")
status_text = data.get('Status') status_text = data.get("Status")
image_url = data.get('Bild_URL') image_url = data.get("Bild_URL")
full_desc = f"**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}" full_desc = f"**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}"
# Determine final category # Determine final category
cat = file_cat cat = file_cat
if not cat: if not cat:
# Fallback to name/desc detection if filename was ambiguous # Fallback to name/desc detection if filename was ambiguous
if "hydration" in name.lower() or "hydration" in desc.lower(): if "hydration" in name.lower() or "hydration" in desc.lower():
cat = cat_map['hydration'] cat = cat_map["hydration"]
elif "iced tea" in name.lower() or "eistee" in name.lower(): elif "iced tea" in name.lower() or "eistee" in name.lower():
cat = cat_map['iced-tea'] cat = cat_map["iced-tea"]
elif "milkshake" in name.lower(): elif "milkshake" in name.lower():
cat = cat_map['milkshake'] cat = cat_map["milkshake"]
elif any(k in name.lower() for k in ['bundle', 'set', 'box', 'probe', 'sample', 'taster', 'shaker', 'starter']): elif any(
cat = cat_map['packs'] k in name.lower()
for k in [
"bundle",
"set",
"box",
"probe",
"sample",
"taster",
"shaker",
"starter",
]
):
cat = cat_map["packs"]
else: else:
cat = cat_map['energy'] # Ultimate fallback cat = cat_map["energy"] # Ultimate fallback
# Try to find existing flavor by name and category regardless of legacy status # Try to find existing flavor by name and category regardless of legacy status
flavor = Flavor.objects.filter(name__iexact=name, category=cat).first() flavor = Flavor.objects.filter(name__iexact=name, category=cat).first()
# If it exists and is an active Shopify product, skip legacy processing # If it exists and is an active Shopify product, skip legacy processing
if flavor and not flavor.is_legacy: if flavor and not flavor.is_legacy:
self.stdout.write(f"Skipping active flavor: {name}") self.stdout.write(f"Skipping active flavor: {name}")
continue continue
if not flavor: if not flavor:
flavor = Flavor( flavor = Flavor(name=name, category=cat, is_legacy=True, external_id=None)
name=name,
category=cat,
is_legacy=True,
external_id=None
)
self.stdout.write(f"Adding new legacy flavor: {name}") self.stdout.write(f"Adding new legacy flavor: {name}")
else: else:
self.stdout.write(f"Updating legacy flavor: {name}") self.stdout.write(f"Updating legacy flavor: {name}")
flavor.description = full_desc flavor.description = full_desc
flavor.is_available = False # Legacy is always unavailable via API flavor.is_available = False # Legacy is always unavailable via API
if image_url: if image_url:
# Check if local file exists # Check if local file exists
@@ -140,9 +159,11 @@ class Command(BaseCommand):
if rel_path: if rel_path:
flavor.image = rel_path flavor.image = rel_path
self.stdout.write(f" -> Updated image for: {name}") self.stdout.write(f" -> Updated image for: {name}")
flavor.image_url = image_url flavor.image_url = image_url
flavor.save() flavor.save()
processed_count += 1 processed_count += 1
self.stdout.write(self.style.SUCCESS(f"Finished! Processed {processed_count} legacy flavors.")) self.stdout.write(
self.style.SUCCESS(f"Finished! Processed {processed_count} legacy flavors.")
)
+103 -67
View File
@@ -1,21 +1,22 @@
import json
import requests
import re
import os import os
from django.core.management.base import BaseCommand import re
from django.core.files import File
import requests
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.text import slugify from django.utils.text import slugify
from api.models import Category, Flavor from api.models import Category, Flavor
class Command(BaseCommand): class Command(BaseCommand):
help = 'Syncs flavors from Holy Energy Shopify API' help = "Syncs flavors from Holy Energy Shopify API"
def clean_html(self, raw_html): def clean_html(self, raw_html):
if not raw_html: if not raw_html:
return "" return ""
cleanr = re.compile('<.*?>') cleanr = re.compile("<.*?>")
cleantext = re.sub(cleanr, '', raw_html) cleantext = re.sub(cleanr, "", raw_html)
return cleantext.strip() return cleantext.strip()
def download_image(self, url, flavor_name): def download_image(self, url, flavor_name):
@@ -23,50 +24,60 @@ class Command(BaseCommand):
return None return None
try: try:
safe_name = slugify(flavor_name) safe_name = slugify(flavor_name)
ext = url.split('.')[-1].split('?')[0].lower() ext = url.split(".")[-1].split("?")[0].lower()
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']: if ext not in ["jpg", "jpeg", "png", "webp", "gif"]:
ext = 'png' ext = "png"
filename = f"{safe_name}.{ext}" filename = f"{safe_name}.{ext}"
filepath = os.path.join(settings.MEDIA_ROOT, 'flavors', filename) filepath = os.path.join(settings.MEDIA_ROOT, "flavors", filename)
os.makedirs(os.path.dirname(filepath), exist_ok=True) os.makedirs(os.path.dirname(filepath), exist_ok=True)
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10) response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
response.raise_for_status() response.raise_for_status()
if os.path.exists(filepath): if os.path.exists(filepath):
os.remove(filepath) os.remove(filepath)
with open(filepath, 'wb') as f: with open(filepath, "wb") as f:
f.write(response.content) f.write(response.content)
return f"flavors/{filename}" return f"flavors/{filename}"
except Exception as e: except Exception as e:
self.stdout.write(self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")) self.stdout.write(
self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")
)
return None return None
def handle(self, *args, **options): def handle(self, *args, **options):
url = "https://weareholy.com/products.json?limit=250" url = "https://weareholy.com/products.json?limit=250"
self.stdout.write(f"Fetching from {url}...") self.stdout.write(f"Fetching from {url}...")
try: try:
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=15) response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
except Exception as e: except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to fetch data: {e}")) self.stdout.write(self.style.ERROR(f"Failed to fetch data: {e}"))
return return
products = data.get('products', []) products = data.get("products", [])
self.stdout.write(f"Found {len(products)} products.") self.stdout.write(f"Found {len(products)} products.")
cat_map = { cat_map = {
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0], "energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[0], "hydration": Category.objects.get_or_create(
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0], name="Hydration", defaults={"slug": "hydration"}
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0], )[0],
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[0], "iced-tea": Category.objects.get_or_create(
name="Iced Tea", defaults={"slug": "iced-tea"}
)[0],
"milkshake": Category.objects.get_or_create(
name="Milkshake", defaults={"slug": "milkshake"}
)[0],
"packs": Category.objects.get_or_create(
name="Packs and other", defaults={"slug": "packs-and-other"}
)[0],
} }
created_count = 0 created_count = 0
@@ -74,82 +85,97 @@ class Command(BaseCommand):
synced_external_ids = [] synced_external_ids = []
for p in products: for p in products:
title = p.get('title', '').strip() title = p.get("title", "").strip()
tags = p.get('tags', []) tags = p.get("tags", [])
if isinstance(tags, str): if isinstance(tags, str):
tags = [t.strip() for t in tags.split(',')] tags = [t.strip() for t in tags.split(",")]
tags_lower = [t.lower() for t in tags] tags_lower = [t.lower() for t in tags]
title_lower = title.lower() title_lower = title.lower()
is_pack = False is_pack = False
pack_keywords = ['bundle', 'set', 'box', 'probe', 'sample', 'taster', 'starter', 'collection', 'probier'] pack_keywords = [
"bundle",
"set",
"box",
"probe",
"sample",
"taster",
"starter",
"collection",
"probier",
]
if any(k in title_lower for k in pack_keywords): if any(k in title_lower for k in pack_keywords):
is_pack = True is_pack = True
if 'shaker' in title_lower or 'merch' in tags_lower: if "shaker" in title_lower or "merch" in tags_lower:
is_pack = True is_pack = True
category = None category = None
if is_pack: if is_pack:
category = cat_map['packs'] category = cat_map["packs"]
elif 'holy energy' in tags_lower or 'energy' in tags_lower: elif "holy energy" in tags_lower or "energy" in tags_lower:
category = cat_map['energy'] category = cat_map["energy"]
elif 'holy hydration' in tags_lower or 'hydration' in tags_lower: elif "holy hydration" in tags_lower or "hydration" in tags_lower:
category = cat_map['hydration'] category = cat_map["hydration"]
elif 'holy iced tea' in tags_lower or 'iced tea' in tags_lower: elif "holy iced tea" in tags_lower or "iced tea" in tags_lower:
category = cat_map['iced-tea'] category = cat_map["iced-tea"]
elif 'milkshake' in tags_lower: elif "milkshake" in tags_lower:
category = cat_map['milkshake'] category = cat_map["milkshake"]
if not category: if not category:
if 'shaker' in tags_lower: if "shaker" in tags_lower:
category = cat_map['packs'] category = cat_map["packs"]
else: else:
continue continue
variants = p.get('variants', []) variants = p.get("variants", [])
is_available = any(v.get('available') for v in variants) is_available = any(v.get("available") for v in variants)
images = p.get('images', []) images = p.get("images", [])
image_url = images[0].get('src') if images else None image_url = images[0].get("src") if images else None
description = self.clean_html(p.get('body_html', '')) description = self.clean_html(p.get("body_html", ""))
handle = p.get('handle') handle = p.get("handle")
shop_url = f"https://weareholy.com/products/{handle}" if handle else None shop_url = f"https://weareholy.com/products/{handle}" if handle else None
# Find or create - be very specific to avoid duplicates # Find or create - be very specific to avoid duplicates
# 1. Try external_id # 1. Try external_id
flavor = Flavor.objects.filter(external_id=p['id']).first() flavor = Flavor.objects.filter(external_id=p["id"]).first()
if not flavor: if not flavor:
# 2. Try exact name match in category # 2. Try exact name match in category
# REMOVED: is_legacy=False # REMOVED: is_legacy=False
flavor = Flavor.objects.filter(name=title, category=category).first() flavor = Flavor.objects.filter(name=title, category=category).first()
if not flavor: if not flavor:
# 3. Try case-insensitive name match in category (safety for whitespace/case) # 3. Try case-insensitive name match in category (safety for whitespace/case)
# REMOVED: is_legacy=False # REMOVED: is_legacy=False
flavor = Flavor.objects.filter(name__iexact=title, category=category).first() flavor = Flavor.objects.filter(name__iexact=title, category=category).first()
if not flavor: if not flavor:
flavor = Flavor(external_id=p['id'], name=title, category=category, is_legacy=False) flavor = Flavor(external_id=p["id"], name=title, category=category, is_legacy=False)
created_count += 1 created_count += 1
else: else:
updated_count += 1 updated_count += 1
# Ensure external_id is updated if it was missing # Ensure external_id is updated if it was missing
if not flavor.external_id: if not flavor.external_id:
flavor.external_id = p['id'] flavor.external_id = p["id"]
# Reactivate the flavor if it was previously marked as legacy # Reactivate the flavor if it was previously marked as legacy
if flavor.is_legacy: if flavor.is_legacy:
flavor.is_legacy = False flavor.is_legacy = False
# Ensure all synced fields are accurately reflected # Ensure all synced fields are accurately reflected
if flavor.name != title and not Flavor.objects.filter(name=title, category=category).exclude(pk=flavor.pk).exists(): if (
flavor.name != title
and not Flavor.objects.filter(name=title, category=category)
.exclude(pk=flavor.pk)
.exists()
):
flavor.name = title flavor.name = title
flavor.description = description flavor.description = description
flavor.shop_url = shop_url flavor.shop_url = shop_url
flavor.is_available = is_available flavor.is_available = is_available
if image_url: if image_url:
# Check if local file exists # Check if local file exists
file_exists = False file_exists = False
@@ -165,11 +191,21 @@ class Command(BaseCommand):
flavor.image = rel_path flavor.image = rel_path
self.stdout.write(f"Downloaded image for: {title}") self.stdout.write(f"Downloaded image for: {title}")
else: else:
self.stdout.write(self.style.WARNING(f"Failed to ensure image for: {title}")) self.stdout.write(
self.style.WARNING(f"Failed to ensure image for: {title}")
)
flavor.image_url = image_url flavor.image_url = image_url
flavor.save() flavor.save()
synced_external_ids.append(p['id']) synced_external_ids.append(p["id"])
discontinued_count = Flavor.objects.filter(external_id__isnull=False).exclude(external_id__in=synced_external_ids).update(is_available=False) discontinued_count = (
self.stdout.write(self.style.SUCCESS(f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {discontinued_count}")) Flavor.objects.filter(external_id__isnull=False)
.exclude(external_id__in=synced_external_ids)
.update(is_available=False)
)
self.stdout.write(
self.style.SUCCESS(
f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {discontinued_count}"
)
)
-81
View File
@@ -1,81 +0,0 @@
import time
import sys
from datetime import datetime, timedelta
from django.core.management.base import BaseCommand
from django.core.management import call_command
from django.utils import timezone
from django.db import models
from api.models import Job
from io import StringIO
class Command(BaseCommand):
help = 'Background worker to process scheduled and on-demand jobs.'
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS("Starting Holy Archive Worker..."))
# Ensure all core jobs exist in the DB
for job_type, label in Job.JOB_TYPES:
Job.objects.get_or_create(name=job_type)
while True:
# 1. Find jobs that are either explicitly set to 'pending' (manual trigger)
# or those where next_run has passed.
now = timezone.now()
jobs_to_run = Job.objects.filter(
~models.Q(status='running')
).filter(
# Run if pending OR (next_run is set AND has passed)
models.Q(status='pending') |
models.Q(next_run__lte=now, interval_hours__gt=0)
)
for job in jobs_to_run:
self.run_job(job)
time.sleep(30) # Check every 30 seconds
def run_job(self, job):
self.stdout.write(f"Executing Job: {job.get_name_display()}...")
job.status = 'running'
job.last_run = timezone.now()
job.last_output = "" # Reset output for new run
job.save()
# Capture management command output
class LiveStream(StringIO):
def write(self, s):
super().write(s)
# Periodically update the DB with current output
job.last_output = self.getvalue()
job.save(update_fields=['last_output'])
out = LiveStream()
error = ""
try:
if job.name == 'sync_flavors':
call_command('sync_flavors', stdout=out)
elif job.name == 'cleanup_duplicates':
call_command('cleanup_duplicates', stdout=out)
elif job.name == 'backup_db':
call_command('backup_db', '--full', stdout=out)
elif job.name == 'seed_legacy':
call_command('seed_legacy_flavors', stdout=out)
job.status = 'completed'
except Exception as e:
job.status = 'failed'
error = str(e)
self.stdout.write(self.style.ERROR(f"Job failed: {error}"))
# Update scheduling for next run if interval is set
if job.interval_hours > 0:
job.next_run = timezone.now() + timedelta(hours=job.interval_hours)
else:
job.next_run = None
job.last_output = out.getvalue()
job.error_message = error
job.save()
self.stdout.write(self.style.SUCCESS(f"Job finished: {job.status}"))
@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-04-11 00:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0029_alter_job_name'),
]
operations = [
migrations.AddField(
model_name='flavor',
name='image_urls',
field=models.JSONField(blank=True, default=list),
),
]
-266
View File
@@ -1,266 +0,0 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.core.validators import MinValueValidator, MaxValueValidator
class User(AbstractUser):
THEME_CHOICES = [
('holy_light', 'Holy Light'),
('holy_dark', 'Holy Dark'),
('latte', 'Catppuccin Latte'),
('frappe', 'Catppuccin Frappé'),
('macchiato', 'Catppuccin Macchiato'),
('mocha', 'Catppuccin Mocha'),
('pink_pastel', 'Pink Pastel'),
('mint_pastel', 'Mint Pastel'),
('lavender_pastel', 'Lavender Pastel'),
('dracula', 'Dracula'),
('nord', 'Nordic Frost'),
('gruvbox', 'Gruvbox Retro'),
('oceanic', 'Oceanic Deep'),
('t0p_sai', 'SAI (T0P)'),
('t0p_trench', 'Trench (T0P)'),
('t0p_blurryface', 'Blurryface (T0P)'),
('t0p_clancy', 'Clancy (T0P)'),
]
LANGUAGE_CHOICES = [
('en', 'English'),
('de', 'Deutsch'),
]
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default='holy_light')
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default='en')
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
pending_email = models.EmailField(max_length=254, blank=True, null=True)
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
following = models.ManyToManyField('self', symmetrical=False, related_name='followers', blank=True)
selected_banner = models.ForeignKey('Banner', on_delete=models.SET_NULL, null=True, blank=True, related_name='users')
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.avatar:
try:
from PIL import Image
img = Image.open(self.avatar.path)
if img.height > 256 or img.width > 256 or img.format != 'JPEG':
output_size = (256, 256)
img.thumbnail(output_size)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(self.avatar.path, 'JPEG', quality=90)
except Exception:
pass
def __str__(self):
return self.username
class Category(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
class Meta:
verbose_name_plural = "Categories"
ordering = ['name']
def __str__(self):
return self.name
class Flavor(models.Model):
category = models.ForeignKey(Category, related_name='flavors', on_delete=models.CASCADE)
name = models.CharField(max_length=100) # Removed unique=True here
description = models.TextField(blank=True)
image_url = models.URLField(max_length=1000, blank=True, null=True)
image = models.ImageField(upload_to='flavors/', blank=True, null=True)
shop_url = models.URLField(max_length=500, blank=True, null=True)
is_available = models.BooleanField(default=True)
is_legacy = models.BooleanField(default=False)
external_id = models.BigIntegerField(unique=True, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
# This allows "Erdbeere" in Iced Tea and "Erdbeere" in Milkshake
constraints = [
models.UniqueConstraint(fields=['name', 'category'], name='unique_flavor_name_per_category')
]
ordering = ['name']
def __str__(self):
return f"{self.name} ({self.category.name})"
@property
def get_average_rating(self):
ratings = self.ratings.all()
if ratings.exists():
return sum(r.score for r in ratings) / ratings.count()
return 0.0
@property
def cached_image_url(self):
if self.image:
return self.image.url
return self.image_url
class Rating(models.Model):
user = models.ForeignKey(User, related_name='ratings', on_delete=models.CASCADE)
flavor = models.ForeignKey(Flavor, related_name='ratings', on_delete=models.CASCADE)
score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
comment = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'flavor')
ordering = ['-created_at']
def __str__(self):
return f"{self.user.username} - {self.flavor.name}: {self.score}"
class Reply(models.Model):
user = models.ForeignKey(User, related_name='replies', on_delete=models.CASCADE)
rating = models.ForeignKey(Rating, related_name='replies', on_delete=models.CASCADE)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f"Reply by {self.user.username} on {self.rating.id}"
class Notification(models.Model):
TYPE_CHOICES = [
('reply', 'Reply'),
('mention', 'Mention'),
('follow', 'Follow'),
('ticket_new', 'New Ticket'),
('ticket_reply', 'Ticket Reply'),
('profile_comment', 'Profile Comment'),
]
recipient = models.ForeignKey(User, related_name='notifications', on_delete=models.CASCADE)
actor = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE)
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
rating = models.ForeignKey(Rating, on_delete=models.CASCADE, null=True, blank=True)
reply = models.ForeignKey(Reply, on_delete=models.CASCADE, null=True, blank=True)
ticket = models.ForeignKey('Ticket', on_delete=models.CASCADE, null=True, blank=True)
profile_comment = models.ForeignKey('ProfileComment', on_delete=models.CASCADE, null=True, blank=True)
is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.notification_type} for {self.recipient.username}"
class UserIP(models.Model):
user = models.ForeignKey(User, related_name='ips', on_delete=models.CASCADE)
ip_address = models.GenericIPAddressField()
last_login = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'ip_address')
ordering = ['-last_login']
class Ticket(models.Model):
STATUS_CHOICES = [
('open', 'Open'),
('in_progress', 'In Progress'),
('resolved', 'Resolved'),
('closed', 'Closed'),
]
user = models.ForeignKey(User, related_name='tickets', on_delete=models.CASCADE)
subject = models.CharField(max_length=200)
description = models.TextField()
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-updated_at']
class TicketMessage(models.Model):
ticket = models.ForeignKey(Ticket, related_name='messages', on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
class ProfileComment(models.Model):
profile_owner = models.ForeignKey(User, related_name='profile_comments', on_delete=models.CASCADE)
author = models.ForeignKey(User, related_name='authored_profile_comments', on_delete=models.CASCADE)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
class Banner(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
is_active = models.BooleanField(default=False, help_text="Designates this as the global default banner.")
is_enabled = models.BooleanField(default=True, help_text="Allows users to select this banner.")
settings = models.JSONField(default=dict, blank=True)
schema = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
if self.is_active:
# Set all others to inactive
Banner.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False)
elif not Banner.objects.filter(is_active=True).exclude(pk=self.pk).exists():
# If this was the only active banner, don't allow deactivating it
# unless there's another one to take its place.
self.is_active = True
super().save(*args, **kwargs)
def __str__(self):
return self.name
class SystemConfig(models.Model):
"""Singleton model for application-wide settings editable via UI."""
site_name = models.CharField(max_length=100, default="Holy Flavors Archive")
maintenance_mode = models.BooleanField(default=False)
allow_new_signups = models.BooleanField(default=True)
require_email_verification = models.BooleanField(default=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
self.pk = 1 # Force singleton
super().save(*args, **kwargs)
@classmethod
def get_solo(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
class Meta:
verbose_name = "System Configuration"
class Job(models.Model):
"""Tracks background jobs, their status and scheduling."""
JOB_TYPES = [
('sync_flavors', 'Sync Holy Flavors'),
('cleanup_duplicates', 'Cleanup Duplicate Flavors'),
('backup_db', 'Database Backup'),
('seed_legacy', 'Seed Legacy Data'),
]
STATUS_CHOICES = [
('pending', 'Pending'),
('running', 'Running'),
('completed', 'Completed'),
('failed', 'Failed'),
]
name = models.CharField(max_length=100, choices=JOB_TYPES)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
last_run = models.DateTimeField(null=True, blank=True)
next_run = models.DateTimeField(null=True, blank=True)
interval_hours = models.IntegerField(default=0, help_text="Set to 0 to disable periodic run.")
last_output = models.TextField(blank=True)
error_message = models.TextField(blank=True)
def __str__(self):
return f"{self.get_name_display()} ({self.status})"
+22
View File
@@ -0,0 +1,22 @@
from api.models.flavor import Category, Flavor
from api.models.rating import Rating, Reply
from api.models.social import Notification, ProfileComment
from api.models.support import Ticket, TicketMessage
from api.models.system import Banner, Job, SystemConfig
from api.models.user import User, UserIP
__all__ = [
"Banner",
"Category",
"Flavor",
"Job",
"Notification",
"ProfileComment",
"Rating",
"Reply",
"SystemConfig",
"Ticket",
"TicketMessage",
"User",
"UserIP",
]
+51
View File
@@ -0,0 +1,51 @@
from django.db import models
from django.db.models import Avg
class Category(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
class Meta:
verbose_name_plural = "Categories"
ordering = ["name"]
def __str__(self) -> str:
return self.name
class Flavor(models.Model):
category = models.ForeignKey(Category, related_name="flavors", on_delete=models.CASCADE)
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
image_url = models.URLField(max_length=1000, blank=True, null=True)
image_urls = models.JSONField(blank=True, default=list)
image = models.ImageField(upload_to="flavors/", blank=True, null=True)
shop_url = models.URLField(max_length=500, blank=True, null=True)
is_available = models.BooleanField(default=True)
is_legacy = models.BooleanField(default=False)
external_id = models.BigIntegerField(unique=True, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["name", "category"],
name="unique_flavor_name_per_category",
)
]
ordering = ["name"]
def __str__(self) -> str:
return f"{self.name} ({self.category.name})"
@property
def get_average_rating(self) -> float:
result = self.ratings.aggregate(avg=Avg("score"))["avg"]
return result or 0.0
@property
def cached_image_url(self) -> str | None:
if self.image:
return self.image.url
return self.image_url
+30
View File
@@ -0,0 +1,30 @@
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
class Rating(models.Model):
user = models.ForeignKey("api.User", related_name="ratings", on_delete=models.CASCADE)
flavor = models.ForeignKey("api.Flavor", related_name="ratings", on_delete=models.CASCADE)
score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
comment = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ("user", "flavor")
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.user.username} - {self.flavor.name}: {self.score}"
class Reply(models.Model):
user = models.ForeignKey("api.User", related_name="replies", on_delete=models.CASCADE)
rating = models.ForeignKey(Rating, related_name="replies", on_delete=models.CASCADE)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
def __str__(self) -> str:
return f"Reply by {self.user.username} on {self.rating.id}"
+45
View File
@@ -0,0 +1,45 @@
from django.db import models
class Notification(models.Model):
TYPE_CHOICES = [
("reply", "Reply"),
("mention", "Mention"),
("follow", "Follow"),
("ticket_new", "New Ticket"),
("ticket_reply", "Ticket Reply"),
("profile_comment", "Profile Comment"),
]
recipient = models.ForeignKey(
"api.User", related_name="notifications", on_delete=models.CASCADE
)
actor = models.ForeignKey("api.User", related_name="actions", on_delete=models.CASCADE)
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
rating = models.ForeignKey("api.Rating", on_delete=models.CASCADE, null=True, blank=True)
reply = models.ForeignKey("api.Reply", on_delete=models.CASCADE, null=True, blank=True)
ticket = models.ForeignKey("api.Ticket", on_delete=models.CASCADE, null=True, blank=True)
profile_comment = models.ForeignKey(
"api.ProfileComment", on_delete=models.CASCADE, null=True, blank=True
)
is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.notification_type} for {self.recipient.username}"
class ProfileComment(models.Model):
profile_owner = models.ForeignKey(
"api.User", related_name="profile_comments", on_delete=models.CASCADE
)
author = models.ForeignKey(
"api.User", related_name="authored_profile_comments", on_delete=models.CASCADE
)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
+29
View File
@@ -0,0 +1,29 @@
from django.db import models
class Ticket(models.Model):
STATUS_CHOICES = [
("open", "Open"),
("in_progress", "In Progress"),
("resolved", "Resolved"),
("closed", "Closed"),
]
user = models.ForeignKey("api.User", related_name="tickets", on_delete=models.CASCADE)
subject = models.CharField(max_length=200)
description = models.TextField()
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="open")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-updated_at"]
class TicketMessage(models.Model):
ticket = models.ForeignKey(Ticket, related_name="messages", on_delete=models.CASCADE)
user = models.ForeignKey("api.User", on_delete=models.CASCADE)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
+77
View File
@@ -0,0 +1,77 @@
from django.db import models
class Banner(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
is_active = models.BooleanField(
default=False, help_text="Designates this as the global default banner."
)
is_enabled = models.BooleanField(default=True, help_text="Allows users to select this banner.")
settings = models.JSONField(default=dict, blank=True)
schema = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
if self.is_active:
Banner.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False)
elif not Banner.objects.filter(is_active=True).exclude(pk=self.pk).exists():
self.is_active = True
super().save(*args, **kwargs)
def __str__(self) -> str:
return self.name
class SystemConfig(models.Model):
"""Singleton model for application-wide settings editable via UI."""
site_name = models.CharField(max_length=100, default="Holy Flavors Archive")
maintenance_mode = models.BooleanField(default=False)
allow_new_signups = models.BooleanField(default=True)
require_email_verification = models.BooleanField(default=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "System Configuration"
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_solo(cls) -> "SystemConfig":
obj, _ = cls.objects.get_or_create(pk=1)
return obj
class Job(models.Model):
"""Tracks background jobs, their status and scheduling."""
JOB_TYPES = [
("sync_flavors", "Sync Holy Flavors"),
("cleanup_duplicates", "Cleanup Duplicate Flavors"),
("backup_db", "Database Backup"),
("seed_legacy", "Seed Legacy Data"),
]
STATUS_CHOICES = [
("pending", "Pending"),
("running", "Running"),
("completed", "Completed"),
("failed", "Failed"),
]
name = models.CharField(max_length=100, choices=JOB_TYPES)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
last_run = models.DateTimeField(null=True, blank=True)
next_run = models.DateTimeField(null=True, blank=True)
interval_hours = models.IntegerField(default=0, help_text="Set to 0 to disable periodic run.")
last_output = models.TextField(blank=True)
error_message = models.TextField(blank=True)
def __str__(self) -> str:
return f"{self.get_name_display()} ({self.status})"
+72
View File
@@ -0,0 +1,72 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
THEME_CHOICES = [
("holy_light", "Holy Light"),
("holy_dark", "Holy Dark"),
("latte", "Catppuccin Latte"),
("frappe", "Catppuccin Frappé"),
("macchiato", "Catppuccin Macchiato"),
("mocha", "Catppuccin Mocha"),
("pink_pastel", "Pink Pastel"),
("mint_pastel", "Mint Pastel"),
("lavender_pastel", "Lavender Pastel"),
("dracula", "Dracula"),
("nord", "Nordic Frost"),
("gruvbox", "Gruvbox Retro"),
("oceanic", "Oceanic Deep"),
("t0p_sai", "SAI (T0P)"),
("t0p_trench", "Trench (T0P)"),
("t0p_blurryface", "Blurryface (T0P)"),
("t0p_clancy", "Clancy (T0P)"),
]
LANGUAGE_CHOICES = [
("en", "English"),
("de", "Deutsch"),
]
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default="holy_light")
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="en")
avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
pending_email = models.EmailField(max_length=254, blank=True, null=True)
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
following = models.ManyToManyField(
"self", symmetrical=False, related_name="followers", blank=True
)
selected_banner = models.ForeignKey(
"api.Banner",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="users",
)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.avatar:
try:
from PIL import Image
img = Image.open(self.avatar.path)
if img.height > 256 or img.width > 256 or img.format != "JPEG":
output_size = (256, 256)
img.thumbnail(output_size)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(self.avatar.path, "JPEG", quality=90)
except Exception:
pass
def __str__(self) -> str:
return self.username
class UserIP(models.Model):
user = models.ForeignKey(User, related_name="ips", on_delete=models.CASCADE)
ip_address = models.GenericIPAddressField()
last_login = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ("user", "ip_address")
ordering = ["-last_login"]
-237
View File
@@ -1,237 +0,0 @@
from rest_framework import serializers
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, Banner, SystemConfig, Job
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = ['id', 'name', 'slug']
class SystemConfigSerializer(serializers.ModelSerializer):
class Meta:
model = SystemConfig
fields = ['site_name', 'maintenance_mode', 'allow_new_signups', 'require_email_verification', 'updated_at']
class JobSerializer(serializers.ModelSerializer):
name_display = serializers.CharField(source='get_name_display', read_only=True)
class Meta:
model = Job
fields = ['id', 'name', 'name_display', 'status', 'last_run', 'next_run', 'interval_hours', 'last_output', 'error_message']
read_only_fields = ['status', 'last_run', 'next_run', 'last_output', 'error_message']
class BannerSerializer(serializers.ModelSerializer):
class Meta:
model = Banner
fields = ['id', 'name', 'slug', 'description', 'is_active', 'is_enabled', 'settings', 'schema', 'created_at', 'updated_at']
class ReplySerializer(serializers.ModelSerializer):
user = serializers.StringRelatedField(read_only=True)
class Meta:
model = Reply
fields = ['id', 'user', 'rating', 'text', 'created_at']
read_only_fields = ['rating']
class RatingSerializer(serializers.ModelSerializer):
user = serializers.StringRelatedField(read_only=True)
user_id = serializers.IntegerField(source='user.id', read_only=True)
user_avatar = serializers.SerializerMethodField()
flavor_name = serializers.CharField(source='flavor.name', read_only=True)
flavor_image = serializers.SerializerMethodField()
category_name = serializers.CharField(source='flavor.category.name', read_only=True)
category_slug = serializers.CharField(source='flavor.category.slug', read_only=True)
is_available = serializers.BooleanField(source='flavor.is_available', read_only=True)
is_legacy = serializers.BooleanField(source='flavor.is_legacy', read_only=True)
replies = ReplySerializer(many=True, read_only=True)
class Meta:
model = Rating
fields = ['id', 'user', 'user_id', 'user_avatar', 'flavor', 'flavor_name', 'flavor_image', 'category_name', 'category_slug', 'is_available', 'is_legacy', 'score', 'comment', 'created_at', 'replies']
def get_user_avatar(self, obj):
if obj.user.avatar:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.user.avatar.url)
return obj.user.avatar.url
return None
def get_flavor_image(self, obj):
if obj.flavor.image:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.flavor.image.url)
return obj.flavor.image.url
return obj.flavor.image_url
class FlavorSerializer(serializers.ModelSerializer):
category_name = serializers.CharField(source='category.name', read_only=True)
category_slug = serializers.CharField(source='category.slug', read_only=True)
average_rating = serializers.FloatField(read_only=True)
followed_average_rating = serializers.FloatField(read_only=True)
user_rating = serializers.SerializerMethodField()
ratings = RatingSerializer(many=True, read_only=True)
image_url = serializers.SerializerMethodField()
class Meta:
model = Flavor
fields = ['id', 'name', 'category', 'category_name', 'category_slug', 'description', 'average_rating', 'followed_average_rating', 'user_rating', 'ratings', 'image_url', 'image', 'is_available', 'is_legacy', 'shop_url']
extra_kwargs = {
'image': {'write_only': True}
}
def get_image_url(self, obj):
if obj.image:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.image.url)
return obj.image.url
return obj.image_url
def get_user_rating(self, obj):
request = self.context.get('request')
if request and request.user.is_authenticated:
try:
rating = request.user.ratings.get(flavor=obj)
return rating.score
except Rating.DoesNotExist:
return None
return None
class UserSerializer(serializers.ModelSerializer):
avatar = serializers.SerializerMethodField()
following_count = serializers.IntegerField(source='following.count', read_only=True)
followers_count = serializers.IntegerField(source='followers.count', read_only=True)
is_following = serializers.SerializerMethodField()
unread_notifications_count = serializers.SerializerMethodField()
is_superuser = serializers.BooleanField(read_only=True)
selected_banner_slug = serializers.CharField(source='selected_banner.slug', read_only=True, allow_null=True)
class Meta:
model = User
fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following', 'unread_notifications_count', 'is_superuser', 'selected_banner', 'selected_banner_slug']
def get_avatar(self, obj):
if obj.avatar:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.avatar.url)
return obj.avatar.url
return None
def get_is_following(self, obj):
request = self.context.get('request')
if request and request.user.is_authenticated:
return request.user.following.filter(pk=obj.pk).exists()
return False
def get_unread_notifications_count(self, obj):
return obj.notifications.filter(is_read=False).count()
class UserIPSerializer(serializers.ModelSerializer):
class Meta:
model = UserIP
fields = ['id', 'ip_address', 'last_login']
class TicketMessageSerializer(serializers.ModelSerializer):
username = serializers.CharField(source='user.username', read_only=True)
is_admin = serializers.BooleanField(source='user.is_superuser', read_only=True)
class Meta:
model = TicketMessage
fields = ['id', 'username', 'text', 'created_at', 'is_admin']
class TicketSerializer(serializers.ModelSerializer):
username = serializers.CharField(source='user.username', read_only=True)
user_email = serializers.EmailField(source='user.email', read_only=True)
user_avatar = serializers.SerializerMethodField()
messages = TicketMessageSerializer(many=True, read_only=True)
class Meta:
model = Ticket
fields = ['id', 'user', 'username', 'user_email', 'user_avatar', 'subject', 'description', 'status', 'created_at', 'updated_at', 'messages']
read_only_fields = ['status', 'user']
def get_user_avatar(self, obj):
if obj.user.avatar:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.user.avatar.url)
return obj.user.avatar.url
return None
class ProfileCommentSerializer(serializers.ModelSerializer):
author_username = serializers.CharField(source='author.username', read_only=True)
author_avatar = serializers.SerializerMethodField()
class Meta:
from .models import ProfileComment
model = ProfileComment
fields = ['id', 'author_username', 'author_avatar', 'text', 'created_at']
def get_author_avatar(self, obj):
if obj.author.avatar:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.author.avatar.url)
return obj.author.avatar.url
return None
class AdminUserListSerializer(serializers.ModelSerializer):
ips = serializers.SerializerMethodField()
class Meta:
model = User
fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'ips']
def get_ips(self, obj):
return [ip.ip_address for ip in obj.ips.all()[:3]]
class AdminUserDetailSerializer(serializers.ModelSerializer):
ips = serializers.SerializerMethodField()
ratings = RatingSerializer(many=True, read_only=True)
class Meta:
model = User
fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'ips', 'ratings']
def get_ips(self, obj):
return [ip.ip_address for ip in obj.ips.all()]
class NotificationSerializer(serializers.ModelSerializer):
actor_username = serializers.CharField(source='actor.username', read_only=True)
actor_avatar = serializers.SerializerMethodField()
flavor_name = serializers.SerializerMethodField()
flavor_id = serializers.SerializerMethodField()
profile_owner_username = serializers.SerializerMethodField()
class Meta:
model = Notification
fields = ['id', 'actor_username', 'actor_avatar', 'notification_type', 'rating', 'reply', 'ticket', 'profile_comment', 'profile_owner_username', 'is_read', 'created_at', 'flavor_name', 'flavor_id']
def get_actor_avatar(self, obj):
if obj.actor.avatar:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.actor.avatar.url)
return obj.actor.avatar.url
return None
def get_flavor_name(self, obj):
if obj.rating:
return obj.rating.flavor.name
if obj.reply:
return obj.reply.rating.flavor.name
return None
def get_flavor_id(self, obj):
if obj.rating:
return obj.rating.flavor.id
if obj.reply:
return obj.reply.rating.flavor.id
return None
def get_profile_owner_username(self, obj):
if obj.profile_comment:
return obj.profile_comment.profile_owner.username
return None
+33
View File
@@ -0,0 +1,33 @@
from api.serializers.flavor import CategorySerializer, FlavorSerializer
from api.serializers.rating import RatingSerializer, ReplySerializer
from api.serializers.social import NotificationSerializer, ProfileCommentSerializer
from api.serializers.support import TicketMessageSerializer, TicketSerializer
from api.serializers.system import (
BannerSerializer,
JobSerializer,
SystemConfigSerializer,
)
from api.serializers.user import (
AdminUserDetailSerializer,
AdminUserListSerializer,
UserIPSerializer,
UserSerializer,
)
__all__ = [
"AdminUserDetailSerializer",
"AdminUserListSerializer",
"BannerSerializer",
"CategorySerializer",
"FlavorSerializer",
"JobSerializer",
"NotificationSerializer",
"ProfileCommentSerializer",
"RatingSerializer",
"ReplySerializer",
"SystemConfigSerializer",
"TicketMessageSerializer",
"TicketSerializer",
"UserIPSerializer",
"UserSerializer",
]
+10
View File
@@ -0,0 +1,10 @@
from typing import Any
def absolute_image_url(serializer: Any, image_field: Any) -> str | None:
if not image_field:
return None
request = serializer.context.get("request")
if request:
return request.build_absolute_uri(image_field.url)
return image_field.url
+56
View File
@@ -0,0 +1,56 @@
from rest_framework import serializers
from api.models import Category, Flavor, Rating
from api.serializers._helpers import absolute_image_url
from api.serializers.rating import RatingSerializer
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = ["id", "name", "slug"]
class FlavorSerializer(serializers.ModelSerializer):
category_name = serializers.CharField(source="category.name", read_only=True)
category_slug = serializers.CharField(source="category.slug", read_only=True)
average_rating = serializers.FloatField(read_only=True)
followed_average_rating = serializers.FloatField(read_only=True)
user_rating = serializers.SerializerMethodField()
ratings = RatingSerializer(many=True, read_only=True)
image_url = serializers.SerializerMethodField()
class Meta:
model = Flavor
fields = [
"id",
"name",
"category",
"category_name",
"category_slug",
"description",
"average_rating",
"followed_average_rating",
"user_rating",
"ratings",
"image_url",
"image",
"is_available",
"is_legacy",
"shop_url",
]
extra_kwargs = {"image": {"write_only": True}}
def get_image_url(self, obj: Flavor) -> str | None:
if obj.image:
return absolute_image_url(self, obj.image)
return obj.image_url
def get_user_rating(self, obj: Flavor) -> int | None:
request = self.context.get("request")
if request and request.user.is_authenticated:
try:
return request.user.ratings.get(flavor=obj).score
except Rating.DoesNotExist:
return None
return None
+54
View File
@@ -0,0 +1,54 @@
from rest_framework import serializers
from api.models import Rating, Reply
from api.serializers._helpers import absolute_image_url
class ReplySerializer(serializers.ModelSerializer):
user = serializers.StringRelatedField(read_only=True)
class Meta:
model = Reply
fields = ["id", "user", "rating", "text", "created_at"]
read_only_fields = ["rating"]
class RatingSerializer(serializers.ModelSerializer):
user = serializers.StringRelatedField(read_only=True)
user_id = serializers.IntegerField(source="user.id", read_only=True)
user_avatar = serializers.SerializerMethodField()
flavor_name = serializers.CharField(source="flavor.name", read_only=True)
flavor_image = serializers.SerializerMethodField()
category_name = serializers.CharField(source="flavor.category.name", read_only=True)
category_slug = serializers.CharField(source="flavor.category.slug", read_only=True)
is_available = serializers.BooleanField(source="flavor.is_available", read_only=True)
is_legacy = serializers.BooleanField(source="flavor.is_legacy", read_only=True)
replies = ReplySerializer(many=True, read_only=True)
class Meta:
model = Rating
fields = [
"id",
"user",
"user_id",
"user_avatar",
"flavor",
"flavor_name",
"flavor_image",
"category_name",
"category_slug",
"is_available",
"is_legacy",
"score",
"comment",
"created_at",
"replies",
]
def get_user_avatar(self, obj: Rating) -> str | None:
return absolute_image_url(self, obj.user.avatar)
def get_flavor_image(self, obj: Rating) -> str | None:
if obj.flavor.image:
return absolute_image_url(self, obj.flavor.image)
return obj.flavor.image_url
+64
View File
@@ -0,0 +1,64 @@
from rest_framework import serializers
from api.models import Notification, ProfileComment
from api.serializers._helpers import absolute_image_url
class ProfileCommentSerializer(serializers.ModelSerializer):
author_username = serializers.CharField(source="author.username", read_only=True)
author_avatar = serializers.SerializerMethodField()
class Meta:
model = ProfileComment
fields = ["id", "author_username", "author_avatar", "text", "created_at"]
def get_author_avatar(self, obj: ProfileComment) -> str | None:
return absolute_image_url(self, obj.author.avatar)
class NotificationSerializer(serializers.ModelSerializer):
actor_username = serializers.CharField(source="actor.username", read_only=True)
actor_avatar = serializers.SerializerMethodField()
flavor_name = serializers.SerializerMethodField()
flavor_id = serializers.SerializerMethodField()
profile_owner_username = serializers.SerializerMethodField()
class Meta:
model = Notification
fields = [
"id",
"actor_username",
"actor_avatar",
"notification_type",
"rating",
"reply",
"ticket",
"profile_comment",
"profile_owner_username",
"is_read",
"created_at",
"flavor_name",
"flavor_id",
]
def get_actor_avatar(self, obj: Notification) -> str | None:
return absolute_image_url(self, obj.actor.avatar)
def get_flavor_name(self, obj: Notification) -> str | None:
if obj.rating:
return obj.rating.flavor.name
if obj.reply:
return obj.reply.rating.flavor.name
return None
def get_flavor_id(self, obj: Notification) -> int | None:
if obj.rating:
return obj.rating.flavor.id
if obj.reply:
return obj.reply.rating.flavor.id
return None
def get_profile_owner_username(self, obj: Notification) -> str | None:
if obj.profile_comment:
return obj.profile_comment.profile_owner.username
return None
+40
View File
@@ -0,0 +1,40 @@
from rest_framework import serializers
from api.models import Ticket, TicketMessage
from api.serializers._helpers import absolute_image_url
class TicketMessageSerializer(serializers.ModelSerializer):
username = serializers.CharField(source="user.username", read_only=True)
is_admin = serializers.BooleanField(source="user.is_superuser", read_only=True)
class Meta:
model = TicketMessage
fields = ["id", "username", "text", "created_at", "is_admin"]
class TicketSerializer(serializers.ModelSerializer):
username = serializers.CharField(source="user.username", read_only=True)
user_email = serializers.EmailField(source="user.email", read_only=True)
user_avatar = serializers.SerializerMethodField()
messages = TicketMessageSerializer(many=True, read_only=True)
class Meta:
model = Ticket
fields = [
"id",
"user",
"username",
"user_email",
"user_avatar",
"subject",
"description",
"status",
"created_at",
"updated_at",
"messages",
]
read_only_fields = ["status", "user"]
def get_user_avatar(self, obj: Ticket) -> str | None:
return absolute_image_url(self, obj.user.avatar)
+57
View File
@@ -0,0 +1,57 @@
from rest_framework import serializers
from api.models import Banner, Job, SystemConfig
class SystemConfigSerializer(serializers.ModelSerializer):
class Meta:
model = SystemConfig
fields = [
"site_name",
"maintenance_mode",
"allow_new_signups",
"require_email_verification",
"updated_at",
]
class JobSerializer(serializers.ModelSerializer):
name_display = serializers.CharField(source="get_name_display", read_only=True)
class Meta:
model = Job
fields = [
"id",
"name",
"name_display",
"status",
"last_run",
"next_run",
"interval_hours",
"last_output",
"error_message",
]
read_only_fields = [
"status",
"last_run",
"next_run",
"last_output",
"error_message",
]
class BannerSerializer(serializers.ModelSerializer):
class Meta:
model = Banner
fields = [
"id",
"name",
"slug",
"description",
"is_active",
"is_enabled",
"settings",
"schema",
"created_at",
"updated_at",
]
+95
View File
@@ -0,0 +1,95 @@
from rest_framework import serializers
from api.models import User, UserIP
from api.serializers._helpers import absolute_image_url
from api.serializers.rating import RatingSerializer
class UserSerializer(serializers.ModelSerializer):
avatar = serializers.SerializerMethodField()
following_count = serializers.IntegerField(source="following.count", read_only=True)
followers_count = serializers.IntegerField(source="followers.count", read_only=True)
is_following = serializers.SerializerMethodField()
unread_notifications_count = serializers.SerializerMethodField()
is_superuser = serializers.BooleanField(read_only=True)
selected_banner_slug = serializers.CharField(
source="selected_banner.slug", read_only=True, allow_null=True
)
class Meta:
model = User
fields = [
"id",
"username",
"email",
"theme",
"language",
"avatar",
"following_count",
"followers_count",
"is_following",
"unread_notifications_count",
"is_superuser",
"selected_banner",
"selected_banner_slug",
]
def get_avatar(self, obj: User) -> str | None:
return absolute_image_url(self, obj.avatar)
def get_is_following(self, obj: User) -> bool:
request = self.context.get("request")
if request and request.user.is_authenticated:
return request.user.following.filter(pk=obj.pk).exists()
return False
def get_unread_notifications_count(self, obj: User) -> int:
return obj.notifications.filter(is_read=False).count()
class UserIPSerializer(serializers.ModelSerializer):
class Meta:
model = UserIP
fields = ["id", "ip_address", "last_login"]
class AdminUserListSerializer(serializers.ModelSerializer):
ips = serializers.SerializerMethodField()
class Meta:
model = User
fields = [
"id",
"username",
"email",
"is_active",
"is_superuser",
"date_joined",
"last_login",
"ips",
]
def get_ips(self, obj: User) -> list[str]:
return [ip.ip_address for ip in obj.ips.all()[:3]]
class AdminUserDetailSerializer(serializers.ModelSerializer):
ips = serializers.SerializerMethodField()
ratings = RatingSerializer(many=True, read_only=True)
class Meta:
model = User
fields = [
"id",
"username",
"email",
"is_active",
"is_superuser",
"date_joined",
"last_login",
"ips",
"ratings",
]
def get_ips(self, obj: User) -> list[str]:
return [ip.ip_address for ip in obj.ips.all()]
View File
Whitespace-only changes.
+14
View File
@@ -0,0 +1,14 @@
from rest_framework.request import Request
from api.models import User, UserIP
def log_user_ip(user: User, request: Request) -> None:
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
ip = x_forwarded_for.split(",")[0].strip()
else:
ip = request.META.get("REMOTE_ADDR")
if ip and user.is_authenticated:
UserIP.objects.update_or_create(user=user, ip_address=ip)
+29
View File
@@ -0,0 +1,29 @@
import re
from api.models import Notification, Rating, Reply, User
_MENTION_RE = re.compile(r"@(\w+)")
def parse_mentions(
text: str,
actor: User,
rating: Rating | None = None,
reply: Reply | None = None,
) -> None:
if not text:
return
for username in set(_MENTION_RE.findall(text)):
try:
recipient = User.objects.get(username__iexact=username)
except User.DoesNotExist:
continue
if recipient == actor:
continue
Notification.objects.get_or_create(
recipient=recipient,
actor=actor,
notification_type="mention",
rating=rating,
reply=reply,
)
+44
View File
@@ -0,0 +1,44 @@
from django.db.models import Q, QuerySet
from api.models import Flavor
CATEGORY_KEYWORDS: dict[str, str] = {
"iced tea": "iced-tea",
"eistee": "iced-tea",
"energy": "energy",
"hydration": "hydration",
"milkshake": "milkshake",
}
def extract_category_slug(query: str) -> tuple[str | None, str]:
lowered = query.lower().strip()
for keyword, slug in CATEGORY_KEYWORDS.items():
if keyword in lowered:
return slug, lowered.replace(keyword, "").strip()
return None, lowered
def filter_flavors_by_query(queryset: QuerySet[Flavor], query: str) -> QuerySet[Flavor]:
slug, remaining = extract_category_slug(query)
if slug:
queryset = queryset.filter(category__slug=slug)
if remaining:
word_query = Q()
for word in remaining.split():
if len(word) > 2:
word_query |= Q(name__icontains=word) | Q(description__icontains=word)
if word_query:
queryset = queryset.filter(word_query)
return queryset
def score_relevance(name: str, query: str) -> int:
name_lower = name.lower()
if name_lower == query:
return 3
if name_lower.startswith(query):
return 2
if query in name_lower:
return 1
return 0
+96
View File
@@ -0,0 +1,96 @@
"""Celery tasks for Holy Flavors.
Each @shared_task wraps an existing management command so that the same
code path remains usable from the CLI for manual ops. The legacy ``Job``
rows are kept as an execution log: tasks update the matching row with
status/output/timestamps so the admin UI keeps working.
"""
from __future__ import annotations
from datetime import timedelta
from io import StringIO
from typing import Any
from celery import shared_task
from celery.utils.log import get_task_logger
from django.core.mail import send_mail
from django.core.management import call_command
from django.utils import timezone
logger = get_task_logger(__name__)
def _run_command_job(job_name: str, command: str, *args: str) -> str:
"""Run a management command and mirror status/output into the Job row."""
from api.models import Job
job, _ = Job.objects.get_or_create(name=job_name)
job.status = "running"
job.last_run = timezone.now()
job.last_output = ""
job.error_message = ""
job.save(update_fields=["status", "last_run", "last_output", "error_message"])
out = StringIO()
try:
call_command(command, *args, stdout=out)
job.status = "completed"
except Exception as exc: # noqa: BLE001
job.status = "failed"
job.error_message = str(exc)
logger.exception("Job %s failed", job_name)
raise
finally:
job.last_output = out.getvalue()
if job.interval_hours > 0:
job.next_run = timezone.now() + timedelta(hours=job.interval_hours)
else:
job.next_run = None
job.save()
return job.last_output
@shared_task(name="api.sync_flavors")
def sync_flavors_task() -> str:
return _run_command_job("sync_flavors", "sync_flavors")
@shared_task(name="api.cleanup_duplicates")
def cleanup_duplicates_task() -> str:
return _run_command_job("cleanup_duplicates", "cleanup_duplicates")
@shared_task(name="api.backup_db")
def backup_db_task() -> str:
return _run_command_job("backup_db", "backup_db", "--full")
@shared_task(name="api.seed_legacy")
def seed_legacy_task() -> str:
return _run_command_job("seed_legacy", "seed_legacy_flavors")
@shared_task(
name="api.send_email",
autoretry_for=(Exception,),
retry_backoff=True,
retry_kwargs={"max_retries": 3},
)
def send_email_task(
subject: str,
message: str,
recipient_list: list[str],
from_email: str | None = None,
**kwargs: Any,
) -> int:
"""Async wrapper around django.core.mail.send_mail."""
return send_mail(
subject=subject,
message=message,
from_email=from_email,
recipient_list=recipient_list,
fail_silently=False,
**kwargs,
)
-45
View File
@@ -1,45 +0,0 @@
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from .models import Category, Flavor, User, Rating
class FlavorTopTests(APITestCase):
def setUp(self):
self.category_energy = Category.objects.create(name='Energy', slug='energy')
self.category_iced_tea = Category.objects.create(name='Iced Tea', slug='iced-tea')
self.user = User.objects.create_user(username='testuser', password='password')
# Create flavors
self.flavor1 = Flavor.objects.create(name='Energy 1', category=self.category_energy)
self.flavor2 = Flavor.objects.create(name='Iced Tea 1', category=self.category_iced_tea)
# Add ratings to make them appear in top (since we filter by ratings__isnull=False)
Rating.objects.create(user=self.user, flavor=self.flavor1, score=5)
Rating.objects.create(user=self.user, flavor=self.flavor2, score=4)
def test_top_flavors_all(self):
url = reverse('flavor-top')
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)
def test_top_flavors_filtered_energy(self):
url = reverse('flavor-top')
response = self.client.get(url, {'category': 'energy'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['name'], 'Energy 1')
def test_top_flavors_filtered_iced_tea(self):
url = reverse('flavor-top')
response = self.client.get(url, {'category': 'iced-tea'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['name'], 'Iced Tea 1')
def test_top_flavors_filtered_non_existent(self):
url = reverse('flavor-top')
response = self.client.get(url, {'category': 'non-existent'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 0)
View File
Whitespace-only changes.
+55
View File
@@ -0,0 +1,55 @@
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from api.models import User
class JWTAuthTests(APITestCase):
def setUp(self) -> None:
self.username = "jwtuser"
self.password = "correct-horse-battery-staple"
self.user = User.objects.create_user(username=self.username, password=self.password)
def test_obtain_pair_returns_access_and_refresh(self) -> None:
url = reverse("token_obtain_pair")
response = self.client.post(
url,
{"username": self.username, "password": self.password},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("access", response.data)
self.assertIn("refresh", response.data)
def test_access_token_authenticates_protected_endpoint(self) -> None:
pair = self.client.post(
reverse("token_obtain_pair"),
{"username": self.username, "password": self.password},
format="json",
).data
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {pair['access']}")
response = self.client.get("/api/notifications/", follow=True)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_refresh_issues_new_access_token(self) -> None:
pair = self.client.post(
reverse("token_obtain_pair"),
{"username": self.username, "password": self.password},
format="json",
).data
response = self.client.post(
reverse("token_refresh"),
{"refresh": pair["refresh"]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("access", response.data)
def test_invalid_refresh_is_rejected(self) -> None:
response = self.client.post(
reverse("token_refresh"),
{"refresh": "not-a-real-token"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+45
View File
@@ -0,0 +1,45 @@
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from api.models import Category, Flavor, Rating, User
class FlavorTopTests(APITestCase):
def setUp(self) -> None:
self.category_energy = Category.objects.create(name="Energy", slug="energy")
self.category_iced_tea = Category.objects.create(name="Iced Tea", slug="iced-tea")
self.user = User.objects.create_user(username="testuser", password="password")
self.flavor1 = Flavor.objects.create(name="Energy 1", category=self.category_energy)
self.flavor2 = Flavor.objects.create(name="Iced Tea 1", category=self.category_iced_tea)
Rating.objects.create(user=self.user, flavor=self.flavor1, score=5)
Rating.objects.create(user=self.user, flavor=self.flavor2, score=4)
def test_top_flavors_all(self) -> None:
url = reverse("flavor-top")
response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)
def test_top_flavors_filtered_energy(self) -> None:
url = reverse("flavor-top")
response = self.client.get(url, {"category": "energy"}, follow=True)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["name"], "Energy 1")
def test_top_flavors_filtered_iced_tea(self) -> None:
url = reverse("flavor-top")
response = self.client.get(url, {"category": "iced-tea"}, follow=True)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["name"], "Iced Tea 1")
def test_top_flavors_filtered_non_existent(self) -> None:
url = reverse("flavor-top")
response = self.client.get(url, {"category": "non-existent"}, follow=True)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 0)
+19
View File
@@ -0,0 +1,19 @@
from django.test import TestCase
from api.models import Category, Flavor, Rating, User
class FlavorAverageRatingTests(TestCase):
def setUp(self) -> None:
self.category = Category.objects.create(name="Energy", slug="energy")
self.flavor = Flavor.objects.create(name="Test", category=self.category)
self.u1 = User.objects.create_user(username="u1", password="x")
self.u2 = User.objects.create_user(username="u2", password="x")
def test_average_zero_when_no_ratings(self) -> None:
self.assertEqual(self.flavor.get_average_rating, 0.0)
def test_average_uses_aggregate(self) -> None:
Rating.objects.create(user=self.u1, flavor=self.flavor, score=4)
Rating.objects.create(user=self.u2, flavor=self.flavor, score=8)
self.assertEqual(self.flavor.get_average_rating, 6.0)
+12 -11
View File
@@ -1,18 +1,19 @@
from django.urls import path, include from django.urls import include, path
from rest_framework import routers from rest_framework import routers
from . import views from . import views
router = routers.DefaultRouter() router = routers.DefaultRouter()
router.register('users', views.UserViewSet) router.register("users", views.UserViewSet)
router.register('flavors', views.FlavorViewSet) router.register("flavors", views.FlavorViewSet)
router.register('categories', views.CategoryViewSet) router.register("categories", views.CategoryViewSet)
router.register('ratings', views.RatingViewSet) router.register("ratings", views.RatingViewSet)
router.register('replies', views.ReplyViewSet) router.register("replies", views.ReplyViewSet)
router.register('notifications', views.NotificationViewSet, basename='notification') router.register("notifications", views.NotificationViewSet, basename="notification")
router.register('tickets', views.TicketViewSet, basename='ticket') router.register("tickets", views.TicketViewSet, basename="ticket")
router.register('admin-custom', views.AdminViewSet, basename='admin-custom') router.register("admin-custom", views.AdminViewSet, basename="admin-custom")
router.register('banners', views.BannerViewSet, basename='banner') router.register("banners", views.BannerViewSet, basename="banner")
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path("", include(router.urls)),
] ]
-979
View File
@@ -1,979 +0,0 @@
from django.core.mail import send_mail
from django.conf import settings
from rest_framework import viewsets, permissions, status, filters, serializers
from rest_framework.response import Response
from rest_framework.decorators import action
from django.db.models import Avg, Q
from django_filters.rest_framework import DjangoFilterBackend
from django_ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
from rest_framework.pagination import PageNumberPagination
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, ProfileComment, Banner, SystemConfig, Job
from .serializers import (
UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer,
ReplySerializer, NotificationSerializer, TicketSerializer,
TicketMessageSerializer, AdminUserListSerializer, AdminUserDetailSerializer,
ProfileCommentSerializer, BannerSerializer, SystemConfigSerializer, JobSerializer
)
def log_user_ip(user, request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
if ip and user.is_authenticated:
UserIP.objects.update_or_create(user=user, ip_address=ip)
def parse_mentions(text, actor, rating=None, reply=None):
import re
mentions = re.findall(r'@(\w+)', text)
for username in set(mentions):
try:
recipient = User.objects.get(username__iexact=username)
if recipient != actor:
Notification.objects.get_or_create(
recipient=recipient,
actor=actor,
notification_type='mention',
rating=rating,
reply=reply
)
except User.DoesNotExist:
continue
class FeedPagination(PageNumberPagination):
page_size = 10
class FlavorViewSet(viewsets.ModelViewSet):
queryset = Flavor.objects.annotate(average_rating=Avg('ratings__score')).order_by('-average_rating')
serializer_class = FlavorSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ['category', 'category__slug']
search_fields = ['name', 'description']
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def get_queryset(self):
qs = Flavor.objects.select_related('category').annotate(average_rating=Avg('ratings__score')).order_by('-average_rating').distinct()
# Handle category keywords in search query for main results too
search_query = self.request.query_params.get('search', '').lower().strip()
if search_query:
cat_keywords = {
'iced tea': 'iced-tea',
'eistee': 'iced-tea',
'energy': 'energy',
'hydration': 'hydration',
'milkshake': 'milkshake'
}
for keyword, slug in cat_keywords.items():
if keyword in search_query:
qs = qs.filter(category__slug=slug)
break # Only one category at a time
return qs
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
def search(self, request):
query = request.query_params.get('q', '').lower().strip()
if not query:
return Response([])
# Start with all flavors
flavors = Flavor.objects.select_related('category').distinct()
# Intelligent category filtering based on keywords
cat_keywords = {
'iced tea': 'iced-tea',
'eistee': 'iced-tea',
'energy': 'energy',
'hydration': 'hydration',
'milkshake': 'milkshake'
}
active_cat_slug = None
remaining_query = query
for keyword, slug in cat_keywords.items():
if keyword in query:
active_cat_slug = slug
remaining_query = remaining_query.replace(keyword, '').strip()
break
if active_cat_slug:
flavors = flavors.filter(category__slug=active_cat_slug)
# Filter by remaining query words in name or description
if remaining_query:
words = remaining_query.split()
# Construct a combined OR query for all words to be more permissive (fuzzy-ish)
word_query = Q()
for word in words:
if len(word) > 2: # Only filter by words longer than 2 chars to avoid noise
word_query |= Q(name__icontains=word) | Q(description__icontains=word)
if word_query:
flavors = flavors.filter(word_query)
# Limit results and deduplicate in Python to be safe against DB inconsistencies
results_list = []
seen_keys = set()
# Priority sorting in Python
search_lower = remaining_query.lower()
# Order by name, but we'll also try to prioritize exact matches in Python
for f in flavors.order_by('name'):
# Create a unique key for this flavor (name + category)
key = f"{f.name.strip().lower()}|{f.category.slug}"
if key in seen_keys:
continue
seen_keys.add(key)
name_lower = f.name.lower()
relevance = 0
if name_lower == search_lower:
relevance = 3
elif name_lower.startswith(search_lower):
relevance = 2
elif search_lower in name_lower:
relevance = 1
results_list.append({
'id': f.id,
'name': f.name,
'type': 'flavor',
'subtitle': f.category.name,
'image_url': request.build_absolute_uri(f.image.url) if f.image else f.image_url,
'slug': None,
'relevance': relevance
})
# Sort by relevance desc, then name
results_list.sort(key=lambda x: (-x['relevance'], x['name']))
# Remove relevance helper and limit to 15
final_results = []
for r in results_list:
del r['relevance']
final_results.append(r)
if len(final_results) >= 15:
break
return Response(final_results)
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
def top(self, request):
queryset = self.get_queryset().filter(ratings__isnull=False)
category_slug = request.query_params.get('category')
if category_slug:
queryset = queryset.filter(category__slug=category_slug)
top_flavors = queryset.distinct()[:10]
serializer = self.get_serializer(top_flavors, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
def newest(self, request):
# Get the 10 newest flavors
newest_flavors = Flavor.objects.select_related('category').order_by('-created_at')[:10]
serializer = self.get_serializer(newest_flavors, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
def followed_top(self, request):
followed_users = request.user.following.all()
top_flavors = Flavor.objects.filter(ratings__user__in=followed_users) \
.annotate(average_rating=Avg('ratings__score')) \
.order_by('-average_rating') \
.distinct()[:10]
serializer = self.get_serializer(top_flavors, many=True)
return Response(serializer.data)
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
class RatingViewSet(viewsets.ModelViewSet):
queryset = Rating.objects.all()
serializer_class = RatingSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
@method_decorator(ratelimit(key='user', rate='10/m', method='POST', block=True))
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
def perform_create(self, serializer):
# Ensure user can only have one rating per flavor
flavor = serializer.validated_data['flavor']
if Rating.objects.filter(user=self.request.user, flavor=flavor).exists():
raise serializers.ValidationError('You have already rated this flavor.')
rating = serializer.save(user=self.request.user)
# Parse mentions in the rating comment
if rating.comment:
parse_mentions(rating.comment, self.request.user, rating=rating)
def get_queryset(self):
return Rating.objects.select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at')
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
def feed(self, request):
# Get ratings from people the user follows
followed_users = request.user.following.all()
feed_ratings = Rating.objects.filter(user__in=followed_users).select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at')
paginator = FeedPagination()
page = paginator.paginate_queryset(feed_ratings, request)
if page is not None:
serializer = self.get_serializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(feed_ratings, many=True)
return Response(serializer.data)
def check_object_permissions(self, request, obj):
super().check_object_permissions(request, obj)
if request.method in ['PUT', 'PATCH', 'DELETE'] and obj.user != request.user and not request.user.is_superuser:
self.permission_denied(request, message='You cannot edit/delete this rating.')
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
def recent(self, request):
recent_ratings = Rating.objects.filter(comment__isnull=False).exclude(comment='').select_related('user', 'flavor').order_by('-created_at')[:10]
serializer = self.get_serializer(recent_ratings, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def reply(self, request, pk=None):
rating = self.get_object()
text = request.data.get('text')
if not text:
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
reply = Reply.objects.create(user=request.user, rating=rating, text=text)
# Notify the rating author if it's not the same person
if rating.user != request.user:
Notification.objects.create(
recipient=rating.user,
actor=request.user,
notification_type='reply',
rating=rating,
reply=reply
)
# Parse mentions in the reply text
parse_mentions(text, request.user, rating=rating, reply=reply)
return Response(ReplySerializer(reply, context={'request': request}).data, status=status.HTTP_201_CREATED)
class ReplyViewSet(viewsets.ModelViewSet):
queryset = Reply.objects.all()
serializer_class = ReplySerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Reply.objects.select_related('user', 'rating').order_by('created_at')
def perform_update(self, serializer):
reply = self.get_object()
if reply.user != self.request.user and not self.request.user.is_superuser:
raise permissions.PermissionDenied("You cannot edit this reply.")
new_reply = serializer.save()
# Parse mentions in the updated text
parse_mentions(new_reply.text, self.request.user, rating=new_reply.rating, reply=new_reply)
def perform_destroy(self, instance):
if instance.user != self.request.user and not self.request.user.is_superuser:
raise permissions.PermissionDenied("You cannot delete this reply.")
instance.delete()
class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = NotificationSerializer
permission_classes = [permissions.IsAuthenticated]
pagination_class = None
def get_queryset(self):
return self.request.user.notifications.select_related('actor', 'rating__flavor', 'reply__rating__flavor').order_by('-created_at')
@action(detail=False, methods=['post'])
def mark_all_read(self, request):
request.user.notifications.filter(is_read=False).update(is_read=True)
return Response({'status': 'all read'})
@action(detail=True, methods=['post'])
def mark_read(self, request, pk=None):
notification = self.get_object()
notification.is_read = True
notification.save()
return Response({'status': 'marked read'})
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
def get_permissions(self):
if self.action in ['signup', 'verify_signup', 'request_password_reset', 'complete_password_reset']:
return [permissions.AllowAny()]
return super().get_permissions()
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
def following_list(self, request):
following = request.user.following.all()
serializer = self.get_serializer(following, many=True)
return Response(serializer.data)
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
@method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True))
def signup(self, request):
username = request.data.get('username')
email = request.data.get('email')
password = request.data.get('password')
if not username or not email or not password:
return Response({'error': 'Username, email and password required'}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists():
return Response({'error': 'Username already exists'}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(email=email).exists():
return Response({'error': 'Email already exists'}, status=status.HTTP_400_BAD_REQUEST)
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user = User.objects.create_user(
username=username,
email=email,
password=password,
is_active=False,
email_confirmation_code=code
)
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={username}&code={code}"
try:
send_mail(
'Verify your Holy Flavors account',
f'Hi {username},\n\nYour verification code is: {code}\n\nAlternatively, you can complete your registration by clicking the link below:\n{verification_link}\n\nWelcome to the archive!',
settings.DEFAULT_FROM_EMAIL,
[email],
fail_silently=False,
)
except Exception as e:
# If email fails for any reason, delete the user so they can try again
user.delete()
return Response(
{'error': f'Failed to send verification email. Please check your SMTP settings in the .env file. The server error was: {e}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({'status': 'User created, please verify your email', 'username': username, 'email': email}, status=status.HTTP_201_CREATED)
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def resend_verification(self, request):
username = request.data.get('username')
if not username:
return Response({'error': 'Username required'}, status=status.HTTP_400_BAD_REQUEST)
try:
user = User.objects.get(username=username, is_active=False)
except User.DoesNotExist:
return Response({'error': 'User not found or already active'}, status=status.HTTP_404_NOT_FOUND)
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user.email_confirmation_code = code
user.save()
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={user.username}&code={code}"
try:
send_mail(
'Verify your Holy Flavors account',
f'Hi {user.username},\n\nYour new verification code is: {code}\n\nAlternatively, you can complete your registration by clicking the link below:\n{verification_link}',
settings.DEFAULT_FROM_EMAIL,
[user.email],
fail_silently=False,
)
except Exception as e:
return Response(
{'error': f'Failed to send email: {e}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response({'status': 'Verification code resent!'})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def verify_signup(self, request):
username = request.data.get('username')
code = request.data.get('code')
try:
user = User.objects.get(username=username, is_active=False)
except User.DoesNotExist:
return Response({'error': 'User not found or already active'}, status=status.HTTP_404_NOT_FOUND)
if user.email_confirmation_code == code:
user.is_active = True
user.email_confirmation_code = None
user.save()
return Response({'status': 'Account verified successfully!'})
else:
return Response({'error': 'Invalid verification code'}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny], url_path='profile/(?P<username>[^/.]+)')
def public_profile(self, request, username=None):
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
ratings = user.ratings.select_related('flavor', 'flavor__category').order_by('-score')
comments = user.profile_comments.select_related('author').order_by('-created_at')
followers = user.followers.all()
following = user.following.all()
return Response({
'id': user.id,
'username': user.username,
'theme': user.theme,
'avatar': request.build_absolute_uri(user.avatar.url) if user.avatar else None,
'following_count': following.count(),
'followers_count': followers.count(),
'is_following': request.user.following.filter(pk=user.pk).exists() if request.user.is_authenticated else False,
'ratings': RatingSerializer(ratings, many=True, context={'request': request}).data,
'comments': ProfileCommentSerializer(comments, many=True, context={'request': request}).data,
'followers': UserSerializer(followers, many=True, context={'request': request}).data,
'following': UserSerializer(following, many=True, context={'request': request}).data
})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def add_comment(self, request, pk=None):
profile_owner = self.get_object()
text = request.data.get('text')
if not text:
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
comment = ProfileComment.objects.create(
profile_owner=profile_owner,
author=request.user,
text=text
)
# Notify profile owner
if profile_owner != request.user:
Notification.objects.create(
recipient=profile_owner,
actor=request.user,
notification_type='profile_comment',
profile_comment=comment
)
return Response(ProfileCommentSerializer(comment, context={'request': request}).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['delete'], url_path='delete_comment/(?P<comment_id>[^/.]+)', permission_classes=[permissions.IsAuthenticated])
def delete_comment(self, request, pk=None, comment_id=None):
profile_owner = self.get_object()
try:
comment = ProfileComment.objects.get(pk=comment_id, profile_owner=profile_owner)
except ProfileComment.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if comment.author != request.user and not request.user.is_superuser and profile_owner != request.user:
return Response(status=status.HTTP_403_FORBIDDEN)
comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=['get'])
def me(self, request):
log_user_ip(request.user, request)
serializer = self.get_serializer(request.user)
return Response(serializer.data)
@action(detail=False, methods=['patch'])
def update_preferences(self, request):
user = request.user
theme = request.data.get('theme')
language = request.data.get('language')
banner_id = request.data.get('selected_banner')
updated = False
if theme in dict(User.THEME_CHOICES):
user.theme = theme
updated = True
if language in dict(User.LANGUAGE_CHOICES):
user.language = language
updated = True
if banner_id is not None:
if banner_id == "":
user.selected_banner = None
updated = True
else:
try:
banner = Banner.objects.get(pk=banner_id, is_enabled=True)
user.selected_banner = banner
updated = True
except Banner.DoesNotExist:
return Response({'error': 'Invalid or disabled banner'}, status=status.HTTP_400_BAD_REQUEST)
if updated:
user.save()
return Response({
'status': 'preferences updated',
'theme': user.theme,
'language': user.language,
'selected_banner': user.selected_banner.id if user.selected_banner else None
})
return Response({'error': 'No valid updates provided'}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['post'])
def update_avatar(self, request):
user = request.user
avatar = request.FILES.get('avatar')
if not avatar:
return Response({'error': 'No avatar provided'}, status=status.HTTP_400_BAD_REQUEST)
# Check size (2MB = 2 * 1024 * 1024 bytes)
if avatar.size > 2 * 1024 * 1024:
return Response({'error': 'File size exceeds 2MB limit'}, status=status.HTTP_400_BAD_REQUEST)
user.avatar = avatar
user.save()
serializer = self.get_serializer(user)
return Response(serializer.data)
@action(detail=False, methods=['post'])
def change_password(self, request):
user = request.user
old_password = request.data.get('old_password')
new_password = request.data.get('new_password')
if not user.check_password(old_password):
return Response({'error': 'Wrong old password'}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password)
user.save()
return Response({'status': 'password changed'})
@action(detail=False, methods=['patch'])
def update_profile(self, request):
user = request.user
username = request.data.get('username')
email = request.data.get('email')
message = ""
if username and username != user.username:
if User.objects.filter(username=username).exists():
return Response({'error': 'Username already taken'}, status=status.HTTP_400_BAD_REQUEST)
user.username = username
if email and email != user.email:
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user.pending_email = email
user.email_confirmation_code = code
send_mail(
'Confirm your new email',
f'Hi {user.username},\n\nYour confirmation code is: {code}',
settings.DEFAULT_FROM_EMAIL,
[email],
fail_silently=False,
)
message = " Confirmation code sent to new email."
user.save()
serializer = self.get_serializer(user)
data = serializer.data
if message:
data['message'] = message
return Response(data)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def follow(self, request, pk=None):
user_to_follow = self.get_object()
if user_to_follow == request.user:
return Response({'error': 'You cannot follow yourself'}, status=status.HTTP_400_BAD_REQUEST)
if not request.user.following.filter(pk=user_to_follow.pk).exists():
request.user.following.add(user_to_follow)
# Notify the user being followed
Notification.objects.create(
recipient=user_to_follow,
actor=request.user,
notification_type='follow'
)
return Response({'status': 'followed'})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def unfollow(self, request, pk=None):
user_to_unfollow = self.get_object()
request.user.following.remove(user_to_unfollow)
return Response({'status': 'unfollowed'})
@action(detail=False, methods=['post'])
def confirm_email(self, request):
user = request.user
code = request.data.get('code')
if not user.email_confirmation_code or code != user.email_confirmation_code:
return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST)
user.email = user.pending_email
user.pending_email = None
user.email_confirmation_code = None
user.save()
return Response({'status': 'Email confirmed', 'email': user.email})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
@method_decorator(ratelimit(key='ip', rate='3/h', method='POST', block=True))
def request_password_reset(self, request):
email = request.data.get('email')
try:
user = User.objects.get(email=email, is_active=True)
except User.DoesNotExist:
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user.email_confirmation_code = code
user.save()
send_mail(
'Password Reset Request',
f'Hi {user.username},\n\nYour password reset code is: {code}',
settings.DEFAULT_FROM_EMAIL,
[email],
fail_silently=False,
)
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
@method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True))
def complete_password_reset(self, request):
email = request.data.get('email')
code = request.data.get('code')
new_password = request.data.get('password')
try:
user = User.objects.get(email=email, email_confirmation_code=code)
except User.DoesNotExist:
return Response({'error': 'Invalid email or code'}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password)
user.email_confirmation_code = None
user.save()
return Response({'status': 'Password reset successful!'})
@action(detail=False, methods=['post'])
@method_decorator(ratelimit(key='user', rate='3/h', method='POST', block=True))
def request_account_deletion(self, request):
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user = request.user
user.email_confirmation_code = code
user.save()
send_mail(
'Confirm Account Deletion - Holy Flavors Archive',
f'Hi {user.username},\n\nYou requested to delete your account. This action is permanent.\n\nYour deletion code is: {code}',
settings.DEFAULT_FROM_EMAIL,
[user.email],
fail_silently=False,
)
return Response({'status': 'Code sent to your email'})
@action(detail=False, methods=['post'])
def confirm_account_deletion(self, request):
user = request.user
code = request.data.get('code')
if not user.email_confirmation_code or code != user.email_confirmation_code:
return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST)
user.delete()
return Response({'status': 'Account deleted'})
@action(detail=False, methods=['get'])
def dashboard(self, request):
user = request.user
rated_ids = user.ratings.values_list('flavor_id', flat=True)
followed_users = user.following.all()
# Missing flavors with community avg and followed circle avg
missing_flavors = Flavor.objects.exclude(id__in=rated_ids) \
.exclude(category__name='Packs and other') \
.select_related('category') \
.annotate(
average_rating=Avg('ratings__score'),
followed_average_rating=Avg('ratings__score', filter=Q(ratings__user__in=followed_users))
).order_by('category__name', 'name')
rated_flavors = user.ratings.select_related('flavor', 'flavor__category') \
.prefetch_related('replies', 'replies__user') \
.order_by('-created_at')
return Response({
'user': UserSerializer(user).data,
'rated_count': rated_flavors.count(),
'missing_count': missing_flavors.count(),
'missing_flavors': FlavorSerializer(missing_flavors, many=True, context={'request': request}).data,
'my_ratings': RatingSerializer(rated_flavors, many=True, context={'request': request}).data
})
class TicketViewSet(viewsets.ModelViewSet):
serializer_class = TicketSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
if self.request.user.is_superuser:
return Ticket.objects.all().prefetch_related('messages', 'messages__user').order_by('-updated_at')
return Ticket.objects.filter(user=self.request.user).prefetch_related('messages', 'messages__user').order_by('-updated_at')
def perform_create(self, serializer):
ticket = serializer.save(user=self.request.user)
# Notify all superusers about new ticket
admins = User.objects.filter(is_superuser=True).exclude(pk=self.request.user.pk)
for admin in admins:
Notification.objects.create(
recipient=admin,
actor=self.request.user,
notification_type='ticket_new',
ticket=ticket
)
@action(detail=True, methods=['post'])
def add_message(self, request, pk=None):
ticket = self.get_object()
text = request.data.get('text')
if not text:
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
msg = TicketMessage.objects.create(ticket=ticket, user=request.user, text=text)
# Notification logic
if request.user.is_superuser:
# Notify the user who opened the ticket
if ticket.user != request.user:
Notification.objects.create(
recipient=ticket.user,
actor=request.user,
notification_type='ticket_reply',
ticket=ticket
)
else:
# Notify all superusers
admins = User.objects.filter(is_superuser=True).exclude(pk=request.user.pk)
for admin in admins:
Notification.objects.create(
recipient=admin,
actor=request.user,
notification_type='ticket_reply',
ticket=ticket
)
return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
def update_status(self, request, pk=None):
ticket = self.get_object()
new_status = request.data.get('status')
if new_status in dict(Ticket.STATUS_CHOICES):
ticket.status = new_status
ticket.save()
return Response({'status': 'updated'})
return Response({'error': 'Invalid status'}, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, *args, **kwargs):
if not request.user.is_superuser:
raise permissions.PermissionDenied("Only admins can delete tickets.")
return super().destroy(request, *args, **kwargs)
class BannerViewSet(viewsets.ModelViewSet):
queryset = Banner.objects.all()
serializer_class = BannerSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
pagination_class = None
def get_queryset(self):
if self.request.user.is_authenticated and self.request.user.is_superuser:
return Banner.objects.all()
return Banner.objects.filter(is_enabled=True)
def get_permissions(self):
if self.action in ['list', 'retrieve', 'active']:
return [permissions.AllowAny()]
return [permissions.IsAdminUser()]
@action(detail=False, methods=['get'])
def active(self, request):
# Determine which banner to show
# Priority:
# 1. Requested user's preference (if username provided in query)
# 2. Logged in user's preference
# 3. Global default
username = request.query_params.get('username')
user = None
if username:
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
pass
if not user and request.user.is_authenticated:
user = request.user
if user and user.selected_banner and user.selected_banner.is_enabled:
serializer = self.get_serializer(user.selected_banner)
return Response(serializer.data)
active_banner = Banner.objects.filter(is_active=True).first()
if active_banner:
serializer = self.get_serializer(active_banner)
return Response(serializer.data)
return Response(None)
@action(detail=True, methods=['post'])
def activate(self, request, pk=None):
banner = self.get_object()
banner.is_active = True
banner.is_enabled = True # Active banner must be enabled
banner.save()
return Response({'status': 'banner set as global default'})
@action(detail=True, methods=['post'])
def toggle_enabled(self, request, pk=None):
banner = self.get_object()
if banner.is_active:
return Response({'error': 'Cannot disable the global default banner.'}, status=status.HTTP_400_BAD_REQUEST)
banner.is_enabled = not banner.is_enabled
banner.save()
return Response({'status': 'enabled' if banner.is_enabled else 'disabled'})
class AdminViewSet(viewsets.ViewSet):
permission_classes = [permissions.IsAdminUser]
@action(detail=False, methods=['get'])
def stats(self, request):
return Response({
'total_users': User.objects.count(),
'total_ratings': Rating.objects.count(),
'total_replies': Reply.objects.count(),
'open_tickets': Ticket.objects.filter(status='open').count(),
'email_config': {
'host': getattr(settings, 'EMAIL_HOST', 'None'),
'port': getattr(settings, 'EMAIL_PORT', 'None'),
'use_tls': getattr(settings, 'EMAIL_USE_TLS', False),
'use_ssl': getattr(settings, 'EMAIL_USE_SSL', False),
'skip_verify': getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False),
},
'server_info': {
'debug': settings.DEBUG,
'allowed_hosts': settings.ALLOWED_HOSTS,
'frontend_url': getattr(settings, 'FRONTEND_URL', 'Not set'),
'media_root': str(settings.MEDIA_ROOT),
'static_root': str(settings.STATIC_ROOT),
}
})
@action(detail=False, methods=['get', 'patch'])
def config(self, request):
config = SystemConfig.get_solo()
if request.method == 'PATCH':
serializer = SystemConfigSerializer(config, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
serializer = SystemConfigSerializer(config)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def jobs(self, request):
# Ensure all jobs exist
for job_type, label in Job.JOB_TYPES:
Job.objects.get_or_create(name=job_type)
jobs = Job.objects.all().order_by('name')
serializer = JobSerializer(jobs, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def trigger_job(self, request, pk=None):
try:
job = Job.objects.get(pk=pk)
job.status = 'pending'
job.save()
return Response({'status': 'Job queued'})
except Job.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
@action(detail=True, methods=['patch'])
def update_job_schedule(self, request, pk=None):
try:
job = Job.objects.get(pk=pk)
interval = request.data.get('interval_hours')
next_run = request.data.get('next_run')
if interval is not None:
job.interval_hours = int(interval)
if next_run is not None:
from django.utils.dateparse import parse_datetime
from django.utils import timezone
parsed_dt = parse_datetime(next_run)
if parsed_dt:
# If it's naive (has no timezone info), assume it's local time
if timezone.is_naive(parsed_dt):
job.next_run = timezone.make_aware(parsed_dt, timezone.get_current_timezone())
else:
job.next_run = parsed_dt
elif interval is not None and job.interval_hours > 0 and not job.next_run:
from django.utils import timezone
job.next_run = timezone.now()
job.save()
return Response(JobSerializer(job).data)
except Job.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
@action(detail=False, methods=['post'])
def send_test_email(self, request):
try:
send_mail(
'Holy Flavors Admin Test Email',
f'This is a test email sent to {request.user.email} from the Holy Flavors Admin Interface.',
settings.DEFAULT_FROM_EMAIL,
[request.user.email],
fail_silently=False,
)
return Response({'status': 'Test email sent!'})
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=False, methods=['get'])
def users(self, request):
users = User.objects.all().prefetch_related('ips').order_by('-date_joined')
return Response(AdminUserListSerializer(users, many=True).data)
@action(detail=True, methods=['get', 'patch', 'delete'])
def user_detail(self, request, pk=None):
try:
user = User.objects.get(pk=pk)
except User.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if request.method == 'GET':
return Response(AdminUserDetailSerializer(user, context={'request': request}).data)
elif request.method == 'PATCH':
is_active = request.data.get('is_active')
if is_active is not None:
user.is_active = is_active
user.save()
return Response(AdminUserDetailSerializer(user, context={'request': request}).data)
elif request.method == 'DELETE':
user.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+20
View File
@@ -0,0 +1,20 @@
from api.views._pagination import FeedPagination
from api.views.admin import AdminViewSet
from api.views.banner import BannerViewSet
from api.views.flavor import CategoryViewSet, FlavorViewSet
from api.views.rating import RatingViewSet, ReplyViewSet
from api.views.support import TicketViewSet
from api.views.user import NotificationViewSet, UserViewSet
__all__ = [
"AdminViewSet",
"BannerViewSet",
"CategoryViewSet",
"FeedPagination",
"FlavorViewSet",
"NotificationViewSet",
"RatingViewSet",
"ReplyViewSet",
"TicketViewSet",
"UserViewSet",
]
+5
View File
@@ -0,0 +1,5 @@
from rest_framework.pagination import PageNumberPagination
class FeedPagination(PageNumberPagination):
page_size = 10
+145
View File
@@ -0,0 +1,145 @@
from django.conf import settings
from django.core.mail import send_mail
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from api.models import Job, Rating, Reply, SystemConfig, Ticket, User
from api.serializers import (
AdminUserDetailSerializer,
AdminUserListSerializer,
JobSerializer,
SystemConfigSerializer,
)
class AdminViewSet(viewsets.ViewSet):
permission_classes = [permissions.IsAdminUser]
@action(detail=False, methods=["get"])
def stats(self, request: Request) -> Response:
return Response(
{
"total_users": User.objects.count(),
"total_ratings": Rating.objects.count(),
"total_replies": Reply.objects.count(),
"open_tickets": Ticket.objects.filter(status="open").count(),
"email_config": {
"host": getattr(settings, "EMAIL_HOST", "None"),
"port": getattr(settings, "EMAIL_PORT", "None"),
"use_tls": getattr(settings, "EMAIL_USE_TLS", False),
"use_ssl": getattr(settings, "EMAIL_USE_SSL", False),
"skip_verify": getattr(settings, "EMAIL_SKIP_CERT_VERIFICATION", False),
},
"server_info": {
"debug": settings.DEBUG,
"allowed_hosts": settings.ALLOWED_HOSTS,
"frontend_url": getattr(settings, "FRONTEND_URL", "Not set"),
"media_root": str(settings.MEDIA_ROOT),
"static_root": str(settings.STATIC_ROOT),
},
}
)
@action(detail=False, methods=["get", "patch"])
def config(self, request: Request) -> Response:
config = SystemConfig.get_solo()
if request.method == "PATCH":
serializer = SystemConfigSerializer(config, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
return Response(SystemConfigSerializer(config).data)
@action(detail=False, methods=["get"])
def jobs(self, request: Request) -> Response:
for job_type, _ in Job.JOB_TYPES:
Job.objects.get_or_create(name=job_type)
jobs = Job.objects.all().order_by("name")
return Response(JobSerializer(jobs, many=True).data)
@action(detail=True, methods=["post"])
def trigger_job(self, request: Request, pk=None) -> Response:
try:
job = Job.objects.get(pk=pk)
job.status = "pending"
job.save()
return Response({"status": "Job queued"})
except Job.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
@action(detail=True, methods=["patch"])
def update_job_schedule(self, request: Request, pk=None) -> Response:
try:
job = Job.objects.get(pk=pk)
interval = request.data.get("interval_hours")
next_run = request.data.get("next_run")
if interval is not None:
job.interval_hours = int(interval)
if next_run is not None:
from django.utils import timezone
from django.utils.dateparse import parse_datetime
parsed_dt = parse_datetime(next_run)
if parsed_dt:
if timezone.is_naive(parsed_dt):
job.next_run = timezone.make_aware(
parsed_dt, timezone.get_current_timezone()
)
else:
job.next_run = parsed_dt
elif interval is not None and job.interval_hours > 0 and not job.next_run:
from django.utils import timezone
job.next_run = timezone.now()
job.save()
return Response(JobSerializer(job).data)
except Job.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
@action(detail=False, methods=["post"])
def send_test_email(self, request: Request) -> Response:
try:
send_mail(
"Holy Flavors Admin Test Email",
(
f"This is a test email sent to {request.user.email} from the "
f"Holy Flavors Admin Interface."
),
settings.DEFAULT_FROM_EMAIL,
[request.user.email],
fail_silently=False,
)
return Response({"status": "Test email sent!"})
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=False, methods=["get"])
def users(self, request: Request) -> Response:
users = User.objects.all().prefetch_related("ips").order_by("-date_joined")
return Response(AdminUserListSerializer(users, many=True).data)
@action(detail=True, methods=["get", "patch", "delete"])
def user_detail(self, request: Request, pk=None) -> Response:
try:
user = User.objects.get(pk=pk)
except User.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if request.method == "GET":
return Response(AdminUserDetailSerializer(user, context={"request": request}).data)
elif request.method == "PATCH":
is_active = request.data.get("is_active")
if is_active is not None:
user.is_active = is_active
user.save()
return Response(AdminUserDetailSerializer(user, context={"request": request}).data)
elif request.method == "DELETE":
user.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+65
View File
@@ -0,0 +1,65 @@
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from api.models import Banner, User
from api.serializers import BannerSerializer
class BannerViewSet(viewsets.ModelViewSet):
queryset = Banner.objects.all()
serializer_class = BannerSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
pagination_class = None
def get_queryset(self):
if self.request.user.is_authenticated and self.request.user.is_superuser:
return Banner.objects.all()
return Banner.objects.filter(is_enabled=True)
def get_permissions(self):
if self.action in ["list", "retrieve", "active"]:
return [permissions.AllowAny()]
return [permissions.IsAdminUser()]
@action(detail=False, methods=["get"])
def active(self, request: Request) -> Response:
username = request.query_params.get("username")
user = None
if username:
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
pass
if not user and request.user.is_authenticated:
user = request.user
if user and user.selected_banner and user.selected_banner.is_enabled:
return Response(self.get_serializer(user.selected_banner).data)
active_banner = Banner.objects.filter(is_active=True).first()
if active_banner:
return Response(self.get_serializer(active_banner).data)
return Response(None)
@action(detail=True, methods=["post"])
def activate(self, request: Request, pk=None) -> Response:
banner = self.get_object()
banner.is_active = True
banner.is_enabled = True
banner.save()
return Response({"status": "banner set as global default"})
@action(detail=True, methods=["post"])
def toggle_enabled(self, request: Request, pk=None) -> Response:
banner = self.get_object()
if banner.is_active:
return Response(
{"error": "Cannot disable the global default banner."},
status=status.HTTP_400_BAD_REQUEST,
)
banner.is_enabled = not banner.is_enabled
banner.save()
return Response({"status": "enabled" if banner.is_enabled else "disabled"})
+105
View File
@@ -0,0 +1,105 @@
from django.db.models import Avg
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, permissions, viewsets
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from api.models import Category, Flavor
from api.serializers import CategorySerializer, FlavorSerializer
from api.services.search import (
extract_category_slug,
filter_flavors_by_query,
score_relevance,
)
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
class FlavorViewSet(viewsets.ModelViewSet):
queryset = Flavor.objects.annotate(average_rating=Avg("ratings__score")).order_by(
"-average_rating"
)
serializer_class = FlavorSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_fields = ["category", "category__slug"]
search_fields = ["name", "description"]
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def get_queryset(self):
qs = (
Flavor.objects.select_related("category")
.annotate(average_rating=Avg("ratings__score"))
.order_by("-average_rating")
.distinct()
)
search_query = self.request.query_params.get("search", "")
slug, _ = extract_category_slug(search_query)
if slug:
qs = qs.filter(category__slug=slug)
return qs
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
def search(self, request: Request) -> Response:
query = request.query_params.get("q", "").lower().strip()
if not query:
return Response([])
flavors = Flavor.objects.select_related("category").distinct()
flavors = filter_flavors_by_query(flavors, query)
_, remaining = extract_category_slug(query)
seen_keys: set[str] = set()
results: list[dict] = []
for f in flavors.order_by("name"):
key = f"{f.name.strip().lower()}|{f.category.slug}"
if key in seen_keys:
continue
seen_keys.add(key)
results.append(
{
"id": f.id,
"name": f.name,
"type": "flavor",
"subtitle": f.category.name,
"image_url": (
request.build_absolute_uri(f.image.url) if f.image else f.image_url
),
"slug": None,
"relevance": score_relevance(f.name, remaining),
}
)
results.sort(key=lambda x: (-x["relevance"], x["name"]))
for r in results:
del r["relevance"]
return Response(results[:15])
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
def top(self, request: Request) -> Response:
queryset = self.get_queryset().filter(ratings__isnull=False)
category_slug = request.query_params.get("category")
if category_slug:
queryset = queryset.filter(category__slug=category_slug)
top_flavors = queryset.distinct()[:10]
return Response(self.get_serializer(top_flavors, many=True).data)
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
def newest(self, request: Request) -> Response:
newest_flavors = Flavor.objects.select_related("category").order_by("-created_at")[:10]
return Response(self.get_serializer(newest_flavors, many=True).data)
@action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated])
def followed_top(self, request: Request) -> Response:
followed_users = request.user.following.all()
top_flavors = (
Flavor.objects.filter(ratings__user__in=followed_users)
.annotate(average_rating=Avg("ratings__score"))
.order_by("-average_rating")
.distinct()[:10]
)
return Response(self.get_serializer(top_flavors, many=True).data)
+120
View File
@@ -0,0 +1,120 @@
from django.utils.decorators import method_decorator
from django_ratelimit.decorators import ratelimit
from rest_framework import permissions, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request
from rest_framework.response import Response
from api.models import Notification, Rating, Reply
from api.serializers import RatingSerializer, ReplySerializer
from api.services.mentions import parse_mentions
from api.views._pagination import FeedPagination
class RatingViewSet(viewsets.ModelViewSet):
queryset = Rating.objects.all()
serializer_class = RatingSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
@method_decorator(ratelimit(key="user", rate="10/m", method="POST", block=True))
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
def perform_create(self, serializer):
flavor = serializer.validated_data["flavor"]
if Rating.objects.filter(user=self.request.user, flavor=flavor).exists():
raise serializers.ValidationError("You have already rated this flavor.")
rating = serializer.save(user=self.request.user)
if rating.comment:
parse_mentions(rating.comment, self.request.user, rating=rating)
def get_queryset(self):
return (
Rating.objects.select_related("user", "flavor", "flavor__category")
.prefetch_related("replies", "replies__user")
.order_by("-created_at")
)
@action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated])
def feed(self, request: Request) -> Response:
followed_users = request.user.following.all()
feed_ratings = (
Rating.objects.filter(user__in=followed_users)
.select_related("user", "flavor", "flavor__category")
.prefetch_related("replies", "replies__user")
.order_by("-created_at")
)
paginator = FeedPagination()
page = paginator.paginate_queryset(feed_ratings, request)
if page is not None:
serializer = self.get_serializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
return Response(self.get_serializer(feed_ratings, many=True).data)
def check_object_permissions(self, request, obj):
super().check_object_permissions(request, obj)
if (
request.method in ["PUT", "PATCH", "DELETE"]
and obj.user != request.user
and not request.user.is_superuser
):
self.permission_denied(request, message="You cannot edit/delete this rating.")
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
def recent(self, request: Request) -> Response:
recent_ratings = (
Rating.objects.filter(comment__isnull=False)
.exclude(comment="")
.select_related("user", "flavor")
.order_by("-created_at")[:10]
)
return Response(self.get_serializer(recent_ratings, many=True).data)
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
def reply(self, request: Request, pk=None) -> Response:
rating = self.get_object()
text = request.data.get("text")
if not text:
return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST)
reply = Reply.objects.create(user=request.user, rating=rating, text=text)
if rating.user != request.user:
Notification.objects.create(
recipient=rating.user,
actor=request.user,
notification_type="reply",
rating=rating,
reply=reply,
)
parse_mentions(text, request.user, rating=rating, reply=reply)
return Response(
ReplySerializer(reply, context={"request": request}).data,
status=status.HTTP_201_CREATED,
)
class ReplyViewSet(viewsets.ModelViewSet):
queryset = Reply.objects.all()
serializer_class = ReplySerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Reply.objects.select_related("user", "rating").order_by("created_at")
def perform_update(self, serializer):
reply = self.get_object()
if reply.user != self.request.user and not self.request.user.is_superuser:
raise PermissionDenied("You cannot edit this reply.")
new_reply = serializer.save()
parse_mentions(new_reply.text, self.request.user, rating=new_reply.rating, reply=new_reply)
def perform_destroy(self, instance):
if instance.user != self.request.user and not self.request.user.is_superuser:
raise PermissionDenied("You cannot delete this reply.")
instance.delete()
+81
View File
@@ -0,0 +1,81 @@
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request
from rest_framework.response import Response
from api.models import Notification, Ticket, TicketMessage, User
from api.serializers import TicketMessageSerializer, TicketSerializer
class TicketViewSet(viewsets.ModelViewSet):
serializer_class = TicketSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
if self.request.user.is_superuser:
return (
Ticket.objects.all()
.prefetch_related("messages", "messages__user")
.order_by("-updated_at")
)
return (
Ticket.objects.filter(user=self.request.user)
.prefetch_related("messages", "messages__user")
.order_by("-updated_at")
)
def perform_create(self, serializer):
ticket = serializer.save(user=self.request.user)
admins = User.objects.filter(is_superuser=True).exclude(pk=self.request.user.pk)
for admin in admins:
Notification.objects.create(
recipient=admin,
actor=self.request.user,
notification_type="ticket_new",
ticket=ticket,
)
@action(detail=True, methods=["post"])
def add_message(self, request: Request, pk=None) -> Response:
ticket = self.get_object()
text = request.data.get("text")
if not text:
return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST)
msg = TicketMessage.objects.create(ticket=ticket, user=request.user, text=text)
if request.user.is_superuser:
if ticket.user != request.user:
Notification.objects.create(
recipient=ticket.user,
actor=request.user,
notification_type="ticket_reply",
ticket=ticket,
)
else:
admins = User.objects.filter(is_superuser=True).exclude(pk=request.user.pk)
for admin in admins:
Notification.objects.create(
recipient=admin,
actor=request.user,
notification_type="ticket_reply",
ticket=ticket,
)
return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
def update_status(self, request: Request, pk=None) -> Response:
ticket = self.get_object()
new_status = request.data.get("status")
if new_status in dict(Ticket.STATUS_CHOICES):
ticket.status = new_status
ticket.save()
return Response({"status": "updated"})
return Response({"error": "Invalid status"}, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, *args, **kwargs):
if not request.user.is_superuser:
raise PermissionDenied("Only admins can delete tickets.")
return super().destroy(request, *args, **kwargs)
+546
View File
@@ -0,0 +1,546 @@
import secrets
import string
from django.conf import settings
from django.utils.decorators import method_decorator
from django_ratelimit.decorators import ratelimit
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from api.models import Banner, Flavor, Notification, ProfileComment, User
from api.serializers import (
FlavorSerializer,
NotificationSerializer,
ProfileCommentSerializer,
RatingSerializer,
UserSerializer,
)
from api.services.ip_logging import log_user_ip
from api.tasks import send_email_task
def _generate_code() -> str:
return "".join(secrets.choice(string.digits) for _ in range(6))
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
def get_permissions(self):
if self.action in [
"signup",
"verify_signup",
"request_password_reset",
"complete_password_reset",
]:
return [permissions.AllowAny()]
return super().get_permissions()
@action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated])
def following_list(self, request: Request) -> Response:
following = request.user.following.all()
return Response(self.get_serializer(following, many=True).data)
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
@method_decorator(ratelimit(key="ip", rate="5/h", method="POST", block=True))
def signup(self, request: Request) -> Response:
username = request.data.get("username")
email = request.data.get("email")
password = request.data.get("password")
if not username or not email or not password:
return Response(
{"error": "Username, email and password required"},
status=status.HTTP_400_BAD_REQUEST,
)
if User.objects.filter(username=username).exists():
return Response(
{"error": "Username already exists"}, status=status.HTTP_400_BAD_REQUEST
)
if User.objects.filter(email=email).exists():
return Response({"error": "Email already exists"}, status=status.HTTP_400_BAD_REQUEST)
code = _generate_code()
user = User.objects.create_user(
username=username,
email=email,
password=password,
is_active=False,
email_confirmation_code=code,
)
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={username}&code={code}"
try:
send_email_task.delay(
subject="Verify your Holy Flavors account",
message=(
f"Hi {username},\n\nYour verification code is: {code}\n\n"
f"Alternatively, you can complete your registration by clicking the link below:\n"
f"{verification_link}\n\nWelcome to the archive!"
),
recipient_list=[email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
except Exception as e:
user.delete()
return Response(
{
"error": (
f"Failed to send verification email. Please check your SMTP settings "
f"in the .env file. The server error was: {e}"
)
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(
{
"status": "User created, please verify your email",
"username": username,
"email": email,
},
status=status.HTTP_201_CREATED,
)
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
def resend_verification(self, request: Request) -> Response:
username = request.data.get("username")
if not username:
return Response({"error": "Username required"}, status=status.HTTP_400_BAD_REQUEST)
try:
user = User.objects.get(username=username, is_active=False)
except User.DoesNotExist:
return Response(
{"error": "User not found or already active"},
status=status.HTTP_404_NOT_FOUND,
)
code = _generate_code()
user.email_confirmation_code = code
user.save()
verification_link = (
f"{settings.FRONTEND_URL}/verify-email?username={user.username}&code={code}"
)
try:
send_email_task.delay(
subject="Verify your Holy Flavors account",
message=(
f"Hi {user.username},\n\nYour new verification code is: {code}\n\n"
f"Alternatively, you can complete your registration by clicking the link below:\n"
f"{verification_link}"
),
recipient_list=[user.email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
except Exception as e:
return Response(
{"error": f"Failed to send email: {e}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"status": "Verification code resent!"})
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
def verify_signup(self, request: Request) -> Response:
username = request.data.get("username")
code = request.data.get("code")
try:
user = User.objects.get(username=username, is_active=False)
except User.DoesNotExist:
return Response(
{"error": "User not found or already active"},
status=status.HTTP_404_NOT_FOUND,
)
if user.email_confirmation_code == code:
user.is_active = True
user.email_confirmation_code = None
user.save()
return Response({"status": "Account verified successfully!"})
return Response({"error": "Invalid verification code"}, status=status.HTTP_400_BAD_REQUEST)
@action(
detail=False,
methods=["get"],
permission_classes=[permissions.AllowAny],
url_path="profile/(?P<username>[^/.]+)",
)
def public_profile(self, request: Request, username=None) -> Response:
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND)
ratings = user.ratings.select_related("flavor", "flavor__category").order_by("-score")
comments = user.profile_comments.select_related("author").order_by("-created_at")
followers = user.followers.all()
following = user.following.all()
return Response(
{
"id": user.id,
"username": user.username,
"theme": user.theme,
"avatar": (request.build_absolute_uri(user.avatar.url) if user.avatar else None),
"following_count": following.count(),
"followers_count": followers.count(),
"is_following": (
request.user.following.filter(pk=user.pk).exists()
if request.user.is_authenticated
else False
),
"ratings": RatingSerializer(ratings, many=True, context={"request": request}).data,
"comments": ProfileCommentSerializer(
comments, many=True, context={"request": request}
).data,
"followers": UserSerializer(
followers, many=True, context={"request": request}
).data,
"following": UserSerializer(
following, many=True, context={"request": request}
).data,
}
)
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
def add_comment(self, request: Request, pk=None) -> Response:
profile_owner = self.get_object()
text = request.data.get("text")
if not text:
return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST)
comment = ProfileComment.objects.create(
profile_owner=profile_owner,
author=request.user,
text=text,
)
if profile_owner != request.user:
Notification.objects.create(
recipient=profile_owner,
actor=request.user,
notification_type="profile_comment",
profile_comment=comment,
)
return Response(
ProfileCommentSerializer(comment, context={"request": request}).data,
status=status.HTTP_201_CREATED,
)
@action(
detail=True,
methods=["delete"],
url_path="delete_comment/(?P<comment_id>[^/.]+)",
permission_classes=[permissions.IsAuthenticated],
)
def delete_comment(self, request: Request, pk=None, comment_id=None) -> Response:
profile_owner = self.get_object()
try:
comment = ProfileComment.objects.get(pk=comment_id, profile_owner=profile_owner)
except ProfileComment.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if (
comment.author != request.user
and not request.user.is_superuser
and profile_owner != request.user
):
return Response(status=status.HTTP_403_FORBIDDEN)
comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["get"])
def me(self, request: Request) -> Response:
log_user_ip(request.user, request)
return Response(self.get_serializer(request.user).data)
@action(detail=False, methods=["patch"])
def update_preferences(self, request: Request) -> Response:
user = request.user
theme = request.data.get("theme")
language = request.data.get("language")
banner_id = request.data.get("selected_banner")
updated = False
if theme in dict(User.THEME_CHOICES):
user.theme = theme
updated = True
if language in dict(User.LANGUAGE_CHOICES):
user.language = language
updated = True
if banner_id is not None:
if banner_id == "":
user.selected_banner = None
updated = True
else:
try:
banner = Banner.objects.get(pk=banner_id, is_enabled=True)
user.selected_banner = banner
updated = True
except Banner.DoesNotExist:
return Response(
{"error": "Invalid or disabled banner"},
status=status.HTTP_400_BAD_REQUEST,
)
if updated:
user.save()
return Response(
{
"status": "preferences updated",
"theme": user.theme,
"language": user.language,
"selected_banner": (user.selected_banner.id if user.selected_banner else None),
}
)
return Response({"error": "No valid updates provided"}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=["post"])
def update_avatar(self, request: Request) -> Response:
user = request.user
avatar = request.FILES.get("avatar")
if not avatar:
return Response({"error": "No avatar provided"}, status=status.HTTP_400_BAD_REQUEST)
if avatar.size > 2 * 1024 * 1024:
return Response(
{"error": "File size exceeds 2MB limit"},
status=status.HTTP_400_BAD_REQUEST,
)
user.avatar = avatar
user.save()
return Response(self.get_serializer(user).data)
@action(detail=False, methods=["post"])
def change_password(self, request: Request) -> Response:
user = request.user
old_password = request.data.get("old_password")
new_password = request.data.get("new_password")
if not user.check_password(old_password):
return Response({"error": "Wrong old password"}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password)
user.save()
return Response({"status": "password changed"})
@action(detail=False, methods=["patch"])
def update_profile(self, request: Request) -> Response:
user = request.user
username = request.data.get("username")
email = request.data.get("email")
message = ""
if username and username != user.username:
if User.objects.filter(username=username).exists():
return Response(
{"error": "Username already taken"},
status=status.HTTP_400_BAD_REQUEST,
)
user.username = username
if email and email != user.email:
code = _generate_code()
user.pending_email = email
user.email_confirmation_code = code
send_email_task.delay(
subject="Confirm your new email",
message=f"Hi {user.username},\n\nYour confirmation code is: {code}",
recipient_list=[email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
message = " Confirmation code sent to new email."
user.save()
data = self.get_serializer(user).data
if message:
data["message"] = message
return Response(data)
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
def follow(self, request: Request, pk=None) -> Response:
user_to_follow = self.get_object()
if user_to_follow == request.user:
return Response(
{"error": "You cannot follow yourself"},
status=status.HTTP_400_BAD_REQUEST,
)
if not request.user.following.filter(pk=user_to_follow.pk).exists():
request.user.following.add(user_to_follow)
Notification.objects.create(
recipient=user_to_follow,
actor=request.user,
notification_type="follow",
)
return Response({"status": "followed"})
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
def unfollow(self, request: Request, pk=None) -> Response:
user_to_unfollow = self.get_object()
request.user.following.remove(user_to_unfollow)
return Response({"status": "unfollowed"})
@action(detail=False, methods=["post"])
def confirm_email(self, request: Request) -> Response:
user = request.user
code = request.data.get("code")
if not user.email_confirmation_code or code != user.email_confirmation_code:
return Response(
{"error": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST
)
user.email = user.pending_email
user.pending_email = None
user.email_confirmation_code = None
user.save()
return Response({"status": "Email confirmed", "email": user.email})
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
@method_decorator(ratelimit(key="ip", rate="3/h", method="POST", block=True))
def request_password_reset(self, request: Request) -> Response:
email = request.data.get("email")
try:
user = User.objects.get(email=email, is_active=True)
except User.DoesNotExist:
return Response(
{"status": "If an account exists with this email, a reset code has been sent."}
)
code = _generate_code()
user.email_confirmation_code = code
user.save()
send_email_task.delay(
subject="Password Reset Request",
message=f"Hi {user.username},\n\nYour password reset code is: {code}",
recipient_list=[email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
return Response(
{"status": "If an account exists with this email, a reset code has been sent."}
)
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
@method_decorator(ratelimit(key="ip", rate="5/h", method="POST", block=True))
def complete_password_reset(self, request: Request) -> Response:
email = request.data.get("email")
code = request.data.get("code")
new_password = request.data.get("password")
try:
user = User.objects.get(email=email, email_confirmation_code=code)
except User.DoesNotExist:
return Response({"error": "Invalid email or code"}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password)
user.email_confirmation_code = None
user.save()
return Response({"status": "Password reset successful!"})
@action(detail=False, methods=["post"])
@method_decorator(ratelimit(key="user", rate="3/h", method="POST", block=True))
def request_account_deletion(self, request: Request) -> Response:
code = _generate_code()
user = request.user
user.email_confirmation_code = code
user.save()
send_email_task.delay(
subject="Confirm Account Deletion - Holy Flavors Archive",
message=(
f"Hi {user.username},\n\nYou requested to delete your account. "
f"This action is permanent.\n\nYour deletion code is: {code}"
),
recipient_list=[user.email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
return Response({"status": "Code sent to your email"})
@action(detail=False, methods=["post"])
def confirm_account_deletion(self, request: Request) -> Response:
user = request.user
code = request.data.get("code")
if not user.email_confirmation_code or code != user.email_confirmation_code:
return Response(
{"error": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST
)
user.delete()
return Response({"status": "Account deleted"})
@action(detail=False, methods=["get"])
def dashboard(self, request: Request) -> Response:
from django.db.models import Avg, Q
user = request.user
rated_ids = user.ratings.values_list("flavor_id", flat=True)
followed_users = user.following.all()
missing_flavors = (
Flavor.objects.exclude(id__in=rated_ids)
.exclude(category__name="Packs and other")
.select_related("category")
.annotate(
average_rating=Avg("ratings__score"),
followed_average_rating=Avg(
"ratings__score", filter=Q(ratings__user__in=followed_users)
),
)
.order_by("category__name", "name")
)
rated_flavors = (
user.ratings.select_related("flavor", "flavor__category")
.prefetch_related("replies", "replies__user")
.order_by("-created_at")
)
return Response(
{
"user": UserSerializer(user).data,
"rated_count": rated_flavors.count(),
"missing_count": missing_flavors.count(),
"missing_flavors": FlavorSerializer(
missing_flavors, many=True, context={"request": request}
).data,
"my_ratings": RatingSerializer(
rated_flavors, many=True, context={"request": request}
).data,
}
)
class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = NotificationSerializer
permission_classes = [permissions.IsAuthenticated]
pagination_class = None
def get_queryset(self):
return self.request.user.notifications.select_related(
"actor", "rating__flavor", "reply__rating__flavor"
).order_by("-created_at")
@action(detail=False, methods=["post"])
def mark_all_read(self, request: Request) -> Response:
request.user.notifications.filter(is_read=False).update(is_read=True)
return Response({"status": "all read"})
@action(detail=True, methods=["post"])
def mark_read(self, request: Request, pk=None) -> Response:
notification = self.get_object()
notification.is_read = True
notification.save()
return Response({"status": "marked read"})
+3
View File
@@ -0,0 +1,3 @@
from holy_backend.celery import app as celery_app
__all__ = ("celery_app",)
+1 -1
View File
@@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
application = get_asgi_application() application = get_asgi_application()
+15
View File
@@ -0,0 +1,15 @@
"""Celery application for holy_backend.
Autodiscovers tasks from installed Django apps. Broker/backend configuration
is read from Django settings (CELERY_* namespace).
"""
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
app = Celery("holy_backend")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
-231
View File
@@ -1,231 +0,0 @@
"""
Django settings for holy_backend project.
Generated by 'django-admin startproject' using Django 6.0.2.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
from pathlib import Path
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-zos99ue2a89!4jrv4g@_b4nn4z!5o*@8k!5cr9!u$j%_@xb=l8')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.environ.get('DEBUG', 'false').lower() == 'true'
# Simplification: Use a single DOMAIN variable to derive multiple settings
DOMAIN = os.environ.get('DOMAIN', 'localhost')
# Helper to build lists from env or defaults
def get_env_list(var_name, default_list):
val = os.environ.get(var_name)
if val:
return [item.strip() for item in val.split(',')]
return default_list
ALLOWED_HOSTS = get_env_list('ALLOWED_HOSTS', ['localhost', '127.0.0.1', 'backend', 'worker', DOMAIN])
# Application definition
INSTALLED_APPS = [
'whitenoise.runserver_nostatic',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third party
'rest_framework',
'rest_framework.authtoken',
'corsheaders',
'django_filters',
'drf_spectacular',
'django_ratelimit',
# Local
'api',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'compression_middleware.middleware.CompressionMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'holy_backend.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'holy_backend.wsgi.application'
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Cache configuration for Ratelimiting
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
'LOCATION': 'django_ratelimit_cache',
}
}
# Silence ratelimit warnings for non-Redis/Memcached backends
SILENCED_SYSTEM_CHECKS = ['django_ratelimit.W001', 'django_ratelimit.E003']
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage'
WHITENOISE_INDEX_PAGE = True
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
# --- Reverse Proxy Configuration ---
# These settings are crucial for running behind a proxy like Traefik
USE_X_FORWARDED_HOST = True
USE_X_FORWARDED_PORT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Production Security
if not DEBUG:
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Read CSRF trusted origins from environment variable
CSRF_TRUSTED_ORIGINS = get_env_list('CSRF_TRUSTED_ORIGINS', [f'https://{DOMAIN}', 'http://localhost:5173'])
# Custom User Model
AUTH_USER_MODEL = 'api.User'
# REST Framework
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 50,
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}
# CORS
CORS_ALLOWED_ORIGINS = get_env_list('CORS_ALLOWED_ORIGINS', [f'https://{DOMAIN}', 'http://localhost:5173', 'http://127.0.0.1:5173'])
FRONTEND_URL = os.environ.get('FRONTEND_URL', f'https://{DOMAIN}').rstrip('/')
import os
# ... (near top of file)
# Email (SMTP)
# For production, set these via environment variables.
# For development, it will default to printing emails to the console.
EMAIL_HOST = os.environ.get('EMAIL_HOST')
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
# Default to False unless explicitly set to 'true' in the .env file
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'false').lower() == 'true'
EMAIL_USE_SSL = os.environ.get('EMAIL_USE_SSL', 'false').lower() == 'true'
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD')
EMAIL_SKIP_CERT_VERIFICATION = os.environ.get('EMAIL_SKIP_CERT_VERIFICATION', 'false').lower() == 'true'
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', EMAIL_HOST_USER)
SERVER_EMAIL = DEFAULT_FROM_EMAIL
if EMAIL_HOST:
EMAIL_BACKEND = 'api.email_backend.InsecureEmailBackend'
else:
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Swagger
SPECTACULAR_SETTINGS = {
'TITLE': 'Holy Flavors API',
'DESCRIPTION': 'API for rating flavors',
'VERSION': '1.0.0',
'SERVE_INCLUDE_SCHEMA': False,
}
Whitespace-only changes.
+191
View File
@@ -0,0 +1,191 @@
"""
Base Django settings shared across all environments.
Environment-specific overrides live in `dev.py` and `prod.py`. Select one with
the DJANGO_SETTINGS_MODULE environment variable.
"""
import os
from datetime import timedelta
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
def get_env_list(var_name: str, default_list: list[str]) -> list[str]:
val = os.environ.get(var_name)
if val:
return [item.strip() for item in val.split(",")]
return default_list
DOMAIN = os.environ.get("DOMAIN", "localhost")
ALLOWED_HOSTS = get_env_list(
"ALLOWED_HOSTS",
["localhost", "127.0.0.1", "backend", "worker", DOMAIN],
)
INSTALLED_APPS = [
"whitenoise.runserver_nostatic",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Third party
"rest_framework",
"rest_framework.authtoken",
"rest_framework_simplejwt",
"rest_framework_simplejwt.token_blacklist",
"corsheaders",
"django_filters",
"drf_spectacular",
"django_ratelimit",
"django_celery_beat",
# Local
"api",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"compression_middleware.middleware.CompressionMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "holy_backend.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "holy_backend.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
"LOCATION": "django_ratelimit_cache",
}
}
SILENCED_SYSTEM_CHECKS = ["django_ratelimit.W001", "django_ratelimit.E003"]
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"
WHITENOISE_INDEX_PAGE = True
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
# Reverse proxy
USE_X_FORWARDED_HOST = True
USE_X_FORWARDED_PORT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_TRUSTED_ORIGINS = get_env_list(
"CSRF_TRUSTED_ORIGINS",
[f"https://{DOMAIN}", "http://localhost:5173"],
)
AUTH_USER_MODEL = "api.User"
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 50,
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
"REFRESH_TOKEN_LIFETIME": timedelta(days=14),
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
"AUTH_HEADER_TYPES": ("Bearer",),
}
CORS_ALLOWED_ORIGINS = get_env_list(
"CORS_ALLOWED_ORIGINS",
[f"https://{DOMAIN}", "http://localhost:5173", "http://127.0.0.1:5173"],
)
FRONTEND_URL = os.environ.get("FRONTEND_URL", f"https://{DOMAIN}").rstrip("/")
# Email (SMTP)
EMAIL_HOST = os.environ.get("EMAIL_HOST")
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "false").lower() == "true"
EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "false").lower() == "true"
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
EMAIL_SKIP_CERT_VERIFICATION = (
os.environ.get("EMAIL_SKIP_CERT_VERIFICATION", "false").lower() == "true"
)
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER)
SERVER_EMAIL = DEFAULT_FROM_EMAIL
if EMAIL_HOST:
EMAIL_BACKEND = "api.email_backend.InsecureEmailBackend"
else:
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
SPECTACULAR_SETTINGS = {
"TITLE": "Holy Flavors API",
"DESCRIPTION": "API for rating flavors",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
}
# Celery
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0")
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://redis:6379/0")
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
CELERY_TASK_ALWAYS_EAGER = os.environ.get("CELERY_TASK_ALWAYS_EAGER", "false").lower() == "true"
+18
View File
@@ -0,0 +1,18 @@
"""Development settings. Selected by default via manage.py / wsgi.py / asgi.py."""
import os
from holy_backend.settings.base import * # noqa: F401,F403
DEBUG = True
# Dev-only fallback. Override via SECRET_KEY env var if you want consistency
# across restarts (sessions invalidate when this changes).
SECRET_KEY = os.environ.get(
"SECRET_KEY",
"django-insecure-dev-only-do-not-use-in-production",
)
# Run Celery tasks synchronously in dev/tests unless a broker is available.
CELERY_TASK_ALWAYS_EAGER = os.environ.get("CELERY_TASK_ALWAYS_EAGER", "true").lower() == "true"
CELERY_TASK_EAGER_PROPAGATES = True
+22
View File
@@ -0,0 +1,22 @@
"""Production settings. Set DJANGO_SETTINGS_MODULE=holy_backend.settings.prod."""
import os
from holy_backend.settings.base import * # noqa: F401,F403
DEBUG = False
# Required in production. Fail loudly if not set.
try:
SECRET_KEY = os.environ["SECRET_KEY"]
except KeyError as exc:
raise RuntimeError("SECRET_KEY environment variable must be set in production.") from exc
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
+31 -22
View File
@@ -1,34 +1,43 @@
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from rest_framework.authtoken.views import obtain_auth_token
from django.conf import settings
from django.conf.urls.static import static
from django.views.static import serve
from django.views.generic.base import TemplateView
from django.http import HttpResponse
import os import os
from django.conf import settings
from django.contrib import admin
from django.http import HttpResponse
from django.urls import include, path
from django.views.generic.base import TemplateView
from django.views.static import serve
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView
def health_check(request): def health_check(request):
return HttpResponse("OK", content_type="text/plain") return HttpResponse("OK", content_type="text/plain")
# Clean the admin path # Clean the admin path
admin_url = os.environ.get('ADMIN_URL', 'admin').strip('/') admin_url = os.environ.get("ADMIN_URL", "admin").strip("/")
urlpatterns = [ urlpatterns = [
# Health check # Health check
path('health/', health_check, name='health_check'), path("health/", health_check, name="health_check"),
# Robots.txt # Robots.txt
path('robots.txt', TemplateView.as_view(template_name="robots.txt", content_type="text/plain")), path("robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
# Ensure admin ends with a slash for Django's redirection logic # Ensure admin ends with a slash for Django's redirection logic
path(f'{admin_url}/', admin.site.urls), path(f"{admin_url}/", admin.site.urls),
path('api/', include('api.urls')), path("api/", include("api.urls")),
path('api/token/', obtain_auth_token, name='api_token'), path("api/auth/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path('api-auth/', include('rest_framework.urls')), path("api/auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path("api/auth/token/verify/", TokenVerifyView.as_view(), name="token_verify"),
path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), # Legacy alias: old DRF TokenAuth endpoint now returns a JWT pair so clients
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), # relying on POST /api/token/ keep working for one release.
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}), path("api/token/", TokenObtainPairView.as_view(), name="api_token_legacy"),
path("api-auth/", include("rest_framework.urls")),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"api/schema/swagger-ui/",
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
path("api/schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
path("media/<path:path>", serve, {"document_root": settings.MEDIA_ROOT}),
] ]
+1 -1
View File
@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
application = get_wsgi_application() application = get_wsgi_application()
+3 -2
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:
@@ -18,5 +19,5 @@ def main():
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)
if __name__ == '__main__': if __name__ == "__main__":
main() main()
+49
View File
@@ -0,0 +1,49 @@
[tool.ruff]
line-length = 100
target-version = "py312"
extend-exclude = ["migrations", "venv", ".venv"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
"C4", # flake8-comprehensions
"DJ", # flake8-django
]
ignore = [
"E501", # line too long (formatter handles it)
"B008", # function call in default argument (common in DRF)
"DJ001", # null=True on string fields (pre-existing schema decisions)
"DJ008", # missing __str__ (not all models need one)
"DJ012", # model attribute ordering style
]
[tool.ruff.lint.per-file-ignores]
"api/migrations/*.py" = ["ALL"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.mypy]
python_version = "3.12"
plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"]
exclude = ["migrations/", "venv/", ".venv/"]
disallow_untyped_defs = false
check_untyped_defs = true
warn_unused_ignores = true
warn_redundant_casts = true
ignore_missing_imports = true
[tool.django-stubs]
django_settings_module = "holy_backend.settings.dev"
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "holy_backend.settings.dev"
python_files = ["test_*.py", "tests.py"]
testpaths = ["api/tests"]
addopts = "-ra --strict-markers"
+14
View File
@@ -0,0 +1,14 @@
-r requirements.txt
# Linting / formatting
ruff>=0.6.0
# Type checking
mypy>=1.11.0
django-stubs[compatible-mypy]>=5.0.0
djangorestframework-stubs[compatible-mypy]>=3.15.0
# Testing
pytest>=8.0.0
pytest-django>=4.8.0
pytest-cov>=5.0.0
+4
View File
@@ -10,3 +10,7 @@ gunicorn>=21.2.0
django-ratelimit>=4.1.0 django-ratelimit>=4.1.0
django-compression-middleware>=0.5.0 django-compression-middleware>=0.5.0
requests>=2.31.0 requests>=2.31.0
celery[redis]>=5.4.0
django-celery-beat>=2.6.0
redis>=5.0.0
djangorestframework-simplejwt>=5.3.1
+54 -1
View File
@@ -1,4 +1,19 @@
services: services:
# --- Redis (Celery broker + result backend) ---
redis:
image: redis:7-alpine
container_name: holy-redis
restart: unless-stopped
networks:
- holy-net
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# --- Backend Service --- # --- Backend Service ---
backend: backend:
build: build:
@@ -17,6 +32,12 @@ services:
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- DOMAIN=${DOMAIN:-localhost} - DOMAIN=${DOMAIN:-localhost}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
depends_on:
redis:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"] test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
interval: 10s interval: 10s
@@ -28,18 +49,49 @@ services:
build: build:
context: ./backend context: ./backend
container_name: holy-worker container_name: holy-worker
command: python manage.py worker command: celery -A holy_backend worker -l info
restart: unless-stopped restart: unless-stopped
networks: networks:
- holy-net - holy-net
env_file: env_file:
- .env - .env
environment: environment:
- PYTHONUNBUFFERED=1
- DOMAIN=${DOMAIN:-localhost} - DOMAIN=${DOMAIN:-localhost}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./legacy:/app/legacy - ./legacy:/app/legacy
depends_on: depends_on:
redis:
condition: service_healthy
backend:
condition: service_healthy
beat:
build:
context: ./backend
container_name: holy-beat
command: celery -A holy_backend beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
restart: unless-stopped
networks:
- holy-net
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
- DOMAIN=${DOMAIN:-localhost}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
volumes:
- ./backend:/app
- ./legacy:/app/legacy
depends_on:
redis:
condition: service_healthy
backend: backend:
condition: service_healthy condition: service_healthy
@@ -71,3 +123,4 @@ networks:
volumes: volumes:
db_data: db_data:
redis_data:
+5
View File
@@ -0,0 +1,5 @@
dist
node_modules
public
*.lock
package-lock.json
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"semi": true,
"arrowParens": "always"
}
+13
View File
@@ -19,5 +19,18 @@ export default defineConfig([
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-empty-object-type': 'warn',
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/immutability': 'warn',
'react-hooks/purity': 'warn',
'react-refresh/only-export-components': 'warn',
'no-empty': 'warn',
},
}, },
]) ])
+1240 -4
View File
File diff suppressed because it is too large. Load diff
+14 -1
View File
@@ -7,6 +7,11 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc -b --noEmit",
"format": "prettier --write \"src/**/*.{ts,tsx,js,json,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,json,css}\"",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -26,17 +31,25 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/colorthief": "^2.6.1", "@types/colorthief": "^2.6.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/ui": "^4.1.4",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"jsdom": "^29.0.2",
"prettier": "^3.8.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1" "vite": "^7.3.1",
"vitest": "^4.1.4"
} }
} }
+495 -621
View File
File diff suppressed because it is too large. Load diff
-42
View File
@@ -1,42 +0,0 @@
import axios from 'axios';
function getCookie(name: string): string | null {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/',
});
api.interceptors.request.use((config) => {
// Set DRF Auth Token if available from localStorage
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Token ${token}`;
}
// Set CSRF token for state-changing methods
const csrfToken = getCookie('csrftoken');
const method = config.method?.toUpperCase();
if (csrfToken && method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
if (config.headers) {
config.headers['X-CSRFToken'] = csrfToken;
}
}
return config;
});
export default api;
+121
View File
@@ -0,0 +1,121 @@
import React from 'react';
import {
Autocomplete,
Box,
CircularProgress,
InputAdornment,
TextField,
Typography,
useMediaQuery,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useGlobalSearch } from '../hooks/useGlobalSearch';
export const GlobalSearch = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useMediaQuery('(max-width:1150px)');
const { query, setQuery, options, loading } = useGlobalSearch();
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
navigate(`/?q=${encodeURIComponent(query.trim())}`);
} else {
navigate('/');
}
};
return (
<Box sx={{ flexGrow: 1, mx: isMobile ? 1 : 4 }}>
<Autocomplete
fullWidth
freeSolo
size="small"
loading={loading}
options={options}
filterOptions={(x) => x}
getOptionLabel={(option) => (typeof option === 'string' ? option : option.name || '')}
inputValue={query}
onInputChange={(_, newValue) => setQuery(typeof newValue === 'string' ? newValue : '')}
onChange={(_, newValue) => {
if (newValue && typeof newValue !== 'string' && newValue.type === 'flavor') {
navigate(`/flavor/${newValue.id}`);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSearch(e as unknown as React.FormEvent);
}}
renderInput={(params) => (
<TextField
{...params}
fullWidth
placeholder={t('common.search')}
sx={{
bgcolor: 'action.hover',
borderRadius: 2,
'& .MuiOutlinedInput-root': {
color: 'inherit',
'& fieldset': { border: 'none' },
'&:hover fieldset': { border: 'none' },
'&.Mui-focused fieldset': { border: 'none' },
outline: 'none',
},
'& .MuiInputBase-input': { outline: 'none' },
}}
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: 'inherit', opacity: 0.7 }} />
</InputAdornment>
),
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
renderOption={(props, option) => {
const { key, ...optionProps } = props as React.HTMLAttributes<HTMLLIElement> & {
key: React.Key;
};
return (
<Box
component="li"
key={key}
{...optionProps}
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
>
<Box
component="img"
src={option.image_url || undefined}
sx={{
width: 32,
height: 32,
borderRadius: 1,
objectFit: 'contain',
bgcolor: 'action.hover',
p: 0.5,
}}
/>
<Box>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{option.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{option.subtitle}
</Typography>
</Box>
</Box>
);
}}
/>
</Box>
);
};
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
import { ThemeProvider } from '../contexts/ThemeContext';
import { AuthProvider } from '../contexts/AuthContext';
import { NotificationProvider } from '../contexts/NotificationContext';
import '../i18n';
export const AppProviders = ({ children }: { children: ReactNode }) => (
<ThemeProvider>
<AuthProvider>
<NotificationProvider>{children}</NotificationProvider>
</AuthProvider>
</ThemeProvider>
);
+150 -137
View File
@@ -1,162 +1,175 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Dialog, DialogTitle, DialogContent, DialogActions, Button, Dialog,
TextField, Box, Typography, Alert, Slider, Stack, DialogTitle,
Tooltip, IconButton DialogContent,
DialogActions,
Button,
TextField,
Box,
Typography,
Alert,
Slider,
Stack,
Tooltip,
IconButton,
} from '@mui/material'; } from '@mui/material';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import api from '../api'; import api from '../lib/api';
interface BannerSettingSchema { interface BannerSettingSchema {
key: string; key: string;
label: string; label: string;
type: 'number' | 'slider' | 'text' | 'boolean'; type: 'number' | 'slider' | 'text' | 'boolean';
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
description?: string; description?: string;
} }
interface Banner { interface Banner {
id: number; id: number;
name: string; name: string;
slug: string; slug: string;
description: string; description: string;
is_active: boolean; is_active: boolean;
settings: any; settings: any;
schema: BannerSettingSchema[]; schema: BannerSettingSchema[];
} }
interface BannerSettingsDialogProps { interface BannerSettingsDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
banner: Banner | null; banner: Banner | null;
onSave: () => void; onSave: () => void;
} }
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClose, banner, onSave }) => { const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
const { t } = useTranslation(); open,
const [localSettings, setLocalSettings] = useState<any>({}); onClose,
const [error, setError] = useState(''); banner,
const [saving, setSaving] = useState(false); onSave,
}) => {
const { t } = useTranslation();
const [localSettings, setLocalSettings] = useState<any>({});
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (banner) { if (banner) {
setLocalSettings(banner.settings || {}); setLocalSettings(banner.settings || {});
setError(''); setError('');
} }
}, [banner]); }, [banner]);
const handleSave = async () => { const handleSave = async () => {
if (!banner) return; if (!banner) return;
try {
setSaving(true);
await api.patch(`banners/${banner.id}/`, { settings: localSettings });
onSave();
onClose();
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to save settings');
} finally {
setSaving(false);
}
};
const updateSetting = (key: string, value: any) => { try {
setLocalSettings((prev: any) => ({ setSaving(true);
...prev, await api.patch(`banners/${banner.id}/`, { settings: localSettings });
[key]: value onSave();
})); onClose();
}; } catch (err: any) {
setError(err.response?.data?.error || 'Failed to save settings');
} finally {
setSaving(false);
}
};
const renderField = (schema: BannerSettingSchema) => { const updateSetting = (key: string, value: any) => {
const value = localSettings[schema.key] ?? ''; setLocalSettings((prev: any) => ({
...prev,
[key]: value,
}));
};
return ( const renderField = (schema: BannerSettingSchema) => {
<Box key={schema.key} sx={{ mb: 3 }}> const value = localSettings[schema.key] ?? '';
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
{schema.label}
</Typography>
{schema.description && (
<Tooltip title={schema.description} arrow>
<IconButton size="small" sx={{ p: 0.5 }}>
<HelpOutlineIcon sx={{ fontSize: '1rem', opacity: 0.6 }} />
</IconButton>
</Tooltip>
)}
</Stack>
{schema.type === 'slider' ? (
<Box sx={{ px: 1 }}>
<Slider
value={Number(value)}
min={schema.min ?? 0}
max={schema.max ?? 100}
step={schema.step ?? 1}
onChange={(_, val) => updateSetting(schema.key, val)}
valueLabelDisplay="auto"
/>
</Box>
) : schema.type === 'number' ? (
<TextField
type="number"
fullWidth
size="small"
value={value}
inputProps={{ min: schema.min, max: schema.max, step: schema.step }}
onChange={(e) => updateSetting(schema.key, Number(e.target.value))}
/>
) : (
<TextField
fullWidth
size="small"
value={value}
onChange={(e) => updateSetting(schema.key, e.target.value)}
/>
)}
{schema.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{schema.description}
</Typography>
)}
</Box>
);
};
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <Box key={schema.key} sx={{ mb: 3 }}>
<DialogTitle sx={{ fontWeight: 'bold' }}> <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
Banner Settings: {banner?.name} <Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
</DialogTitle> {schema.label}
<DialogContent dividers> </Typography>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>} {schema.description && (
<Tooltip title={schema.description} arrow>
{banner?.schema && banner.schema.length > 0 ? ( <IconButton size="small" sx={{ p: 0.5 }}>
<Box sx={{ mt: 1 }}> <HelpOutlineIcon sx={{ fontSize: '1rem', opacity: 0.6 }} />
{banner.schema.map(field => renderField(field))} </IconButton>
</Box> </Tooltip>
) : ( )}
<Typography color="text.secondary" sx={{ fontStyle: 'italic', py: 2 }}> </Stack>
No dynamic schema defined for this banner. Please use the JSON editor or check the banner configuration files.
</Typography> {schema.type === 'slider' ? (
)} <Box sx={{ px: 1 }}>
</DialogContent> <Slider
<DialogActions sx={{ px: 3, py: 2 }}> value={Number(value)}
<Button onClick={onClose} color="inherit">{t('common.cancel')}</Button> min={schema.min ?? 0}
<Button max={schema.max ?? 100}
onClick={handleSave} step={schema.step ?? 1}
variant="contained" onChange={(_, val) => updateSetting(schema.key, val)}
disabled={saving} valueLabelDisplay="auto"
sx={{ px: 4 }} />
> </Box>
{saving ? t('common.loading') : t('common.save')} ) : schema.type === 'number' ? (
</Button> <TextField
</DialogActions> type="number"
</Dialog> fullWidth
size="small"
value={value}
inputProps={{ min: schema.min, max: schema.max, step: schema.step }}
onChange={(e) => updateSetting(schema.key, Number(e.target.value))}
/>
) : (
<TextField
fullWidth
size="small"
value={value}
onChange={(e) => updateSetting(schema.key, e.target.value)}
/>
)}
{schema.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{schema.description}
</Typography>
)}
</Box>
); );
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 'bold' }}>Banner Settings: {banner?.name}</DialogTitle>
<DialogContent dividers>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{banner?.schema && banner.schema.length > 0 ? (
<Box sx={{ mt: 1 }}>{banner.schema.map((field) => renderField(field))}</Box>
) : (
<Typography color="text.secondary" sx={{ fontStyle: 'italic', py: 2 }}>
No dynamic schema defined for this banner. Please use the JSON editor or check the
banner configuration files.
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} color="inherit">
{t('common.cancel')}
</Button>
<Button onClick={handleSave} variant="contained" disabled={saving} sx={{ px: 4 }}>
{saving ? t('common.loading') : t('common.save')}
</Button>
</DialogActions>
</Dialog>
);
}; };
export default BannerSettingsDialog; export default BannerSettingsDialog;
+106 -91
View File
@@ -1,108 +1,123 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Typography, Typography,
Button, Button,
alpha, alpha,
Dialog, Dialog,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
DialogActions, DialogActions,
Box Box,
} from '@mui/material'; } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { CatppuccinTheme } from '../theme'; import type { CatppuccinTheme } from '../theme';
interface CookieBannerProps { interface CookieBannerProps {
onThemeChange: (newTheme: CatppuccinTheme) => void; onThemeChange: (newTheme: CatppuccinTheme) => void;
currentTheme: CatppuccinTheme; currentTheme: CatppuccinTheme;
} }
const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => { const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
const consent = localStorage.getItem('cookie-consent'); const consent = localStorage.getItem('cookie-consent');
if (!consent) { if (!consent) {
setOpen(true); setOpen(true);
} }
}, []); }, []);
const handleAccept = () => { const handleAccept = () => {
localStorage.setItem('cookie-consent', 'true'); localStorage.setItem('cookie-consent', 'true');
setOpen(false); setOpen(false);
}; };
const handleToggleTheme = () => { const handleToggleTheme = () => {
const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light'; const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light';
onThemeChange(newTheme); onThemeChange(newTheme);
}; };
return ( return (
<Dialog <Dialog
open={open} open={open}
onClose={() => {}} // Disable closing by clicking backdrop onClose={() => {}} // Disable closing by clicking backdrop
disableEscapeKeyDown disableEscapeKeyDown
maxWidth="sm" maxWidth="sm"
fullWidth fullWidth
PaperProps={{ PaperProps={{
sx: { sx: {
borderRadius: 4, borderRadius: 4,
border: '1px solid', border: '1px solid',
borderColor: 'primary.main', borderColor: 'primary.main',
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98), bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
backdropFilter: 'blur(10px)', backdropFilter: 'blur(10px)',
boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`, boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
} },
}} }}
>
<DialogTitle sx={{ fontWeight: 'bold', fontSize: '1.5rem', pt: 3 }}>
{t('cookieBanner.title')} 🍪
</DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.7 }}>
{t('cookieBanner.description')}
</Typography>
<Box
sx={{
p: 2,
borderRadius: 2,
bgcolor: 'action.hover',
border: '1px solid',
borderColor: 'divider',
mt: 2,
}}
> >
<DialogTitle sx={{ fontWeight: 'bold', fontSize: '1.5rem', pt: 3 }}> <Typography
{t('cookieBanner.title')} 🍪 variant="caption"
</DialogTitle> color="text.secondary"
<DialogContent> sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.7 }}> >
{t('cookieBanner.description')} Technical Details:
</Typography> </Typography>
<Box sx={{ <Typography variant="caption" color="text.secondary">
p: 2, • Session ID: Required for login & authentication.
borderRadius: 2, <br />
bgcolor: 'action.hover', • CSRF Token: Protects your account from malicious attacks.
border: '1px solid', <br />• Preference Store: Remembers your theme and language choices.
borderColor: 'divider', </Typography>
mt: 2 </Box>
}}> </DialogContent>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}> <DialogActions sx={{ p: 3, gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
Technical Details: <Button
</Typography> fullWidth
<Typography variant="caption" color="text.secondary"> variant="outlined"
• Session ID: Required for login & authentication.<br /> color="primary"
• CSRF Token: Protects your account from malicious attacks.<br /> onClick={handleToggleTheme}
• Preference Store: Remembers your theme and language choices. sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }}
</Typography> >
</Box> {currentTheme === 'holy_light'
</DialogContent> ? t('cookieBanner.switchToDark')
<DialogActions sx={{ p: 3, gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}> : t('cookieBanner.switchToLight')}
<Button </Button>
fullWidth <Button
variant="outlined" fullWidth
color="primary" variant="contained"
onClick={handleToggleTheme} color="primary"
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }} onClick={handleAccept}
> sx={{
{currentTheme === 'holy_light' ? t('cookieBanner.switchToDark') : t('cookieBanner.switchToLight')} borderRadius: 2,
</Button> textTransform: 'none',
<Button fontWeight: 'bold',
fullWidth px: 4,
variant="contained" m: '0 !important',
color="primary" }}
onClick={handleAccept} >
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', px: 4, m: '0 !important' }} {t('cookieBanner.accept')}
> </Button>
{t('cookieBanner.accept')} </DialogActions>
</Button> </Dialog>
</DialogActions> );
</Dialog>
);
}; };
export default CookieBanner; export default CookieBanner;
+92 -75
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import api from '../api'; import api from '../lib/api';
import GenerativeBanner from './GenerativeBanner'; import GenerativeBanner from './GenerativeBanner';
import NebulaBanner from './NebulaBanner'; import NebulaBanner from './NebulaBanner';
import FirefliesBanner from './FirefliesBanner'; import FirefliesBanner from './FirefliesBanner';
@@ -8,106 +8,123 @@ import HextechCorruptionBanner from './HextechCorruptionBanner';
import GenerativeFlowBanner from './GenerativeFlowBanner'; import GenerativeFlowBanner from './GenerativeFlowBanner';
interface Banner { interface Banner {
id: number; id: number;
name: string; name: string;
slug: string; slug: string;
description: string; description: string;
is_active: boolean; is_active: boolean;
settings: any; settings: any;
} }
interface DynamicBannerProps { interface DynamicBannerProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
} }
// Future expansion: Add more banner components here // Future expansion: Add more banner components here
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => { const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => {
// Placeholder for a second model // Placeholder for a second model
return ( return (
<div style={{ <div
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', style={{
background: 'rgba(0,0,0,0.8)', color: '#0f0', display: 'flex', position: 'absolute',
alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', top: 0,
zIndex: 1, overflow: 'hidden', opacity: settings.opacity || 0.5 left: 0,
}}> width: '100%',
<div> height: '100%',
{Array.from({ length: 20 }).map((_, i) => ( background: 'rgba(0,0,0,0.8)',
<div key={i} style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}> color: '#0f0',
{username.split('').map((char, j) => ( display: 'flex',
<span key={j} style={{ display: 'block' }}>{char}</span> alignItems: 'center',
))} justifyContent: 'center',
</div> fontFamily: 'monospace',
))} zIndex: 1,
</div> overflow: 'hidden',
<style> opacity: settings.opacity || 0.5,
{` }}
>
<div>
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}
>
{username.split('').map((char, j) => (
<span key={j} style={{ display: 'block' }}>
{char}
</span>
))}
</div>
))}
</div>
<style>
{`
@keyframes matrixFall { @keyframes matrixFall {
0% { transform: translateY(-100%); } 0% { transform: translateY(-100%); }
100% { transform: translateY(100%); } 100% { transform: translateY(100%); }
} }
`} `}
</style> </style>
</div> </div>
); );
}; };
const DynamicBanner: React.FC<DynamicBannerProps> = (props) => { const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
const [banner, setBanner] = useState<Banner | null>(null); const [banner, setBanner] = useState<Banner | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const fetchActiveBanner = async () => { const fetchActiveBanner = async () => {
try { try {
const res = await api.get('banners/active/', { const res = await api.get('banners/active/', {
params: { username: props.username } params: { username: props.username },
}); });
setBanner(res.data); setBanner(res.data);
} catch (err) { } catch (err) {
console.error('Failed to fetch active banner:', err); console.error('Failed to fetch active banner:', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchActiveBanner(); fetchActiveBanner();
}, [props.username]); }, [props.username]);
if (loading) return null; if (loading) return null;
// Default to GenerativeBanner if no active banner or unrecognized slug // Default to GenerativeBanner if no active banner or unrecognized slug
if (!banner || banner.slug === 'generative-web') { if (!banner || banner.slug === 'generative-web') {
return <GenerativeBanner {...props} settings={banner?.settings} />; return <GenerativeBanner {...props} settings={banner?.settings} />;
} }
if (banner.slug === 'matrix') { if (banner.slug === 'matrix') {
return <MatrixBanner {...props} settings={banner.settings} />; return <MatrixBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'nebula') { if (banner.slug === 'nebula') {
return <NebulaBanner {...props} settings={banner.settings} />; return <NebulaBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'fireflies') { if (banner.slug === 'fireflies') {
return <FirefliesBanner {...props} settings={banner.settings} />; return <FirefliesBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'hextech') { if (banner.slug === 'hextech') {
return <HextechBanner {...props} settings={banner.settings} />; return <HextechBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'hextech-corruption') { if (banner.slug === 'hextech-corruption') {
return <HextechCorruptionBanner {...props} settings={banner.settings} />; return <HextechCorruptionBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'generative-flow') { if (banner.slug === 'generative-flow') {
return <GenerativeFlowBanner {...props} settings={banner.settings} />; return <GenerativeFlowBanner {...props} settings={banner.settings} />;
} }
// Fallback // Fallback
return <GenerativeBanner {...props} />; return <GenerativeBanner {...props} />;
}; };
export default DynamicBanner; export default DynamicBanner;
+247 -224
View File
@@ -2,256 +2,279 @@ import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from '@mui/material';
interface FirefliesBannerProps { interface FirefliesBannerProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
settings?: { settings?: {
count?: number; count?: number;
speed?: number; speed?: number;
evasionRadius?: number; evasionRadius?: number;
evasionStrength?: number; evasionStrength?: number;
glowSize?: number; glowSize?: number;
flickerSpeed?: number; flickerSpeed?: number;
}; };
} }
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => { const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme(); const theme = useTheme();
const count = settings?.count ?? 150;
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
const evasionRadius = settings?.evasionRadius ?? 180;
const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion
const glowSizeMultiplier = settings?.glowSize ?? 4;
const flickerSpeed = settings?.flickerSpeed ?? 0.05;
const getSeed = (str: string) => { const count = settings?.count ?? 150;
let hash = 0; const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
for (let i = 0; i < str.length; i++) { const evasionRadius = settings?.evasionRadius ?? 180;
hash = str.charCodeAt(i) + ((hash << 5) - hash); const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion
} const glowSizeMultiplier = settings?.glowSize ?? 4;
return hash; const flickerSpeed = settings?.flickerSpeed ?? 0.05;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => {
const colors =
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
return sorted.slice(0, 3);
}, [palette, theme]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let animationFrameId: number;
interface Firefly {
x: number;
y: number;
vx: number;
vy: number;
size: number;
color: string;
phase: number;
noiseOffset: number;
isEvading: boolean;
}
let swarm: Firefly[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
const seed = useMemo(() => getSeed(username), [username]); const initSwarm = (width: number, height: number) => {
const newSwarm = [];
for (let i = 0; i < count; i++) {
newSwarm.push({
x: pseudoRandom(i) * width,
y: pseudoRandom(i + 500) * height,
vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed,
vy: (pseudoRandom(i + 1500) - 0.5) * baseSpeed,
size: 1 + pseudoRandom(i + 2000) * 2,
color: brightColors[i % brightColors.length],
phase: pseudoRandom(i + 2500) * Math.PI * 2,
noiseOffset: pseudoRandom(i + 3000) * 100,
isEvading: false,
});
}
return newSwarm;
};
const brightColors = useMemo(() => { const resize = () => {
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; const parent = canvas.parentElement;
const getBrightness = (hex: string) => { if (parent) {
const r = parseInt(hex.slice(1, 3), 16) || 0; const rect = parent.getBoundingClientRect();
const g = parseInt(hex.slice(3, 5), 16) || 0; const dpr = window.devicePixelRatio || 1;
const b = parseInt(hex.slice(5, 7), 16) || 0; canvas.width = rect.width * dpr;
return (r * 299 + g * 587 + b * 114) / 1000; canvas.height = rect.height * dpr;
}; ctx.scale(dpr, dpr);
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a)); canvas.style.width = `${rect.width}px`;
return sorted.slice(0, 3); canvas.style.height = `${rect.height}px`;
}, [palette, theme]); swarm = initSwarm(rect.width, rect.height);
}
};
useEffect(() => { const resizeObserver = new ResizeObserver(() => resize());
const canvas = canvasRef.current; if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
if (!canvas) return; resize();
const ctx = canvas.getContext('2d', { alpha: true }); const mouse = { x: -1000, y: -1000 };
if (!ctx) return; const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
let animationFrameId: number; const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mouseleave', () => {
mouse.x = -1000;
mouse.y = -1000;
});
}
interface Firefly { let time = 0;
x: number; const draw = () => {
y: number; const width = canvas.width / (window.devicePixelRatio || 1);
vx: number; const height = canvas.height / (window.devicePixelRatio || 1);
vy: number; if (!width || !height) {
size: number; animationFrameId = requestAnimationFrame(draw);
color: string; return;
phase: number; }
noiseOffset: number;
isEvading: boolean; ctx.clearRect(0, 0, width, height);
// Vibrant theme-aware night sky background
const isLight = theme.palette.mode === 'light';
const bgGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) {
// For light themes, we still need a "dusk/night" feel for fireflies to make sense.
// We use a deeper, more saturated version of the theme's colors.
bgGrad.addColorStop(0, alpha(brightColors[2] || '#1a1a2e', 0.85));
bgGrad.addColorStop(1, alpha(brightColors[2] || '#16213e', 1.0));
} else {
bgGrad.addColorStop(0, '#141a2a'); // Noticeable blue-grey tint
bgGrad.addColorStop(1, '#05080f');
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
time += flickerSpeed;
swarm.forEach((f, i) => {
// 1. Autonomous Chaotic Movement (Perlin + Jitter)
const driftX = Math.sin(time * 0.5 + f.noiseOffset) * 0.05;
const driftY = Math.cos(time * 0.4 + f.noiseOffset) * 0.05;
// High-frequency jitter for insect feel
const jitterX = (pseudoRandom(i + time * 10) - 0.5) * 0.2;
const jitterY = (pseudoRandom(i + 500 + time * 10) - 0.5) * 0.2;
f.vx += driftX + jitterX;
f.vy += driftY + jitterY;
// 2. Mouse Evasion (Fast Dash)
const dx = mouse.x - f.x;
const dy = mouse.y - f.y;
const dist = Math.sqrt(dx * dx + dy * dy);
f.isEvading = dist < evasionRadius;
if (f.isEvading) {
const force = (evasionRadius - dist) / evasionRadius;
const angle = Math.atan2(dy, dx);
// Sharp acceleration away
f.vx -= Math.cos(angle) * force * evasionStrength * 1.5;
f.vy -= Math.sin(angle) * force * evasionStrength * 1.5;
} }
let swarm: Firefly[] = []; // 3. Dynamic Speed Limits
const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy);
const pseudoRandom = (offset: number) => { const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5;
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const initSwarm = (width: number, height: number) => { if (currentSpeed > maxSpeed) {
const newSwarm = []; f.vx = (f.vx / currentSpeed) * maxSpeed;
for (let i = 0; i < count; i++) { f.vy = (f.vy / currentSpeed) * maxSpeed;
newSwarm.push({
x: pseudoRandom(i) * width,
y: pseudoRandom(i + 500) * height,
vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed,
vy: (pseudoRandom(i + 1500) - 0.5) * baseSpeed,
size: 1 + pseudoRandom(i + 2000) * 2,
color: brightColors[i % brightColors.length],
phase: pseudoRandom(i + 2500) * Math.PI * 2,
noiseOffset: pseudoRandom(i + 3000) * 100,
isEvading: false
});
}
return newSwarm;
};
const resize = () => {
const parent = canvas.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
swarm = initSwarm(rect.width, rect.height);
}
};
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
let mouse = { x: -1000, y: -1000 };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
} }
let time = 0; // 4. Smooth Friction
const draw = () => { f.vx *= 0.97;
const width = canvas.width / (window.devicePixelRatio || 1); f.vy *= 0.97;
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height); f.x += f.vx;
f.y += f.vy;
// Vibrant theme-aware night sky background
const isLight = theme.palette.mode === 'light';
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
if (isLight) {
// For light themes, we still need a "dusk/night" feel for fireflies to make sense.
// We use a deeper, more saturated version of the theme's colors.
bgGrad.addColorStop(0, alpha(brightColors[2] || '#1a1a2e', 0.85));
bgGrad.addColorStop(1, alpha(brightColors[2] || '#16213e', 1.0));
} else {
bgGrad.addColorStop(0, '#141a2a'); // Noticeable blue-grey tint
bgGrad.addColorStop(1, '#05080f');
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
time += flickerSpeed; // Screen Wrap
const margin = 20;
if (f.x < -margin) f.x = width + margin;
if (f.x > width + margin) f.x = -margin;
if (f.y < -margin) f.y = height + margin;
if (f.y > height + margin) f.y = -margin;
swarm.forEach((f, i) => { // Flicker
// 1. Autonomous Chaotic Movement (Perlin + Jitter) const flicker = 0.4 + Math.sin(time + f.phase) * 0.6;
const driftX = Math.sin(time * 0.5 + f.noiseOffset) * 0.05; const opacity = flicker > 0 ? flicker : 0;
const driftY = Math.cos(time * 0.4 + f.noiseOffset) * 0.05;
// High-frequency jitter for insect feel
const jitterX = (pseudoRandom(i + time * 10) - 0.5) * 0.2;
const jitterY = (pseudoRandom(i + 500 + time * 10) - 0.5) * 0.2;
f.vx += driftX + jitterX; // Render
f.vy += driftY + jitterY; const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier);
grad.addColorStop(0, alpha(f.color, opacity));
grad.addColorStop(0.4, alpha(f.color, opacity * 0.3));
grad.addColorStop(1, alpha(f.color, 0));
// 2. Mouse Evasion (Fast Dash) ctx.fillStyle = grad;
const dx = mouse.x - f.x; ctx.beginPath();
const dy = mouse.y - f.y; ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2);
const dist = Math.sqrt(dx * dx + dy * dy); ctx.fill();
f.isEvading = dist < evasionRadius;
if (f.isEvading) {
const force = (evasionRadius - dist) / evasionRadius;
const angle = Math.atan2(dy, dx);
// Sharp acceleration away
f.vx -= Math.cos(angle) * force * evasionStrength * 1.5;
f.vy -= Math.sin(angle) * force * evasionStrength * 1.5;
}
// 3. Dynamic Speed Limits ctx.fillStyle = alpha('#fff', opacity * 0.8);
const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy); ctx.beginPath();
const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5; ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2);
ctx.fill();
if (currentSpeed > maxSpeed) { });
f.vx = (f.vx / currentSpeed) * maxSpeed;
f.vy = (f.vy / currentSpeed) * maxSpeed;
}
// 4. Smooth Friction animationFrameId = requestAnimationFrame(draw);
f.vx *= 0.97; };
f.vy *= 0.97;
f.x += f.vx; draw();
f.y += f.vy;
// Screen Wrap return () => {
const margin = 20; resizeObserver.disconnect();
if (f.x < -margin) f.x = width + margin; if (parent) {
if (f.x > width + margin) f.x = -margin; parent.removeEventListener('mousemove', handleMouseMove);
if (f.y < -margin) f.y = height + margin; }
if (f.y > height + margin) f.y = -margin; cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
count,
baseSpeed,
evasionRadius,
evasionStrength,
glowSizeMultiplier,
flickerSpeed,
theme,
]);
// Flicker return (
const flicker = 0.4 + Math.sin(time + f.phase) * 0.6; <canvas
const opacity = flicker > 0 ? flicker : 0; ref={canvasRef}
style={{
// Render position: 'absolute',
const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier); top: 0,
grad.addColorStop(0, alpha(f.color, opacity)); left: 0,
grad.addColorStop(0.4, alpha(f.color, opacity * 0.3)); width: '100%',
grad.addColorStop(1, alpha(f.color, 0)); height: '100%',
pointerEvents: 'none',
ctx.fillStyle = grad; zIndex: 1,
ctx.beginPath(); }}
ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2); />
ctx.fill(); );
ctx.fillStyle = alpha('#fff', opacity * 0.8);
ctx.beginPath();
ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2);
ctx.fill();
});
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, count, baseSpeed, evasionRadius, evasionStrength, glowSizeMultiplier, flickerSpeed, theme]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
}}
/>
);
}; };
export default FirefliesBanner; export default FirefliesBanner;
+52 -15
View File
@@ -8,25 +8,62 @@ const Footer: React.FC = () => {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
return ( return (
<Box component="footer" sx={{ bgcolor: 'background.paper', pt: 6, pb: 4, mt: 'auto', borderTop: '1px solid', borderColor: 'divider' }}> <Box
component="footer"
sx={{
bgcolor: 'background.paper',
pt: 6,
pb: 4,
mt: 'auto',
borderTop: '1px solid',
borderColor: 'divider',
}}
>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 2 }}> <Box
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}> sx={{
© {year} Holy Flavors Archive display: 'flex',
</Typography> flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'space-between',
<Box sx={{ display: 'flex', gap: 3 }}> alignItems: 'center',
<MuiLink component={Link} to="/about" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}> gap: 2,
Impressum mb: 2,
</MuiLink> }}
<MuiLink component={Link} to="/privacy" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}> >
Privacy Policy <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
</MuiLink> © {year} Holy Flavors Archive
</Box> </Typography>
<Box sx={{ display: 'flex', gap: 3 }}>
<MuiLink
component={Link}
to="/about"
color="inherit"
sx={{
fontSize: '0.8rem',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
Impressum
</MuiLink>
<MuiLink
component={Link}
to="/privacy"
color="inherit"
sx={{
fontSize: '0.8rem',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
Privacy Policy
</MuiLink>
</Box>
</Box> </Box>
<Divider sx={{ my: 2, opacity: 0.5 }} /> <Divider sx={{ my: 2, opacity: 0.5 }} />
<Typography variant="caption" color="text.secondary" align="center" display="block"> <Typography variant="caption" color="text.secondary" align="center" display="block">
{t('nav.about')} • Fan Project {t('nav.about')} • Fan Project
</Typography> </Typography>
</Container> </Container>
</Box> </Box>
+290 -252
View File
@@ -2,277 +2,315 @@ import React, { useRef, useEffect } from 'react';
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from '@mui/material';
interface GenerativeBannerProps { interface GenerativeBannerProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
settings?: { settings?: {
nodeCountBase?: number; nodeCountBase?: number;
connectionDist?: number; connectionDist?: number;
speed?: number; speed?: number;
opacity?: number; opacity?: number;
}; };
} }
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => { const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
const canvasRef = useRef<HTMLCanvasElement>(null); username,
const theme = useTheme(); palette,
ratingsCount,
const nodeCountBase = settings?.nodeCountBase ?? 60; followersCount,
const connectionDist = settings?.connectionDist ?? 140; settings,
const speed = settings?.speed ?? 0.005; }) => {
const globalOpacity = settings?.opacity ?? 0.35; const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const getSeed = (str: string) => { const nodeCountBase = settings?.nodeCountBase ?? 60;
let hash = 0; const connectionDist = settings?.connectionDist ?? 140;
for (let i = 0; i < str.length; i++) { const speed = settings?.speed ?? 0.005;
hash = str.charCodeAt(i) + ((hash << 5) - hash); const globalOpacity = settings?.opacity ?? 0.35;
}
return hash; const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let animationFrameId: number;
const seed = getSeed(username);
// Configuration - High density for "complex web" feel
const nodeCount = Math.min(nodeCountBase + Math.floor(ratingsCount * 1.5), 120);
const colors =
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
interface Node {
x: number;
y: number;
vx: number;
vy: number;
baseX: number;
baseY: number;
color: string;
size: number;
}
let nodes: Node[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
useEffect(() => { const initNodes = (width: number, height: number) => {
const canvas = canvasRef.current; const newNodes = [];
if (!canvas) return; for (let i = 0; i < nodeCount; i++) {
const bx = pseudoRandom(i) * width;
const by = pseudoRandom(i + 100) * height;
newNodes.push({
x: bx,
y: by,
baseX: bx,
baseY: by,
vx: 0,
vy: 0,
color: colors[i % colors.length],
size: 1 + pseudoRandom(i + 200) * 3,
});
}
return newNodes;
};
const ctx = canvas.getContext('2d', { alpha: true }); const resize = () => {
if (!ctx) return; const parent = canvas.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
nodes = initNodes(rect.width, rect.height);
}
};
let animationFrameId: number; const resizeObserver = new ResizeObserver(() => resize());
const seed = getSeed(username); if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
// Configuration - High density for "complex web" feel
const nodeCount = Math.min(nodeCountBase + Math.floor(ratingsCount * 1.5), 120);
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
interface Node { const mouse = { x: -1000, y: -1000, down: false };
x: number;
y: number; const handleMouseMove = (e: MouseEvent) => {
vx: number; const rect = canvas.getBoundingClientRect();
vy: number; mouse.x = e.clientX - rect.left;
baseX: number; mouse.y = e.clientY - rect.top;
baseY: number; };
color: string;
size: number; const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length > 0) {
const rect = canvas.getBoundingClientRect();
mouse.x = e.touches[0].clientX - rect.left;
mouse.y = e.touches[0].clientY - rect.top;
}
};
const handleMouseDown = () => {
mouse.down = true;
};
const handleMouseUp = () => {
mouse.down = false;
};
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('touchmove', handleTouchMove, { passive: false });
parent.addEventListener('mousedown', handleMouseDown);
parent.addEventListener('mouseup', handleMouseUp);
parent.addEventListener('touchstart', (e) => {
mouse.down = true;
handleTouchMove(e);
});
parent.addEventListener('touchend', handleMouseUp);
parent.addEventListener('mouseleave', () => {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
});
}
const draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height);
// Physics Update
nodes.forEach((node) => {
// Return force (Elasticity) - slightly slower return
const dxBase = node.baseX - node.x;
const dyBase = node.baseY - node.y;
node.vx += dxBase * speed;
node.vy += dyBase * speed;
// Mouse interaction
const dxMouse = mouse.x - node.x;
const dyMouse = mouse.y - node.y;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
if (mouse.down) {
// Grab and drag effect - less strong
const dragDist = 300;
if (distMouse < dragDist) {
const power = (dragDist - distMouse) / dragDist;
node.vx += dxMouse * power * 0.08;
node.vy += dyMouse * power * 0.08;
}
} else {
// Hover disturbance - less strong pushing effect
const hoverDist = 150;
if (distMouse < hoverDist) {
const power = (hoverDist - distMouse) / hoverDist;
node.vx -= dxMouse * power * 0.02;
node.vy -= dyMouse * power * 0.02;
}
} }
let nodes: Node[] = []; // Damping (Friction) - stronger damping for less "fast" feel
node.vx *= 0.88;
const pseudoRandom = (offset: number) => { node.vy *= 0.88;
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const initNodes = (width: number, height: number) => { // Update Position
const newNodes = []; node.x += node.vx;
for (let i = 0; i < nodeCount; i++) { node.y += node.vy;
const bx = pseudoRandom(i) * width; });
const by = pseudoRandom(i + 100) * height;
newNodes.push({ // Draw Mesh (Connections)
x: bx, y: by, ctx.lineWidth = 1;
baseX: bx, baseY: by,
vx: 0, vy: 0, for (let i = 0; i < nodes.length; i++) {
color: colors[i % colors.length], let currentConnections = 0;
size: 1 + pseudoRandom(i + 200) * 3 // We only connect a few to keep it a "web" and not a solid block
}); for (let j = i + 1; j < nodes.length && currentConnections < 6; j++) {
const dx = nodes[i].x - nodes[j].x;
const dy = nodes[i].y - nodes[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < connectionDist) {
currentConnections++;
const opacity = (1 - dist / connectionDist) * globalOpacity;
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
// Dynamic gradient for the line
const lineGrad = ctx.createLinearGradient(
nodes[i].x,
nodes[i].y,
nodes[j].x,
nodes[j].y,
);
lineGrad.addColorStop(0, alpha(nodes[i].color, opacity));
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
ctx.strokeStyle = lineGrad;
ctx.stroke();
// Fill some triangles for complexity
if (dist < connectionDist * 0.6 && pseudoRandom(i + j) > 0.92) {
for (let k = j + 1; k < nodes.length; k++) {
const d3x = nodes[i].x - nodes[k].x;
const d3y = nodes[i].y - nodes[k].y;
if (Math.sqrt(d3x * d3x + d3y * d3y) < connectionDist * 0.6) {
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
ctx.lineTo(nodes[k].x, nodes[k].y);
ctx.closePath();
ctx.fillStyle = alpha(nodes[i].color, 0.04);
ctx.fill();
break;
}
}
} }
return newNodes; }
};
const resize = () => {
const parent = canvas.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
nodes = initNodes(rect.width, rect.height);
}
};
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
let mouse = { x: -1000, y: -1000, down: false };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length > 0) {
const rect = canvas.getBoundingClientRect();
mouse.x = e.touches[0].clientX - rect.left;
mouse.y = e.touches[0].clientY - rect.top;
}
};
const handleMouseDown = () => { mouse.down = true; };
const handleMouseUp = () => { mouse.down = false; };
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('touchmove', handleTouchMove, { passive: false });
parent.addEventListener('mousedown', handleMouseDown);
parent.addEventListener('mouseup', handleMouseUp);
parent.addEventListener('touchstart', (e) => { mouse.down = true; handleTouchMove(e); });
parent.addEventListener('touchend', handleMouseUp);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
} }
}
const draw = () => { // Draw Nodes
const width = canvas.width / (window.devicePixelRatio || 1); nodes.forEach((node) => {
const height = canvas.height / (window.devicePixelRatio || 1); ctx.beginPath();
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
if (!width || !height) { ctx.fillStyle = node.color;
animationFrameId = requestAnimationFrame(draw); ctx.fill();
return;
}
ctx.clearRect(0, 0, width, height); // Core glow
ctx.beginPath();
// Physics Update ctx.arc(node.x, node.y, node.size * 2.5, 0, Math.PI * 2);
nodes.forEach((node) => { ctx.fillStyle = alpha(node.color, 0.15);
// Return force (Elasticity) - slightly slower return ctx.fill();
const dxBase = node.baseX - node.x; });
const dyBase = node.baseY - node.y;
node.vx += dxBase * speed;
node.vy += dyBase * speed;
// Mouse interaction animationFrameId = requestAnimationFrame(draw);
const dxMouse = mouse.x - node.x; };
const dyMouse = mouse.y - node.y;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
if (mouse.down) {
// Grab and drag effect - less strong
const dragDist = 300;
if (distMouse < dragDist) {
const power = (dragDist - distMouse) / dragDist;
node.vx += dxMouse * power * 0.08;
node.vy += dyMouse * power * 0.08;
}
} else {
// Hover disturbance - less strong pushing effect
const hoverDist = 150;
if (distMouse < hoverDist) {
const power = (hoverDist - distMouse) / hoverDist;
node.vx -= dxMouse * power * 0.02;
node.vy -= dyMouse * power * 0.02;
}
}
// Damping (Friction) - stronger damping for less "fast" feel draw();
node.vx *= 0.88;
node.vy *= 0.88;
// Update Position return () => {
node.x += node.vx; resizeObserver.disconnect();
node.y += node.vy; if (parent) {
}); parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('touchmove', handleTouchMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
parent.removeEventListener('touchstart', handleMouseDown);
parent.removeEventListener('touchend', handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [
username,
palette,
ratingsCount,
followersCount,
theme,
nodeCountBase,
connectionDist,
speed,
globalOpacity,
]);
// Draw Mesh (Connections) return (
ctx.lineWidth = 1; <canvas
ref={canvasRef}
for (let i = 0; i < nodes.length; i++) { style={{
let currentConnections = 0; position: 'absolute',
// We only connect a few to keep it a "web" and not a solid block top: 0,
for (let j = i + 1; j < nodes.length && currentConnections < 6; j++) { left: 0,
const dx = nodes[i].x - nodes[j].x; width: '100%',
const dy = nodes[i].y - nodes[j].y; height: '100%',
const dist = Math.sqrt(dx * dx + dy * dy); pointerEvents: 'none',
zIndex: 1,
if (dist < connectionDist) { mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen',
currentConnections++; }}
const opacity = (1 - dist / connectionDist) * globalOpacity; />
ctx.beginPath(); );
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
// Dynamic gradient for the line
const lineGrad = ctx.createLinearGradient(nodes[i].x, nodes[i].y, nodes[j].x, nodes[j].y);
lineGrad.addColorStop(0, alpha(nodes[i].color, opacity));
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
ctx.strokeStyle = lineGrad;
ctx.stroke();
// Fill some triangles for complexity
if (dist < connectionDist * 0.6 && pseudoRandom(i + j) > 0.92) {
for (let k = j + 1; k < nodes.length; k++) {
const d3x = nodes[i].x - nodes[k].x;
const d3y = nodes[i].y - nodes[k].y;
if (Math.sqrt(d3x*d3x + d3y*d3y) < connectionDist * 0.6) {
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
ctx.lineTo(nodes[k].x, nodes[k].y);
ctx.closePath();
ctx.fillStyle = alpha(nodes[i].color, 0.04);
ctx.fill();
break;
}
}
}
}
}
}
// Draw Nodes
nodes.forEach(node => {
ctx.beginPath();
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
ctx.fillStyle = node.color;
ctx.fill();
// Core glow
ctx.beginPath();
ctx.arc(node.x, node.y, node.size * 2.5, 0, Math.PI * 2);
ctx.fillStyle = alpha(node.color, 0.15);
ctx.fill();
});
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('touchmove', handleTouchMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
parent.removeEventListener('touchstart', handleMouseDown);
parent.removeEventListener('touchend', handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [username, palette, ratingsCount, followersCount, theme, nodeCountBase, connectionDist, speed, globalOpacity]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1,
mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen'
}}
/>
);
}; };
export default GenerativeBanner; export default GenerativeBanner;
+211 -189
View File
@@ -2,222 +2,244 @@ import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from '@mui/material';
interface GenerativeFlowProps { interface GenerativeFlowProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
settings?: { settings?: {
blobCount?: number; blobCount?: number;
flowSpeed?: number; flowSpeed?: number;
vibrancy?: number; vibrancy?: number;
blur?: number; blur?: number;
distortion?: number; distortion?: number;
}; };
} }
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => { const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme(); const theme = useTheme();
const blobCount = settings?.blobCount ?? 8;
const flowSpeed = settings?.flowSpeed ?? 0.008;
const vibrancy = settings?.vibrancy ?? 0.8;
const blurAmount = settings?.blur ?? 60;
const distortionFactor = settings?.distortion ?? 1.2;
const getSeed = (str: string) => { const blobCount = settings?.blobCount ?? 8;
let hash = 0; const flowSpeed = settings?.flowSpeed ?? 0.008;
for (let i = 0; i < str.length; i++) { const vibrancy = settings?.vibrancy ?? 0.8;
hash = str.charCodeAt(i) + ((hash << 5) - hash); const blurAmount = settings?.blur ?? 60;
} const distortionFactor = settings?.distortion ?? 1.2;
return hash;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => {
const colors =
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
return sorted.slice(0, 4);
}, [palette, theme]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let animationFrameId: number;
let time = 0;
interface Blob {
x: number;
y: number;
ox: number;
oy: number;
vx: number;
vy: number;
size: number;
color: string;
phase: number;
speedFactor: number;
}
let blobs: Blob[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
const seed = useMemo(() => getSeed(username), [username]); const initBlobs = (width: number, height: number) => {
const newBlobs = [];
for (let i = 0; i < blobCount; i++) {
const rx = pseudoRandom(i) * width;
const ry = pseudoRandom(i + 500) * height;
newBlobs.push({
x: rx,
y: ry,
ox: rx,
oy: ry,
vx: 0,
vy: 0,
size: width / 4 + pseudoRandom(i + 1000) * (width / 3),
color: brightColors[i % brightColors.length],
phase: pseudoRandom(i + 1500) * Math.PI * 2,
speedFactor: 0.5 + pseudoRandom(i + 2000),
});
}
return newBlobs;
};
const brightColors = useMemo(() => { const resize = () => {
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; const parent = canvas.parentElement;
const getBrightness = (hex: string) => { if (parent) {
const r = parseInt(hex.slice(1, 3), 16) || 0; const rect = parent.getBoundingClientRect();
const g = parseInt(hex.slice(3, 5), 16) || 0; const dpr = window.devicePixelRatio || 1;
const b = parseInt(hex.slice(5, 7), 16) || 0; canvas.width = rect.width * dpr;
return (r * 299 + g * 587 + b * 114) / 1000; canvas.height = rect.height * dpr;
}; ctx.scale(dpr, dpr);
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a)); canvas.style.width = `${rect.width}px`;
return sorted.slice(0, 4); canvas.style.height = `${rect.height}px`;
}, [palette, theme]); blobs = initBlobs(rect.width, rect.height);
}
};
useEffect(() => { const resizeObserver = new ResizeObserver(() => resize());
const canvas = canvasRef.current; if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
if (!canvas) return; resize();
const ctx = canvas.getContext('2d', { alpha: true }); const mouse = { x: -1000, y: -1000 };
if (!ctx) return; const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
let animationFrameId: number; const parent = canvas.parentElement;
let time = 0; if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mouseleave', () => {
mouse.x = -1000;
mouse.y = -1000;
});
}
interface Blob { const draw = () => {
x: number; const width = canvas.width / (window.devicePixelRatio || 1);
y: number; const height = canvas.height / (window.devicePixelRatio || 1);
ox: number; if (!width || !height) {
oy: number; animationFrameId = requestAnimationFrame(draw);
vx: number; return;
vy: number; }
size: number;
color: string;
phase: number;
speedFactor: number;
}
let blobs: Blob[] = []; ctx.clearRect(0, 0, width, height);
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const initBlobs = (width: number, height: number) => { // Background Base
const newBlobs = []; const isLight = theme.palette.mode === 'light';
for (let i = 0; i < blobCount; i++) { const bgGrad = ctx.createRadialGradient(
const rx = pseudoRandom(i) * width; width / 2,
const ry = pseudoRandom(i + 500) * height; height / 2,
newBlobs.push({ 0,
x: rx, y: ry, width / 2,
ox: rx, oy: ry, height / 2,
vx: 0, vy: 0, width,
size: (width / 4) + pseudoRandom(i + 1000) * (width / 3), );
color: brightColors[i % brightColors.length], if (isLight) {
phase: pseudoRandom(i + 1500) * Math.PI * 2, // For light themes, use a deeper, more saturated version of the theme color
speedFactor: 0.5 + pseudoRandom(i + 2000) bgGrad.addColorStop(
}); 0,
} alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6),
return newBlobs; );
}; bgGrad.addColorStop(
1,
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9),
);
} else {
bgGrad.addColorStop(0, '#0a0a1a');
bgGrad.addColorStop(1, '#020205');
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
const resize = () => { time += flowSpeed;
const parent = canvas.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
blobs = initBlobs(rect.width, rect.height);
}
};
const resizeObserver = new ResizeObserver(() => resize()); // Apply filter for "Liquid" look
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); ctx.filter = `blur(${blurAmount}px)`;
resize(); ctx.globalCompositeOperation = 'screen';
let mouse = { x: -1000, y: -1000 }; blobs.forEach((b) => {
const handleMouseMove = (e: MouseEvent) => { // Harmonic autonomous drift
const rect = canvas.getBoundingClientRect(); const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5);
mouse.x = e.clientX - rect.left; const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
mouse.y = e.clientY - rect.top;
};
const parent = canvas.parentElement; // Mouse distortion
if (parent) { const dxMouse = mouse.x - b.ox;
parent.addEventListener('mousemove', handleMouseMove); const dyMouse = mouse.y - b.oy;
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; }); const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
} const mouseForce = Math.max(0, (500 - distMouse) / 500);
const draw = () => { const mX = dxMouse * mouseForce * distortionFactor;
const width = canvas.width / (window.devicePixelRatio || 1); const mY = dyMouse * mouseForce * distortionFactor;
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height); b.x = b.ox + driftX + mX;
b.y = b.oy + driftY + mY;
// Background Base
const isLight = theme.palette.mode === 'light';
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
if (isLight) {
// For light themes, use a deeper, more saturated version of the theme color
bgGrad.addColorStop(0, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6));
bgGrad.addColorStop(1, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9));
} else {
bgGrad.addColorStop(0, '#0a0a1a');
bgGrad.addColorStop(1, '#020205');
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
time += flowSpeed; // Pulsing size
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
const currentSize = b.size * pulse;
// Apply filter for "Liquid" look const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, currentSize);
ctx.filter = `blur(${blurAmount}px)`; grad.addColorStop(0, alpha(b.color, vibrancy));
ctx.globalCompositeOperation = 'screen'; grad.addColorStop(1, alpha(b.color, 0));
blobs.forEach((b) => { ctx.fillStyle = grad;
// Harmonic autonomous drift ctx.beginPath();
const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5); ctx.arc(b.x, b.y, currentSize, 0, Math.PI * 2);
const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5); ctx.fill();
});
// Mouse distortion
const dxMouse = mouse.x - b.ox;
const dyMouse = mouse.y - b.oy;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
const mouseForce = Math.max(0, (500 - distMouse) / 500);
const mX = dxMouse * mouseForce * distortionFactor;
const mY = dyMouse * mouseForce * distortionFactor;
b.x = b.ox + driftX + mX; ctx.filter = 'none';
b.y = b.oy + driftY + mY; ctx.globalCompositeOperation = 'source-over';
// Pulsing size animationFrameId = requestAnimationFrame(draw);
const pulse = 1 + Math.sin(time + b.phase) * 0.1; };
const currentSize = b.size * pulse;
const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, currentSize); draw();
grad.addColorStop(0, alpha(b.color, vibrancy));
grad.addColorStop(1, alpha(b.color, 0));
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(b.x, b.y, currentSize, 0, Math.PI * 2);
ctx.fill();
});
ctx.filter = 'none'; return () => {
ctx.globalCompositeOperation = 'source-over'; resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]);
animationFrameId = requestAnimationFrame(draw); return (
}; <canvas
ref={canvasRef}
draw(); style={{
position: 'absolute',
return () => { top: 0,
resizeObserver.disconnect(); left: 0,
if (parent) { width: '100%',
parent.removeEventListener('mousemove', handleMouseMove); height: '100%',
} pointerEvents: 'none',
cancelAnimationFrame(animationFrameId); zIndex: 1,
}; }}
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]); />
);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
}}
/>
);
}; };
export default GenerativeFlowBanner; export default GenerativeFlowBanner;
+267 -238
View File
@@ -2,268 +2,297 @@ import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from '@mui/material';
interface HextechBannerProps { interface HextechBannerProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
settings?: { settings?: {
gridSize?: number; gridSize?: number;
pulseSpeed?: number; pulseSpeed?: number;
glowIntensity?: number; glowIntensity?: number;
streamCount?: number; streamCount?: number;
distortion?: number; distortion?: number;
}; };
} }
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => { const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme(); const theme = useTheme();
const gridSize = settings?.gridSize ?? 40;
const pulseSpeed = settings?.pulseSpeed ?? 0.02;
const glowIntensity = settings?.glowIntensity ?? 0.8;
const streamCount = settings?.streamCount ?? 12;
const distortionFactor = settings?.distortion ?? 0.5;
const getSeed = (str: string) => { const gridSize = settings?.gridSize ?? 40;
let hash = 0; const pulseSpeed = settings?.pulseSpeed ?? 0.02;
for (let i = 0; i < str.length; i++) { const glowIntensity = settings?.glowIntensity ?? 0.8;
hash = str.charCodeAt(i) + ((hash << 5) - hash); const streamCount = settings?.streamCount ?? 12;
} const distortionFactor = settings?.distortion ?? 0.5;
return hash;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => {
const colors =
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
return sorted.slice(0, 3);
}, [palette, theme]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let animationFrameId: number;
let time = 0;
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
const seed = useMemo(() => getSeed(username), [username]); // Mana Stream Logic
interface Stream {
segments: { x: number; y: number }[];
color: string;
life: number;
speed: number;
}
const brightColors = useMemo(() => { let streams: Stream[] = [];
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
return sorted.slice(0, 3);
}, [palette, theme]);
useEffect(() => { const createStream = (width: number, height: number) => {
const canvas = canvasRef.current; const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
if (!canvas) return; const x = pseudoRandom(time + 1) * width;
const y = pseudoRandom(time + 2) * height;
const ctx = canvas.getContext('2d', { alpha: true }); return {
if (!ctx) return; segments: [{ x, y }],
color,
life: 1.0,
speed: 2 + pseudoRandom(time + 3) * 4,
};
};
let animationFrameId: number; const drawHexagon = (x: number, y: number, r: number, tilt: number) => {
let time = 0; ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i + tilt;
const px = x + r * Math.cos(angle);
const py = y + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
};
const pseudoRandom = (offset: number) => { const resize = () => {
const x = Math.sin(seed + offset) * 10000; const parent = canvas.parentElement;
return x - Math.floor(x); if (parent) {
}; const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
}
};
// Mana Stream Logic const resizeObserver = new ResizeObserver(() => resize());
interface Stream { if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
segments: { x: number, y: number }[]; resize();
color: string;
life: number; const mouse = { x: -1000, y: -1000, down: false };
speed: number; const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const handleMouseDown = () => {
mouse.down = true;
};
const handleMouseUp = () => {
mouse.down = false;
};
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mousedown', handleMouseDown);
parent.addEventListener('mouseup', handleMouseUp);
parent.addEventListener('mouseleave', () => {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
});
}
const draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height);
// Background
const isLight = theme.palette.mode === 'light';
const bgGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) {
// For light themes, use a deeper, more saturated version of the theme color
bgGrad.addColorStop(0, alpha(brightColors[2] || brightColors[0], 0.65));
bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95));
} else {
bgGrad.addColorStop(0, '#0a051a'); // Arcane Purple tint
bgGrad.addColorStop(1, '#020105');
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
time += pulseSpeed;
// 1. Draw Hex Grid
ctx.lineWidth = 1;
const h = gridSize * Math.sqrt(3);
const w = gridSize * 2;
for (let y = -gridSize; y < height + gridSize; y += h / 2) {
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) {
const xOffset = Math.floor(y / (h / 2)) % 2 === 0 ? 0 : w * 0.375;
const finalX = x + xOffset;
const finalY = y;
// Apply Trippiness (Distortion)
const dist = Math.sqrt(Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2));
const mouseImpact = Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1);
const dx =
Math.sin(time + finalX * 0.01) * gridSize * distortionFactor * (1 + mouseImpact);
const dy =
Math.cos(time + finalY * 0.01) * gridSize * distortionFactor * (1 + mouseImpact);
const drawX = finalX + dx;
const drawY = finalY + dy;
// Pulse glow
const pulseVal = 0.1 + Math.sin(time * 2 + (finalX + finalY) * 0.005) * 0.1;
const opacity = (0.1 + pulseVal + mouseImpact * 0.3) * glowIntensity;
ctx.strokeStyle = alpha(brightColors[0], opacity);
drawHexagon(drawX, drawY, gridSize * 0.9, time * 0.1);
ctx.stroke();
// Occasional Runes
if (pseudoRandom(Math.floor(finalX) + Math.floor(finalY)) > 0.92) {
ctx.fillStyle = alpha(brightColors[1], opacity * 0.5);
ctx.font = `${gridSize * 0.5}px serif`;
ctx.fillText('⚡', drawX - 5, drawY + 5);
}
} }
}
let streams: Stream[] = []; // 2. Process Mana Streams
if (streams.length < streamCount && Math.random() > 0.9) {
streams.push(createStream(width, height));
}
const createStream = (width: number, height: number) => { ctx.globalCompositeOperation = 'screen';
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)]; streams.forEach((s, idx) => {
let x = pseudoRandom(time + 1) * width; s.life -= 0.005;
let y = pseudoRandom(time + 2) * height; const last = s.segments[s.segments.length - 1];
return {
segments: [{ x, y }],
color,
life: 1.0,
speed: 2 + pseudoRandom(time + 3) * 4
};
};
const drawHexagon = (x: number, y: number, r: number, tilt: number) => { const ang = pseudoRandom(time + idx) * Math.PI * 2;
ctx.beginPath(); const nextX = last.x + Math.cos(ang) * s.speed;
for (let i = 0; i < 6; i++) { const nextY = last.y + Math.sin(ang) * s.speed;
const angle = (Math.PI / 3) * i + tilt;
const px = x + r * Math.cos(angle);
const py = y + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
};
const resize = () => { s.segments.push({ x: nextX, y: nextY });
const parent = canvas.parentElement; if (s.segments.length > 20) s.segments.shift();
if (parent) {
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
}
};
const resizeObserver = new ResizeObserver(() => resize()); ctx.beginPath();
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); ctx.strokeStyle = alpha(s.color, s.life * glowIntensity);
resize(); ctx.lineWidth = 2;
s.segments.forEach((seg, i) => {
if (i === 0) ctx.moveTo(seg.x, seg.y);
else ctx.lineTo(seg.x, seg.y);
});
ctx.stroke();
let mouse = { x: -1000, y: -1000, down: false }; const head = s.segments[s.segments.length - 1];
const handleMouseMove = (e: MouseEvent) => { const g = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 15);
const rect = canvas.getBoundingClientRect(); g.addColorStop(0, alpha(s.color, s.life));
mouse.x = e.clientX - rect.left; g.addColorStop(1, alpha(s.color, 0));
mouse.y = e.clientY - rect.top; ctx.fillStyle = g;
}; ctx.beginPath();
const handleMouseDown = () => { mouse.down = true; }; ctx.arc(head.x, head.y, 15, 0, Math.PI * 2);
const handleMouseUp = () => { mouse.down = false; }; ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
const parent = canvas.parentElement; streams = streams.filter((s) => s.life > 0);
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mousedown', handleMouseDown);
parent.addEventListener('mouseup', handleMouseUp);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
}
const draw = () => { animationFrameId = requestAnimationFrame(draw);
const width = canvas.width / (window.devicePixelRatio || 1); };
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height); draw();
// Background
const isLight = theme.palette.mode === 'light';
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
if (isLight) {
// For light themes, use a deeper, more saturated version of the theme color
bgGrad.addColorStop(0, alpha(brightColors[2] || brightColors[0], 0.65));
bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95));
} else {
bgGrad.addColorStop(0, '#0a051a'); // Arcane Purple tint
bgGrad.addColorStop(1, '#020105');
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
time += pulseSpeed; return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
gridSize,
pulseSpeed,
glowIntensity,
streamCount,
distortionFactor,
theme,
]);
// 1. Draw Hex Grid return (
ctx.lineWidth = 1; <canvas
const h = gridSize * Math.sqrt(3); ref={canvasRef}
const w = gridSize * 2; style={{
position: 'absolute',
for (let y = -gridSize; y < height + gridSize; y += h/2) { top: 0,
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) { left: 0,
const xOffset = (Math.floor(y / (h/2)) % 2 === 0) ? 0 : w * 0.375; width: '100%',
const finalX = x + xOffset; height: '100%',
const finalY = y; pointerEvents: 'none',
zIndex: 1,
// Apply Trippiness (Distortion) }}
const dist = Math.sqrt(Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2)); />
const mouseImpact = Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1); );
const dx = Math.sin(time + (finalX * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact);
const dy = Math.cos(time + (finalY * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact);
const drawX = finalX + dx;
const drawY = finalY + dy;
// Pulse glow
const pulseVal = 0.1 + Math.sin(time * 2 + (finalX + finalY) * 0.005) * 0.1;
const opacity = (0.1 + pulseVal + mouseImpact * 0.3) * glowIntensity;
ctx.strokeStyle = alpha(brightColors[0], opacity);
drawHexagon(drawX, drawY, gridSize * 0.9, time * 0.1);
ctx.stroke();
// Occasional Runes
if (pseudoRandom(Math.floor(finalX) + Math.floor(finalY)) > 0.92) {
ctx.fillStyle = alpha(brightColors[1], opacity * 0.5);
ctx.font = `${gridSize * 0.5}px serif`;
ctx.fillText("⚡", drawX - 5, drawY + 5);
}
}
}
// 2. Process Mana Streams
if (streams.length < streamCount && Math.random() > 0.9) {
streams.push(createStream(width, height));
}
ctx.globalCompositeOperation = 'screen';
streams.forEach((s, idx) => {
s.life -= 0.005;
const last = s.segments[s.segments.length - 1];
const ang = pseudoRandom(time + idx) * Math.PI * 2;
const nextX = last.x + Math.cos(ang) * s.speed;
const nextY = last.y + Math.sin(ang) * s.speed;
s.segments.push({ x: nextX, y: nextY });
if (s.segments.length > 20) s.segments.shift();
ctx.beginPath();
ctx.strokeStyle = alpha(s.color, s.life * glowIntensity);
ctx.lineWidth = 2;
s.segments.forEach((seg, i) => {
if (i === 0) ctx.moveTo(seg.x, seg.y);
else ctx.lineTo(seg.x, seg.y);
});
ctx.stroke();
const head = s.segments[s.segments.length - 1];
const g = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 15);
g.addColorStop(0, alpha(s.color, s.life));
g.addColorStop(1, alpha(s.color, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(head.x, head.y, 15, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
streams = streams.filter(s => s.life > 0);
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, gridSize, pulseSpeed, glowIntensity, streamCount, distortionFactor, theme]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
}}
/>
);
}; };
export default HextechBanner; export default HextechBanner;
@@ -2,257 +2,287 @@ import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from '@mui/material';
interface HextechCorruptionBannerProps { interface HextechCorruptionBannerProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
settings?: { settings?: {
cellCount?: number; cellCount?: number;
shimmerSpeed?: number; shimmerSpeed?: number;
glowIntensity?: number; glowIntensity?: number;
instability?: number; instability?: number;
shellDarkness?: number; shellDarkness?: number;
}; };
} }
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ username, palette, settings }) => { const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
const canvasRef = useRef<HTMLCanvasElement>(null); username,
const theme = useTheme(); palette,
settings,
const cellCount = settings?.cellCount ?? 40; }) => {
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015; const canvasRef = useRef<HTMLCanvasElement>(null);
const glowIntensity = settings?.glowIntensity ?? 0.9; const theme = useTheme();
const instability = settings?.instability ?? 0.6;
const shellDarkness = settings?.shellDarkness ?? 0.85;
const getSeed = (str: string) => { const cellCount = settings?.cellCount ?? 40;
let hash = 0; const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
for (let i = 0; i < str.length; i++) { const glowIntensity = settings?.glowIntensity ?? 0.9;
hash = str.charCodeAt(i) + ((hash << 5) - hash); const instability = settings?.instability ?? 0.6;
} const shellDarkness = settings?.shellDarkness ?? 0.85;
return hash;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => {
const colors =
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
return sorted.slice(0, 3);
}, [palette, theme]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let animationFrameId: number;
let time = 0;
interface ControlPoint {
x: number;
y: number;
ox: number;
oy: number;
vx: number;
vy: number;
phase: number;
color: string;
}
let points: ControlPoint[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
const seed = useMemo(() => getSeed(username), [username]); const initPoints = (width: number, height: number) => {
const newPoints = [];
for (let i = 0; i < cellCount; i++) {
const rx = pseudoRandom(i) * width;
const ry = pseudoRandom(i + 500) * height;
newPoints.push({
x: rx,
y: ry,
ox: rx,
oy: ry,
vx: 0,
vy: 0,
phase: pseudoRandom(i + 1000) * Math.PI * 2,
color: brightColors[i % brightColors.length],
});
}
return newPoints;
};
const brightColors = useMemo(() => { const resize = () => {
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; const parent = canvas.parentElement;
const getBrightness = (hex: string) => { if (parent) {
const r = parseInt(hex.slice(1, 3), 16) || 0; const rect = parent.getBoundingClientRect();
const g = parseInt(hex.slice(3, 5), 16) || 0; const dpr = window.devicePixelRatio || 1;
const b = parseInt(hex.slice(5, 7), 16) || 0; canvas.width = rect.width * dpr;
return (r * 299 + g * 587 + b * 114) / 1000; canvas.height = rect.height * dpr;
}; ctx.scale(dpr, dpr);
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a)); canvas.style.width = `${rect.width}px`;
return sorted.slice(0, 3); canvas.style.height = `${rect.height}px`;
}, [palette, theme]); points = initPoints(rect.width, rect.height);
}
};
useEffect(() => { const resizeObserver = new ResizeObserver(() => resize());
const canvas = canvasRef.current; if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
if (!canvas) return; resize();
const ctx = canvas.getContext('2d', { alpha: true }); const mouse = { x: -1000, y: -1000, down: false };
if (!ctx) return; const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const handleMouseDown = () => {
mouse.down = true;
};
const handleMouseUp = () => {
mouse.down = false;
};
let animationFrameId: number; const parent = canvas.parentElement;
let time = 0; if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mousedown', handleMouseDown);
parent.addEventListener('mouseup', handleMouseUp);
parent.addEventListener('mouseleave', () => {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
});
}
interface ControlPoint { const draw = () => {
x: number; const width = canvas.width / (window.devicePixelRatio || 1);
y: number; const height = canvas.height / (window.devicePixelRatio || 1);
ox: number; if (!width || !height) {
oy: number; animationFrameId = requestAnimationFrame(draw);
vx: number; return;
vy: number; }
phase: number;
color: string;
}
let points: ControlPoint[] = []; ctx.clearRect(0, 0, width, height);
time += shimmerSpeed;
const pseudoRandom = (offset: number) => { // 1. Draw Under-Glow (Inner Cores)
const x = Math.sin(seed + offset) * 10000; ctx.globalCompositeOperation = 'source-over';
return x - Math.floor(x); points.forEach((p) => {
}; // Harmonic motion + mouse interaction
const dxMouse = mouse.x - p.x;
const dyMouse = mouse.y - p.y;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
const mouseForce = Math.max(0, (300 - distMouse) / 300);
const initPoints = (width: number, height: number) => { const driftX = Math.sin(time + p.phase) * 20 * instability;
const newPoints = []; const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability;
for (let i = 0; i < cellCount; i++) {
const rx = pseudoRandom(i) * width;
const ry = pseudoRandom(i + 500) * height;
newPoints.push({
x: rx, y: ry,
ox: rx, oy: ry,
vx: 0, vy: 0,
phase: pseudoRandom(i + 1000) * Math.PI * 2,
color: brightColors[i % brightColors.length]
});
}
return newPoints;
};
const resize = () => { p.x = p.ox + driftX + (mouse.down ? -dxMouse * mouseForce * 0.5 : 0);
const parent = canvas.parentElement; p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
if (parent) {
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
points = initPoints(rect.width, rect.height);
}
};
const resizeObserver = new ResizeObserver(() => resize()); const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); const glowSize = 60 + mouseForce * 40;
resize();
let mouse = { x: -1000, y: -1000, down: false }; const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
const handleMouseMove = (e: MouseEvent) => { grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
const rect = canvas.getBoundingClientRect(); grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity));
mouse.x = e.clientX - rect.left; grad.addColorStop(1, alpha(p.color, 0));
mouse.y = e.clientY - rect.top;
};
const handleMouseDown = () => { mouse.down = true; };
const handleMouseUp = () => { mouse.down = false; };
const parent = canvas.parentElement; ctx.fillStyle = grad;
if (parent) { ctx.beginPath();
parent.addEventListener('mousemove', handleMouseMove); ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
parent.addEventListener('mousedown', handleMouseDown); ctx.fill();
parent.addEventListener('mouseup', handleMouseUp); });
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
}
const draw = () => { // 2. Draw Dark "Void" Shell (Voronoi-based cutout)
const width = canvas.width / (window.devicePixelRatio || 1); // We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay
const height = canvas.height / (window.devicePixelRatio || 1); ctx.globalCompositeOperation = 'source-over';
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height); // Create the "holes" effect by using a pattern of circles that are actually transparent
time += shimmerSpeed; // Actually, to get the specific look from the image, we can use a simpler approach:
// Layer a dark color over everything, but with 'destination-out' circles at the point locations.
// 1. Draw Under-Glow (Inner Cores) const offCanvas = document.createElement('canvas');
ctx.globalCompositeOperation = 'source-over'; offCanvas.width = canvas.width;
points.forEach((p) => { offCanvas.height = canvas.height;
// Harmonic motion + mouse interaction const offCtx = offCanvas.getContext('2d');
const dxMouse = mouse.x - p.x; if (offCtx) {
const dyMouse = mouse.y - p.y; const dpr = window.devicePixelRatio || 1;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse); offCtx.scale(dpr, dpr);
const mouseForce = Math.max(0, (300 - distMouse) / 300);
const driftX = Math.sin(time + p.phase) * 20 * instability;
const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability;
p.x = p.ox + driftX + (mouse.down ? -dxMouse * mouseForce * 0.5 : 0);
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3; // Draw full dark shell
const glowSize = 60 + (mouseForce * 40); const shellColor =
theme.palette.mode === 'light'
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize); ? alpha('#1a0a2a', shellDarkness)
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse)); : alpha('#05020a', shellDarkness);
grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity)); offCtx.fillStyle = shellColor;
grad.addColorStop(1, alpha(p.color, 0)); offCtx.fillRect(0, 0, width, height);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
ctx.fill();
});
// 2. Draw Dark "Void" Shell (Voronoi-based cutout) // Cut holes
// We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay offCtx.globalCompositeOperation = 'destination-out';
ctx.globalCompositeOperation = 'source-over'; points.forEach((p) => {
const holeSize = 15 + Math.sin(time + p.phase) * 5 + instability * 10;
// Create the "holes" effect by using a pattern of circles that are actually transparent
// Actually, to get the specific look from the image, we can use a simpler approach:
// Layer a dark color over everything, but with 'destination-out' circles at the point locations.
const offCanvas = document.createElement('canvas');
offCanvas.width = canvas.width;
offCanvas.height = canvas.height;
const offCtx = offCanvas.getContext('2d');
if (offCtx) {
const dpr = window.devicePixelRatio || 1;
offCtx.scale(dpr, dpr);
// Draw full dark shell
const shellColor = theme.palette.mode === 'light' ? alpha('#1a0a2a', shellDarkness) : alpha('#05020a', shellDarkness);
offCtx.fillStyle = shellColor;
offCtx.fillRect(0, 0, width, height);
// Cut holes
offCtx.globalCompositeOperation = 'destination-out';
points.forEach(p => {
const holeSize = 15 + Math.sin(time + p.phase) * 5 + (instability * 10);
// Main hole
offCtx.beginPath();
offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2);
offCtx.fill();
// Add secondary smaller holes for that "punctured" look
for(let j=0; j<3; j++) {
const angle = p.phase + (j * Math.PI * 0.6);
const hx = p.x + Math.cos(angle) * (holeSize * 1.5);
const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
offCtx.beginPath();
offCtx.arc(hx, hy, holeSize * 0.4, 0, Math.PI * 2);
offCtx.fill();
}
});
ctx.drawImage(offCanvas, 0, 0, width, height);
}
// 3. Add Iridescent Edge Highlights // Main hole
ctx.globalCompositeOperation = 'screen'; offCtx.beginPath();
ctx.lineWidth = 2; offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2);
points.forEach(p => { offCtx.fill();
const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
ctx.strokeStyle = alpha(p.color, pulse * 0.4);
ctx.beginPath();
ctx.arc(p.x, p.y, 20 + pulse * 5, 0, Math.PI * 2);
ctx.stroke();
});
ctx.globalCompositeOperation = 'source-over'; // Add secondary smaller holes for that "punctured" look
animationFrameId = requestAnimationFrame(draw); for (let j = 0; j < 3; j++) {
}; const angle = p.phase + j * Math.PI * 0.6;
const hx = p.x + Math.cos(angle) * (holeSize * 1.5);
const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
offCtx.beginPath();
offCtx.arc(hx, hy, holeSize * 0.4, 0, Math.PI * 2);
offCtx.fill();
}
});
draw(); ctx.drawImage(offCanvas, 0, 0, width, height);
}
return () => { // 3. Add Iridescent Edge Highlights
resizeObserver.disconnect(); ctx.globalCompositeOperation = 'screen';
if (parent) { ctx.lineWidth = 2;
parent.removeEventListener('mousemove', handleMouseMove); points.forEach((p) => {
parent.removeEventListener('mousedown', handleMouseDown); const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
parent.removeEventListener('mouseup', handleMouseUp); ctx.strokeStyle = alpha(p.color, pulse * 0.4);
} ctx.beginPath();
cancelAnimationFrame(animationFrameId); ctx.arc(p.x, p.y, 20 + pulse * 5, 0, Math.PI * 2);
}; ctx.stroke();
}, [seed, brightColors, cellCount, shimmerSpeed, glowIntensity, instability, shellDarkness, theme]); });
return ( ctx.globalCompositeOperation = 'source-over';
<canvas animationFrameId = requestAnimationFrame(draw);
ref={canvasRef} };
style={{
position: 'absolute', draw();
top: 0,
left: 0, return () => {
width: '100%', resizeObserver.disconnect();
height: '100%', if (parent) {
pointerEvents: 'none', parent.removeEventListener('mousemove', handleMouseMove);
zIndex: 1 parent.removeEventListener('mousedown', handleMouseDown);
}} parent.removeEventListener('mouseup', handleMouseUp);
/> }
); cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
cellCount,
shimmerSpeed,
glowIntensity,
instability,
shellDarkness,
theme,
]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1,
}}
/>
);
}; };
export default HextechCorruptionBanner; export default HextechCorruptionBanner;
+187 -178
View File
@@ -1,199 +1,208 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { import {
TextField, TextField,
Popper, Popper,
Paper, Paper,
MenuList, MenuList,
MenuItem, MenuItem,
Avatar, Avatar,
ListItemAvatar, ListItemAvatar,
ListItemText, ListItemText,
Box, Box,
alpha alpha,
} from '@mui/material'; } from '@mui/material';
import api from '../api'; import api from '../lib/api';
interface UserSuggestion { interface UserSuggestion {
id: number; id: number;
username: string; username: string;
avatar: string | null; avatar: string | null;
} }
interface MentionTextFieldProps { interface MentionTextFieldProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
placeholder?: string; placeholder?: string;
fullWidth?: boolean; fullWidth?: boolean;
multiline?: boolean; multiline?: boolean;
rows?: number; rows?: number;
size?: 'small' | 'medium'; size?: 'small' | 'medium';
onKeyDown?: (e: React.KeyboardEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void;
} }
const MentionTextField: React.FC<MentionTextFieldProps> = ({ const MentionTextField: React.FC<MentionTextFieldProps> = ({
value, value,
onChange, onChange,
placeholder, placeholder,
fullWidth = true, fullWidth = true,
multiline = false, multiline = false,
rows, rows,
size = 'small', size = 'small',
onKeyDown onKeyDown,
}) => { }) => {
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]); const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
// Fetch users for suggestions (we could cache this or search on demand) // Fetch users for suggestions (we could cache this or search on demand)
const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]); const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]);
useEffect(() => { useEffect(() => {
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
const res = await api.get('flavors/search/'); const res = await api.get('flavors/search/');
const users = (res.data || []) const users = (res.data || [])
.filter((item: any) => item.type === 'user') .filter((item: any) => item.type === 'user')
.map((item: any) => ({ .map((item: any) => ({
id: item.id, id: item.id,
username: item.name, username: item.name,
avatar: item.image_url avatar: item.image_url,
})); }));
setAllUsers(users); setAllUsers(users);
} catch (err) { } catch (err) {
console.error('Failed to fetch users for mentions'); console.error('Failed to fetch users for mentions');
} }
}; };
fetchUsers(); fetchUsers();
}, []); }, []);
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
onChange(newValue); onChange(newValue);
const selectionStart = e.target.selectionStart || 0; const selectionStart = e.target.selectionStart || 0;
const textBeforeCursor = newValue.substring(0, selectionStart); const textBeforeCursor = newValue.substring(0, selectionStart);
const lastAtPos = textBeforeCursor.lastIndexOf('@'); const lastAtPos = textBeforeCursor.lastIndexOf('@');
if (lastAtPos !== -1) { if (lastAtPos !== -1) {
const query = textBeforeCursor.substring(lastAtPos + 1); const query = textBeforeCursor.substring(lastAtPos + 1);
// Ensure there's no space or newline between @ and the cursor // Ensure there's no space or newline between @ and the cursor
if (!query.includes(' ') && !query.includes('\n')) { if (!query.includes(' ') && !query.includes('\n')) {
const filtered = allUsers.filter(u => const filtered = allUsers.filter((u) =>
u.username.toLowerCase().startsWith(query.toLowerCase()) u.username.toLowerCase().startsWith(query.toLowerCase()),
); );
setSuggestions(filtered); setSuggestions(filtered);
setShowSuggestions(filtered.length > 0); setShowSuggestions(filtered.length > 0);
setSelectedIndex(0); setSelectedIndex(0);
setAnchorEl(inputRef.current); setAnchorEl(inputRef.current);
return; return;
} }
} }
setShowSuggestions(false);
};
const handleSelectSuggestion = (user: UserSuggestion) => {
const selectionStart = inputRef.current?.selectionStart || 0;
const textBeforeCursor = value.substring(0, selectionStart);
const textAfterCursor = value.substring(selectionStart);
const lastAtPos = textBeforeCursor.lastIndexOf('@');
const newValue = value.substring(0, lastAtPos) + '@' + user.username + ' ' + textAfterCursor;
onChange(newValue);
setShowSuggestions(false);
// Return focus to input and move cursor after the mention
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
const newPos = lastAtPos + user.username.length + 2; // +1 for @, +1 for space
inputRef.current.setSelectionRange(newPos, newPos);
}
}, 0);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showSuggestions) {
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
handleSelectSuggestion(suggestions[selectedIndex]);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % suggestions.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length);
return;
}
if (e.key === 'Escape') {
setShowSuggestions(false); setShowSuggestions(false);
}; return;
}
}
if (onKeyDown) onKeyDown(e);
};
const handleSelectSuggestion = (user: UserSuggestion) => { return (
const selectionStart = inputRef.current?.selectionStart || 0; <Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}>
const textBeforeCursor = value.substring(0, selectionStart); <TextField
const textAfterCursor = value.substring(selectionStart); inputRef={inputRef}
const lastAtPos = textBeforeCursor.lastIndexOf('@'); fullWidth={fullWidth}
multiline={multiline}
const newValue = rows={rows}
value.substring(0, lastAtPos) + size={size}
'@' + user.username + ' ' + placeholder={placeholder}
textAfterCursor; value={value}
onChange={handleTextChange}
onChange(newValue); onKeyDown={handleKeyDown}
setShowSuggestions(false); onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
autoComplete="off"
// Return focus to input and move cursor after the mention />
setTimeout(() => { <Popper
if (inputRef.current) { open={showSuggestions}
inputRef.current.focus(); anchorEl={anchorEl}
const newPos = lastAtPos + user.username.length + 2; // +1 for @, +1 for space placement="bottom-start"
inputRef.current.setSelectionRange(newPos, newPos); style={{ zIndex: 2000, width: 250 }}
} >
}, 0); <Paper
}; elevation={8}
sx={{
const handleKeyDown = (e: React.KeyboardEvent) => { mt: 1,
if (showSuggestions) { borderRadius: 2,
if (e.key === 'Tab' || e.key === 'Enter') { overflow: 'hidden',
e.preventDefault(); border: '1px solid',
handleSelectSuggestion(suggestions[selectedIndex]); borderColor: 'divider',
return; }}
} >
if (e.key === 'ArrowDown') { <MenuList dense sx={{ py: 0 }}>
e.preventDefault(); {suggestions.map((user, index) => (
setSelectedIndex((prev) => (prev + 1) % suggestions.length); <MenuItem
return; key={user.id}
} selected={index === selectedIndex}
if (e.key === 'ArrowUp') { onClick={() => handleSelectSuggestion(user)}
e.preventDefault(); sx={{
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length); py: 1,
return; '&.Mui-selected': {
} bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
if (e.key === 'Escape') { '&:hover': { bgcolor: (theme) => alpha(theme.palette.primary.main, 0.25) },
setShowSuggestions(false); },
return; }}
} >
} <ListItemAvatar sx={{ minWidth: 40 }}>
if (onKeyDown) onKeyDown(e); <Avatar
}; src={user.avatar || undefined}
sx={{ width: 28, height: 28, fontSize: '0.8rem' }}
return ( >
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}> {user.username.charAt(0).toUpperCase()}
<TextField </Avatar>
inputRef={inputRef} </ListItemAvatar>
fullWidth={fullWidth} <ListItemText
multiline={multiline} primary={user.username}
rows={rows} primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
size={size} />
placeholder={placeholder} </MenuItem>
value={value} ))}
onChange={handleTextChange} </MenuList>
onKeyDown={handleKeyDown} </Paper>
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)} </Popper>
autoComplete="off" </Box>
/> );
<Popper
open={showSuggestions}
anchorEl={anchorEl}
placement="bottom-start"
style={{ zIndex: 2000, width: 250 }}
>
<Paper elevation={8} sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider' }}>
<MenuList dense sx={{ py: 0 }}>
{suggestions.map((user, index) => (
<MenuItem
key={user.id}
selected={index === selectedIndex}
onClick={() => handleSelectSuggestion(user)}
sx={{
py: 1,
'&.Mui-selected': {
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
'&:hover': { bgcolor: (theme) => alpha(theme.palette.primary.main, 0.25) }
}
}}
>
<ListItemAvatar sx={{ minWidth: 40 }}>
<Avatar src={user.avatar || undefined} sx={{ width: 28, height: 28, fontSize: '0.8rem' }}>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={user.username}
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
/>
</MenuItem>
))}
</MenuList>
</Paper>
</Popper>
</Box>
);
}; };
export default MentionTextField; export default MentionTextField;
+309 -268
View File
@@ -2,293 +2,334 @@ import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from '@mui/material';
interface NebulaBannerProps { interface NebulaBannerProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
settings?: { settings?: {
particleCount?: number; particleCount?: number;
speed?: number; speed?: number;
opacity?: number; opacity?: number;
smearRadius?: number; smearRadius?: number;
turbulence?: number; turbulence?: number;
returnSpeed?: number; returnSpeed?: number;
starDensity?: number; starDensity?: number;
}; };
} }
const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => { const NebulaBanner: React.FC<NebulaBannerProps> = ({
const canvasRef = useRef<HTMLCanvasElement>(null); username,
const theme = useTheme(); palette,
ratingsCount,
const particleCount = settings?.particleCount ?? 400; followersCount,
const speed = settings?.speed ?? 0.0015; settings,
const globalOpacity = settings?.opacity ?? 0.6; }) => {
const smearRadius = settings?.smearRadius ?? 120; const canvasRef = useRef<HTMLCanvasElement>(null);
const turbulenceStrength = settings?.turbulence ?? 0.4; const theme = useTheme();
const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel
const starDensity = settings?.starDensity ?? 150;
const getSeed = (str: string) => { const particleCount = settings?.particleCount ?? 400;
let hash = 0; const speed = settings?.speed ?? 0.0015;
for (let i = 0; i < str.length; i++) { const globalOpacity = settings?.opacity ?? 0.6;
hash = str.charCodeAt(i) + ((hash << 5) - hash); const smearRadius = settings?.smearRadius ?? 120;
} const turbulenceStrength = settings?.turbulence ?? 0.4;
return hash; const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel
const starDensity = settings?.starDensity ?? 150;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let animationFrameId: number;
const colors =
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
interface Particle {
x: number;
y: number;
baseX: number;
baseY: number;
vx: number;
vy: number;
size: number;
color: string;
alpha: number;
targetAlpha: number;
phase: number;
}
interface Star {
x: number;
y: number;
size: number;
opacity: number;
}
let particles: Particle[] = [];
let stars: Star[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
const seed = useMemo(() => getSeed(username), [username]); const initScene = (width: number, height: number) => {
// Init Stars
const newStars = [];
for (let i = 0; i < starDensity; i++) {
newStars.push({
x: pseudoRandom(i + 5000) * width,
y: pseudoRandom(i + 6000) * height,
size: pseudoRandom(i + 7000) * 1.5,
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5,
});
}
useEffect(() => { // Init Gas Clusters
const canvas = canvasRef.current; const newParticles = [];
if (!canvas) return; const clusterCount = colors.length * 3; // More clusters
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width,
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height,
}));
const ctx = canvas.getContext('2d', { alpha: true }); for (let i = 0; i < particleCount; i++) {
if (!ctx) return; const clusterIdx = i % clusterCount;
const colorIdx = clusterIdx % colors.length;
const center = groupCenters[clusterIdx];
const angle = pseudoRandom(i + 100) * Math.PI * 2;
const dist = pseudoRandom(i + 200) * (width * 0.12);
const bx = center.x + Math.cos(angle) * dist;
const by = center.y + Math.sin(angle) * dist;
let animationFrameId: number; newParticles.push({
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; x: bx,
y: by,
baseX: bx,
baseY: by,
vx: 0,
vy: 0,
size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs
color: colors[colorIdx],
alpha: 0,
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15,
phase: pseudoRandom(i + 500) * Math.PI * 2,
});
}
return { stars: newStars, particles: newParticles };
};
interface Particle { const resize = () => {
x: number; const parent = canvas.parentElement;
y: number; if (parent) {
baseX: number; const rect = parent.getBoundingClientRect();
baseY: number; const dpr = window.devicePixelRatio || 1;
vx: number; canvas.width = rect.width * dpr;
vy: number; canvas.height = rect.height * dpr;
size: number; ctx.scale(dpr, dpr);
color: string; canvas.style.width = `${rect.width}px`;
alpha: number; canvas.style.height = `${rect.height}px`;
targetAlpha: number; const scene = initScene(rect.width, rect.height);
phase: number; stars = scene.stars;
particles = scene.particles;
}
};
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.px = mouse.x;
mouse.py = mouse.y;
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const handleMouseDown = () => {
mouse.down = true;
};
const handleMouseUp = () => {
mouse.down = false;
};
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mousedown', handleMouseDown);
parent.addEventListener('mouseup', handleMouseUp);
parent.addEventListener('mouseleave', () => {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
});
}
let time = 0;
const draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height);
// 1. Dynamic Celestial Background
const isLight = theme.palette.mode === 'light';
const skyGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) {
// Vibrant Deep Primary for light mode - but dark enough for glowing gas to pop
skyGrad.addColorStop(0, alpha(colors[colors.length - 1], 0.7));
skyGrad.addColorStop(0.5, alpha(colors[colors.length - 1], 0.9));
skyGrad.addColorStop(1, alpha(colors[colors.length - 1], 1.0));
} else {
// True Deep Space with visible tints for dark mode
skyGrad.addColorStop(0, '#1a1a3a'); // More blue/purple tint
skyGrad.addColorStop(0.5, '#0a0a1a');
skyGrad.addColorStop(1, '#050508');
}
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, width, height);
// 2. Draw Distant Stars
ctx.fillStyle = '#fff';
stars.forEach((s) => {
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3);
ctx.beginPath();
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1.0;
time += speed;
// 3. Draw Gas Clouds
particles.forEach((p) => {
// Restoration Force (Airy return)
const dxBase = p.baseX - p.x;
const dyBase = p.baseY - p.y;
p.vx += dxBase * returnSpeed;
p.vy += dyBase * returnSpeed;
// Mouse Interaction (Airy Smear)
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < smearRadius) {
const force = (smearRadius - dist) / smearRadius;
if (mouse.down) {
// High turbulence on click
const angle = Math.atan2(dy, dx);
p.vx += Math.cos(angle + Math.PI / 2) * turbulenceStrength * 2;
p.vy += Math.sin(angle + Math.PI / 2) * turbulenceStrength * 2;
} else {
// Smoke-like smear
const mdx = mouse.x - mouse.px;
const mdy = mouse.y - mouse.py;
p.vx += mdx * force * 0.15;
p.vy += mdy * force * 0.15;
p.vx -= (dx / dist) * force * 0.8;
p.vy -= (dy / dist) * force * 0.8;
}
} }
interface Star { // Smooth drift
x: number; p.vx += Math.sin(time + p.phase) * 0.03;
y: number; p.vy += Math.cos(time * 0.8 + p.phase) * 0.03;
size: number;
opacity: number;
}
let particles: Particle[] = []; // High friction for gas feel
let stars: Star[] = []; p.vx *= 0.95;
p.vy *= 0.95;
const pseudoRandom = (offset: number) => { p.x += p.vx;
const x = Math.sin(seed + offset) * 10000; p.y += p.vy;
return x - Math.floor(x);
};
const initScene = (width: number, height: number) => { if (p.alpha < p.targetAlpha) p.alpha += 0.001;
// Init Stars
const newStars = [];
for (let i = 0; i < starDensity; i++) {
newStars.push({
x: pseudoRandom(i + 5000) * width,
y: pseudoRandom(i + 6000) * height,
size: pseudoRandom(i + 7000) * 1.5,
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5
});
}
// Init Gas Clusters // Render as very soft additive blob
const newParticles = []; const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
const clusterCount = colors.length * 3; // More clusters const colorAlpha = p.alpha * globalOpacity;
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({ grad.addColorStop(0, alpha(p.color, colorAlpha));
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width, grad.addColorStop(0.6, alpha(p.color, colorAlpha * 0.2));
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height grad.addColorStop(1, alpha(p.color, 0));
}));
for (let i = 0; i < particleCount; i++) { ctx.fillStyle = grad;
const clusterIdx = i % clusterCount; ctx.globalCompositeOperation = 'screen';
const colorIdx = clusterIdx % colors.length; ctx.beginPath();
const center = groupCenters[clusterIdx]; ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
const angle = pseudoRandom(i + 100) * Math.PI * 2; ctx.fill();
const dist = pseudoRandom(i + 200) * (width * 0.12); });
const bx = center.x + Math.cos(angle) * dist;
const by = center.y + Math.sin(angle) * dist;
newParticles.push({ ctx.globalCompositeOperation = 'source-over';
x: bx, y: by, animationFrameId = requestAnimationFrame(draw);
baseX: bx, baseY: by, };
vx: 0, vy: 0,
size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs
color: colors[colorIdx],
alpha: 0,
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15,
phase: pseudoRandom(i + 500) * Math.PI * 2
});
}
return { stars: newStars, particles: newParticles };
};
const resize = () => { draw();
const parent = canvas.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
const scene = initScene(rect.width, rect.height);
stars = scene.stars;
particles = scene.particles;
}
};
const resizeObserver = new ResizeObserver(() => resize()); return () => {
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); resizeObserver.disconnect();
resize(); if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
palette,
ratingsCount,
followersCount,
theme,
particleCount,
speed,
globalOpacity,
smearRadius,
turbulenceStrength,
returnSpeed,
starDensity,
]);
let mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false }; return (
const handleMouseMove = (e: MouseEvent) => { <canvas
const rect = canvas.getBoundingClientRect(); ref={canvasRef}
mouse.px = mouse.x; mouse.py = mouse.y; style={{
mouse.x = e.clientX - rect.left; position: 'absolute',
mouse.y = e.clientY - rect.top; top: 0,
}; left: 0,
const handleMouseDown = () => { mouse.down = true; }; width: '100%',
const handleMouseUp = () => { mouse.down = false; }; height: '100%',
pointerEvents: 'none',
const parent = canvas.parentElement; zIndex: 1,
if (parent) { }}
parent.addEventListener('mousemove', handleMouseMove); />
parent.addEventListener('mousedown', handleMouseDown); );
parent.addEventListener('mouseup', handleMouseUp);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
}
let time = 0;
const draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height);
// 1. Dynamic Celestial Background
const isLight = theme.palette.mode === 'light';
const skyGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
if (isLight) {
// Vibrant Deep Primary for light mode - but dark enough for glowing gas to pop
skyGrad.addColorStop(0, alpha(colors[colors.length - 1], 0.7));
skyGrad.addColorStop(0.5, alpha(colors[colors.length - 1], 0.9));
skyGrad.addColorStop(1, alpha(colors[colors.length - 1], 1.0));
} else {
// True Deep Space with visible tints for dark mode
skyGrad.addColorStop(0, '#1a1a3a'); // More blue/purple tint
skyGrad.addColorStop(0.5, '#0a0a1a');
skyGrad.addColorStop(1, '#050508');
}
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, width, height);
// 2. Draw Distant Stars
ctx.fillStyle = '#fff';
stars.forEach(s => {
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3);
ctx.beginPath();
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1.0;
time += speed;
// 3. Draw Gas Clouds
particles.forEach((p) => {
// Restoration Force (Airy return)
const dxBase = p.baseX - p.x;
const dyBase = p.baseY - p.y;
p.vx += dxBase * returnSpeed;
p.vy += dyBase * returnSpeed;
// Mouse Interaction (Airy Smear)
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < smearRadius) {
const force = (smearRadius - dist) / smearRadius;
if (mouse.down) {
// High turbulence on click
const angle = Math.atan2(dy, dx);
p.vx += Math.cos(angle + Math.PI/2) * turbulenceStrength * 2;
p.vy += Math.sin(angle + Math.PI/2) * turbulenceStrength * 2;
} else {
// Smoke-like smear
const mdx = mouse.x - mouse.px;
const mdy = mouse.y - mouse.py;
p.vx += mdx * force * 0.15;
p.vy += mdy * force * 0.15;
p.vx -= (dx / dist) * force * 0.8;
p.vy -= (dy / dist) * force * 0.8;
}
}
// Smooth drift
p.vx += Math.sin(time + p.phase) * 0.03;
p.vy += Math.cos(time * 0.8 + p.phase) * 0.03;
// High friction for gas feel
p.vx *= 0.95;
p.vy *= 0.95;
p.x += p.vx;
p.y += p.vy;
if (p.alpha < p.targetAlpha) p.alpha += 0.001;
// Render as very soft additive blob
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
const colorAlpha = p.alpha * globalOpacity;
grad.addColorStop(0, alpha(p.color, colorAlpha));
grad.addColorStop(0.6, alpha(p.color, colorAlpha * 0.2));
grad.addColorStop(1, alpha(p.color, 0));
ctx.fillStyle = grad;
ctx.globalCompositeOperation = 'screen';
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, palette, ratingsCount, followersCount, theme, particleCount, speed, globalOpacity, smearRadius, turbulenceStrength, returnSpeed, starDensity]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
}}
/>
);
}; };
export default NebulaBanner; export default NebulaBanner;
+48 -45
View File
@@ -2,54 +2,57 @@ import React from 'react';
import { Box, Typography, useTheme } from '@mui/material'; import { Box, Typography, useTheme } from '@mui/material';
interface RatingBadgeProps { interface RatingBadgeProps {
score: number; score: number;
size?: 'small' | 'medium' | 'large'; size?: 'small' | 'medium' | 'large';
sx?: any; sx?: any;
} }
const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => { const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => {
const theme = useTheme(); const theme = useTheme();
const isLarge = size === 'large'; const isLarge = size === 'large';
const isSmall = size === 'small'; const isSmall = size === 'small';
return ( return (
<Box sx={{ <Box
display: 'inline-flex', sx={{
alignItems: 'center', display: 'inline-flex',
justifyContent: 'center', alignItems: 'center',
gap: 0.5, justifyContent: 'center',
bgcolor: 'primary.main', gap: 0.5,
color: 'primary.contrastText', bgcolor: 'primary.main',
px: isLarge ? 2 : (isSmall ? 1 : 1.5), color: 'primary.contrastText',
py: isLarge ? 0.5 : (isSmall ? 0.2 : 0.4), px: isLarge ? 2 : isSmall ? 1 : 1.5,
borderRadius: 2, py: isLarge ? 0.5 : isSmall ? 0.2 : 0.4,
minWidth: 'fit-content', borderRadius: 2,
height: 'fit-content', minWidth: 'fit-content',
boxShadow: theme.palette.mode === 'dark' height: 'fit-content',
? '0 4px 12px rgba(0,0,0,0.4)' boxShadow:
: '0 2px 8px rgba(0,0,0,0.1)', theme.palette.mode === 'dark'
...sx ? '0 4px 12px rgba(0,0,0,0.4)'
}}> : '0 2px 8px rgba(0,0,0,0.1)',
<Typography ...sx,
variant={isLarge ? "h5" : (isSmall ? "caption" : "subtitle1")} }}
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }} >
> <Typography
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score} variant={isLarge ? 'h5' : isSmall ? 'caption' : 'subtitle1'}
</Typography> sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }}
<Typography >
variant="caption" {typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score}
sx={{ </Typography>
fontSize: isLarge ? '0.75rem' : (isSmall ? '0.55rem' : '0.65rem'), <Typography
fontWeight: '900', variant="caption"
opacity: 0.9, sx={{
lineHeight: 1, fontSize: isLarge ? '0.75rem' : isSmall ? '0.55rem' : '0.65rem',
mt: 0.2 fontWeight: '900',
}} opacity: 0.9,
> lineHeight: 1,
/ 10 mt: 0.2,
</Typography> }}
</Box> >
); / 10
</Typography>
</Box>
);
}; };
export default RatingBadge; export default RatingBadge;
+30 -30
View File
@@ -3,41 +3,41 @@ import { Link } from 'react-router-dom';
import { useTheme, alpha } from '@mui/material'; import { useTheme, alpha } from '@mui/material';
interface RichTextProps { interface RichTextProps {
text: string; text: string;
} }
const RichText: React.FC<RichTextProps> = ({ text }) => { const RichText: React.FC<RichTextProps> = ({ text }) => {
const theme = useTheme(); const theme = useTheme();
if (!text) return null; if (!text) return null;
const parts = text.split(/(@\w+)/g); const parts = text.split(/(@\w+)/g);
return ( return (
<span> <span>
{parts.map((part, i) => { {parts.map((part, i) => {
if (part.startsWith('@')) { if (part.startsWith('@')) {
const username = part.substring(1); const username = part.substring(1);
return ( return (
<Link <Link
key={i} key={i}
to={`/profile/${username}`} to={`/profile/${username}`}
style={{ style={{
color: theme.palette.primary.main, color: theme.palette.primary.main,
textDecoration: 'none', textDecoration: 'none',
fontWeight: 'bold', fontWeight: 'bold',
backgroundColor: alpha(theme.palette.primary.main, 0.1), backgroundColor: alpha(theme.palette.primary.main, 0.1),
padding: '0 4px', padding: '0 4px',
borderRadius: '4px' borderRadius: '4px',
}} }}
> >
{part} {part}
</Link> </Link>
); );
} }
return part; return part;
})} })}
</span> </span>
); );
}; };
export default RichText; export default RichText;
+49 -42
View File
@@ -4,53 +4,60 @@ import HistoryIcon from '@mui/icons-material/History';
import BlockIcon from '@mui/icons-material/Block'; import BlockIcon from '@mui/icons-material/Block';
interface StatusBadgeProps { interface StatusBadgeProps {
isLegacy?: boolean; isLegacy?: boolean;
isAvailable?: boolean; isAvailable?: boolean;
size?: 'small' | 'medium'; size?: 'small' | 'medium';
sx?: any; sx?: any;
} }
const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size = 'medium', sx }) => { const StatusBadge: React.FC<StatusBadgeProps> = ({
const theme = useTheme(); isLegacy,
const isSmall = size === 'small'; isAvailable,
size = 'medium',
sx,
}) => {
const theme = useTheme();
const isSmall = size === 'small';
if (isAvailable && !isLegacy) return null; if (isAvailable && !isLegacy) return null;
const label = isLegacy ? 'Limited' : 'Out of Stock'; const label = isLegacy ? 'Limited' : 'Out of Stock';
const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main; const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main;
const Icon = isLegacy ? HistoryIcon : BlockIcon; const Icon = isLegacy ? HistoryIcon : BlockIcon;
return ( return (
<Box sx={{ <Box
display: 'inline-flex', sx={{
alignItems: 'center', display: 'inline-flex',
gap: 0.5, alignItems: 'center',
bgcolor: alpha(color, 0.9), gap: 0.5,
color: '#fff', bgcolor: alpha(color, 0.9),
px: isSmall ? 0.8 : 1.2, color: '#fff',
py: isSmall ? 0.2 : 0.4, px: isSmall ? 0.8 : 1.2,
borderRadius: 1.5, py: isSmall ? 0.2 : 0.4,
boxShadow: '0 4px 12px rgba(0,0,0,0.2)', borderRadius: 1.5,
backdropFilter: 'blur(4px)', boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
border: '1px solid', backdropFilter: 'blur(4px)',
borderColor: alpha('#fff', 0.2), border: '1px solid',
...sx borderColor: alpha('#fff', 0.2),
}}> ...sx,
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} /> }}
<Typography >
variant="caption" <Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} />
sx={{ <Typography
fontWeight: '900', variant="caption"
textTransform: 'uppercase', sx={{
letterSpacing: 0.5, fontWeight: '900',
fontSize: isSmall ? '0.55rem' : '0.65rem', textTransform: 'uppercase',
lineHeight: 1 letterSpacing: 0.5,
}} fontSize: isSmall ? '0.55rem' : '0.65rem',
> lineHeight: 1,
{label} }}
</Typography> >
</Box> {label}
); </Typography>
</Box>
);
}; };
export default StatusBadge; export default StatusBadge;
+86
View File
@@ -0,0 +1,86 @@
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import api, { clearTokens } from '../lib/api';
export interface AuthUser {
username: string;
avatar: string | null;
unread_notifications_count: number;
is_superuser: boolean;
theme?: string;
language?: string;
}
export interface FollowingEntry {
id: number;
username: string;
avatar: string | null;
}
export interface AuthContextValue {
user: AuthUser | null;
setUser: (user: AuthUser | null) => void;
following: FollowingEntry[];
loadingUser: boolean;
refetchUser: () => Promise<void>;
logout: () => void;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const { i18n } = useTranslation();
const [user, setUser] = useState<AuthUser | null>(null);
const [following, setFollowing] = useState<FollowingEntry[]>([]);
const [loadingUser, setLoadingUser] = useState(true);
const refetchUser = useCallback(async () => {
const token = localStorage.getItem('access');
if (!token) {
setUser(null);
setLoadingUser(false);
return;
}
try {
const res = await api.get('users/me/');
if (res.data?.username) {
setUser(res.data);
if (res.data.theme) localStorage.setItem('theme', res.data.theme);
if (res.data.language) i18n.changeLanguage(res.data.language);
try {
const followRes = await api.get('users/following_list/');
const data = Array.isArray(followRes.data)
? followRes.data
: followRes.data.results || [];
setFollowing(data);
} catch {
/* ignore */
}
} else {
setUser(null);
}
} catch {
setUser(null);
} finally {
setLoadingUser(false);
}
}, [i18n]);
useEffect(() => {
refetchUser();
}, [refetchUser]);
const logout = useCallback(() => {
clearTokens();
setUser(null);
setFollowing([]);
i18n.changeLanguage(navigator.language.split('-')[0] || 'en');
window.location.href = '/';
}, [i18n]);
return (
<AuthContext.Provider value={{ user, setUser, following, loadingUser, refetchUser, logout }}>
{children}
</AuthContext.Provider>
);
};
@@ -0,0 +1,99 @@
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react';
import api from '../lib/api';
import { useAuth } from '../hooks/useAuth';
export interface Notification {
id: number;
actor_username: string;
actor_avatar: string | null;
notification_type:
| 'reply'
| 'mention'
| 'follow'
| 'ticket_new'
| 'ticket_reply'
| 'profile_comment';
rating: number | null;
reply: number | null;
is_read: boolean;
created_at: string;
flavor_name: string | null;
flavor_id: number | null;
profile_owner_username: string | null;
}
export interface NotificationContextValue {
notifications: Notification[];
fetchNotifications: () => Promise<void>;
markAllRead: () => Promise<void>;
markRead: (id: number) => Promise<void>;
}
export const NotificationContext = createContext<NotificationContextValue | null>(null);
export const NotificationProvider = ({ children }: { children: ReactNode }) => {
const { user, setUser, refetchUser } = useAuth();
const [notifications, setNotifications] = useState<Notification[]>([]);
const fetchNotifications = useCallback(async () => {
try {
const res = await api.get('notifications/');
const data = Array.isArray(res.data) ? res.data : res.data.results || [];
setNotifications(data);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
if (user) {
fetchNotifications();
}
}, [user, fetchNotifications]);
useEffect(() => {
const interval = setInterval(() => {
if (localStorage.getItem('access')) {
fetchNotifications();
refetchUser();
}
}, 60000);
return () => clearInterval(interval);
}, [fetchNotifications, refetchUser]);
const markAllRead = useCallback(async () => {
try {
await api.post('notifications/mark_all_read/');
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
if (user) setUser({ ...user, unread_notifications_count: 0 });
} catch {
/* ignore */
}
}, [user, setUser]);
const markRead = useCallback(
async (id: number) => {
try {
await api.post(`notifications/${id}/mark_read/`);
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)));
if (user) {
setUser({
...user,
unread_notifications_count: Math.max(0, user.unread_notifications_count - 1),
});
}
} catch {
/* ignore */
}
},
[user, setUser],
);
return (
<NotificationContext.Provider
value={{ notifications, fetchNotifications, markAllRead, markRead }}
>
{children}
</NotificationContext.Provider>
);
};
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useCallback, useMemo, useState, type ReactNode } from 'react';
import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material';
import { getTheme, type CatppuccinTheme } from '../theme';
import api from '../lib/api';
export interface ThemeContextValue {
themeName: CatppuccinTheme;
setThemeName: (name: CatppuccinTheme) => void;
handleThemeChange: (name: CatppuccinTheme, isLoggedIn: boolean) => Promise<void>;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [themeName, setThemeName] = useState<CatppuccinTheme>(
(localStorage.getItem('theme') as CatppuccinTheme) || 'holy_light',
);
const theme = useMemo(() => getTheme(themeName), [themeName]);
const handleThemeChange = useCallback(async (name: CatppuccinTheme, isLoggedIn: boolean) => {
setThemeName(name);
localStorage.setItem('theme', name);
document.body.style.backgroundColor = getTheme(name).palette.background.default;
document.body.style.color = getTheme(name).palette.text.primary;
if (isLoggedIn) {
try {
await api.patch('users/update_preferences/', { theme: name });
} catch {
/* ignore */
}
}
}, []);
return (
<ThemeContext.Provider value={{ themeName, setThemeName, handleThemeChange }}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
</ThemeContext.Provider>
);
};
+8
View File
@@ -0,0 +1,8 @@
import { useContext } from 'react';
import { AuthContext } from '../contexts/AuthContext';
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
};
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import api from '../lib/api';
export interface SearchResult {
id: number;
name: string;
type: 'flavor' | 'category' | 'user';
subtitle: string;
image_url: string | null;
slug: string | null;
}
export const useGlobalSearch = () => {
const [query, setQuery] = useState('');
const [options, setOptions] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const location = useLocation();
useEffect(() => {
if (!query.trim()) {
setOptions([]);
return;
}
const timer = setTimeout(async () => {
setLoading(true);
try {
const res = await api.get(`flavors/search/?q=${encodeURIComponent(query)}`);
const results = Array.isArray(res.data) ? res.data : res.data.results || [];
setOptions(results);
} catch {
/* ignore */
} finally {
setLoading(false);
}
}, 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
const params = new URLSearchParams(location.search);
const q = params.get('q');
if (q) setQuery(q);
}, [location.search]);
return { query, setQuery, options, loading };
};
+8
View File
@@ -0,0 +1,8 @@
import { useContext } from 'react';
import { NotificationContext } from '../contexts/NotificationContext';
export const useNotifications = () => {
const ctx = useContext(NotificationContext);
if (!ctx) throw new Error('useNotifications must be used within NotificationProvider');
return ctx;
};
+8
View File
@@ -0,0 +1,8 @@
import { useContext } from 'react';
import { ThemeContext } from '../contexts/ThemeContext';
export const useTheme = () => {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
};
+505 -456
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { clearTokens, getAccessToken, setTokens } from './api';
describe('token helpers', () => {
beforeEach(() => {
clearTokens();
});
it('returns null when no access token is stored', () => {
expect(getAccessToken()).toBeNull();
});
it('stores and retrieves the access token', () => {
setTokens('access-123', 'refresh-abc');
expect(getAccessToken()).toBe('access-123');
expect(localStorage.getItem('refresh')).toBe('refresh-abc');
});
it('updates access without clobbering refresh when refresh is omitted', () => {
setTokens('first-access', 'first-refresh');
setTokens('second-access');
expect(getAccessToken()).toBe('second-access');
expect(localStorage.getItem('refresh')).toBe('first-refresh');
});
it('clearTokens removes both access and refresh', () => {
setTokens('a', 'b');
clearTokens();
expect(getAccessToken()).toBeNull();
expect(localStorage.getItem('refresh')).toBeNull();
});
});
+93
View File
@@ -0,0 +1,93 @@
import axios from 'axios';
import type { AxiosError, AxiosRequestConfig } from 'axios';
const ACCESS_KEY = 'access';
const REFRESH_KEY = 'refresh';
export function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_KEY);
}
export function setTokens(access: string, refresh?: string): void {
localStorage.setItem(ACCESS_KEY, access);
if (refresh) {
localStorage.setItem(REFRESH_KEY, refresh);
}
}
export function clearTokens(): void {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
}
/** One-time migration: remove the legacy DRF TokenAuth key from localStorage. */
export function clearLegacyToken(): void {
localStorage.removeItem('token');
}
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/',
});
api.interceptors.request.use((config) => {
const access = getAccessToken();
if (access) {
config.headers.Authorization = `Bearer ${access}`;
}
return config;
});
type RetriableConfig = AxiosRequestConfig & { _retry?: boolean };
let refreshPromise: Promise<string | null> | null = null;
async function refreshAccessToken(): Promise<string | null> {
const refresh = localStorage.getItem(REFRESH_KEY);
if (!refresh) return null;
try {
const res = await axios.post(`${api.defaults.baseURL}auth/token/refresh/`, { refresh });
const newAccess = res.data.access as string;
const newRefresh = res.data.refresh as string | undefined;
setTokens(newAccess, newRefresh);
return newAccess;
} catch {
clearTokens();
return null;
}
}
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as RetriableConfig | undefined;
if (!original || error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
const url = original.url ?? '';
if (url.includes('auth/token/')) {
return Promise.reject(error);
}
original._retry = true;
refreshPromise = refreshPromise ?? refreshAccessToken();
const newAccess = await refreshPromise;
refreshPromise = null;
if (!newAccess) {
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login';
}
return Promise.reject(error);
}
original.headers = {
...(original.headers ?? {}),
Authorization: `Bearer ${newAccess}`,
};
return api(original);
},
);
export default api;
+13 -7
View File
@@ -1,13 +1,19 @@
import { StrictMode } from 'react' import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom' import { BrowserRouter as Router } from 'react-router-dom';
import './index.css' import './index.css';
import App from './App.tsx' import App from './App.tsx';
import { AppProviders } from './app/providers';
import { clearLegacyToken } from './lib/api';
clearLegacyToken();
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<Router> <Router>
<App /> <AppProviders>
<App />
</AppProviders>
</Router> </Router>
</StrictMode>, </StrictMode>,
) );
+82 -46
View File
@@ -12,10 +12,10 @@ const About: React.FC = () => {
return ( return (
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}> <Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}>
<Button <Button
variant="outlined" variant="outlined"
component={Link} component={Link}
to="/" to="/"
startIcon={<ArrowBackIcon />} startIcon={<ArrowBackIcon />}
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }} sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }}
> >
@@ -23,67 +23,103 @@ const About: React.FC = () => {
</Button> </Button>
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}> <Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}>
<Typography variant={isMobile ? "h4" : "h3"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.title')}</Typography> <Typography variant={isMobile ? 'h4' : 'h3'} gutterBottom sx={{ fontWeight: 'bold' }}>
<Typography variant="body1" paragraph sx={{ fontSize: { xs: '1rem', md: '1.1rem' }, lineHeight: 1.8 }}> {t('about.title')}
{t('about.mission')}
</Typography> </Typography>
<Typography
variant="body1"
paragraph
sx={{ fontSize: { xs: '1rem', md: '1.1rem' }, lineHeight: 1.8 }}
>
{t('about.mission')}
</Typography>
<Box sx={{ my: 4 }}> <Box sx={{ my: 4 }}>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.canDoTitle')}</Typography> <Typography variant={isMobile ? 'h6' : 'h5'} gutterBottom sx={{ fontWeight: 'bold' }}>
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}> {t('about.canDoTitle')}
<li>{t('about.featureExplore')}</li> </Typography>
<li>{t('about.featureRate')}</li> <Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
<li>{t('about.featureCommunity')}</li> <li>{t('about.featureExplore')}</li>
<li>{t('about.featureProfile')}</li> <li>{t('about.featureRate')}</li>
<li>{t('about.featurePersonalize')}</li> <li>{t('about.featureCommunity')}</li>
<li>{t('about.featureEngage')}</li> <li>{t('about.featureProfile')}</li>
<li>{t('about.featureSupport')}</li> <li>{t('about.featurePersonalize')}</li>
</Typography> <li>{t('about.featureEngage')}</li>
<li>{t('about.featureSupport')}</li>
</Typography>
</Box> </Box>
<Typography variant="body2" color="text.secondary" paragraph> <Typography variant="body2" color="text.secondary" paragraph>
{t('about.disclaimer')} {t('about.disclaimer')}
</Typography> </Typography>
<Divider sx={{ my: { xs: 4, md: 6 } }} /> <Divider sx={{ my: { xs: 4, md: 6 } }} />
<Typography variant={isMobile ? "h5" : "h4"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.impressumTitle')}</Typography> <Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom sx={{ fontWeight: 'bold' }}>
{t('about.impressumTitle')}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 2 }}> <Typography variant="body2" sx={{ lineHeight: 2 }}>
<strong>{t('about.legalInfo')}</strong><br /> <strong>{t('about.legalInfo')}</strong>
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}<br /> <br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br /> {import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}<br /> <br />
{t('about.region')} {import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}
<br />
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
<br />
{t('about.region')}
</Typography> </Typography>
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.contact')}:</Typography> <Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
<Typography variant="body2"> {t('about.contact')}:
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}<br /> </Typography>
{import.meta.env.VITE_IMPRESSUM_PHONE && ( <Typography variant="body2">
<>Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}<br /></> E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}
)} <br />
Internet: holy.narl.io {import.meta.env.VITE_IMPRESSUM_PHONE && (
</Typography> <>
Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}
<br />
</>
)}
Internet: holy.narl.io
</Typography>
</Box> </Box>
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.responsible')}</Typography> <Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
<Typography variant="body2"> {t('about.responsible')}
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br /> </Typography>
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br /> <Typography variant="body2">
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'} {import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}
</Typography> <br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}
<br />
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
</Typography>
</Box> </Box>
<Box sx={{ mt: 4 }}> <Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.disclaimerTitle')}</Typography> <Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
<Typography variant="caption" color="text.secondary" component="p" sx={{ mb: 2, lineHeight: 1.6 }}> {t('about.disclaimerTitle')}
<strong>{t('about.liabilityContentTitle')}</strong> {t('about.liabilityContentDesc')} </Typography>
</Typography> <Typography
<Typography variant="caption" color="text.secondary" component="p" sx={{ lineHeight: 1.6 }}> variant="caption"
<strong>{t('about.copyrightTitle')}</strong> {t('about.copyrightDesc')} color="text.secondary"
</Typography> component="p"
sx={{ mb: 2, lineHeight: 1.6 }}
>
<strong>{t('about.liabilityContentTitle')}</strong> {t('about.liabilityContentDesc')}
</Typography>
<Typography
variant="caption"
color="text.secondary"
component="p"
sx={{ lineHeight: 1.6 }}
>
<strong>{t('about.copyrightTitle')}</strong> {t('about.copyrightDesc')}
</Typography>
</Box> </Box>
</Paper> </Paper>
</Container> </Container>
File diff suppressed because it is too large. Load diff
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more