diff --git a/requirements.txt b/requirements.txt index 745a33b..f5a827e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,8 @@ celery cssselect==1.3.0 Django==6.0 dj-database-url +django-compressor +django-libsass django-stubs==5.2.8 django-stubs-ext==5.2.8 djangorestframework diff --git a/src/apps/dashboard/tests/integrated/test_views.py b/src/apps/dashboard/tests/integrated/test_views.py index 30c4586..9311471 100644 --- a/src/apps/dashboard/tests/integrated/test_views.py +++ b/src/apps/dashboard/tests/integrated/test_views.py @@ -255,3 +255,50 @@ class ViewAuthListTest(TestCase): self.client.force_login(guest) response = self.client.get(reverse("view_list", args=[self.our_list.id])) self.assertEqual(response.status_code, 200) + +class SetThemeTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="a@b.cde") + self.client.force_login(self.user) + self.url = reverse("home") + + def test_anonymous_user_is_redirected_home(self): + response = self.client.post("/dashboard/set_theme") + self.assertRedirects(response, "/") + + def test_set_theme_updates_user_theme(self): + User.objects.filter(pk=self.user.pk).update(theme="theme-sheol") + self.client.post("/dashboard/set_theme", data={"theme": "theme-default"}) + self.user.refresh_from_db() + self.assertEqual(self.user.theme, "theme-default") + + def test_locked_theme_is_rejected(self): + response = self.client.post("/dashboard/set_theme", data={"theme": "theme-nirvana"}) + self.user.refresh_from_db() + self.assertEqual(self.user.theme, "theme-default") + self.assertRedirects(response, "/") + + def test_set_theme_redirects_home(self): + response = self.client.post("/dashboard/set_theme", data={"theme": "theme-default"}) + self.assertRedirects(response, "/") + + def test_my_lists_contains_set_theme_form(self): + response = self.client.get(self.url) + parsed = lxml.html.fromstring(response.content) + forms = parsed.cssselect('form[action="/dashboard/set_theme"]') + self.assertEqual(len(forms), 1) + + def test_active_theme_swatch_has_active_class(self): + response = self.client.get(self.url) + parsed = lxml.html.fromstring(response.content) + [active] = parsed.cssselect(".swatch.active") + self.assertIn("theme-default", active.classes) + + def test_locked_themes_are_not_forms(self): + response = self.client.get(self.url) + parsed = lxml.html.fromstring(response.content) + locked = parsed.cssselect(".swatch.locked") + self.assertEqual(len(locked), 2) + # they mustn't be button els + for swatch in locked: + self.assertNotEqual(swatch.tag, "button") diff --git a/src/apps/dashboard/urls.py b/src/apps/dashboard/urls.py index cefd498..62d7af8 100644 --- a/src/apps/dashboard/urls.py +++ b/src/apps/dashboard/urls.py @@ -6,4 +6,5 @@ urlpatterns = [ path('list//', views.view_list, name='view_list'), path('users//', views.my_lists, name='my_lists'), path('list//share_list', views.share_list, name="share_list"), + path('set_theme', views.set_theme, name='set_theme'), ] diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index fb11257..0e1997a 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -7,6 +7,9 @@ from .models import Item, List from apps.lyric.models import User +UNLOCKED_THEMES = frozenset(["theme-default"]) + + def home_page(request): return render(request, "apps/dashboard/home.html", {"form": ItemForm()}) @@ -59,3 +62,13 @@ def share_list(request, list_id): pass messages.success(request, "An invite has been sent if that address is registered.") return redirect(our_list) + +def set_theme(request): + if not request.user.is_authenticated: + return redirect("home") + if request.method == "POST": + theme = request.POST.get("theme", "") + if theme in UNLOCKED_THEMES: + request.user.theme = theme + request.user.save(update_fields=["theme"]) + return redirect("home") diff --git a/src/apps/lyric/migrations/0003_user_theme.py b/src/apps/lyric/migrations/0003_user_theme.py new file mode 100644 index 0000000..318887d --- /dev/null +++ b/src/apps/lyric/migrations/0003_user_theme.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-03-02 04:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lyric', '0002_user_searchable_user_username'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='theme', + field=models.CharField(default='theme-default', max_length=32), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index 39e798d..efb92da 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -26,6 +26,8 @@ class User(AbstractBaseUser): email = models.EmailField(unique=True) username = models.CharField(max_length=35, unique=True, null=True, blank=True) searchable = models.BooleanField(default=False) + theme = models.CharField(max_length=32, default="theme-default") + is_staff = models.BooleanField(default=False) is_superuser = models.BooleanField(default=False) diff --git a/src/apps/lyric/tests/integrated/test_models.py b/src/apps/lyric/tests/integrated/test_models.py index cc4a9fb..ce5240d 100644 --- a/src/apps/lyric/tests/integrated/test_models.py +++ b/src/apps/lyric/tests/integrated/test_models.py @@ -50,3 +50,8 @@ class UserManagerTest(TestCase): password="correct-password", ) self.assertTrue(user.check_password("correct-password")) + +class UserThemeTest(TestCase): + def test_theme_field_defaults_to_theme_default(self): + user = User.objects.create(email="a@b.cde") + self.assertEqual(user.theme, "theme-default") diff --git a/src/core/context_processors.py b/src/core/context_processors.py new file mode 100644 index 0000000..d7e1fd1 --- /dev/null +++ b/src/core/context_processors.py @@ -0,0 +1,4 @@ +def user_theme(request): + if request.user.is_authenticated: + return {"user_theme": request.user.theme} + return {"user_theme": "theme-default"} \ No newline at end of file diff --git a/src/core/settings.py b/src/core/settings.py index a05ac4f..1c5f3ec 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -57,6 +57,7 @@ INSTALLED_APPS = [ 'apps.api', 'functional_tests', # Depend apps + 'compressor', 'rest_framework', ] @@ -86,6 +87,7 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'core.context_processors.user_theme', ], }, }, @@ -167,6 +169,14 @@ STATIC_ROOT = BASE_DIR / 'static' STATICFILES_DIRS = [ BASE_DIR / 'static_src', ] +STATICFILES_FINDERS = [ + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + 'compressor.finders.CompressorFinder', +] +COMPRESS_PRECOMPILERS = [ + ('text/x-scss', 'django_libsass.SassCompiler'), +] LOGGING = { "version": 1, diff --git a/src/functional_tests/test_theme.py b/src/functional_tests/test_theme.py new file mode 100644 index 0000000..d12cd70 --- /dev/null +++ b/src/functional_tests/test_theme.py @@ -0,0 +1,10 @@ +from selenium.webdriver.common.by import By + +from .base import FunctionalTest + + +class SiteThemeTest(FunctionalTest): + def test_page_renders_with_earthman_theme(self): + self.browser.get(self.live_server_url) + body = self.browser.find_element(By.TAG_NAME, "body") + self.assertIn("theme-default", body.get_attribute("class")) diff --git a/src/static_src/scss/core.scss b/src/static_src/scss/core.scss new file mode 100644 index 0000000..503d6e2 --- /dev/null +++ b/src/static_src/scss/core.scss @@ -0,0 +1,9 @@ +@import 'rootvars'; + +input, +textarea, +select, +[contenteditable] { + user-select: text; + touch-action: auto; +} \ No newline at end of file diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss new file mode 100644 index 0000000..e5c1764 --- /dev/null +++ b/src/static_src/scss/rootvars.scss @@ -0,0 +1,473 @@ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + touch-action: none; +} + +:root { + /* rgb Variable Index */ + + /* Precious Metal Palette */ + // nickel + --priNi: 141, 142, 140; + --secNi: 118, 120, 118; + --terNi: 93, 95, 94; + --terNi: 0, 0, 0; + --quaNi: 0, 0, 0; + --quiNi: 0, 0, 0; + --sixNi: 0, 0, 0; + // palladium + --priPd: 188, 193, 165; + --secPd: 155, 160, 138; + --terPd: 124, 129, 111; + --quaPd: 0, 0, 0; + --quiPd: 0, 0, 0; + --sixPd: 0, 0, 0; + // platinum + --priPt: 229, 228, 226; + --secPt: 189, 190, 189; + --terPt: 152, 153, 153; + --quaPt: 0, 0, 0; + --quiPt: 0, 0, 0; + --sixPt: 0, 0, 0; + // titanium + --priTi: 38, 57, 69; + --secTi: 57, 79, 94; + --terTi: 75, 101, 119; + --quaTi: 91, 121, 142; + --quiTi: 124, 166, 191; + --sixTi: 159, 200, 224; + // gold (Sun) + --priAu: 61, 48, 2; + --secAu: 99, 80, 8; + --terAu: 148, 121, 24; + --quaAu: 181, 154, 54; + --quiAu: 214, 186, 84; + --sixAu: 237, 214, 130; + // silver (Moon) + --priAg: 30, 30, 30; + --secAg: 60, 60, 60; + --terAg: 100, 100, 100; + --quaAg: 133, 133, 133; + --quiAg: 175, 175, 175; + --sixAg: 240, 240, 240; + + /* Cosmic Metal Palette */ + // mercury (Mercury) + --priHg: 23, 31, 51; + --secHg: 51, 62, 87; + --terHg: 87, 98, 128; + --quaHg: 139, 148, 176; + --quiHg: 176, 186, 209; + --sixHg: 206, 214, 237; + // copper (Venus) + --priCu: 46, 24, 5; + --secCu: 84, 48, 17; + --terCu: 133, 81, 36; + --quaCu: 171, 112, 60; + --quiCu: 207, 173, 143; + --sixCu: 242, 216, 191; + // iron (Mars) ***n.b.!—ancient 'iron' was actually meteoric iron, an iron-nickel alloy like kamacite (weaponry) or taenite (decor) + --priFe: 51, 47, 26; + --secFe: 74, 72, 45; + --terFe: 105, 103, 74; + --quaFe: 148, 144, 115; + --quiFe: 184, 178, 154; + --sixFe: 224, 219, 202; + // tin (Jupiter) + --priSn: 36, 36, 19; + --secSn: 68, 72, 42; + --terSn: 100, 102, 66; + --quaSn: 148, 150, 110; + --quiSn: 207, 209, 180; + --sixSn: 243, 245, 223; + // lead (Saturn) + --priPb: 33, 40, 46; + --secPb: 48, 59, 64; + --terPb: 87, 102, 107; + --quaPb: 126, 142, 148; + --quiPb: 163, 180, 184; + --sixPb: 213, 228, 232; + // uranium (Uranus) + --priU: 21, 39, 18; + --secU: 32, 59, 41; + --terU: 85, 129, 69; + --quaU: 114, 156, 100; + --quiU: 167, 196, 149; + --sixU: 209, 240, 192; + // neptunium (Neptune) + --priNp: 16, 59, 49; + --secNp: 37, 84, 76; + --terNp: 85, 135, 129; + --quaNp: 107, 156, 148; + --quiNp: 139, 181, 175; + --sixNp: 197, 227, 224; + // plutonium (Pluto) + --priPu: 29, 18, 38; + --secPu: 59, 44, 71; + --terPu: 84, 71, 97; + --quaPu: 109, 98, 128; + --quiPu: 169, 155, 194; + --sixPu: 235, 211, 217; + + /* Chroma Palette */ + // red + --priRd: 233, 53, 37; + --secRd: 193, 43, 28; + --terRd: 155, 31, 15; + // orange + --priOr: 225, 133, 40; + --secOr: 187, 111, 30; + --terOr: 150, 88, 17; + // yellow + --priYl: 255, 207, 52; + --secYl: 211, 172, 44; + --terYl: 168, 138, 33; + // lime + --priLm: 151, 174, 60; + --secLm: 124, 145, 48; + --terLm: 97, 117, 36; + // green + --priGn: 0, 160, 75; + --secGn: 0, 135, 62; + --terGn: 0, 109, 48; + // teal + --priTk: 0, 184, 162; + --secTk: 0, 154, 136; + --terTk: 0, 125, 110; + // cyan + --priCy: 13, 179, 200; + --secCy: 12, 150, 168; + --terCy: 0, 121, 136; + // blue + --priBl: 20, 141, 205; + --secBl: 18, 119, 173; + --terBl: 8, 95, 140; + // indigo + --priId: 79, 102, 212; + --secId: 66, 88, 184; + --terId: 53, 74, 156; + --quaId: 44, 60, 131; + --quiId: 32, 44, 106; + --sixId: 21, 29, 71; + // violet + --priVt: 120, 72, 183; + --secVt: 108, 65, 165; + --terVt: 96, 58, 147; + --quaVt: 80, 45, 124; + --quiVt: 64, 30, 100; + --sixVt: 43, 20, 66; + // fuschia + --priFs: 158, 61, 150; + --secFs: 133, 47, 126; + --terFs: 107, 31, 101; + // magenta + --priMe: 237, 30, 129; + --secMe: 196, 18, 108; + --terMe: 158, 1, 86; + + /* Earthman Palette */ + // bark + --priBrk: 182, 103, 98; + --secBrk: 132, 78, 68; + --terBrk: 82, 53, 38; + // khaki + --priKhk: 195, 176, 145; + --secKhk: 145, 126, 95; + --terKhk: 95, 76, 45; + // cotton + --priCtn: 255, 251, 246; + --secCtn: 205, 201, 196; + --terCtn: 155, 151, 146; + // maize + --priMze: 242, 200, 63; + --secMze: 192, 151, 42; + --terMze: 142, 101, 21; + // cornflower + --priCfw: 100, 149, 237; + --secCfw: 67, 99, 187; + --terCfw: 33, 49, 137; + // purple mountain's majesty + --priPmm: 189, 170, 209; + --secPmm: 150, 120, 182; + --terPmm: 112, 79, 146; + // forest + --priFor: 190, 209, 170; + --secFor: 152, 182, 120; + --terFor: 114, 146, 79; + + /* Technoman Palette */ + // carbon steel + // stainless steel + // maraging steel + // silicon semiconductor + // wrought iron + // carbon fiber + // glass (optic) + + /* Other H. sapiens variants */ + // glass (frosted) + // glass (borosilicate) + // quartz + // iron (meteoric) + + + /* Inferno Palette (4 per) */ + // mist (Elpis's Lethe) + --priMst: 168, 202, 172; + --secMst: 103, 145, 105; + --terMst: 90, 129, 198; + --quaMst: 13, 71, 47; + // tears (Ananke's Acheron) + --priTrs: 212, 221, 190; + --secTrs: 161, 208, 202; + --terTrs: 81, 153, 139; + --quaTrs: 47, 89, 85; + // swamp (Eros's Styx) + --priSwp: 221, 206, 149; + --secSwp: 148, 150, 103; + --terSwp: 102, 92, 67; + --quaSwp: 43, 46, 37; + // blood (Tyche's Phlegethon) + --priBld: 190, 69, 40; + --secBld: 167, 53, 42; + --terBld: 120, 37, 33; + --quaBld: 77, 23, 13; + // ice (Daimon's Cocytus) + --priIce: 165, 190, 187; + --secIce: 121, 150, 156; + --terIce: 74, 119, 125; + --quaIce: 35, 65, 75; + + /* Terrestre Palette (6 per) */ + // crumbling perse (Contrition) + --priPer: 34, 30, 77; + --secPer: 52, 45, 99; + --terPer: 88, 77, 145; + --quaPer: 127, 116, 194; + --quiPer: 164, 160, 222; + --sixPer: 206, 201, 242; + // polished marble (Confession) + --priMrb: 231, 233, 234; + --secMrb: 115, 116, 117; + // flaming porphyry (Satisfaction) + --priPhy: 200, 55, 66; + --secPhy: 75, 31, 48; + // threshold of adamant (Absolution) + --priAdm: 35, 40, 43; + --secAdm: 75, 81, 84; + --terAdm: 119, 131, 135; + --quaAdm: 164, 180, 186; + --quiAdm: 197, 213, 228; + --sixAdm: 226, 244, 253; + + /* Emanation Palettes */ + // Plant Bundle + // • beige-pink (streetlamps) + --priBpk: 223, 159, 140; + // • pale-yellow (poisonous) + --priBpk: 255, 235, 169; + // • bright violet (medicinal) + --priBpk: 223, 64, 196; + // • white, murky (power) + --priBpk: 196, 180, 193; + // • white, brilliant (power) + --sixBpk: 250, 246, 249; + // Insect Bundle + // • buff peach (neon lights) + --priBfp: 255, 92, 43; + // Animal Bundle + // • amber (clear honey) + --priClh: 238, 160, 70; + --secClh: 255, 216, 171; + // • pink (common) + --terClh: 238, 70, 148; + --quaClh: 96, 5, 57; + // • pale green (common) + --quiClh: 120, 203, 53; + --sixClh: 220, 255, 125; + // • blue (unusual) + --sepClh: 56, 84, 173; + --octClh: 26, 51, 105; + // • pure (rare) + --ninClh: 192, 77, 1; + --decClh: 255, 174, 0; +} + +/* Default Earthman Theme */ +.theme-default { + --priUser: var(--terBrk); + --secUser: var(--priKhk); + --terUser: var(--priMze); + --quaUser: var(--priPmm); + --quiUser: var(--terPmm); + --sixUser: var(--priFor); + --sepUser: var(--terFor); + --octUser: var(--priCfw); + --ninUser: var(--priCtn); + --decUser: var(--terCtn); +} +/* Grave Sheol Theme */ +.theme-sheol { + --priUser: var(--priPu); + --secUser: var(--quiPu); + --terUser: var(--terFs); + --quaUser: var(--priCfw); + --quiUser: var(--terCfw); + --sixUser: var(--terId); + --sepUser: var(--secId); + --octUser: var(--priFs); + --ninUser: var(--sixPu); + --decUser: var(--terPu); +} +/* Blissful Nirvana Theme */ +.theme-nirvana { + --priUser: var(--priU); + --secUser: var(--quiU); + --terUser: var(--terMe); + --quaUser: var(--quiCu); + --quiUser: var(--terCu); + --sixUser: var(--terKhk); + --sepUser: var(--priKhk); + --octUser: var(--priMe); + --ninUser: var(--sixCu); + --decUser: var(--terU); +} +/* Disco Inferno Theme */ +.theme-inferno { + --priUser: var(--quaSwp); + --secUser: var(--priSwp); + --terUser: var(--terBld); + --quaUser: var(--priIce); + --quiUser: var(--quaIce); + --sixUser: var(--priTrs); + --sepUser: var(--terTrs); + --octUser: var(--priBld); + --ninUser: var(--priMst); + --decUser: var(--terMst); +} +/* Torre Terrestre Theme */ +.theme-terrestre { + --priUser: var(--priAdm); + --secUser: var(--quaAdm); + --terUser: var(--sixAdm); + --quaUser: var(--priPhy); + --quiUser: var(--secPhy); + --sixUser: var(--priMrb); + --sepUser: var(--terPer); + --octUser: var(--quaAdm); + --ninUser: var(--sixPer); + --decUser: var(--terMrb); +} +/* Fantastia Celestia Theme */ +.theme-celestia { + --priUser: var(--octClh); + --secUser: var(--sixClh); + --terUser: var(--quaClh); + --quaUser: var(--decClh); + --quiUser: var(--ninClh); + --sixUser: var(--sepClh); + --sepUser: var(--secClh); + --octUser: var(--terClh); + --ninUser: var(--priClh); + --decUser: var(--quiClh); +} + +/* Theme Classes */ +.priUser { + color: rgba(var(--priUser), 1); +} +.priUser-bg { + background-color: rgba(var(--priUser), 1); +} +.priUser-bd { + border-color: rgba(var(--priUser), 1); +} +.secUser { + color: rgba(var(--secUser), 1); +} +.secUser-bg { + background-color: rgba(var(--secUser), 1); +} +.secUser-bd { + border-color: rgba(var(--secUser), 1); +} +.terUser { + color: rgba(var(--terUser), 1); +} +.terUser-bg { + background-color: rgba(var(--terUser), 1); +} +.terUser-bd { + border-color: rgba(var(--terUser), 1); +} +.quaUser { + color: rgba(var(--quaUser), 1); +} +.quaUser-bg { + background-color: rgba(var(--quaUser), 1); +} +.quaUser-bd { + border-color: rgba(var(--secUser), 1); +} +.quiUser { + color: rgba(var(--quiUser), 1); +} +.quiUser-bg { + background-color: rgba(var(--quiUser), 1); +} +.quiUser-bd { + border-color: rgba(var(--quiUser), 1); +} +.sixUser { + color: rgba(var(--sixUser), 1); +} +.sixUser-bg { + background-color: rgba(var(--sixUser), 1); +} +.sixUser-bd { + border-color: rgba(var(--sixUser), 1); +} +.sepUser { + color: rgba(var(--sepUser), 1); +} +.sepUser-bg { + background-color: rgba(var(--sepUser), 1); +} +.sepUser-bd { + border-color: rgba(var(--sepUser), 1); +} +.octUser { + color: rgba(var(--octUser), 1); +} +.octUser-bg { + background-color: rgba(var(--octUser), 1); +} +.octUser-bd { + border-color: rgba(var(--octUser), 1); +} +.ninUser { + color: rgba(var(--ninUser), 1); +} +.ninUser-bg { + background-color: rgba(var(--ninUser), 1); +} +.ninUser-bd { + border-color: rgba(var(--ninUser), 1); +} +.decUser { + color: rgba(var(--decUser), 1); +} +.decUser-bg { + background-color: rgba(var(--decUser), 1); +} +.decUser-bd { + border-color: rgba(var(--decUser), 1); +} \ No newline at end of file diff --git a/src/templates/apps/dashboard/home.html b/src/templates/apps/dashboard/home.html index d9ea19f..e5a4c8a 100644 --- a/src/templates/apps/dashboard/home.html +++ b/src/templates/apps/dashboard/home.html @@ -11,3 +11,25 @@ {% block scripts %} {% include "apps/dashboard/_partials/_scripts.html" %} {% endblock scripts %} + +{% block content %} + {% if user.is_authenticated %} +
+
+
+
+ {% csrf_token %} + +
+
+
+
+ 🔒 +
+
+
+ 🔒 +
+
+ {% endif %} +{% endblock content %} diff --git a/src/templates/core/base.html b/src/templates/core/base.html index 9aaf0b3..68d84f5 100644 --- a/src/templates/core/base.html +++ b/src/templates/core/base.html @@ -1,5 +1,8 @@ +{% load compress %} +{% load static %} + - + @@ -7,10 +10,12 @@ Earthman RPG | {% block title_text %}{% endblock title_text %} - + {% compress css %} + + {% endcompress %} - +