Compare commits

..

2 Commits

33 changed files with 613 additions and 304 deletions

View File

@@ -0,0 +1,23 @@
const initGearMenus = () => {
document.querySelectorAll('.gear-btn').forEach(gear => {
const menuId = gear.dataset.menuTarget;
gear.addEventListener('click', (e) => {
e.stopPropagation();
const menu = document.getElementById(menuId);
if (!menu) return;
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', (e) => {
const menu = document.getElementById(menuId);
if (!menu || menu.style.display === 'none') return;
if (e.target.closest('.applet-menu-cancel') || !menu.contains(e.target)) {
menu.style.display = 'none';
}
});
})
};
document.addEventListener('DOMContentLoaded', initGearMenus);

View File

@@ -2,6 +2,7 @@ from django.db.utils import IntegrityError
from django.test import TestCase
from apps.applets.models import Applet, UserApplet
from apps.applets.utils import applet_context
from apps.lyric.models import User
@@ -25,6 +26,7 @@ class AppletModelTest(TestCase):
self.assertEqual(self.applet.grid_cols, 12)
self.assertEqual(self.applet.grid_rows, 3)
class UserAppletModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
@@ -38,3 +40,26 @@ class UserAppletModelTest(TestCase):
UserApplet.objects.create(user=self.user, applet=self.applet, visible=True)
with self.assertRaises(IntegrityError):
UserApplet.objects.create(user=self.user, applet=self.applet, visible=False)
class AppletContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
self.dash_applet = Applet.objects.create(slug="username", name="Username", context="dashboard")
self.game_applet = Applet.objects.create(slug="new-game", name="New Game", context="gameboard")
def test_filters_by_context(self):
result = applet_context(self.user, "dashboard")
slugs = [e["applet"].slug for e in result]
self.assertIn("username", slugs)
self.assertNotIn("new-game", slugs)
def test_defaults_to_applet_default_visible(self):
result = applet_context(self.user, "dashboard")
[entry] = [e for e in result if e["applet"].slug == "username"]
self.assertTrue(entry["visible"])
def test_respects_user_applet_visible_false(self):
UserApplet.objects.create(user=self.user, applet=self.dash_applet, visible=False)
result = applet_context(self.user, "dashboard")
[entry] = [e for e in result if e["applet"].slug == "username"]
self.assertFalse(entry["visible"])

11
src/apps/applets/utils.py Normal file
View File

@@ -0,0 +1,11 @@
from apps.applets.models import Applet, UserApplet
def applet_context(user, context):
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
applets = {a.slug: a for a in Applet.objects.filter(context=context)}
return [
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)}
for slug in applets
if slug in applets
]

View File

@@ -8,23 +8,3 @@ const initialize = (inputSelector) => {
textInput.classList.remove("is-invalid");
};
};
const initGearMenu = () => {
const gear = document.getElementById('id_dash_gear');
if (!gear) return;
gear.addEventListener('click', (e) => {
e.stopPropagation();
const menu = document.getElementById('id_applet_menu');
if (!menu) return;
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', (e) => {
const menu = document.getElementById('id_applet_menu');
if (!menu || menu.style.display === 'none') return;
if (e.target.closest('#id_applet_menu_cancel') || !menu.contains(e.target)) {
menu.style.display = 'none';
}
});
};

View File

@@ -66,6 +66,7 @@ class NewListTest(TestCase):
response = self.post_invalid_input()
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
@override_settings(COMPRESS_ENABLED=False)
class ListViewTest(TestCase):
def test_uses_list_template(self):
mylist = List.objects.create()
@@ -358,7 +359,7 @@ class ProfileViewTest(TestCase):
[username_input] = parsed.cssselect("#id_new_username")
self.assertEqual("discoman", username_input.get("value"))
class ToggleAppletsViewTest(TestCase):
class ToggleDashAppletsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
@@ -402,6 +403,13 @@ class ToggleAppletsViewTest(TestCase):
self.assertEqual(len(parsed.cssselect("#id_applet_username")), 1)
self.assertEqual(len(parsed.cssselect("#id_applet_palette")), 0)
def test_toggle_applets_does_not_affect_gameboard_applets(self):
game_applet, _ = Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
self.client.post(self.url, {"applets": ["username", "palette"]})
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=game_applet). exists())
class AppletVisibilityContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")

