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.
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"
# 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
DEBUG=true
# Custom URL for the Django Admin interface (e.g., my-secret-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) ---
# ALLOWED_HOSTS=example.com,another-domain.com
# CSRF_TRUSTED_ORIGINS=https://example.com
+42 -17
View File
@@ -8,23 +8,40 @@ on:
jobs:
frontend-build:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '25'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install Dependencies
run: npm install
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
working-directory: frontend
@@ -32,6 +49,11 @@ jobs:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
env:
SECRET_KEY: "ci-test-key"
DEBUG: "false"
ALLOWED_HOSTS: "localhost"
DJANGO_SETTINGS_MODULE: holy_backend.settings.dev
steps:
- uses: actions/checkout@v4
@@ -40,24 +62,27 @@ jobs:
with:
python-version: '3.14.3'
cache: 'pip'
cache-dependency-path: backend/requirements.txt
cache-dependency-path: backend/requirements-dev.txt
- 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
- name: Django System Check
run: python manage.py check
working-directory: backend
env:
SECRET_KEY: "ci-test-key"
DEBUG: "false"
ALLOWED_HOSTS: "localhost"
- name: Check Migrations
run: python manage.py makemigrations --check --dry-run
working-directory: backend
env:
SECRET_KEY: "ci-test-key"
DEBUG: "false"
ALLOWED_HOSTS: "localhost"
- name: Run Tests
run: pytest
working-directory: backend
+6 -7
View File
@@ -1,14 +1,13 @@
from django.contrib import admin
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):
fieldsets = UserAdmin.fieldsets + (
(None, {'fields': ('theme',)}),
)
add_fieldsets = UserAdmin.add_fieldsets + (
(None, {'fields': ('theme',)}),
)
fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("theme",)}),)
add_fieldsets = UserAdmin.add_fieldsets + ((None, {"fields": ("theme",)}),)
admin.site.register(User, CustomUserAdmin)
admin.site.register(Category)
+1 -1
View File
@@ -2,4 +2,4 @@ from django.apps import AppConfig
class ApiConfig(AppConfig):
name = 'api'
name = "api"
+5 -2
View File
@@ -1,6 +1,8 @@
import ssl
from django.core.mail.backends.smtp import EmailBackend
from django.conf import settings
from django.core.mail.backends.smtp import EmailBackend
class InsecureEmailBackend(EmailBackend):
"""
@@ -8,9 +10,10 @@ class InsecureEmailBackend(EmailBackend):
Useful for local development or internal SMTP servers with self-signed certificates.
Set EMAIL_SKIP_CERT_VERIFICATION = True in settings to use.
"""
@property
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
context = ssl._create_unverified_context()
return context
+36 -26
View File
@@ -1,38 +1,43 @@
import sqlite3
import os
import shutil
import sqlite3
from datetime import datetime, timedelta
from django.conf import settings
from django.core.management.base import 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):
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('--full', action='store_true', help='Create a full archive including media')
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(
"--full", action="store_true", help="Create a full archive including media"
)
def handle(self, *args, **options):
db_conf = settings.DATABASES['default']
if db_conf['ENGINE'] != 'django.db.backends.sqlite3':
db_conf = settings.DATABASES["default"]
if db_conf["ENGINE"] != "django.db.backends.sqlite3":
self.stdout.write(self.style.ERROR("This command only supports SQLite."))
return
db_path = str(db_conf['NAME'])
backup_dir = os.path.join(settings.BASE_DIR, 'backups')
db_path = str(db_conf["NAME"])
backup_dir = os.path.join(settings.BASE_DIR, "backups")
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
if options['output']:
db_backup_path = options['output']
if options["output"]:
db_backup_path = options["output"]
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}...")
try:
src = sqlite3.connect(db_path)
dst = sqlite3.connect(db_backup_path)
@@ -46,12 +51,13 @@ class Command(BaseCommand):
return
# 2. Full Archive (Optional)
if options['full']:
if options["full"]:
import tarfile
archive_name = f"full_backup_{timestamp}.tar.gz"
archive_path = os.path.join(backup_dir, archive_name)
self.stdout.write(f"Creating full archive at {archive_path}...")
try:
with tarfile.open(archive_path, "w:gz") as tar:
# Add database
@@ -60,19 +66,19 @@ class Command(BaseCommand):
media_dir = str(settings.MEDIA_ROOT)
if os.path.exists(media_dir):
tar.add(media_dir, arcname="media")
# Remove the raw sqlite backup after bundling it into the archive
if not options['output']:
if not options["output"]:
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:
self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}"))
# 3. Cleanup Old Backups
retention_days = options['retention']
retention_days = options["retention"]
cutoff = datetime.now() - timedelta(days=retention_days)
self.stdout.write(f"Cleaning up backups older than {retention_days} days...")
for filename in os.listdir(backup_dir):
file_path = os.path.join(backup_dir, filename)
@@ -81,6 +87,10 @@ class Command(BaseCommand):
if file_time < cutoff:
try:
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:
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 api.models import Flavor, Rating
from django.db.models import Count
from api.models import Flavor, Rating
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):
# Find duplicates based on name and category
duplicates = Flavor.objects.values('name', 'category').annotate(
name_count=Count('id')
).filter(name_count__gt=1)
duplicates = (
Flavor.objects.values("name", "category")
.annotate(name_count=Count("id"))
.filter(name_count__gt=1)
)
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
for dup in duplicates:
name = dup['name']
category_id = dup['category']
name = dup["name"]
category_id = dup["category"]
# Get all flavors in this set, ordered by having an external_id first, then by date
flavor_set = Flavor.objects.filter(
name=name,
category_id=category_id
).order_by('-external_id', 'created_at')
flavor_set = Flavor.objects.filter(name=name, category_id=category_id).order_by(
"-external_id", "created_at"
)
# Keep the first one, merge others into it
keep = flavor_set[0]
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:
# Re-assign ratings
for rating in Rating.objects.filter(flavor=other):
@@ -39,7 +44,7 @@ class Command(BaseCommand):
# User already has a rating for the 'keep' flavor
# Delete the duplicate rating instead
rating.delete()
# Delete the duplicate flavor
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 os
from django.core.management.base import BaseCommand
from api.models import Banner
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):
# Determine the banners directory relative to the project root
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):
self.stdout.write(self.style.WARNING(f"Banners directory not found at {banners_dir}"))
return
for filename in os.listdir(banners_dir):
if filename.endswith('.json'):
if filename.endswith(".json"):
filepath = os.path.join(banners_dir, filename)
try:
with open(filepath, 'r') as f:
with open(filepath) as f:
data = json.load(f)
slug = data.get('slug')
slug = data.get("slug")
if not slug:
continue
banner, created = Banner.objects.get_or_create(
slug=slug,
defaults={
'name': data.get('name', slug),
'description': data.get('description', ''),
'settings': data.get('settings', {})
}
"name": data.get("name", slug),
"description": data.get("description", ""),
"settings": data.get("settings", {}),
},
)
if not created:
# Sync missing settings from JSON to DB without overwriting existing keys
db_settings = banner.settings or {}
json_settings = data.get('settings', {})
json_schema = data.get('schema', [])
json_settings = data.get("settings", {})
json_schema = data.get("schema", [])
updated = False
# Sync missing settings
for key, value in json_settings.items():
if key not in db_settings:
db_settings[key] = value
updated = True
# Always sync schema as it represents the UI structure
if banner.schema != json_schema:
banner.schema = json_schema
updated = True
if updated:
banner.settings = db_settings
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:
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:
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 os
import requests
from django.core.management.base import BaseCommand
from django.core.files import File
from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.text import slugify
from api.models import Category, Flavor
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):
if not url:
return None
try:
safe_name = slugify(flavor_name)
ext = url.split('.')[-1].split('?')[0].lower()
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
ext = 'png'
ext = url.split(".")[-1].split("?")[0].lower()
if ext not in ["jpg", "jpeg", "png", "webp", "gif"]:
ext = "png"
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)
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()
if os.path.exists(filepath):
os.remove(filepath)
with open(filepath, 'wb') as f:
with open(filepath, "wb") as f:
f.write(response.content)
return f"flavors/{filename}"
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
def handle(self, *args, **options):
@@ -43,88 +47,103 @@ class Command(BaseCommand):
# Instead, we update existing entries.
cat_map = {
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0],
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[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],
"energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
"hydration": Category.objects.get_or_create(
name="Hydration", defaults={"slug": "hydration"}
)[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):
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):
self.stdout.write(self.style.ERROR(f"Legacy directory not found."))
self.stdout.write(self.style.ERROR("Legacy directory not found."))
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
for json_file in json_files:
file_path = os.path.join(legacy_dir, json_file)
file_lower = json_file.lower()
# Determine category from filename
if 'energy' in file_lower:
file_cat = cat_map['energy']
elif 'iced_tea' in file_lower or 'iced-tea' in file_lower:
file_cat = cat_map['iced-tea']
elif 'hydration' in file_lower:
file_cat = cat_map['hydration']
elif 'milkshake' in file_lower:
file_cat = cat_map['milkshake']
if "energy" in file_lower:
file_cat = cat_map["energy"]
elif "iced_tea" in file_lower or "iced-tea" in file_lower:
file_cat = cat_map["iced-tea"]
elif "hydration" in file_lower:
file_cat = cat_map["hydration"]
elif "milkshake" in file_lower:
file_cat = cat_map["milkshake"]
else:
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)
for data in data_list:
name = data.get('Name')
desc = data.get('Beschreibung')
taste = data.get('Geschmack')
status_text = data.get('Status')
image_url = data.get('Bild_URL')
name = data.get("Name")
desc = data.get("Beschreibung")
taste = data.get("Geschmack")
status_text = data.get("Status")
image_url = data.get("Bild_URL")
full_desc = f"**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}"
# Determine final category
cat = file_cat
if not cat:
# Fallback to name/desc detection if filename was ambiguous
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():
cat = cat_map['iced-tea']
cat = cat_map["iced-tea"]
elif "milkshake" in name.lower():
cat = cat_map['milkshake']
elif any(k in name.lower() for k in ['bundle', 'set', 'box', 'probe', 'sample', 'taster', 'shaker', 'starter']):
cat = cat_map['packs']
cat = cat_map["milkshake"]
elif any(
k in name.lower()
for k in [
"bundle",
"set",
"box",
"probe",
"sample",
"taster",
"shaker",
"starter",
]
):
cat = cat_map["packs"]
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
flavor = Flavor.objects.filter(name__iexact=name, category=cat).first()
# If it exists and is an active Shopify product, skip legacy processing
if flavor and not flavor.is_legacy:
self.stdout.write(f"Skipping active flavor: {name}")
continue
if not flavor:
flavor = Flavor(
name=name,
category=cat,
is_legacy=True,
external_id=None
)
flavor = Flavor(name=name, category=cat, is_legacy=True, external_id=None)
self.stdout.write(f"Adding new legacy flavor: {name}")
else:
self.stdout.write(f"Updating legacy flavor: {name}")
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:
# Check if local file exists
@@ -140,9 +159,11 @@ class Command(BaseCommand):
if rel_path:
flavor.image = rel_path
self.stdout.write(f" -> Updated image for: {name}")
flavor.image_url = image_url
flavor.save()
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
from django.core.management.base import BaseCommand
from django.core.files import File
import re
import requests
from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.text import slugify
from api.models import Category, Flavor
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):
if not raw_html:
return ""
cleanr = re.compile('<.*?>')
cleantext = re.sub(cleanr, '', raw_html)
cleanr = re.compile("<.*?>")
cleantext = re.sub(cleanr, "", raw_html)
return cleantext.strip()
def download_image(self, url, flavor_name):
@@ -23,50 +24,60 @@ class Command(BaseCommand):
return None
try:
safe_name = slugify(flavor_name)
ext = url.split('.')[-1].split('?')[0].lower()
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
ext = 'png'
ext = url.split(".")[-1].split("?")[0].lower()
if ext not in ["jpg", "jpeg", "png", "webp", "gif"]:
ext = "png"
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)
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()
if os.path.exists(filepath):
os.remove(filepath)
with open(filepath, 'wb') as f:
with open(filepath, "wb") as f:
f.write(response.content)
return f"flavors/{filename}"
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
def handle(self, *args, **options):
url = "https://weareholy.com/products.json?limit=250"
self.stdout.write(f"Fetching from {url}...")
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()
data = response.json()
except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to fetch data: {e}"))
return
products = data.get('products', [])
products = data.get("products", [])
self.stdout.write(f"Found {len(products)} products.")
cat_map = {
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0],
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[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],
"energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
"hydration": Category.objects.get_or_create(
name="Hydration", defaults={"slug": "hydration"}
)[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
@@ -74,82 +85,97 @@ class Command(BaseCommand):
synced_external_ids = []
for p in products:
title = p.get('title', '').strip()
tags = p.get('tags', [])
title = p.get("title", "").strip()
tags = p.get("tags", [])
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]
title_lower = title.lower()
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):
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
category = None
if is_pack:
category = cat_map['packs']
elif 'holy energy' in tags_lower or 'energy' in tags_lower:
category = cat_map['energy']
elif 'holy hydration' in tags_lower or 'hydration' in tags_lower:
category = cat_map['hydration']
elif 'holy iced tea' in tags_lower or 'iced tea' in tags_lower:
category = cat_map['iced-tea']
elif 'milkshake' in tags_lower:
category = cat_map['milkshake']
category = cat_map["packs"]
elif "holy energy" in tags_lower or "energy" in tags_lower:
category = cat_map["energy"]
elif "holy hydration" in tags_lower or "hydration" in tags_lower:
category = cat_map["hydration"]
elif "holy iced tea" in tags_lower or "iced tea" in tags_lower:
category = cat_map["iced-tea"]
elif "milkshake" in tags_lower:
category = cat_map["milkshake"]
if not category:
if 'shaker' in tags_lower:
category = cat_map['packs']
if "shaker" in tags_lower:
category = cat_map["packs"]
else:
continue
variants = p.get('variants', [])
is_available = any(v.get('available') for v in variants)
images = p.get('images', [])
image_url = images[0].get('src') if images else None
description = self.clean_html(p.get('body_html', ''))
handle = p.get('handle')
variants = p.get("variants", [])
is_available = any(v.get("available") for v in variants)
images = p.get("images", [])
image_url = images[0].get("src") if images else None
description = self.clean_html(p.get("body_html", ""))
handle = p.get("handle")
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
# Find or create - be very specific to avoid duplicates
# 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:
# 2. Try exact name match in category
# REMOVED: is_legacy=False
flavor = Flavor.objects.filter(name=title, category=category).first()
if not flavor:
# 3. Try case-insensitive name match in category (safety for whitespace/case)
# REMOVED: is_legacy=False
flavor = Flavor.objects.filter(name__iexact=title, category=category).first()
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
else:
updated_count += 1
# Ensure external_id is updated if it was missing
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
if flavor.is_legacy:
flavor.is_legacy = False
# 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.description = description
flavor.shop_url = shop_url
flavor.is_available = is_available
if image_url:
# Check if local file exists
file_exists = False
@@ -165,11 +191,21 @@ class Command(BaseCommand):
flavor.image = rel_path
self.stdout.write(f"Downloaded image for: {title}")
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.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)
self.stdout.write(self.style.SUCCESS(f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {discontinued_count}"))
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 . import views
router = routers.DefaultRouter()
router.register('users', views.UserViewSet)
router.register('flavors', views.FlavorViewSet)
router.register('categories', views.CategoryViewSet)
router.register('ratings', views.RatingViewSet)
router.register('replies', views.ReplyViewSet)
router.register('notifications', views.NotificationViewSet, basename='notification')
router.register('tickets', views.TicketViewSet, basename='ticket')
router.register('admin-custom', views.AdminViewSet, basename='admin-custom')
router.register('banners', views.BannerViewSet, basename='banner')
router.register("users", views.UserViewSet)
router.register("flavors", views.FlavorViewSet)
router.register("categories", views.CategoryViewSet)
router.register("ratings", views.RatingViewSet)
router.register("replies", views.ReplyViewSet)
router.register("notifications", views.NotificationViewSet, basename="notification")
router.register("tickets", views.TicketViewSet, basename="ticket")
router.register("admin-custom", views.AdminViewSet, basename="admin-custom")
router.register("banners", views.BannerViewSet, basename="banner")
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
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
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
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):
return HttpResponse("OK", content_type="text/plain")
# Clean the admin path
admin_url = os.environ.get('ADMIN_URL', 'admin').strip('/')
admin_url = os.environ.get("ADMIN_URL", "admin").strip("/")
urlpatterns = [
# Health check
path('health/', health_check, name='health_check'),
path("health/", health_check, name="health_check"),
# 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
path(f'{admin_url}/', admin.site.urls),
path('api/', include('api.urls')),
path('api/token/', obtain_auth_token, name='api_token'),
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}),
path(f"{admin_url}/", admin.site.urls),
path("api/", include("api.urls")),
path("api/auth/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("api/auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
path("api/auth/token/verify/", TokenVerifyView.as_view(), name="token_verify"),
# Legacy alias: old DRF TokenAuth endpoint now returns a JWT pair so clients
# relying on POST /api/token/ keep working for one release.
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
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
application = get_wsgi_application()
+3 -2
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@@ -18,5 +19,5 @@ def main():
execute_from_command_line(sys.argv)
if __name__ == '__main__':
if __name__ == "__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-compression-middleware>=0.5.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:
# --- 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:
build:
@@ -17,6 +32,12 @@ services:
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}
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
interval: 10s
@@ -28,18 +49,49 @@ services:
build:
context: ./backend
container_name: holy-worker
command: python manage.py worker
command: celery -A holy_backend worker -l info
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:
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:
condition: service_healthy
@@ -71,3 +123,4 @@ networks:
volumes:
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,
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",
"build": "tsc -b && vite build",
"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"
},
"dependencies": {
@@ -26,17 +31,25 @@
},
"devDependencies": {
"@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/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/ui": "^4.1.4",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^29.0.2",
"prettier": "^3.8.2",
"typescript": "~5.9.3",
"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 {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
TextField, Box, Typography, Alert, Slider, Stack,
Tooltip, IconButton
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Typography,
Alert,
Slider,
Stack,
Tooltip,
IconButton,
} from '@mui/material';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import { useTranslation } from 'react-i18next';
import api from '../api';
import api from '../lib/api';
interface BannerSettingSchema {
key: string;
label: string;
type: 'number' | 'slider' | 'text' | 'boolean';
min?: number;
max?: number;
step?: number;
description?: string;
key: string;
label: string;
type: 'number' | 'slider' | 'text' | 'boolean';
min?: number;
max?: number;
step?: number;
description?: string;
}
interface Banner {
id: number;
name: string;
slug: string;
description: string;
is_active: boolean;
settings: any;
schema: BannerSettingSchema[];
id: number;
name: string;
slug: string;
description: string;
is_active: boolean;
settings: any;
schema: BannerSettingSchema[];
}
interface BannerSettingsDialogProps {
open: boolean;
onClose: () => void;
banner: Banner | null;
onSave: () => void;
open: boolean;
onClose: () => void;
banner: Banner | null;
onSave: () => void;
}
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClose, banner, onSave }) => {
const { t } = useTranslation();
const [localSettings, setLocalSettings] = useState<any>({});
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
open,
onClose,
banner,
onSave,
}) => {
const { t } = useTranslation();
const [localSettings, setLocalSettings] = useState<any>({});
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (banner) {
setLocalSettings(banner.settings || {});
setError('');
}
}, [banner]);
useEffect(() => {
if (banner) {
setLocalSettings(banner.settings || {});
setError('');
}
}, [banner]);
const handleSave = async () => {
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 handleSave = async () => {
if (!banner) return;
const updateSetting = (key: string, value: any) => {
setLocalSettings((prev: any) => ({
...prev,
[key]: value
}));
};
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 renderField = (schema: BannerSettingSchema) => {
const value = localSettings[schema.key] ?? '';
const updateSetting = (key: string, value: any) => {
setLocalSettings((prev: any) => ({
...prev,
[key]: value,
}));
};
return (
<Box key={schema.key} sx={{ mb: 3 }}>
<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>
);
};
const renderField = (schema: BannerSettingSchema) => {
const value = localSettings[schema.key] ?? '';
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>
<Box key={schema.key} sx={{ mb: 3 }}>
<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 (
<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;
+106 -91
View File
@@ -1,108 +1,123 @@
import React, { useState, useEffect } from 'react';
import {
Typography,
Button,
alpha,
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Box
import {
Typography,
Button,
alpha,
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Box,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import type { CatppuccinTheme } from '../theme';
interface CookieBannerProps {
onThemeChange: (newTheme: CatppuccinTheme) => void;
currentTheme: CatppuccinTheme;
onThemeChange: (newTheme: CatppuccinTheme) => void;
currentTheme: CatppuccinTheme;
}
const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const [open, setOpen] = useState(false);
useEffect(() => {
const consent = localStorage.getItem('cookie-consent');
if (!consent) {
setOpen(true);
}
}, []);
useEffect(() => {
const consent = localStorage.getItem('cookie-consent');
if (!consent) {
setOpen(true);
}
}, []);
const handleAccept = () => {
localStorage.setItem('cookie-consent', 'true');
setOpen(false);
};
const handleAccept = () => {
localStorage.setItem('cookie-consent', 'true');
setOpen(false);
};
const handleToggleTheme = () => {
const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light';
onThemeChange(newTheme);
};
const handleToggleTheme = () => {
const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light';
onThemeChange(newTheme);
};
return (
<Dialog
open={open}
onClose={() => {}} // Disable closing by clicking backdrop
disableEscapeKeyDown
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 4,
border: '1px solid',
borderColor: 'primary.main',
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
backdropFilter: 'blur(10px)',
boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
}
}}
return (
<Dialog
open={open}
onClose={() => {}} // Disable closing by clicking backdrop
disableEscapeKeyDown
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 4,
border: '1px solid',
borderColor: 'primary.main',
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
backdropFilter: 'blur(10px)',
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 }}>
{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
}}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}>
Technical Details:
</Typography>
<Typography variant="caption" color="text.secondary">
• Session ID: Required for login & authentication.<br />
• CSRF Token: Protects your account from malicious attacks.<br />
• Preference Store: Remembers your theme and language choices.
</Typography>
</Box>
</DialogContent>
<DialogActions sx={{ p: 3, gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
<Button
fullWidth
variant="outlined"
color="primary"
onClick={handleToggleTheme}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }}
>
{currentTheme === 'holy_light' ? t('cookieBanner.switchToDark') : t('cookieBanner.switchToLight')}
</Button>
<Button
fullWidth
variant="contained"
color="primary"
onClick={handleAccept}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', px: 4, m: '0 !important' }}
>
{t('cookieBanner.accept')}
</Button>
</DialogActions>
</Dialog>
);
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}
>
Technical Details:
</Typography>
<Typography variant="caption" color="text.secondary">
• Session ID: Required for login & authentication.
<br />
• CSRF Token: Protects your account from malicious attacks.
<br />• Preference Store: Remembers your theme and language choices.
</Typography>
</Box>
</DialogContent>
<DialogActions sx={{ p: 3, gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
<Button
fullWidth
variant="outlined"
color="primary"
onClick={handleToggleTheme}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }}
>
{currentTheme === 'holy_light'
? t('cookieBanner.switchToDark')
: t('cookieBanner.switchToLight')}
</Button>
<Button
fullWidth
variant="contained"
color="primary"
onClick={handleAccept}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 'bold',
px: 4,
m: '0 !important',
}}
>
{t('cookieBanner.accept')}
</Button>
</DialogActions>
</Dialog>
);
};
export default CookieBanner;
+92 -75
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import api from '../api';
import api from '../lib/api';
import GenerativeBanner from './GenerativeBanner';
import NebulaBanner from './NebulaBanner';
import FirefliesBanner from './FirefliesBanner';
@@ -8,106 +8,123 @@ import HextechCorruptionBanner from './HextechCorruptionBanner';
import GenerativeFlowBanner from './GenerativeFlowBanner';
interface Banner {
id: number;
name: string;
slug: string;
description: string;
is_active: boolean;
settings: any;
id: number;
name: string;
slug: string;
description: string;
is_active: boolean;
settings: any;
}
interface DynamicBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
}
// Future expansion: Add more banner components here
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => {
// Placeholder for a second model
return (
<div style={{
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
background: 'rgba(0,0,0,0.8)', color: '#0f0', display: 'flex',
alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace',
zIndex: 1, overflow: 'hidden', 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>
{`
// Placeholder for a second model
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(0,0,0,0.8)',
color: '#0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'monospace',
zIndex: 1,
overflow: 'hidden',
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 {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
`}
</style>
</div>
);
</style>
</div>
);
};
const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
const [banner, setBanner] = useState<Banner | null>(null);
const [loading, setLoading] = useState(true);
const [banner, setBanner] = useState<Banner | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchActiveBanner = async () => {
try {
const res = await api.get('banners/active/', {
params: { username: props.username }
});
setBanner(res.data);
} catch (err) {
console.error('Failed to fetch active banner:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
const fetchActiveBanner = async () => {
try {
const res = await api.get('banners/active/', {
params: { username: props.username },
});
setBanner(res.data);
} catch (err) {
console.error('Failed to fetch active banner:', err);
} finally {
setLoading(false);
}
};
fetchActiveBanner();
}, [props.username]);
fetchActiveBanner();
}, [props.username]);
if (loading) return null;
if (loading) return null;
// Default to GenerativeBanner if no active banner or unrecognized slug
if (!banner || banner.slug === 'generative-web') {
return <GenerativeBanner {...props} settings={banner?.settings} />;
}
// Default to GenerativeBanner if no active banner or unrecognized slug
if (!banner || banner.slug === 'generative-web') {
return <GenerativeBanner {...props} settings={banner?.settings} />;
}
if (banner.slug === 'matrix') {
return <MatrixBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'matrix') {
return <MatrixBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'nebula') {
return <NebulaBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'nebula') {
return <NebulaBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'fireflies') {
return <FirefliesBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'fireflies') {
return <FirefliesBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'hextech') {
return <HextechBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'hextech') {
return <HextechBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'hextech-corruption') {
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'hextech-corruption') {
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'generative-flow') {
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'generative-flow') {
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
}
// Fallback
return <GenerativeBanner {...props} />;
// Fallback
return <GenerativeBanner {...props} />;
};
export default DynamicBanner;
+247 -224
View File
@@ -2,256 +2,279 @@ import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
interface FirefliesBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
count?: number;
speed?: number;
evasionRadius?: number;
evasionStrength?: number;
glowSize?: number;
flickerSpeed?: number;
};
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
count?: number;
speed?: number;
evasionRadius?: number;
evasionStrength?: number;
glowSize?: number;
flickerSpeed?: number;
};
}
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
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 canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
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 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) => {
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 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]);
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);
}
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
const 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;
};
let animationFrameId: number;
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mouseleave', () => {
mouse.x = -1000;
mouse.y = -1000;
});
}
interface Firefly {
x: number;
y: number;
vx: number;
vy: number;
size: number;
color: string;
phase: number;
noiseOffset: number;
isEvading: boolean;
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);
// 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[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
// 3. Dynamic Speed Limits
const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy);
const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5;
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 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; });
if (currentSpeed > maxSpeed) {
f.vx = (f.vx / currentSpeed) * maxSpeed;
f.vy = (f.vy / currentSpeed) * maxSpeed;
}
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;
}
// 4. Smooth Friction
f.vx *= 0.97;
f.vy *= 0.97;
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);
f.x += f.vx;
f.y += f.vy;
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) => {
// 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;
// Flicker
const flicker = 0.4 + Math.sin(time + f.phase) * 0.6;
const opacity = flicker > 0 ? flicker : 0;
f.vx += driftX + jitterX;
f.vy += driftY + jitterY;
// Render
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)
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;
}
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2);
ctx.fill();
// 3. Dynamic Speed Limits
const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy);
const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5;
if (currentSpeed > maxSpeed) {
f.vx = (f.vx / currentSpeed) * maxSpeed;
f.vy = (f.vy / currentSpeed) * maxSpeed;
}
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();
});
// 4. Smooth Friction
f.vx *= 0.97;
f.vy *= 0.97;
animationFrameId = requestAnimationFrame(draw);
};
f.x += f.vx;
f.y += f.vy;
draw();
// 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;
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
count,
baseSpeed,
evasionRadius,
evasionStrength,
glowSizeMultiplier,
flickerSpeed,
theme,
]);
// Flicker
const flicker = 0.4 + Math.sin(time + f.phase) * 0.6;
const opacity = flicker > 0 ? flicker : 0;
// Render
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));
ctx.fillStyle = grad;
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
}}
/>
);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1,
}}
/>
);
};
export default FirefliesBanner;
+52 -15
View File
@@ -8,25 +8,62 @@ const Footer: React.FC = () => {
const year = new Date().getFullYear();
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">
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 2 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
© {year} Holy Flavors Archive
</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
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'space-between',
alignItems: 'center',
gap: 2,
mb: 2,
}}
>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
© {year} Holy Flavors Archive
</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>
<Divider sx={{ my: 2, opacity: 0.5 }} />
<Typography variant="caption" color="text.secondary" align="center" display="block">
{t('nav.about')} • Fan Project
{t('nav.about')} • Fan Project
</Typography>
</Container>
</Box>
+290 -252
View File
@@ -2,277 +2,315 @@ import React, { useRef, useEffect } from 'react';
import { alpha, useTheme } from '@mui/material';
interface GenerativeBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
nodeCountBase?: number;
connectionDist?: number;
speed?: number;
opacity?: number;
};
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
nodeCountBase?: number;
connectionDist?: number;
speed?: number;
opacity?: number;
};
}
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const nodeCountBase = settings?.nodeCountBase ?? 60;
const connectionDist = settings?.connectionDist ?? 140;
const speed = settings?.speed ?? 0.005;
const globalOpacity = settings?.opacity ?? 0.35;
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
username,
palette,
ratingsCount,
followersCount,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
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 nodeCountBase = settings?.nodeCountBase ?? 60;
const connectionDist = settings?.connectionDist ?? 140;
const speed = settings?.speed ?? 0.005;
const globalOpacity = settings?.opacity ?? 0.35;
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 canvas = canvasRef.current;
if (!canvas) return;
const initNodes = (width: number, height: number) => {
const newNodes = [];
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 });
if (!ctx) return;
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);
}
};
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];
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
interface Node {
x: number;
y: number;
vx: number;
vy: number;
baseX: number;
baseY: number;
color: string;
size: number;
const 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 = () => {
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[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
// Damping (Friction) - stronger damping for less "fast" feel
node.vx *= 0.88;
node.vy *= 0.88;
const initNodes = (width: number, height: number) => {
const newNodes = [];
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
});
// Update Position
node.x += node.vx;
node.y += node.vy;
});
// Draw Mesh (Connections)
ctx.lineWidth = 1;
for (let i = 0; i < nodes.length; i++) {
let currentConnections = 0;
// 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 = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
// 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();
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;
// 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();
});
// 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;
}
}
animationFrameId = requestAnimationFrame(draw);
};
// Damping (Friction) - stronger damping for less "fast" feel
node.vx *= 0.88;
node.vy *= 0.88;
draw();
// Update Position
node.x += node.vx;
node.y += node.vy;
});
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,
]);
// Draw Mesh (Connections)
ctx.lineWidth = 1;
for (let i = 0; i < nodes.length; i++) {
let currentConnections = 0;
// 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;
}
}
}
}
}
}
// 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'
}}
/>
);
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;
+211 -189
View File
@@ -2,222 +2,244 @@ import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
interface GenerativeFlowProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
blobCount?: number;
flowSpeed?: number;
vibrancy?: number;
blur?: number;
distortion?: number;
};
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
blobCount?: number;
flowSpeed?: number;
vibrancy?: number;
blur?: number;
distortion?: number;
};
}
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
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 canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
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 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) => {
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 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]);
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`;
blobs = initBlobs(rect.width, rect.height);
}
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
const 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;
};
let animationFrameId: number;
let time = 0;
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mouseleave', () => {
mouse.x = -1000;
mouse.y = -1000;
});
}
interface Blob {
x: number;
y: number;
ox: number;
oy: number;
vx: number;
vy: number;
size: number;
color: string;
phase: number;
speedFactor: number;
}
const draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
let blobs: Blob[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
ctx.clearRect(0, 0, width, height);
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;
};
// 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);
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`;
blobs = initBlobs(rect.width, rect.height);
}
};
time += flowSpeed;
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
// Apply filter for "Liquid" look
ctx.filter = `blur(${blurAmount}px)`;
ctx.globalCompositeOperation = 'screen';
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;
};
blobs.forEach((b) => {
// Harmonic autonomous drift
const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5);
const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
}
// 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 draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
const mX = dxMouse * mouseForce * distortionFactor;
const mY = dyMouse * mouseForce * distortionFactor;
ctx.clearRect(0, 0, width, height);
// 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);
b.x = b.ox + driftX + mX;
b.y = b.oy + driftY + mY;
time += flowSpeed;
// Pulsing size
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
const currentSize = b.size * pulse;
// Apply filter for "Liquid" look
ctx.filter = `blur(${blurAmount}px)`;
ctx.globalCompositeOperation = 'screen';
const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, currentSize);
grad.addColorStop(0, alpha(b.color, vibrancy));
grad.addColorStop(1, alpha(b.color, 0));
blobs.forEach((b) => {
// Harmonic autonomous drift
const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5);
const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
// 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;
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(b.x, b.y, currentSize, 0, Math.PI * 2);
ctx.fill();
});
b.x = b.ox + driftX + mX;
b.y = b.oy + driftY + mY;
ctx.filter = 'none';
ctx.globalCompositeOperation = 'source-over';
// Pulsing size
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
const currentSize = b.size * pulse;
animationFrameId = requestAnimationFrame(draw);
};
const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, currentSize);
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();
});
draw();
ctx.filter = 'none';
ctx.globalCompositeOperation = 'source-over';
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]);
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [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
}}
/>
);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1,
}}
/>
);
};
export default GenerativeFlowBanner;
+267 -238
View File
@@ -2,268 +2,297 @@ import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
interface HextechBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
gridSize?: number;
pulseSpeed?: number;
glowIntensity?: number;
streamCount?: number;
distortion?: number;
};
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
gridSize?: number;
pulseSpeed?: number;
glowIntensity?: number;
streamCount?: number;
distortion?: number;
};
}
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
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 canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
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 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) => {
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(() => {
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]);
let streams: Stream[] = [];
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const createStream = (width: number, height: number) => {
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
const x = pseudoRandom(time + 1) * width;
const y = pseudoRandom(time + 2) * height;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
return {
segments: [{ x, y }],
color,
life: 1.0,
speed: 2 + pseudoRandom(time + 3) * 4,
};
};
let animationFrameId: number;
let time = 0;
const drawHexagon = (x: number, y: number, r: number, tilt: number) => {
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 x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
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`;
}
};
// Mana Stream Logic
interface Stream {
segments: { x: number, y: number }[];
color: string;
life: number;
speed: number;
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const 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 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) => {
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
let x = pseudoRandom(time + 1) * width;
let y = pseudoRandom(time + 2) * height;
return {
segments: [{ x, y }],
color,
life: 1.0,
speed: 2 + pseudoRandom(time + 3) * 4
};
};
ctx.globalCompositeOperation = 'screen';
streams.forEach((s, idx) => {
s.life -= 0.005;
const last = s.segments[s.segments.length - 1];
const drawHexagon = (x: number, y: number, r: number, tilt: number) => {
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 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;
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`;
}
};
s.segments.push({ x: nextX, y: nextY });
if (s.segments.length > 20) s.segments.shift();
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
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();
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 handleMouseDown = () => { mouse.down = true; };
const handleMouseUp = () => { mouse.down = false; };
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';
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; });
}
streams = streams.filter((s) => s.life > 0);
const draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
animationFrameId = requestAnimationFrame(draw);
};
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);
draw();
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
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);
}
}
}
// 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
}}
/>
);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1,
}}
/>
);
};
export default HextechBanner;
@@ -2,257 +2,287 @@ import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
interface HextechCorruptionBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
cellCount?: number;
shimmerSpeed?: number;
glowIntensity?: number;
instability?: number;
shellDarkness?: number;
};
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
cellCount?: number;
shimmerSpeed?: number;
glowIntensity?: number;
instability?: number;
shellDarkness?: number;
};
}
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const cellCount = settings?.cellCount ?? 40;
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
const glowIntensity = settings?.glowIntensity ?? 0.9;
const instability = settings?.instability ?? 0.6;
const shellDarkness = settings?.shellDarkness ?? 0.85;
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
username,
palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
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 cellCount = settings?.cellCount ?? 40;
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
const glowIntensity = settings?.glowIntensity ?? 0.9;
const instability = settings?.instability ?? 0.6;
const shellDarkness = settings?.shellDarkness ?? 0.85;
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 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]);
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`;
points = initPoints(rect.width, rect.height);
}
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
const 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 handleMouseDown = () => {
mouse.down = true;
};
const handleMouseUp = () => {
mouse.down = false;
};
let animationFrameId: number;
let time = 0;
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;
});
}
interface ControlPoint {
x: number;
y: number;
ox: number;
oy: number;
vx: number;
vy: number;
phase: number;
color: string;
}
const draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
let points: ControlPoint[] = [];
ctx.clearRect(0, 0, width, height);
time += shimmerSpeed;
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
// 1. Draw Under-Glow (Inner Cores)
ctx.globalCompositeOperation = 'source-over';
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 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 driftX = Math.sin(time + p.phase) * 20 * instability;
const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability;
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`;
points = initPoints(rect.width, rect.height);
}
};
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 resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
const glowSize = 60 + mouseForce * 40;
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 handleMouseDown = () => { mouse.down = true; };
const handleMouseUp = () => { mouse.down = false; };
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity));
grad.addColorStop(1, alpha(p.color, 0));
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; });
}
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
ctx.fill();
});
const draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
// 2. Draw Dark "Void" Shell (Voronoi-based cutout)
// We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, width, height);
time += shimmerSpeed;
// 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.
// 1. Draw Under-Glow (Inner Cores)
ctx.globalCompositeOperation = 'source-over';
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 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 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);
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
const glowSize = 60 + (mouseForce * 40);
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity));
grad.addColorStop(1, alpha(p.color, 0));
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
ctx.fill();
});
// 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);
// 2. Draw Dark "Void" Shell (Voronoi-based cutout)
// We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay
ctx.globalCompositeOperation = 'source-over';
// 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);
}
// Cut holes
offCtx.globalCompositeOperation = 'destination-out';
points.forEach((p) => {
const holeSize = 15 + Math.sin(time + p.phase) * 5 + instability * 10;
// 3. Add Iridescent Edge Highlights
ctx.globalCompositeOperation = 'screen';
ctx.lineWidth = 2;
points.forEach(p => {
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();
});
// Main hole
offCtx.beginPath();
offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2);
offCtx.fill();
ctx.globalCompositeOperation = 'source-over';
animationFrameId = requestAnimationFrame(draw);
};
// 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();
}
});
draw();
ctx.drawImage(offCanvas, 0, 0, width, height);
}
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, cellCount, shimmerSpeed, glowIntensity, instability, shellDarkness, theme]);
// 3. Add Iridescent Edge Highlights
ctx.globalCompositeOperation = 'screen';
ctx.lineWidth = 2;
points.forEach((p) => {
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();
});
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
}}
/>
);
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,
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;
+187 -178
View File
@@ -1,199 +1,208 @@
import React, { useState, useEffect, useRef } from 'react';
import {
TextField,
Popper,
Paper,
MenuList,
MenuItem,
Avatar,
ListItemAvatar,
ListItemText,
Box,
alpha
import {
TextField,
Popper,
Paper,
MenuList,
MenuItem,
Avatar,
ListItemAvatar,
ListItemText,
Box,
alpha,
} from '@mui/material';
import api from '../api';
import api from '../lib/api';
interface UserSuggestion {
id: number;
username: string;
avatar: string | null;
id: number;
username: string;
avatar: string | null;
}
interface MentionTextFieldProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
fullWidth?: boolean;
multiline?: boolean;
rows?: number;
size?: 'small' | 'medium';
onKeyDown?: (e: React.KeyboardEvent) => void;
value: string;
onChange: (value: string) => void;
placeholder?: string;
fullWidth?: boolean;
multiline?: boolean;
rows?: number;
size?: 'small' | 'medium';
onKeyDown?: (e: React.KeyboardEvent) => void;
}
const MentionTextField: React.FC<MentionTextFieldProps> = ({
value,
onChange,
placeholder,
fullWidth = true,
multiline = false,
rows,
size = 'small',
onKeyDown
value,
onChange,
placeholder,
fullWidth = true,
multiline = false,
rows,
size = 'small',
onKeyDown,
}) => {
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
// Fetch users for suggestions (we could cache this or search on demand)
const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]);
// Fetch users for suggestions (we could cache this or search on demand)
const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]);
useEffect(() => {
const fetchUsers = async () => {
try {
const res = await api.get('flavors/search/');
const users = (res.data || [])
.filter((item: any) => item.type === 'user')
.map((item: any) => ({
id: item.id,
username: item.name,
avatar: item.image_url
}));
setAllUsers(users);
} catch (err) {
console.error('Failed to fetch users for mentions');
}
};
fetchUsers();
}, []);
useEffect(() => {
const fetchUsers = async () => {
try {
const res = await api.get('flavors/search/');
const users = (res.data || [])
.filter((item: any) => item.type === 'user')
.map((item: any) => ({
id: item.id,
username: item.name,
avatar: item.image_url,
}));
setAllUsers(users);
} catch (err) {
console.error('Failed to fetch users for mentions');
}
};
fetchUsers();
}, []);
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
onChange(newValue);
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
onChange(newValue);
const selectionStart = e.target.selectionStart || 0;
const textBeforeCursor = newValue.substring(0, selectionStart);
const lastAtPos = textBeforeCursor.lastIndexOf('@');
const selectionStart = e.target.selectionStart || 0;
const textBeforeCursor = newValue.substring(0, selectionStart);
const lastAtPos = textBeforeCursor.lastIndexOf('@');
if (lastAtPos !== -1) {
const query = textBeforeCursor.substring(lastAtPos + 1);
// Ensure there's no space or newline between @ and the cursor
if (!query.includes(' ') && !query.includes('\n')) {
const filtered = allUsers.filter(u =>
u.username.toLowerCase().startsWith(query.toLowerCase())
);
setSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
setSelectedIndex(0);
setAnchorEl(inputRef.current);
return;
}
}
if (lastAtPos !== -1) {
const query = textBeforeCursor.substring(lastAtPos + 1);
// Ensure there's no space or newline between @ and the cursor
if (!query.includes(' ') && !query.includes('\n')) {
const filtered = allUsers.filter((u) =>
u.username.toLowerCase().startsWith(query.toLowerCase()),
);
setSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
setSelectedIndex(0);
setAnchorEl(inputRef.current);
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);
};
return;
}
}
if (onKeyDown) onKeyDown(e);
};
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);
return;
}
}
if (onKeyDown) onKeyDown(e);
};
return (
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}>
<TextField
inputRef={inputRef}
fullWidth={fullWidth}
multiline={multiline}
rows={rows}
size={size}
placeholder={placeholder}
value={value}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
autoComplete="off"
/>
<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>
);
return (
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}>
<TextField
inputRef={inputRef}
fullWidth={fullWidth}
multiline={multiline}
rows={rows}
size={size}
placeholder={placeholder}
value={value}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
autoComplete="off"
/>
<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;
+309 -268
View File
@@ -2,293 +2,334 @@ import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
interface NebulaBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
particleCount?: number;
speed?: number;
opacity?: number;
smearRadius?: number;
turbulence?: number;
returnSpeed?: number;
starDensity?: number;
};
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
particleCount?: number;
speed?: number;
opacity?: number;
smearRadius?: number;
turbulence?: number;
returnSpeed?: number;
starDensity?: number;
};
}
const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const particleCount = settings?.particleCount ?? 400;
const speed = settings?.speed ?? 0.0015;
const globalOpacity = settings?.opacity ?? 0.6;
const smearRadius = settings?.smearRadius ?? 120;
const turbulenceStrength = settings?.turbulence ?? 0.4;
const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel
const starDensity = settings?.starDensity ?? 150;
const NebulaBanner: React.FC<NebulaBannerProps> = ({
username,
palette,
ratingsCount,
followersCount,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
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 particleCount = settings?.particleCount ?? 400;
const speed = settings?.speed ?? 0.0015;
const globalOpacity = settings?.opacity ?? 0.6;
const smearRadius = settings?.smearRadius ?? 120;
const turbulenceStrength = settings?.turbulence ?? 0.4;
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(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Init Gas Clusters
const newParticles = [];
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 });
if (!ctx) return;
for (let i = 0; i < particleCount; i++) {
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;
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
newParticles.push({
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 {
x: number;
y: number;
baseX: number;
baseY: number;
vx: number;
vy: number;
size: number;
color: string;
alpha: number;
targetAlpha: number;
phase: number;
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`;
const scene = initScene(rect.width, rect.height);
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 {
x: number;
y: number;
size: number;
opacity: number;
}
// Smooth drift
p.vx += Math.sin(time + p.phase) * 0.03;
p.vy += Math.cos(time * 0.8 + p.phase) * 0.03;
let particles: Particle[] = [];
let stars: Star[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
// High friction for gas feel
p.vx *= 0.95;
p.vy *= 0.95;
p.x += p.vx;
p.y += p.vy;
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
});
}
if (p.alpha < p.targetAlpha) p.alpha += 0.001;
// Init Gas Clusters
const newParticles = [];
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
}));
// 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));
for (let i = 0; i < particleCount; i++) {
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;
ctx.fillStyle = grad;
ctx.globalCompositeOperation = 'screen';
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
});
newParticles.push({
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 };
};
ctx.globalCompositeOperation = 'source-over';
animationFrameId = requestAnimationFrame(draw);
};
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`;
const scene = initScene(rect.width, rect.height);
stars = scene.stars;
particles = scene.particles;
}
};
draw();
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
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,
]);
let 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;
}
}
// 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
}}
/>
);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1,
}}
/>
);
};
export default NebulaBanner;
+48 -45
View File
@@ -2,54 +2,57 @@ import React from 'react';
import { Box, Typography, useTheme } from '@mui/material';
interface RatingBadgeProps {
score: number;
size?: 'small' | 'medium' | 'large';
sx?: any;
score: number;
size?: 'small' | 'medium' | 'large';
sx?: any;
}
const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => {
const theme = useTheme();
const isLarge = size === 'large';
const isSmall = size === 'small';
return (
<Box sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
bgcolor: 'primary.main',
color: 'primary.contrastText',
px: isLarge ? 2 : (isSmall ? 1 : 1.5),
py: isLarge ? 0.5 : (isSmall ? 0.2 : 0.4),
borderRadius: 2,
minWidth: 'fit-content',
height: 'fit-content',
boxShadow: theme.palette.mode === 'dark'
? '0 4px 12px rgba(0,0,0,0.4)'
: '0 2px 8px rgba(0,0,0,0.1)',
...sx
}}>
<Typography
variant={isLarge ? "h5" : (isSmall ? "caption" : "subtitle1")}
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }}
>
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: isLarge ? '0.75rem' : (isSmall ? '0.55rem' : '0.65rem'),
fontWeight: '900',
opacity: 0.9,
lineHeight: 1,
mt: 0.2
}}
>
/ 10
</Typography>
</Box>
);
const theme = useTheme();
const isLarge = size === 'large';
const isSmall = size === 'small';
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
bgcolor: 'primary.main',
color: 'primary.contrastText',
px: isLarge ? 2 : isSmall ? 1 : 1.5,
py: isLarge ? 0.5 : isSmall ? 0.2 : 0.4,
borderRadius: 2,
minWidth: 'fit-content',
height: 'fit-content',
boxShadow:
theme.palette.mode === 'dark'
? '0 4px 12px rgba(0,0,0,0.4)'
: '0 2px 8px rgba(0,0,0,0.1)',
...sx,
}}
>
<Typography
variant={isLarge ? 'h5' : isSmall ? 'caption' : 'subtitle1'}
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }}
>
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: isLarge ? '0.75rem' : isSmall ? '0.55rem' : '0.65rem',
fontWeight: '900',
opacity: 0.9,
lineHeight: 1,
mt: 0.2,
}}
>
/ 10
</Typography>
</Box>
);
};
export default RatingBadge;
+30 -30
View File
@@ -3,41 +3,41 @@ import { Link } from 'react-router-dom';
import { useTheme, alpha } from '@mui/material';
interface RichTextProps {
text: string;
text: string;
}
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);
return (
<span>
{parts.map((part, i) => {
if (part.startsWith('@')) {
const username = part.substring(1);
return (
<Link
key={i}
to={`/profile/${username}`}
style={{
color: theme.palette.primary.main,
textDecoration: 'none',
fontWeight: 'bold',
backgroundColor: alpha(theme.palette.primary.main, 0.1),
padding: '0 4px',
borderRadius: '4px'
}}
>
{part}
</Link>
);
}
return part;
})}
</span>
);
const parts = text.split(/(@\w+)/g);
return (
<span>
{parts.map((part, i) => {
if (part.startsWith('@')) {
const username = part.substring(1);
return (
<Link
key={i}
to={`/profile/${username}`}
style={{
color: theme.palette.primary.main,
textDecoration: 'none',
fontWeight: 'bold',
backgroundColor: alpha(theme.palette.primary.main, 0.1),
padding: '0 4px',
borderRadius: '4px',
}}
>
{part}
</Link>
);
}
return part;
})}
</span>
);
};
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';
interface StatusBadgeProps {
isLegacy?: boolean;
isAvailable?: boolean;
size?: 'small' | 'medium';
sx?: any;
isLegacy?: boolean;
isAvailable?: boolean;
size?: 'small' | 'medium';
sx?: any;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size = 'medium', sx }) => {
const theme = useTheme();
const isSmall = size === 'small';
const StatusBadge: React.FC<StatusBadgeProps> = ({
isLegacy,
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 color = isLegacy ? theme.palette.warning.main : theme.palette.error.main;
const Icon = isLegacy ? HistoryIcon : BlockIcon;
const label = isLegacy ? 'Limited' : 'Out of Stock';
const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main;
const Icon = isLegacy ? HistoryIcon : BlockIcon;
return (
<Box sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
bgcolor: alpha(color, 0.9),
color: '#fff',
px: isSmall ? 0.8 : 1.2,
py: isSmall ? 0.2 : 0.4,
borderRadius: 1.5,
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
backdropFilter: 'blur(4px)',
border: '1px solid',
borderColor: alpha('#fff', 0.2),
...sx
}}>
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} />
<Typography
variant="caption"
sx={{
fontWeight: '900',
textTransform: 'uppercase',
letterSpacing: 0.5,
fontSize: isSmall ? '0.55rem' : '0.65rem',
lineHeight: 1
}}
>
{label}
</Typography>
</Box>
);
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
bgcolor: alpha(color, 0.9),
color: '#fff',
px: isSmall ? 0.8 : 1.2,
py: isSmall ? 0.2 : 0.4,
borderRadius: 1.5,
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
backdropFilter: 'blur(4px)',
border: '1px solid',
borderColor: alpha('#fff', 0.2),
...sx,
}}
>
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} />
<Typography
variant="caption"
sx={{
fontWeight: '900',
textTransform: 'uppercase',
letterSpacing: 0.5,
fontSize: isSmall ? '0.55rem' : '0.65rem',
lineHeight: 1,
}}
>
{label}
</Typography>
</Box>
);
};
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 { createRoot } from 'react-dom/client'
import { BrowserRouter as Router } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom';
import './index.css';
import App from './App.tsx';
import { AppProviders } from './app/providers';
import { clearLegacyToken } from './lib/api';
clearLegacyToken();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Router>
<App />
<AppProviders>
<App />
</AppProviders>
</Router>
</StrictMode>,
)
);
+82 -46
View File
@@ -12,10 +12,10 @@ const About: React.FC = () => {
return (
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}>
<Button
variant="outlined"
component={Link}
to="/"
<Button
variant="outlined"
component={Link}
to="/"
startIcon={<ArrowBackIcon />}
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }}
>
@@ -23,67 +23,103 @@ const About: React.FC = () => {
</Button>
<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="body1" paragraph sx={{ fontSize: { xs: '1rem', md: '1.1rem' }, lineHeight: 1.8 }}>
{t('about.mission')}
<Typography variant={isMobile ? 'h4' : 'h3'} gutterBottom sx={{ fontWeight: 'bold' }}>
{t('about.title')}
</Typography>
<Typography
variant="body1"
paragraph
sx={{ fontSize: { xs: '1rem', md: '1.1rem' }, lineHeight: 1.8 }}
>
{t('about.mission')}
</Typography>
<Box sx={{ my: 4 }}>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.canDoTitle')}</Typography>
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
<li>{t('about.featureExplore')}</li>
<li>{t('about.featureRate')}</li>
<li>{t('about.featureCommunity')}</li>
<li>{t('about.featureProfile')}</li>
<li>{t('about.featurePersonalize')}</li>
<li>{t('about.featureEngage')}</li>
<li>{t('about.featureSupport')}</li>
</Typography>
<Typography variant={isMobile ? 'h6' : 'h5'} gutterBottom sx={{ fontWeight: 'bold' }}>
{t('about.canDoTitle')}
</Typography>
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
<li>{t('about.featureExplore')}</li>
<li>{t('about.featureRate')}</li>
<li>{t('about.featureCommunity')}</li>
<li>{t('about.featureProfile')}</li>
<li>{t('about.featurePersonalize')}</li>
<li>{t('about.featureEngage')}</li>
<li>{t('about.featureSupport')}</li>
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" paragraph>
{t('about.disclaimer')}
{t('about.disclaimer')}
</Typography>
<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 }}>
<strong>{t('about.legalInfo')}</strong><br />
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}<br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br />
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}<br />
{t('about.region')}
<strong>{t('about.legalInfo')}</strong>
<br />
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}
<br />
{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>
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.contact')}:</Typography>
<Typography variant="body2">
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}<br />
{import.meta.env.VITE_IMPRESSUM_PHONE && (
<>Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}<br /></>
)}
Internet: holy.narl.io
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
{t('about.contact')}:
</Typography>
<Typography variant="body2">
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}
<br />
{import.meta.env.VITE_IMPRESSUM_PHONE && (
<>
Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}
<br />
</>
)}
Internet: holy.narl.io
</Typography>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.responsible')}</Typography>
<Typography variant="body2">
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br />
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
{t('about.responsible')}
</Typography>
<Typography variant="body2">
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}
<br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}
<br />
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
</Typography>
</Box>
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.disclaimerTitle')}</Typography>
<Typography variant="caption" color="text.secondary" 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>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
{t('about.disclaimerTitle')}
</Typography>
<Typography
variant="caption"
color="text.secondary"
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>
</Paper>
</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