Scaffold the mobile member app: PWA shell, push, and app-shell tokens
New `mobile` Django app mounted at /app/ -- the installed PWA for Member mode (M1-M7, see design_handoff_rosterchief_platform/README.md). Coach mode (C1-C6) is a later phase and has no routes yet. Foundation pieces: - assets/mobile.css: Tailwind v4 theme reusing management.css's design tokens (same per-club --tenant-* theming pattern), plus the ice/coach accent and mobile's 14px card radius. - PushSubscription model + pywebpush-based sender (mobile/services/push.py), wired to notifications.Notification via a post_save signal so the existing notification system gains a push channel without knowing about PWAs itself. - Per-club manifest.webmanifest + service worker (served at /app/sw.js) + a server-rendered fallback home-screen icon (club initials on secondary_color) for clubs without an uploaded logo -- confirmed with the user as the fallback, never a generic RosterChief mark. - App shell (base.html): navy header, person switcher (every child a signed-in parent manages, plus "Me"), bottom tab bar, safe-area insets. Vendored htmx + Alpine for the screens built on top of it. - Placeholder views/routes for all seven M1-M7 screens so the shell is fully wired end-to-end before each screen is built out individually. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -2774,6 +2774,9 @@
|
||||
.right-0 {
|
||||
right: 0;
|
||||
}
|
||||
.right-1\.5 {
|
||||
right: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
.right-2 {
|
||||
right: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -3853,6 +3856,9 @@
|
||||
.h-1\.5 {
|
||||
height: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
.h-2 {
|
||||
height: calc(var(--spacing) * 2);
|
||||
}
|
||||
.h-4 {
|
||||
height: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -3967,6 +3973,9 @@
|
||||
.w-1\.5 {
|
||||
width: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
.w-2 {
|
||||
width: calc(var(--spacing) * 2);
|
||||
}
|
||||
.w-4 {
|
||||
width: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -4741,6 +4750,9 @@
|
||||
.pt-4 {
|
||||
padding-top: calc(var(--spacing) * 4);
|
||||
}
|
||||
.pb-0\.5 {
|
||||
padding-bottom: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
.pb-2 {
|
||||
padding-bottom: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -4858,6 +4870,9 @@
|
||||
.text-\[17px\] {
|
||||
font-size: 17px;
|
||||
}
|
||||
.text-\[19px\] {
|
||||
font-size: 19px;
|
||||
}
|
||||
.text-\[22px\] {
|
||||
font-size: 22px;
|
||||
}
|
||||
@@ -5260,6 +5275,13 @@
|
||||
--btn-soft-bg: initial;
|
||||
}
|
||||
}
|
||||
.btn-secondary {
|
||||
@layer daisyui.l1.l2 {
|
||||
--btn-color: var(--color-secondary);
|
||||
--btn-fg: var(--color-secondary-content);
|
||||
--btn-soft-bg: initial;
|
||||
}
|
||||
}
|
||||
.btn-sm {
|
||||
@layer daisyui.l1.l2 {
|
||||
--fontsize: 0.75rem;
|
||||
|
||||
2
static/css/mobile.css
Normal file
2
static/css/mobile.css
Normal file
File diff suppressed because one or more lines are too long
21
static/js/alpine.js
Normal file
21
static/js/alpine.js
Normal file
File diff suppressed because one or more lines are too long
1
static/js/htmx.js
Normal file
1
static/js/htmx.js
Normal file
File diff suppressed because one or more lines are too long
55
static/js/mobile-app.js
Normal file
55
static/js/mobile-app.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// RosterChief member app -- registers the service worker and exposes
|
||||
// window.rosterchiefPush.{subscribe,unsubscribe}() for M7 (Notifications)
|
||||
// to wire a permission-request button to (browsers require a user gesture
|
||||
// before Notification.requestPermission() will prompt, so this is never
|
||||
// called automatically on page load).
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => navigator.serviceWorker.register("/app/sw.js"));
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = window.atob(base64);
|
||||
return Uint8Array.from([...raw].map((char) => char.charCodeAt(0)));
|
||||
}
|
||||
|
||||
async function subscribe() {
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return false;
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") return false;
|
||||
|
||||
const publicKey = document.body.dataset.vapidPublicKey;
|
||||
if (!publicKey) return false;
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
|
||||
await fetch("/app/push/subscribe/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-CSRFToken": document.body.dataset.csrftoken || "" },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function unsubscribe() {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) return;
|
||||
|
||||
await fetch("/app/push/subscribe/", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json", "X-CSRFToken": document.body.dataset.csrftoken || "" },
|
||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
||||
});
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
|
||||
window.rosterchiefPush = { subscribe, unsubscribe };
|
||||
Reference in New Issue
Block a user