View File

@@ -1,10 +1,11 @@
import lxml.html
from django.test import TestCase
from django.test import override_settings, TestCase
from apps.lyric.models import Token, User, Wallet
@override_settings(COMPRESS_ENABLED=False)
class WalletViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")

View File

@@ -9,6 +9,7 @@ from django.shortcuts import redirect, render
from django.views.decorators.csrf import ensure_csrf_cookie
from apps.applets.models import Applet, UserApplet
from apps.applets.utils import applet_context
from apps.dashboard.forms import ExistingListItemForm, ItemForm
from apps.dashboard.models import Item, List
from apps.lyric.models import PaymentMethod, Token, User, Wallet
@@ -36,16 +37,6 @@ def _recent_lists(user, limit=3):
.distinct()[:limit]
)
def _applet_context(user):
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
applets = {a.slug: a for a in Applet.objects.all()}
return [
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)}
for slug in APPLET_ORDER
if slug in applets
]
def home_page(request):
context = {
"form": ItemForm(),
@@ -53,7 +44,7 @@ def home_page(request):
"page_class": "page-dashboard",
}
if request.user.is_authenticated:
context["applets"] = _applet_context(request.user)
context["applets"] = applet_context(request.user, "dashboard")
context["recent_lists"] = _recent_lists(request.user)
return render(request, "apps/dashboard/home.html", context)
@@ -73,7 +64,7 @@ def new_list(request):
"page_class": "page-dashboard",
}
if request.user.is_authenticated:
context["applets"] = _applet_context(request.user)
context["applets"] = applet_context(request.user, "dashboard")
context["recent_lists"] = _recent_lists(request.user)
return render(request, "apps/dashboard/home.html", context)
@@ -135,7 +126,7 @@ def set_profile(request):
@login_required(login_url="/")
def toggle_applets(request):
checked = request.POST.getlist("applets")
for applet in Applet.objects.all():
for applet in Applet.objects.filter(context="dashboard"):
UserApplet.objects.update_or_create(
user=request.user,
applet=applet,
@@ -143,7 +134,7 @@ def toggle_applets(request):
)
if request.headers.get("HX-Request"):
return render(request, "apps/dashboard/_partials/_applets.html", {
"applets": _applet_context(request.user),
"applets": applet_context(request.user, "dashboard"),
"palettes": PALETTES,
"form": ItemForm(),
"recent_lists": _recent_lists(request.user),

View File

@@ -1,14 +1,19 @@
import lxml.html
from django.test import TestCase
from django.urls import reverse
from apps.applets.models import Applet, UserApplet
from apps.lyric.models import User
class GameboardViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
response = self.client.get("/gameboard/")
self.parsed = lxml.html.fromstring(response.content)
@@ -29,11 +34,11 @@ class GameboardViewTest(TestCase):
def test_gameboard_shows_new_game_applet(self):
[_] = self.parsed.cssselect("#id_applet_new_game")
def test_gameboard_shows_game_kit_btn(self):
[_] = self.parsed.cssselect("#id_game_kit_btn")
def test_gameboard_shows_game_kit(self):
[_] = self.parsed.cssselect("#id_game_kit")
def test_gameboard_shows_game_gear(self):
[_] = self.parsed.cssselect("#id_game_gear")
[_] = self.parsed.cssselect(".gear-btn")
def test_my_games_has_no_game_items_for_new_user(self):
game_items = self.parsed.cssselect("#id_applet_my_games .game-item")
@@ -50,3 +55,49 @@ class GameboardViewTest(TestCase):
def test_game_kit_has_dice_set_placeholder(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
class ToggleGameAppletsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
self.new_game, _ = Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
self.my_games, _ = Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
self.url = reverse("toggle_game_applets")
def test_unauthenticated_user_is_redirected(self):
self.client.logout()
response = self.client.post(self.url)
self.assertRedirects(
response, f"/?next={self.url}", fetch_redirect_response=False
)
def test_unchecked_applet_gets_user_applet_with_visible_false(self):
self.client.post(self.url, {"applets": ["new-game"]})
ua = UserApplet.objects.get(user=self.user, applet=self.my_games)
self.assertFalse(ua.visible)
def test_redirects_on_normal_post(self):
response = self.client.post(self.url, {"applets": ["new-game", "my-games"]})
self.assertRedirects(
response, reverse("gameboard"), fetch_redirect_response=False
)
def test_returns_200_on_htmx_post(self):
response = self.client.post(
self.url,
{"applets": ["new-game", "my-games"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_does_not_affect_dash_applets(self):
dash_applet, _ = Applet.objects.get_or_create(
slug="username", defaults={"name": "Username", "context": "dashboard"}
)
self.client.post(self.url, {"applets": ["new-game", "my-games"]})
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists())

View File

@@ -5,5 +5,6 @@ from . import views
urlpatterns = [
path('', views.gameboard, name='gameboard'),
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'),
]

View File

@@ -1,9 +1,18 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.shortcuts import redirect, render
from apps.applets.utils import applet_context
from apps.applets.models import Applet, UserApplet
from apps.lyric.models import Token
GAMEBOARD_APPLET_ORDER = [
"new-game",
"my-games",
"game-kit",
]
@login_required(login_url="/")
def gameboard(request):
coin = request.user.tokens.filter(token_type=Token.COIN).first()
@@ -12,5 +21,24 @@ def gameboard(request):
request, "apps/gameboard/gameboard.html", {
"coin": coin,
"free_tokens": free_tokens,
"applets": applet_context(request.user, "gameboard"),
"page_class": "page-gameboard",
}
)
@login_required(login_url="/")
def toggle_game_applets(request):
checked = request.POST.getlist("applets")
for applet in Applet.objects.filter(context="gameboard"):
UserApplet.objects.update_or_create(
user=request.user,
applet=applet,
defaults={"visible": applet.slug in checked},
)
if request.headers.get("HX-Request"):
return render(request, "apps/gameboard/_partials/_applets.html", {
"applets": applet_context(request.user, "gameboard"),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
})
return redirect("gameboard")

View File

@@ -54,16 +54,16 @@ class DashboardMaintenanceTest(FunctionalTest):
self.browser.find_element(By.ID, "id_applet_username")
self.browser.find_element(By.ID, "id_applet_palette")
# 3. Click el w. id="id_dash_gear"
dash_gear = self.browser.find_element(By.ID, "id_dash_gear")
dash_gear = self.browser.find_element(By.CSS_SELECTOR, ".gear-btn")
dash_gear.click()
# 4. A menu appears; wait_for el w. id="id_applet_menu"
# 4. A menu appears; wait_for el w. id="id_dash_applet_menu"
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed()
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
# 5. Find two checkboxes in menu, name="username" & name="palette"; assert both .is_selected()
menu = self.browser.find_element(By.ID, "id_applet_menu")
menu = self.browser.find_element(By.ID, "id_dash_applet_menu")
username_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="username"]')
palette_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="palette"]')
self.assertTrue(username_cb.is_selected())
@@ -76,7 +76,7 @@ class DashboardMaintenanceTest(FunctionalTest):
menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed()
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
# 8. wait_for palette applet to be gone
@@ -93,10 +93,10 @@ class DashboardMaintenanceTest(FunctionalTest):
dash_gear.click()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed()
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
menu = self.browser.find_element(By.ID, "id_applet_menu")
menu = self.browser.find_element(By.ID, "id_dash_applet_menu")
palette_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="palette"]')
self.assertFalse(palette_cb.is_selected())
# 11. Click it to re-check box; submit
@@ -106,7 +106,7 @@ class DashboardMaintenanceTest(FunctionalTest):
# 12. wait_for id_applet_palette to reappear
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed()
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
self.wait_for(
@@ -125,19 +125,19 @@ class AppletMenuDismissTest(FunctionalTest):
self.browser.get(self.live_server_url)
def _open_menu(self):
self.browser.find_element(By.ID, "id_dash_gear").click()
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed()
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
def test_gear_click_toggles_menu_closed(self):
self._open_menu()
self.browser.find_element(By.ID, "id_dash_gear").click()
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed()
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
@@ -146,7 +146,7 @@ class AppletMenuDismissTest(FunctionalTest):
self.browser.find_element(By.ID, "id_applet_menu_cancel").click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed()
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
@@ -155,6 +155,6 @@ class AppletMenuDismissTest(FunctionalTest):
self.browser.find_element(By.TAG_NAME, "h2").click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed()
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)

View File

@@ -1,10 +1,18 @@
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from apps.applets.models import Applet
class GameboardNavigationTest(FunctionalTest):
def setUp(self):
super().setUp()
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
def test_footer_links_to_gameboard(self):
# 1. Log in, nav to dashboard
self.create_pre_authenticated_session("capman@test.io")
@@ -41,20 +49,12 @@ class GameboardNavigationTest(FunctionalTest):
# 1. Log in, nav to gameboard
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
# 2. Assert game kit & gear btns both present (stacked vertically)
# 2. Assert game kit applet & gear btn present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_game_kit_btn")
lambda: self.browser.find_element(By.ID, "id_game_kit")
)
self.browser.find_element(By.ID, "id_game_gear")
# 3. Click game kit btn to open panel
self.browser.find_element(By.ID, "id_game_kit_btn").click()
# 4. Wait for game kit panel to become visible
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_game_kit").is_displayed()
)
)
# 5. Assert Coin-on-a-String present in kit
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn")
# 3. Assert Coin-on-a-String present in kit
coin = self.browser.find_element(By.ID, "id_kit_coin_on_a_string")
# 6. Hover over it; assert tooltip shows name, entry text & reuse description
ActionChains(self.browser).move_to_element(coin).perform()
@@ -91,3 +91,75 @@ class GameboardNavigationTest(FunctionalTest):
# 9. Assert card deck & dice set placeholder present
self.browser.find_element(By.ID, "id_kit_card_deck")
self.browser.find_element(By.ID, "id_kit_dice_set")
class GameboardAppletMenuTest(FunctionalTest):
def setUp(self):
super().setUp()
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
self.create_pre_authenticated_session("gamer@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
def test_user_can_toggle_applet_visibility_via_gear_menu(self):
# 1. Assert both applets present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_my_games")
)
self.browser.find_element(By.ID, "id_applet_new_game")
# 2. Click gear; wait for menu
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_game_applet_menu").is_displayed()
)
)
# 3. Find checkboxes; assert both checked
menu = self.browser.find_element(By.ID, "id_game_applet_menu")
my_games_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="my-games"]')
new_game_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="new-game"]')
self.assertTrue(my_games_cb.is_selected())
self.assertTrue(new_game_cb.is_selected())
# 4. Uncheck my-games; plant no-reload marker; submit
my_games_cb.click()
self.assertFalse(my_games_cb.is_selected())
self.browser.execute_script("window.__no_reload_marker = true")
menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click()
# 5. Wait for menu to close; assert my-games gone, new game remains
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_game_applet_menu").is_displayed()
)
)
self.wait_for(
lambda: self.assertRaises(
NoSuchElementException,
self.browser.find_element,
By.ID, "id_applet_my_games",
)
)
self.browser.find_element(By.ID, "id_applet_new_game")
# 6. Re-check my-games; assert it reappears
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_game_applet_menu").is_displayed()
)
)
menu = self.browser.find_element(By.ID, "id_game_applet_menu")
my_games_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="my-games"]')
self.assertFalse(my_games_cb.is_selected())
my_games_cb.click()
menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_game_applet_menu").is_displayed()
)
)
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_applet_my_games")
)
)
# 7. Assert no full page reload occurred
self.assertTrue(self.browser.execute_script("return window.__no_reload_marker === true"))

