feat(11-03): Alpine.js theme + i18n stores in base.html with top-right controls
- Add Alpine.store('theme') for Light/Dark/System cycling with localStorage persistence
- Add Alpine.store('i18n') with FR/EN translations for all static UI strings
- Add topbar with theme toggle button (:aria-label, @click cycle) and lang button
- Wrap main content in .main-wrapper + .topbar for layout structure
- Add .main-wrapper, .topbar, .topbar-controls styles to app.css
- Add test_theme_toggle_present integration test
- Fix test_dashboard_shows_recent_printers assertion (string now in i18n JS too)
This commit is contained in:
@@ -91,6 +91,36 @@ nav.sidebar li {
|
|||||||
border-left-color: var(--pico-primary, #1a73e8);
|
border-left-color: var(--pico-primary, #1a73e8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Main wrapper: fills remaining space beside sidebar, stacks topbar + content */
|
||||||
|
.main-wrapper {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Topbar: holds top-right controls */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--pico-muted-border-color, #e0e0e0);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-controls button {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Main content */
|
/* Main content */
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
+131
-5
@@ -6,6 +6,110 @@
|
|||||||
<title>ImpTune</title>
|
<title>ImpTune</title>
|
||||||
<link rel="stylesheet" href="/static/pico.min.css">
|
<link rel="stylesheet" href="/static/pico.min.css">
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
// Theme store: cycles Light -> Dark -> System, persists in localStorage
|
||||||
|
Alpine.store('theme', {
|
||||||
|
current: localStorage.getItem('imptune_theme') || 'auto',
|
||||||
|
icons: { light: '☀', dark: '☾', auto: '◑' },
|
||||||
|
init() {
|
||||||
|
document.documentElement.setAttribute('data-theme', this.current);
|
||||||
|
},
|
||||||
|
cycle() {
|
||||||
|
const order = ['light', 'dark', 'auto'];
|
||||||
|
this.current = order[(order.indexOf(this.current) + 1) % order.length];
|
||||||
|
localStorage.setItem('imptune_theme', this.current);
|
||||||
|
document.documentElement.setAttribute('data-theme', this.current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// i18n store: FR/EN toggle, persists in localStorage
|
||||||
|
Alpine.store('i18n', {
|
||||||
|
lang: localStorage.getItem('imptune_lang') || 'fr',
|
||||||
|
t(key) {
|
||||||
|
return (this.translations[this.lang] || {})[key] || key;
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
this.lang = this.lang === 'fr' ? 'en' : 'fr';
|
||||||
|
localStorage.setItem('imptune_lang', this.lang);
|
||||||
|
},
|
||||||
|
translations: {
|
||||||
|
fr: {
|
||||||
|
dashboard: 'Tableau de bord',
|
||||||
|
drivers: 'Pilotes',
|
||||||
|
printers: 'Imprimantes',
|
||||||
|
clients: 'Clients',
|
||||||
|
packages: 'Paquets',
|
||||||
|
add_printer: 'Ajouter une imprimante',
|
||||||
|
add_client: 'Ajouter un client',
|
||||||
|
printer_library: 'Biblioth\u00e8que d\u2019imprimantes',
|
||||||
|
edit: 'Modifier',
|
||||||
|
delete: 'Supprimer',
|
||||||
|
save: 'Enregistrer',
|
||||||
|
cancel: 'Annuler',
|
||||||
|
upload_driver: 'T\u00e9l\u00e9charger un pilote',
|
||||||
|
printer_name: 'Nom de l\u2019imprimante',
|
||||||
|
ip_address: 'Adresse IP',
|
||||||
|
port_name: 'Nom du port',
|
||||||
|
driver: 'Pilote',
|
||||||
|
duplex_mode: 'Mode recto-verso',
|
||||||
|
one_sided: 'Recto simple',
|
||||||
|
long_edge: 'Grand c\u00f4t\u00e9',
|
||||||
|
short_edge: 'Petit c\u00f4t\u00e9',
|
||||||
|
color_mode: 'Mode couleur',
|
||||||
|
paper_size: 'Format papier',
|
||||||
|
collate: 'Assembler',
|
||||||
|
client: 'Client',
|
||||||
|
edit_printer: 'Modifier l\u2019imprimante',
|
||||||
|
no_printers: 'Aucune imprimante configur\u00e9e.',
|
||||||
|
no_clients: 'Aucun client configur\u00e9.',
|
||||||
|
client_list: 'Liste des clients',
|
||||||
|
name: 'Nom',
|
||||||
|
created: 'Cr\u00e9\u00e9 le',
|
||||||
|
back_to_printers: 'Retour aux imprimantes',
|
||||||
|
theme_label: 'Th\u00e8me',
|
||||||
|
lang_label: 'FR'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
drivers: 'Drivers',
|
||||||
|
printers: 'Printers',
|
||||||
|
clients: 'Clients',
|
||||||
|
packages: 'Packages',
|
||||||
|
add_printer: 'Add Printer',
|
||||||
|
add_client: 'Add Client',
|
||||||
|
printer_library: 'Printer Library',
|
||||||
|
edit: 'Edit',
|
||||||
|
delete: 'Delete',
|
||||||
|
save: 'Save',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
upload_driver: 'Upload Driver',
|
||||||
|
printer_name: 'Printer Name',
|
||||||
|
ip_address: 'IP Address',
|
||||||
|
port_name: 'Port Name',
|
||||||
|
driver: 'Driver',
|
||||||
|
duplex_mode: 'Duplex Mode',
|
||||||
|
one_sided: 'One-Sided',
|
||||||
|
long_edge: 'Long Edge',
|
||||||
|
short_edge: 'Short Edge',
|
||||||
|
color_mode: 'Color Mode',
|
||||||
|
paper_size: 'Paper Size',
|
||||||
|
collate: 'Collate',
|
||||||
|
client: 'Client',
|
||||||
|
edit_printer: 'Edit Printer',
|
||||||
|
no_printers: 'No printers configured yet.',
|
||||||
|
no_clients: 'No clients configured yet.',
|
||||||
|
client_list: 'Client List',
|
||||||
|
name: 'Name',
|
||||||
|
created: 'Created',
|
||||||
|
back_to_printers: 'Back to Printers',
|
||||||
|
theme_label: 'Theme',
|
||||||
|
lang_label: 'EN'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<script defer src="/static/alpine.min.js"></script>
|
<script defer src="/static/alpine.min.js"></script>
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -16,16 +120,38 @@
|
|||||||
<strong>ImpTune</strong>
|
<strong>ImpTune</strong>
|
||||||
</div>
|
</div>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}>Dashboard</a></li>
|
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}
|
||||||
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}>Drivers</a></li>
|
x-data x-text="$store.i18n.t('dashboard')">Dashboard</a></li>
|
||||||
<li><a href="/printers" {% if request.url.path == "/printers" %}class="active"{% endif %}>Printers</a></li>
|
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}
|
||||||
<li><a href="/clients" {% if request.url.path == "/clients" %}class="active"{% endif %}>Clients</a></li>
|
x-data x-text="$store.i18n.t('drivers')">Drivers</a></li>
|
||||||
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}>Packages</a></li>
|
<li><a href="/printers" {% if request.url.path == "/printers" %}class="active"{% endif %}
|
||||||
|
x-data x-text="$store.i18n.t('printers')">Printers</a></li>
|
||||||
|
<li><a href="/clients" {% if request.url.path == "/clients" %}class="active"{% endif %}
|
||||||
|
x-data x-text="$store.i18n.t('clients')">Clients</a></li>
|
||||||
|
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}
|
||||||
|
x-data x-text="$store.i18n.t('packages')">Packages</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="main-wrapper">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-controls" x-data>
|
||||||
|
<!-- Theme toggle button: cycles Light -> Dark -> System -->
|
||||||
|
<button class="secondary outline"
|
||||||
|
x-html="$store.theme.icons[$store.theme.current]"
|
||||||
|
:aria-label="$store.theme.current"
|
||||||
|
@click="$store.theme.cycle()"
|
||||||
|
title="Toggle theme">◑</button>
|
||||||
|
<!-- Language toggle button -->
|
||||||
|
<button class="secondary outline"
|
||||||
|
x-text="$store.i18n.t('lang_label')"
|
||||||
|
@click="$store.i18n.toggle()"
|
||||||
|
title="Toggle language">FR</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+21
-1
@@ -43,6 +43,16 @@ def test_packages_returns_200(client):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_toggle_present(client):
|
||||||
|
"""GET / contains a theme toggle button (data-theme cycling control)."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
# The button's @click should reference $store.theme.cycle
|
||||||
|
assert "theme" in response.text
|
||||||
|
assert "cycle" in response.text or "store.theme" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_shows_recent_printers(client):
|
def test_dashboard_shows_recent_printers(client):
|
||||||
"""Dashboard renders names of recently-created printers from DB."""
|
"""Dashboard renders names of recently-created printers from DB."""
|
||||||
from imptune.db.models import Printer
|
from imptune.db.models import Printer
|
||||||
@@ -62,7 +72,17 @@ def test_dashboard_shows_recent_printers(client):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "TestPrinter-Alpha" in response.text
|
assert "TestPrinter-Alpha" in response.text
|
||||||
assert "TestPrinter-Beta" in response.text
|
assert "TestPrinter-Beta" in response.text
|
||||||
assert "No printers configured yet" not in response.text
|
# Use class-specific check: the string also appears in the i18n store JS
|
||||||
|
assert 'class="empty-state">No printers configured yet' not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_toggle_present(client):
|
||||||
|
"""GET / contains a theme toggle button (data-theme cycling control)."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
# The button's @click should reference $store.theme.cycle
|
||||||
|
assert "theme" in response.text
|
||||||
|
assert "cycle" in response.text or "store.theme" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_shows_recent_packages(client):
|
def test_dashboard_shows_recent_packages(client):
|
||||||
|
|||||||
Reference in New Issue
Block a user