refactor: centralize colors and add semantic status colors

This commit is contained in:
FuzzyGrim
2025-11-28 22:08:54 +01:00
parent 0fbfe70832
commit b51285a3a6
12 changed files with 134 additions and 81 deletions

View File

@@ -1,7 +1,20 @@
from django.urls import reverse
from django.utils.http import urlencode
from app.models import MediaTypes, Sources
from app.models import MediaTypes, Sources, Status
# --- Color Constants ---
COLORS = {
"emerald": {"text": "text-emerald-400", "hex": "#10b981"},
"purple": {"text": "text-purple-400", "hex": "#a855f7"},
"indigo": {"text": "text-indigo-400", "hex": "#6366f1"},
"orange": {"text": "text-orange-400", "hex": "#f97316"},
"blue": {"text": "text-blue-400", "hex": "#3b82f6"},
"red": {"text": "text-red-400", "hex": "#ef4444"},
"yellow": {"text": "text-yellow-400", "hex": "#eab308"},
"fuchsia": {"text": "text-fuchsia-400", "hex": "#d946ef"},
"cyan": {"text": "text-cyan-400", "hex": "#06b6d4"},
}
# --- Central Configuration Dictionary ---
MEDIA_TYPE_CONFIG = {
@@ -11,8 +24,8 @@ MEDIA_TYPE_CONFIG = {
"sample_query": "Breaking Bad",
"unicode_icon": "📺",
"verb": ("watch", "watched"),
"text_color": "text-emerald-400",
"stats_color": "#10b981",
"text_color": COLORS["emerald"]["text"],
"stats_color": COLORS["emerald"]["hex"],
"svg_icon": """
<rect width="20" height="15" x="2" y="7" rx="2" ry="2"/>
<polyline points="17 2 12 7 7 2"/>""",
@@ -22,8 +35,8 @@ MEDIA_TYPE_CONFIG = {
"default_source": Sources.TMDB,
"unicode_icon": "📺",
"verb": ("watch", "watched"),
"text_color": "text-purple-400",
"stats_color": "#a855f7",
"text_color": COLORS["purple"]["text"],
"stats_color": COLORS["purple"]["hex"],
"svg_icon": """
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0
1.83l8.58 3.91 a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/>
@@ -36,8 +49,8 @@ MEDIA_TYPE_CONFIG = {
"default_source": Sources.TMDB,
"unicode_icon": "📺",
"verb": ("watch", "watched"),
"text_color": "text-indigo-400",
"stats_color": "#6366f1",
"text_color": COLORS["indigo"]["text"],
"stats_color": COLORS["indigo"]["hex"],
"svg_icon": """<polygon points="6 3 20 12 6 21 6 3"/>""",
},
MediaTypes.MOVIE.value: {
@@ -46,8 +59,8 @@ MEDIA_TYPE_CONFIG = {
"sample_query": "The Shawshank Redemption",
"unicode_icon": "🎬",
"verb": ("watch", "watched"),
"text_color": "text-orange-400",
"stats_color": "#f97316",
"text_color": COLORS["orange"]["text"],
"stats_color": COLORS["orange"]["hex"],
"svg_icon": """
<rect width="18" height="18" x="3" y="3" rx="2"/>
<path d="M7 3v18"/>
@@ -65,8 +78,8 @@ MEDIA_TYPE_CONFIG = {
"sample_query": "Perfect Blue",
"unicode_icon": "🎭",
"verb": ("watch", "watched"),
"text_color": "text-blue-400",
"stats_color": "#3b82f6",
"text_color": COLORS["blue"]["text"],
"stats_color": COLORS["blue"]["hex"],
"svg_icon": """
<circle cx="12" cy="12" r="10"/>
<polygon points="10 8 16 12 10 16 10 8"/>""",
@@ -79,8 +92,8 @@ MEDIA_TYPE_CONFIG = {
"sample_query": "Berserk",
"unicode_icon": "📚",
"verb": ("read", "read"),
"text_color": "text-red-400",
"stats_color": "#ef4444",
"text_color": COLORS["red"]["text"],
"stats_color": COLORS["red"]["hex"],
"svg_icon": """
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2
0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
@@ -97,8 +110,8 @@ MEDIA_TYPE_CONFIG = {
"sample_query": "Half-Life",
"unicode_icon": "🎮",
"verb": ("play", "played"),
"text_color": "text-yellow-400",
"stats_color": "#eab308",
"text_color": COLORS["yellow"]["text"],
"stats_color": COLORS["yellow"]["hex"],
"svg_icon": """
<line x1="6" x2="10" y1="11" y2="11"/>
<line x1="8" x2="8" y1="9" y2="13"/>
@@ -119,8 +132,8 @@ MEDIA_TYPE_CONFIG = {
"sample_query": "The Great Gatsby",
"unicode_icon": "📖",
"verb": ("read", "read"),
"text_color": "text-fuchsia-400",
"stats_color": "#d946ef",
"text_color": COLORS["fuchsia"]["text"],
"stats_color": COLORS["fuchsia"]["hex"],
"svg_icon": """
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5
2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/>""",
@@ -133,8 +146,8 @@ MEDIA_TYPE_CONFIG = {
"sample_query": "Batman",
"unicode_icon": "📕",
"verb": ("read", "read"),
"text_color": "text-cyan-400",
"stats_color": "#06b6d4",
"text_color": COLORS["cyan"]["text"],
"stats_color": COLORS["cyan"]["hex"],
"svg_icon": """
<rect width="8" height="18" x="3" y="3" rx="1"/>
<path d="M7 3v18"/>
@@ -144,6 +157,30 @@ MEDIA_TYPE_CONFIG = {
},
}
# --- Status Configuration ---
STATUS_CONFIG = {
Status.COMPLETED.value: {
"text_color": COLORS["emerald"]["text"],
"stats_color": COLORS["emerald"]["hex"],
},
Status.IN_PROGRESS.value: {
"text_color": COLORS["indigo"]["text"],
"stats_color": COLORS["indigo"]["hex"],
},
Status.PAUSED.value: {
"text_color": COLORS["orange"]["text"],
"stats_color": COLORS["orange"]["hex"],
},
Status.PLANNING.value: {
"text_color": COLORS["blue"]["text"],
"stats_color": COLORS["blue"]["hex"],
},
Status.DROPPED.value: {
"text_color": COLORS["red"]["text"],
"stats_color": COLORS["red"]["hex"],
},
}
def get_config(media_type):
"""Get the full config dictionary for a media type."""
@@ -222,3 +259,31 @@ def get_unit(media_type, short):
"""Get the unit of measurement (e.g., episode, chapter)."""
unit = get_property(media_type, "unit")
return unit[0] if short else unit[1] if unit else None
def get_status_config(status):
"""Get the full config dictionary for a status."""
return STATUS_CONFIG.get(status)
def get_status_property(status, prop_name):
"""Get a specific property for a status."""
config = get_status_config(status)
if config is None:
msg = f"Status '{status}' not found in configuration."
raise KeyError(msg)
try:
return config[prop_name]
except KeyError:
msg = f"Property '{prop_name}' not found for status '{status}'."
raise KeyError(msg) from None
def get_status_text_color(status):
"""Get the text color class for a status."""
return get_status_property(status, "text_color")
def get_status_stats_color(status):
"""Get the stats color for a status."""
return get_status_property(status, "stats_color")

View File

@@ -1,7 +1,7 @@
from django import forms
from django.conf import settings
from app import media_type_config
from app import config
from app.models import (
TV,
Anime,
@@ -236,7 +236,7 @@ class MangaForm(MediaForm):
labels = {
"progress": (
f"Progress "
f"({media_type_config.get_unit(MediaTypes.MANGA.value, short=False)}s)"
f"({config.get_unit(MediaTypes.MANGA.value, short=False)}s)"
),
}
@@ -291,7 +291,7 @@ class BookForm(MediaForm):
labels = {
"progress": (
f"Progress "
f"({media_type_config.get_unit(MediaTypes.BOOK.value, short=False)}s)"
f"({config.get_unit(MediaTypes.BOOK.value, short=False)}s)"
),
}
@@ -306,7 +306,7 @@ class ComicForm(MediaForm):
labels = {
"progress": (
f"Progress "
f"({media_type_config.get_unit(MediaTypes.COMIC.value, short=False)}s)"
f"({config.get_unit(MediaTypes.COMIC.value, short=False)}s)"
),
}

View File

@@ -1,7 +1,7 @@
from django.apps import apps
from django.template.defaultfilters import pluralize
from app import helpers, media_type_config
from app import config, helpers
from app.models import MediaTypes, Status
from app.templatetags import app_tags
@@ -204,7 +204,7 @@ def format_description(field_name, old_value, new_value, media_type=None): # no
# If old_value is None, treat it as an initial setting
if old_value is None:
if field_name == "status":
verb = media_type_config.get_verb(media_type, past_tense=False)
verb = config.get_verb(media_type, past_tense=False)
action = "Marked as"
if new_value == Status.IN_PROGRESS.value:
return f"{action} currently {verb}ing"
@@ -221,10 +221,10 @@ def format_description(field_name, old_value, new_value, media_type=None): # no
return f"Rated {new_value}/10"
if field_name == "progress" and media_type:
verb = media_type_config.get_verb(media_type, past_tense=True).title()
verb = config.get_verb(media_type, past_tense=True).title()
if media_type == MediaTypes.GAME.value:
return f"{verb} for {helpers.minutes_to_hhmm(new_value)}"
unit = media_type_config.get_unit(media_type, short=False).lower()
unit = config.get_unit(media_type, short=False).lower()
return f"{verb} up to {unit} {new_value}"
if field_name in ["start_date", "end_date"]:
@@ -238,7 +238,7 @@ def format_description(field_name, old_value, new_value, media_type=None): # no
# Regular change (old_value to new_value)
if field_name == "status":
verb = media_type_config.get_verb(media_type, past_tense=False)
verb = config.get_verb(media_type, past_tense=False)
# Status transitions
transitions = {
(
@@ -282,7 +282,7 @@ def format_description(field_name, old_value, new_value, media_type=None): # no
return f"Removed {helpers.minutes_to_hhmm(diff_abs)} of playtime"
unit = (
f"{media_type_config.get_unit(media_type, short=False).lower()}"
f"{config.get_unit(media_type, short=False).lower()}"
f"{pluralize(new_value)}"
)

View File

@@ -14,7 +14,7 @@ from django.db.models import (
)
from django.utils import timezone
from app import media_type_config
from app import config
from app.models import TV, BasicMedia, Episode, MediaManager, MediaTypes, Season, Status
from app.templatetags import app_tags
@@ -148,7 +148,7 @@ def get_media_type_distribution(media_count):
chart_data["labels"].append(label)
chart_data["datasets"][0]["data"].append(count)
chart_data["datasets"][0]["backgroundColor"].append(
media_type_config.get_stats_color(media_type),
config.get_stats_color(media_type),
)
return chart_data
@@ -267,7 +267,7 @@ def get_score_distribution(user_media):
{
"label": app_tags.media_type_readable(media_type),
"data": [distribution[media_type][score] for score in score_range],
"background_color": media_type_config.get_stats_color(media_type),
"background_color": config.get_stats_color(media_type),
}
for media_type in distribution
],
@@ -312,24 +312,10 @@ def _annotate_top_rated_media(top_rated_media):
def get_status_color(status):
"""Get the color for the status of the media."""
colors = {
Status.IN_PROGRESS.value: media_type_config.get_stats_color(
MediaTypes.EPISODE.value,
),
Status.COMPLETED.value: media_type_config.get_stats_color(
MediaTypes.TV.value,
),
Status.PLANNING.value: media_type_config.get_stats_color(
MediaTypes.ANIME.value,
),
Status.PAUSED.value: media_type_config.get_stats_color(
MediaTypes.MOVIE.value,
),
Status.DROPPED.value: media_type_config.get_stats_color(
MediaTypes.MANGA.value,
),
}
return colors.get(status, "rgba(201, 203, 207)")
try:
return config.get_status_stats_color(status)
except KeyError:
return "rgba(201, 203, 207)"
def get_timeline(user_media):

View File

@@ -7,7 +7,7 @@ from django.utils import formats, timezone
from django.utils.html import format_html
from unidecode import unidecode
from app import media_type_config
from app import config
from app.models import MediaTypes, Sources, Status
register = template.Library()
@@ -109,37 +109,37 @@ def media_status_readable(media_status):
@register.filter
def default_source(media_type):
"""Return the default source for the media type."""
return media_type_config.get_default_source_name(media_type).label
return config.get_default_source_name(media_type).label
@register.filter
def media_past_verb(media_type):
"""Return the past tense verb for the given media type."""
return media_type_config.get_verb(media_type, past_tense=True)
return config.get_verb(media_type, past_tense=True)
@register.filter
def sample_search(media_type):
"""Return a sample search URL for the given media type using GET parameters."""
return media_type_config.get_sample_search_url(media_type)
return config.get_sample_search_url(media_type)
@register.filter
def short_unit(media_type):
"""Return the short unit for the media type."""
return media_type_config.get_unit(media_type, short=True)
return config.get_unit(media_type, short=True)
@register.filter
def long_unit(media_type):
"""Return the long unit for the media type."""
return media_type_config.get_unit(media_type, short=False)
return config.get_unit(media_type, short=False)
@register.filter
def sources(media_type):
"""Template filter to get source options for a media type."""
return media_type_config.get_sources(media_type)
return config.get_sources(media_type)
@register.simple_tag
@@ -178,7 +178,13 @@ def get_sidebar_media_types(user):
@register.filter
def media_color(media_type):
"""Return the color associated with the media type."""
return media_type_config.get_text_color(media_type)
return config.get_text_color(media_type)
@register.filter
def status_color(status):
"""Return the color associated with the status."""
return config.get_status_text_color(status)
@register.filter
@@ -300,7 +306,7 @@ def component_id(component_type, media, instance_id=None):
@register.simple_tag
def unicode_icon(name):
"""Return the Unicode icon for the media type."""
return media_type_config.get_unicode_icon(name)
return config.get_unicode_icon(name)
@register.simple_tag
@@ -319,7 +325,7 @@ def icon(name, is_active, extra_classes="w-5 h-5"):
{content}
</svg>"""
content = media_type_config.get_svg_icon(name)
content = config.get_svg_icon(name)
active_class = "text-indigo-400 " if is_active else ""
svg = base_svg.format(

View File

@@ -1,6 +1,6 @@
from django.test import TestCase
from app import media_type_config
from app import config
from app.history_processor import format_description
from app.models import MediaTypes, Status
@@ -14,8 +14,8 @@ class HistoryProcessorTests(TestCase):
for media_type in MediaTypes:
# Ensure both present and past tense verbs are defined
try:
media_type_config.get_verb(media_type.value, past_tense=False)
media_type_config.get_verb(media_type.value, past_tense=True)
config.get_verb(media_type.value, past_tense=False)
config.get_verb(media_type.value, past_tense=True)
except KeyError:
self.fail(f"Media type {media_type.name} not defined in get_verb")

View File

@@ -723,10 +723,6 @@ class StatisticsTests(TestCase):
self.assertIsNotNone(color)
self.assertTrue(color.startswith("#"))
# Test unknown status
unknown_color = statistics.get_status_color("unknown")
self.assertEqual(unknown_color, "rgba(201, 203, 207)")
def test_get_timeline(self):
"""Test the get_timeline function."""
# Create user_media dict with our test objects

View File

@@ -15,7 +15,7 @@ from django.utils.dateparse import parse_date
from django.utils.timezone import datetime
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from app import helpers, history_processor, media_type_config
from app import config, helpers, history_processor
from app import statistics as stats
from app.forms import EpisodeForm, ManualItemForm, get_form_class
from app.models import TV, BasicMedia, Item, MediaTypes, Season, Sources, Status
@@ -171,7 +171,7 @@ def media_search(request):
# only receives source when searching with secondary source
source = request.GET.get(
"source",
media_type_config.get_default_source_name(media_type).value,
config.get_default_source_name(media_type).value,
)
data = services.search(media_type, query, page, source)

View File

@@ -7,7 +7,7 @@ from django.core.cache import cache
from django.db.models import Exists, OuterRef, Q, Subquery
from django.utils import timezone
from app import media_type_config
from app import config
from app.models import Item, MediaTypes, Sources
from app.providers import comicvine, services, tmdb
from events.models import Event, SentinelDatetime
@@ -718,7 +718,7 @@ def process_other(item, events_bulk):
)
return
date_key = media_type_config.get_date_key(item.media_type)
date_key = config.get_date_key(item.media_type)
if date_key in metadata["details"] and metadata["details"][date_key]:
try:

View File

@@ -14,7 +14,7 @@ from django.db.models import (
)
from django.utils import timezone
from app import media_type_config
from app import config
from app.models import TV, Item, MediaTypes, Season, Status
# Statuses that represent inactive tracking
@@ -202,7 +202,7 @@ class Event(models.Model):
if self.content_number:
return (
f"{self.item.__str__()} "
f"{media_type_config.get_unit(self.item.media_type, short=True)}"
f"{config.get_unit(self.item.media_type, short=True)}"
f"{self.content_number}"
)
@@ -215,7 +215,7 @@ class Event(models.Model):
return ""
return (
f"{media_type_config.get_unit(self.item.media_type, short=True)}"
f"{config.get_unit(self.item.media_type, short=True)}"
f"{self.content_number}"
)

View File

@@ -7,7 +7,7 @@ from django.conf import settings
from django.utils.dateparse import parse_datetime
import app
from app import media_type_config
from app import config
from app.models import MediaTypes, Sources
from app.providers import services
from app.templatetags import app_tags
@@ -182,7 +182,7 @@ class YamtrackImporter:
if row.get("title", "") != "":
source = row.get("source", "")
if source == "":
source = media_type_config.get_default_source_name(media_type).value
source = config.get_default_source_name(media_type).value
metadata = services.search(
media_type,

View File

@@ -11,27 +11,27 @@
{% if media.status %}
<div class="absolute top-2 left-2 bg-gray-900/90 text-white text-xs px-2 py-1 rounded-md flex items-center shadow-md">
{% if media.status == Status.COMPLETED.value %}
<div class="w-4 h-4 {{ MediaTypes.TV.value|media_color }} {% if media.start_date or media.end_date %}mr-1.5{% endif %}">
<div class="w-4 h-4 {{ Status.COMPLETED.value|status_color }} {% if media.start_date or media.end_date %}mr-1.5{% endif %}">
{% include "app/icons/states/completed.svg" %}
</div>
{% elif media.status == Status.IN_PROGRESS.value %}
<div class="w-4 h-4 {{ MediaTypes.EPISODE.value|media_color }} {% if media.start_date or media.end_date %}mr-1.5{% endif %}">
<div class="w-4 h-4 {{ Status.IN_PROGRESS.value|status_color }} {% if media.start_date or media.end_date %}mr-1.5{% endif %}">
{% include "app/icons/states/in-progress.svg" %}
</div>
{% elif media.status == Status.PAUSED.value %}
<div class="w-4 h-4 {{ MediaTypes.MOVIE.value|media_color }} {% if media.start_date or media.end_date %}mr-1.5{% endif %}">
<div class="w-4 h-4 {{ Status.PAUSED.value|status_color }} {% if media.start_date or media.end_date %}mr-1.5{% endif %}">
{% include "app/icons/states/paused.svg" %}
</div>
{% elif media.status == Status.PLANNING.value %}
<div class="w-4 h-4 {{ MediaTypes.ANIME.value|media_color }} {% if media.start_date or media.end_date %}mr-1.5{% endif %}">
<div class="w-4 h-4 {{ Status.PLANNING.value|status_color }} {% if media.start_date or media.end_date %}mr-1.5{% endif %}">
{% include "app/icons/states/planning.svg" %}
</div>
{% elif media.status == Status.DROPPED.value %}
<div class="w-4 h-4 {{ MediaTypes.MANGA.value|media_color }} {% if media.start_date or media.end_date %}mr-1.5{% endif %}">
<div class="w-4 h-4 {{ Status.DROPPED.value|status_color }} {% if media.start_date or media.end_date %}mr-1.5{% endif %}">
{% include "app/icons/states/dropped.svg" %}
</div>
{% endif %}