View File

@@ -0,0 +1,115 @@
// ── Gear button ────────────────────────────────────────────
.gear-btn {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
z-index: 1;
font-size: 2rem;
cursor: pointer;
color: rgba(var(--secUser), 1);
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background-color: rgba(var(--priUser), 1);
border: 0.15rem solid rgba(var(--secUser), 1);
}
// ── Applet menu (shared structure) ─────────────────────────
%applet-menu {
position: absolute;
bottom: 3rem;
right: 0.5rem;
z-index: 100;
background-color: rgba(var(--priUser), 0.95);
border: 0.15rem solid rgba(var(--secUser), 0.5);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.5),
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
;
border-radius: 0.75rem;
padding: 1rem;
.menu-btns {
display: flex;
gap: 0.25rem;
margin-top: 0.75rem;
}
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 0.9em;
height: 0.9em;
border: 0.1rem solid rgba(var(--secUser), 0.4);
border-radius: 0.25rem;
background: transparent;
cursor: pointer;
position: relative;
flex-shrink: 0;
top: 0.1em;
&:checked::after {
content: '';
position: absolute;
left: 0.2em;
bottom: 0.2em;
width: 0.55em;
height: 1em;
border: 0.12em solid rgba(var(--ninUser), 1);
border-top: none;
border-left: none;
transform: rotate(45deg);
}
}
}
#id_dash_applet_menu { @extend %applet-menu; }
#id_game_applet_menu { @extend %applet-menu; }
// ── Applets grid (shared across all boards) ────────────────
%applets-grid {
container-type: inline-size;
--grid-gap: 0.5rem;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-rows: 3rem;
gap: var(--grid-gap);
padding: 0.75rem;
-webkit-overflow-scrolling: touch;
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 2%,
black 98%,
transparent 100%
);
section {
border: 0.2rem solid rgba(var(--secUser), 0.5);
border-radius: 0.75rem;
padding: 1rem;
overflow: hidden;
min-width: 0;
grid-column: span var(--applet-cols, 12);
grid-row: span var(--applet-rows, 3);
@container (max-width: 550px) {
grid-column: span 12;
}
}
}
#id_applets_container { @extend %applets-grid; }
#id_game_applets_container { @extend %applets-grid; }

View File

@@ -28,110 +28,7 @@ body.page-dashboard {
position: relative;
}
#id_dash_gear {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
z-index: 1;
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: rgba(var(--secUser), 1);
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background-color: rgba(var(--priUser), 1);
border: 0.15rem solid rgba(var(--secUser), 1);
}
#id_applet_menu {
position: absolute;
bottom: 3rem;
right: 0.5rem;
z-index: 100;
background-color: rgba(var(--priUser), 0.95);
border: 0.15rem solid rgba(var(--secUser), 0.5);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.5),
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
;
border-radius: 0.75rem;
padding: 1rem;
.menu-btns {
display: flex;
gap: 0.25rem;
margin-top: 0.75rem;
}
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 0.9em;
height: 0.9em;
border: 0.1rem solid rgba(var(--secUser), 0.4);
border-radius: 0.25rem;
background: transparent;
cursor: pointer;
position: relative;
flex-shrink: 0;
top: 0.1em;
&:checked::after {
content: '';
position: absolute;
left: 0.2em;
bottom: 0.2em;
width: 0.55em;
height: 1em;
border: 0.12em solid rgba(var(--ninUser), 1);
border-top: none;
border-left: none;
transform: rotate(45deg);
}
}
}
#id_applets_container {
container-type: inline-size;
--grid-gap: 0.5rem;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-rows: 3rem;
gap: var(--grid-gap);
padding: 0.75rem;
-webkit-overflow-scrolling: touch;
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 2%,
black 98%,
transparent 100%
);
section {
border: 0.2rem solid rgba(var(--secUser), 0.5);
border-radius: 0.75rem;
padding: 1rem;
overflow: hidden;
min-width: 0;
grid-column: span var(--applet-cols, 12);
grid-row: span var(--applet-rows, 3);
}
#id_applet_my_lists {
padding: 1.25rem 1.5rem;
display: flex;
@@ -240,12 +137,6 @@ body.page-dashboard {
min-width: 0;
overflow: hidden;
}
#id_applets_container {
section {
grid-column: span 12;
}
}
}
@media (min-width: 738px) {

View File

@@ -0,0 +1,73 @@
html:has(body.page-gameboard) {
overflow: hidden;
}
body.page-gameboard {
overflow: hidden;
.container {
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.row {
flex-shrink: 0;
margin-bottom: -1rem;
}
}
.gameboard-page {
flex: 1;
min-width: 425px;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
@media (max-width: 550px) {
.gameboard-page {
min-width: 0;
overflow: hidden;
}
}
@media (min-width: 738px) {
.gameboard-page {
min-width: 666px;
}
}
#id_game_applets_container {
#id_applet_game_kit {
display: flex;
flex-direction: column;
#id_game_kit {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
}
}
@media (max-height: 500px) {
body.page-gameboard {
.container {
.row {
padding: 0.25rem 0;
.col-lg-6 h2 {
margin-bottom: 0.5rem;
}
}
}
}
}

View File

@@ -1,7 +1,9 @@
@import 'rootvars';
@import 'applets';
@import 'base';
@import 'button-pad';
@import 'dashboard';
@import 'gameboard';
@import 'palette-picker';
@import 'wallet-tokens';

View File

@@ -0,0 +1,7 @@
{% for entry in applets %}
{% if entry.visible %}
{% with "apps/"|add:entry.applet.context|add:"/_partials/_applet-"|add:entry.applet.slug|add:".html" as partial %}
{% include partial %}
{% endwith %}
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,3 @@
<button class="gear-btn" data-menu-target="{{ menu_id }}">
<i class="fa-solid fa-gear"></i>
</button>

View File

@@ -0,0 +1,17 @@
<section
id="id_applet_my_lists"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<a href="{% url 'my_lists' user.id %}" class="my-lists-main">My lists:</a>
<div class="my-lists-container">
<ul>
{% for list in recent_lists %}
<li>
<a href="{{ list.get_absolute_url }}">{{ list.name }}</a>
</li>
{% empty %}
<li>No lists yet.</li>
{% endfor %}
</ul>
</div>
</section>

View File

@@ -0,0 +1,8 @@
<section
id="id_applet_new_list"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>Start a new to-do list</h2>
{% url "new_list" as form_action %}
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
</section>

View File

@@ -0,0 +1,21 @@
<section
id="id_applet_palette"
class="palette"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<div class="palette-scroll">
{% for palette in palettes %}
<div class="palette-item">
<div class="swatch {{ palette.name }}{% if user_palette == palette.name %} active{% endif %}{% if palette.locked %} locked{% endif %}"></div>
{% if not palette.locked %}
<form method="POST" action="{% url "set_palette" %}">
{% csrf_token %}
<button type="submit" name="palette" value="{{ palette.name }}" class="btn btn-confirm">OK</button>
</form>
{% else %}
<span class="btn btn-disabled">&times;</span>
{% endif %}
</div>
{% endfor %}
</div>
</section>

View File

@@ -0,0 +1,23 @@
<section
id="id_applet_username"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<form method="POST" action="{% url "set_profile" %}">
{% csrf_token %}
<div class="username-field">
<span class="username-at">@</span>
<input
id="id_new_username"
name="username"
required
value="{{ user.username|default:'' }}"
autocomplete="off"
placeholder="username"
data-original="{{ user.username|default:'' }}"
oninput="this.closest('form').querySelector('.save-btn').hidden = (this.value === this.dataset.original)"
onblur="this.scrollLeft = 0"
>
</div>
<button type="submit" class="btn btn-confirm save-btn" hidden>OK</button>
</form>
</section>

View File

@@ -0,0 +1,7 @@
<section
id="id_applet_wallet"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<span>Writs: {{ user.wallet.writs }}</span>
<a href="{% url "wallet" %}" class="wallet-manage-link">Manage Wallet</a>
</section>

View File

@@ -1,6 +1,7 @@
{% load lyric_extras %}
<div id="id_applets_container">
<div id="id_applet_menu" style="display:none;">
<div id="id_dash_applet_menu" style="display:none;">
<form
hx-post="{% url "toggle_applets" %}"
hx-target="#id_applets_container"
@@ -20,95 +21,9 @@
{% endfor %}
<div class="menu-btns">
<button type="submit" class="btn btn-confirm">OK</button>
<button type="button" id="id_applet_menu_cancel" class="btn btn-cancel">NVM</button>
<button type="button" id="id_applet_menu_cancel" class="btn btn-cancel applet-menu-cancel">NVM</button>
</div>
</form>
</div>
{% for entry in applets %}
{% if entry.visible %}
{% if entry.applet.slug == "wallet" %}
<section
id="id_applet_wallet"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<span>Writs: {{ user.wallet.writs }}</span>
<a href="{% url "wallet" %}" class="wallet-manage-link">Manage Wallet</a>
</section>
{% elif entry.applet.slug == "new-list" %}
<section
id="id_applet_new_list"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>Start a new to-do list</h2>
{% url "new_list" as form_action %}
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
</section>
{% elif entry.applet.slug == "my-lists" %}
<section
id="id_applet_my_lists"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<a href="{% url 'my_lists' user.id %}" class="my-lists-main">My lists:</a>
<div class="my-lists-container">
<ul>
{% for list in recent_lists %}
<li>
<a href="{{ list.get_absolute_url }}">{{ list.name }}</a>
</li>
{% empty %}
<li>No lists yet.</li>
{% endfor %}
</ul>
</div>
</section>
{% elif entry.applet.slug == "username" %}
<section
id="id_applet_username"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<form method="POST" action="{% url "set_profile" %}">
{% csrf_token %}
<div class="username-field">
<span class="username-at">@</span>
<input
id="id_new_username"
name="username"
required
value="{{ user.username|default:'' }}"
autocomplete="off"
placeholder="username"
data-original="{{ user.username|default:'' }}"
oninput="this.closest('form').querySelector('.save-btn').hidden = (this.value === this.dataset.original)"
onblur="this.scrollLeft = 0"
>
</div>
<button type="submit" class="btn btn-confirm save-btn" hidden>OK</button>
</form>
</section>
{% elif entry.applet.slug == "palette" %}
<section
id="id_applet_palette"
class="palette"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<div class="palette-scroll">
{% for palette in palettes %}
<div class="palette-item">
<div class="swatch {{ palette.name }}{% if user_palette == palette.name %} active{% endif %}{% if palette.locked %} locked{% endif %}"></div>
{% if not palette.locked %}
<form method="POST" action="{% url "set_palette" %}">
{% csrf_token %}
<button type="submit" name="palette" value="{{ palette.name }}" class="btn btn-confirm">OK</button>
</form>
{% else %}
<span class="btn btn-disabled">&times;</span>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endif %}
{% endfor %}
{% include "apps/applets/_partials/_applets.html" %}
</div>

View File

@@ -2,6 +2,5 @@
<script>
window.onload = () => {
initialize("#id_text");
initGearMenu();
};
</script>

View File

@@ -11,9 +11,7 @@
{% block content %}
{% if user.is_authenticated %}
<div id="id_dash_content">
<button id="id_dash_gear">
<i class="fa-solid fa-gear"></i>
</button>
{% include "apps/applets/_partials/_gear.html" with menu_id="id_dash_applet_menu" %}
{% include "apps/dashboard/_partials/_applets.html" %}
</div>
{% endif %}

View File

@@ -0,0 +1,22 @@
<section
id="id_applet_game_kit"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>Game Kit</h2>
<div id="id_game_kit">
{% if coin %}
<div id="id_kit_coin_on_a_string" class="token">
<i class="fa-solid fa-clover"></i>
<span class="token-tooltip">{{ coin.tooltip_text }}</span>
</div>
{% endif %}
{% for token in free_tokens %}
<div id="id_kit_free_token_{{ forloop.counter0 }}" class="token">
<i class="fa-solid fa-coins"></i>
<span class="token-tooltip">{{ token.tooltip_text }}</span>
</div>
{% endfor %}
<div id="id_kit_card_deck" class="kit-item"><i class="fa-regular fa-id-badge"></i></div>
<div id="id_kit_dice_set" class="kit-item"><i class="fa-solid fa-dice"></i></div>
</div>
</section>

View File

@@ -0,0 +1,9 @@
<section
id="id_applet_my_games"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>My Games</h2>
<ul class="game-list">
<small>[feature forthcoming]</small>
</ul>
</section>

View File

@@ -0,0 +1,9 @@
<section
id="id_applet_new_game"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>New Game</h2>
<ul class="game-type">
<small>[feature forthcoming]</small>
</ul>
</section>

View File

@@ -0,0 +1,27 @@
<div id="id_game_applets_container">
<div id="id_game_applet_menu" style="display:none;">
<form
hx-post="{% url "toggle_game_applets" %}"
hx-target="#id_game_applets_container"
hx-swap="outerHTML"
>
{% csrf_token %}
{% for entry in applets %}
<label>
<input
type="checkbox"
name="applets"
value="{{ entry.applet.slug }}"
{% if entry.visible %}checked{% endif %}
>
{{ entry.applet.name }}
</label>
{% endfor %}
<div class="menu-btns">
<button type="submit" class="btn btn-confirm">OK</button>
<button type="button" id="id_game_applet_menu_cancel" class="btn btn-cancel applet-menu-cancel">NVM</button>
</div>
</form>
</div>
{% include "apps/applets/_partials/_applets.html" %}
</div>

View File

@@ -5,36 +5,7 @@
{% block content %}
<div class="gameboard-page">
<section id="id_applet_my_games">
<h2>My Games</h2>
</section>
<section id="id_applet_new_game">
<h2>New Game</h2>
</section>
<div id="id_game_gear"></div>
<button
id="id_game_kit_btn"
onclick="document.getElementById('id_game_kit').style.display='block'"
>
Game Kit
</button>
<div id="id_game_kit" style="display:none;">
{% if coin %}
<div id="id_kit_coin_on_a_string" class="token">
<i class="fa-solid fa-clover"></i>
<span class="token-tooltip">{{ coin.tooltip_text }}</span>
</div>
{% endif %}
{% for token in free_tokens %}
<div id="id_kit_free_token_{{ forloop.counter0 }}" class="token">
<i class="fa-solid fa-coins"></i>
<span class="token-tooltip">{{ token.tooltip_text }}</span>
</div>
{% endfor %}
<div id="id_kit_card_deck" class="kit-item"><i class="fa-regular fa-id-badge"></i></div>
<div id="id_kit_dice_set" class="kit-item"><i class="fa-solid fa-dice"></i></div>
</div>
{% include "apps/applets/_partials/_gear.html" with menu_id="id_game_applet_menu" %}
{% include "apps/gameboard/_partials/_applets.html" %}
</div>
{% endblock content %}

View File

@@ -53,6 +53,7 @@
{% block scripts %}
{% endblock scripts %}
<script src="{% static "vendor/htmx.min.js" %}"></script>
<script src="{% static "apps/scripts/applets.js" %}"></script>
<script>
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken');