feat: memory-only sessions on HTTP, streamed exports, UI refresh
Session - COOKIE_SECURE=false no longer persists the owner key for ten years. services/session.cookie_kwargs() drops max_age in that mode, so the browser holds the key in memory and the session ends with the window. Everything still persists server-side; only the browser link is temporary. base.html shows a warning banner (FR/EN) and an extra paragraph in the onboarding modal, and the README explains the trade-off and the backup-key escape hatch. - Both cookie writers (middleware, POST /session/restore) go through cookie_kwargs() so the policy cannot drift between them. - The CSRF guard on /session/restore compared request.url.scheme against the Origin header. Behind a TLS-terminating proxy uvicorn sees http while the browser sends https, so every legitimate restore was rejected with 403. It now compares hosts only, including X-Forwarded-Host. - /static/*, /favicon.ico and /robots.txt skip the middleware. Each cookieless hit was inserting an Owner row no browser could ever use. Reliability - Malformed printer-form FK fields no longer escape as HTTP 500: a non-numeric client_id/driver_id raised ValueError and an unknown driver_id hit a FOREIGN KEY constraint. Both are now 400/404 HTMX fragments, and the duplicated field checks moved into _validate_fields(). - Package exports stream. build_intunewin() encrypts the inner ZIP in 1 MB chunks against temp files with a streaming HMAC and SHA256, and both endpoints serve the result with FileResponse plus a background cleanup task. A 100 MB driver used to be held in memory three or four times over per concurrent download. The byte layout is unchanged. - FileResponse also escapes the download filename, which was previously interpolated raw into Content-Disposition. - python-multipart >= 0.0.18 (CVE-2024-53981, reachable from /drivers/upload) and Pillow >= 10.3 (CVE-2024-28219, reachable from icon upload). - icons.py reads cfg.ICONS_DIR instead of re-deriving the path from DATA_DIR, matching the .intunewin export. UI - Sidebar/topbar shell, inline SVG icon macros (partials/icons.html), card and data-table components, grouped printer list, and the dedicated /printers/new page replacing partials/printer_form.html. Tests - 194 pass with a bare `pytest tests/`: tests/conftest.py now forces cfg.COOKIE_SECURE = False like the e2e conftest already did, so the Secure cookie is no longer dropped over http://testserver. - New coverage for the malformed-FK guards, the chunk-boundary cases in the encrypt loop (every residue mod _CHUNK plus a multi-megabyte payload), temp-dir cleanup after both exports, and the whole COOKIE_SECURE matrix. - test_printer_edit.py located the Edit button by its translated label, so it only passed on English-locale machines. It now targets the showModal() hook, which also cuts the e2e run from 84s to 15s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+338
-160
@@ -1,9 +1,11 @@
|
||||
{% import "partials/icons.html" as ico %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ImpTune</title>
|
||||
<title>{% block head_title %}ImpTune{% endblock %}</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230d6f79'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='16' font-weight='700' text-anchor='middle' fill='white'%3EiT%3C/text%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/static/pico.min.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script>
|
||||
@@ -11,7 +13,11 @@
|
||||
// Theme store: cycles Light -> Dark -> System, persists in localStorage
|
||||
Alpine.store('theme', {
|
||||
current: localStorage.getItem('imptune_theme') || 'auto',
|
||||
icons: { light: '☀', dark: '☾', auto: '◑' },
|
||||
icons: {
|
||||
light: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4"/></svg>',
|
||||
dark: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/></svg>',
|
||||
auto: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="12" cy="12" r="8"/><path d="M12 4v16a8 8 0 0 0 0-16Z" fill="currentColor" stroke="none"/></svg>'
|
||||
},
|
||||
init() {
|
||||
document.documentElement.setAttribute('data-theme', this.current);
|
||||
},
|
||||
@@ -23,6 +29,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Client-side list filter. Lives in a store so rows swapped in by HTMX
|
||||
// (partials/printer_list.html) find it no matter which page included them.
|
||||
Alpine.store('filter', { q: '' });
|
||||
|
||||
// i18n store: FR/EN toggle, persists in localStorage
|
||||
Alpine.store('i18n', {
|
||||
lang: (() => {
|
||||
@@ -46,49 +56,78 @@
|
||||
packages: 'Paquets',
|
||||
add_printer: 'Ajouter une imprimante',
|
||||
add_client: 'Ajouter un client',
|
||||
printer_library: 'Biblioth\u00e8que d\u2019imprimantes',
|
||||
printer_library: 'Bibliothèque d’imprimantes',
|
||||
edit: 'Modifier',
|
||||
delete: 'Supprimer',
|
||||
save: 'Enregistrer',
|
||||
cancel: 'Annuler',
|
||||
upload_driver: 'T\u00e9l\u00e9charger un pilote',
|
||||
printer_name: 'Nom de l\u2019imprimante',
|
||||
upload_driver: 'Importer un pilote',
|
||||
printer_name: 'Nom de l’imprimante',
|
||||
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',
|
||||
long_edge: 'Grand côté',
|
||||
short_edge: 'Petit côté',
|
||||
color_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.',
|
||||
edit_printer: 'Modifier l’imprimante',
|
||||
no_printers: 'Aucune imprimante configurée.',
|
||||
no_clients: 'Aucun client configuré.',
|
||||
client_list: 'Liste des clients',
|
||||
name: 'Nom',
|
||||
created: 'Cr\u00e9\u00e9 le',
|
||||
created: 'Créé le',
|
||||
back_to_printers: 'Retour aux imprimantes',
|
||||
theme_label: 'Th\u00e8me',
|
||||
theme_label: 'Thème',
|
||||
lang_label: 'FR',
|
||||
// Dashboard page
|
||||
new_printer: 'Nouvelle imprimante',
|
||||
upload_driver_btn: 'T\u00e9l\u00e9charger un pilote',
|
||||
export_package: 'Exporter un paquet',
|
||||
recent_activity: 'Activit\u00e9 r\u00e9cente',
|
||||
recent_printers: 'Imprimantes r\u00e9centes',
|
||||
recent_packages: 'Paquets r\u00e9cents',
|
||||
no_packages: 'Aucun paquet export\u00e9 pour l\u2019instant.',
|
||||
// Printers new page
|
||||
// Shell
|
||||
brand_sub: 'Paquets d’impression',
|
||||
nav_library: 'Bibliothèque',
|
||||
nav_deploy: 'Déploiement',
|
||||
session_menu: 'Cette session',
|
||||
session_download_key: 'Télécharger ma clé de secours',
|
||||
session_restore_key: 'Restaurer depuis une clé',
|
||||
session_note: 'Vos imprimantes sont liées à ce navigateur. La clé de secours les récupère ailleurs.',
|
||||
ephemeral_warning: 'Cette session ne dure que tant que ce navigateur reste ouvert : vos imprimantes et réglages sont bien enregistrés, mais le lien vers eux est perdu à la fermeture. Téléchargez votre clé de secours pour les conserver.',
|
||||
open_menu: 'Ouvrir le menu',
|
||||
// Dashboard
|
||||
dashboard_intro: 'Trois étapes du fichier ZIP au paquet déployable.',
|
||||
step_driver_title: 'Importer le pilote',
|
||||
step_driver_desc: 'Un ZIP contenant le .inf et ses fichiers. ImpTune lit le .inf et détecte les modèles.',
|
||||
step_printer_title: 'Configurer l’imprimante',
|
||||
step_printer_desc: 'Nom, adresse IP, pilote et réglages par défaut du poste.',
|
||||
step_package_title: 'Exporter le paquet',
|
||||
step_package_desc: '.intunewin pour Intune, ZIP pour NinjaRMM. Scripts inclus.',
|
||||
step_done: 'Fait',
|
||||
go_upload_driver: 'Importer un pilote',
|
||||
go_add_printer: 'Ajouter une imprimante',
|
||||
go_export: 'Voir les paquets',
|
||||
view_all: 'Tout voir',
|
||||
recent_activity: 'Activité récente',
|
||||
recent_printers: 'Imprimantes récentes',
|
||||
recent_packages: 'Prêtes à exporter',
|
||||
no_packages: 'Aucune imprimante prête à exporter.',
|
||||
ready_to_export: 'Prête',
|
||||
needs_driver: 'Pilote manquant',
|
||||
// Printers new
|
||||
add_printer_title: 'Ajouter une imprimante',
|
||||
back_to_printer_library: '\u2190 Retour \u00e0 la biblioth\u00e8que',
|
||||
save_printer: 'Enregistrer l\u2019imprimante',
|
||||
upload_new_driver: 'T\u00e9l\u00e9charger un nouveau pilote',
|
||||
no_driver_option: '-- Aucun pilote --',
|
||||
unassigned_option: '-- Non assign\u00e9 --',
|
||||
// Printer list table headers
|
||||
back_to_printer_library: 'Imprimantes',
|
||||
save_printer: 'Enregistrer l’imprimante',
|
||||
upload_new_driver: 'Importer un nouveau pilote',
|
||||
no_driver_option: 'Aucun pilote',
|
||||
unassigned_option: 'Non assigné',
|
||||
section_identity: 'Identification',
|
||||
section_connection: 'Connexion réseau',
|
||||
section_driver: 'Pilote',
|
||||
section_defaults: 'Réglages par défaut',
|
||||
hint_port: 'Dérivé automatiquement de l’adresse IP. Modifiable.',
|
||||
hint_name: 'Le nom vu par l’utilisateur sur son poste.',
|
||||
hint_driver: 'Sans pilote, l’imprimante est enregistrée mais l’export reste bloqué.',
|
||||
hint_ip: 'Adresse IPv4 de l’imprimante sur le réseau du client.',
|
||||
// Printer list
|
||||
th_name: 'Nom',
|
||||
th_ip: 'Adresse IP',
|
||||
th_port: 'Port',
|
||||
@@ -98,37 +137,58 @@
|
||||
th_paper: 'Format',
|
||||
th_collate: 'Assemblage',
|
||||
th_actions: 'Actions',
|
||||
th_status: 'État',
|
||||
th_config: 'Réglages',
|
||||
th_export: 'Export',
|
||||
th_printers: 'Imprimantes',
|
||||
th_used_by: 'Utilisé par',
|
||||
th_size: 'Taille',
|
||||
yes: 'Oui',
|
||||
no: 'Non',
|
||||
no_driver_assigned: '\u2014',
|
||||
// Clients page
|
||||
no_driver_assigned: '—',
|
||||
filter_printers: 'Filtrer par nom, IP ou client',
|
||||
no_match: 'Aucune imprimante ne correspond au filtre.',
|
||||
printers_intro: 'Une imprimante prête a un pilote assigné : elle peut être exportée.',
|
||||
// Clients
|
||||
client_name_label: 'Nom du client',
|
||||
add_client_section: 'Ajouter un client',
|
||||
client_list_section: 'Liste des clients',
|
||||
// Client detail page
|
||||
back_to_clients: '\u2190 Tous les clients',
|
||||
clients_intro: 'Les clients regroupent les imprimantes par site ou par organisation.',
|
||||
open_client: 'Ouvrir',
|
||||
// Client detail
|
||||
back_to_clients: 'Clients',
|
||||
printers_section: 'Imprimantes',
|
||||
// Drivers page
|
||||
// Drivers
|
||||
drivers_title: 'Pilotes',
|
||||
upload_driver_section: 'T\u00e9l\u00e9charger un package de pilote',
|
||||
driver_library: 'Biblioth\u00e8que de pilotes',
|
||||
uploading: 'T\u00e9l\u00e9chargement...',
|
||||
upload_btn: 'T\u00e9l\u00e9charger',
|
||||
driver_filename: 'Nom du fichier',
|
||||
upload_driver_section: 'Importer un package de pilote',
|
||||
driver_library: 'Bibliothèque de pilotes',
|
||||
uploading: 'Import en cours…',
|
||||
upload_btn: 'Importer',
|
||||
driver_filename: 'Fichier',
|
||||
driver_names_col: 'Nom(s) du pilote',
|
||||
architecture: 'Architecture',
|
||||
uploaded_at: 'T\u00e9l\u00e9charg\u00e9 le',
|
||||
uploaded_at: 'Importé le',
|
||||
unknown: 'Inconnu',
|
||||
no_drivers: 'Aucun pilote t\u00e9l\u00e9charg\u00e9.',
|
||||
// Packages page
|
||||
no_drivers: 'Aucun pilote importé.',
|
||||
drivers_intro: 'Les pilotes sont partagés : tout le monde voit la même bibliothèque.',
|
||||
driver_package_label: 'Package de pilote (ZIP)',
|
||||
hint_driver_zip: 'Le ZIP doit contenir le fichier .inf et tous les fichiers auxquels il renvoie.',
|
||||
unused_files: 'fichier(s) du ZIP ne sont pas référencés par le .inf.',
|
||||
show_unused: 'Voir ces fichiers',
|
||||
// Packages
|
||||
packages_title: 'Paquets',
|
||||
packages_description: 'Imprimantes avec pilotes assign\u00e9s \u2014 pr\u00eates pour l\u2019export.',
|
||||
packages_description: 'Imprimantes avec un pilote assigné — prêtes pour l’export.',
|
||||
printer_col: 'Imprimante',
|
||||
client_col: 'Client',
|
||||
driver_col: 'Pilote',
|
||||
downloads_col: 'T\u00e9l\u00e9chargements',
|
||||
no_packages_ready: 'Aucune imprimante pr\u00eate. Assignez un pilote pour activer l\u2019export.',
|
||||
// Printer detail page
|
||||
downloads_col: 'Téléchargements',
|
||||
no_packages_ready: 'Aucune imprimante prête. Assignez un pilote pour activer l’export.',
|
||||
format_intune: 'Intune',
|
||||
format_ninja: 'NinjaRMM',
|
||||
hint_intunewin: '.intunewin — à téléverser dans Intune comme application Win32.',
|
||||
hint_ninja: 'ZIP — scripts en clair pour NinjaRMM ou exécution manuelle.',
|
||||
pending_printers: 'imprimante(s) attendent encore un pilote',
|
||||
// Printer detail
|
||||
configuration: 'Configuration',
|
||||
duplex_mode_label: 'Mode recto-verso',
|
||||
color_mode_label: 'Mode couleur',
|
||||
@@ -137,32 +197,41 @@
|
||||
paper_size_label: 'Format papier',
|
||||
collate_label: 'Assemblage',
|
||||
client_label: 'Client',
|
||||
unassigned: 'Non assign\u00e9',
|
||||
unassigned: 'Non assigné',
|
||||
driver_section: 'Pilote',
|
||||
package_label: 'Package',
|
||||
driver_names_label: 'Nom(s) du pilote',
|
||||
architecture_label: 'Architecture',
|
||||
no_driver_detail: 'Aucun pilote assign\u00e9',
|
||||
no_driver_detail: 'Aucun pilote assigné.',
|
||||
assign_driver_cta: 'Assigner un pilote',
|
||||
intune_commands: 'Commandes Intune',
|
||||
install_cmd_label: 'Commande d\u2019installation',
|
||||
uninstall_cmd_label: 'Commande de d\u00e9sinstallation',
|
||||
hint_intune_cmds: 'À coller dans les champs d’installation et de désinstallation de l’application Win32.',
|
||||
install_cmd_label: 'Commande d’installation',
|
||||
uninstall_cmd_label: 'Commande de désinstallation',
|
||||
copy: 'Copier',
|
||||
copied: 'Copi\u00e9\u00a0!',
|
||||
scripts_section: 'Scripts',
|
||||
download_install: 'T\u00e9l\u00e9charger le script d\u2019installation',
|
||||
download_uninstall: 'T\u00e9l\u00e9charger le script de d\u00e9sinstallation',
|
||||
download_detect: 'T\u00e9l\u00e9charger le script de d\u00e9tection',
|
||||
export_section: 'Export',
|
||||
download_ninja: 'T\u00e9l\u00e9charger NinjaRMM ZIP',
|
||||
download_intunewin: 'T\u00e9l\u00e9charger .intunewin',
|
||||
icon_section: 'Ic\u00f4ne',
|
||||
icon_uploaded: 'Ic\u00f4ne t\u00e9l\u00e9charg\u00e9e',
|
||||
upload_icon: 'T\u00e9l\u00e9charger l\u2019ic\u00f4ne',
|
||||
copied: 'Copié',
|
||||
scripts_section: 'Scripts PowerShell',
|
||||
download_install: 'install.ps1',
|
||||
download_uninstall: 'uninstall.ps1',
|
||||
download_detect: 'detect.ps1',
|
||||
export_section: 'Déployer',
|
||||
download_ninja: 'Télécharger le ZIP NinjaRMM',
|
||||
download_intunewin: 'Télécharger le .intunewin',
|
||||
icon_section: 'Icône',
|
||||
icon_uploaded: 'Icône enregistrée',
|
||||
upload_icon: 'Téléverser l’icône',
|
||||
replace_icon: 'Remplacer l’icône',
|
||||
no_icon: 'Aucune icône. Intune affichera l’icône par défaut.',
|
||||
hint_icon: 'PNG, exactement 256 × 256, 750 Ko maximum.',
|
||||
back_to_printers_btn: 'Retour aux imprimantes',
|
||||
export_locked: 'Assignez un pilote à cette imprimante pour débloquer les scripts et l’export.',
|
||||
// Edit modal
|
||||
edit_printer_title: 'Modifier l\u2019imprimante',
|
||||
// Driver upload section
|
||||
driver_package_label: 'Package de pilote (ZIP contenant .inf + fichiers pilote)'
|
||||
edit_printer_title: 'Modifier l’imprimante',
|
||||
// Session restore
|
||||
restore_title: 'Restaurer une clé de secours',
|
||||
restore_desc: 'Collez la clé du fichier imptune-backup-key.txt pour retrouver vos imprimantes, réglages et clients dans ce navigateur.',
|
||||
backup_key_label: 'Clé de secours',
|
||||
restore_btn: 'Restaurer'
|
||||
},
|
||||
en: {
|
||||
dashboard: 'Dashboard',
|
||||
@@ -170,27 +239,27 @@
|
||||
printers: 'Printers',
|
||||
clients: 'Clients',
|
||||
packages: 'Packages',
|
||||
add_printer: 'Add Printer',
|
||||
add_client: 'Add Client',
|
||||
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',
|
||||
upload_driver: 'Add 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',
|
||||
duplex_mode: 'Duplex mode',
|
||||
one_sided: 'One-sided',
|
||||
long_edge: 'Long edge',
|
||||
short_edge: 'Short edge',
|
||||
color_mode: 'Color',
|
||||
paper_size: 'Paper size',
|
||||
collate: 'Collate',
|
||||
client: 'Client',
|
||||
edit_printer: 'Edit Printer',
|
||||
edit_printer: 'Edit printer',
|
||||
no_printers: 'No printers configured yet.',
|
||||
no_clients: 'No clients configured yet.',
|
||||
client_list: 'Client List',
|
||||
@@ -199,24 +268,53 @@
|
||||
back_to_printers: 'Back to Printers',
|
||||
theme_label: 'Theme',
|
||||
lang_label: 'EN',
|
||||
// Dashboard page
|
||||
new_printer: 'New Printer',
|
||||
upload_driver_btn: 'Upload Driver',
|
||||
export_package: 'Export Package',
|
||||
recent_activity: 'Recent Activity',
|
||||
recent_printers: 'Recent Printers',
|
||||
recent_packages: 'Recent Packages',
|
||||
no_packages: 'No packages exported yet.',
|
||||
// Printers new page
|
||||
add_printer_title: 'Add Printer',
|
||||
back_to_printer_library: '\u2190 Back to Printer Library',
|
||||
save_printer: 'Save Printer',
|
||||
upload_new_driver: 'Upload New Driver',
|
||||
no_driver_option: '-- No driver --',
|
||||
unassigned_option: '-- Unassigned --',
|
||||
// Printer list table headers
|
||||
// Shell
|
||||
brand_sub: 'Print packaging',
|
||||
nav_library: 'Library',
|
||||
nav_deploy: 'Deploy',
|
||||
session_menu: 'This session',
|
||||
session_download_key: 'Download my backup key',
|
||||
session_restore_key: 'Restore from a key',
|
||||
session_note: 'Your printers live in this browser. The backup key brings them back elsewhere.',
|
||||
ephemeral_warning: 'This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.',
|
||||
open_menu: 'Open menu',
|
||||
// Dashboard
|
||||
dashboard_intro: 'Three steps from driver ZIP to deployable package.',
|
||||
step_driver_title: 'Add the driver',
|
||||
step_driver_desc: 'A ZIP holding the .inf and its files. ImpTune reads the .inf and finds the models.',
|
||||
step_printer_title: 'Configure the printer',
|
||||
step_printer_desc: 'Name, IP address, driver, and the defaults the workstation gets.',
|
||||
step_package_title: 'Export the package',
|
||||
step_package_desc: '.intunewin for Intune, ZIP for NinjaRMM. Scripts included.',
|
||||
step_done: 'Done',
|
||||
go_upload_driver: 'Add a driver',
|
||||
go_add_printer: 'Add a printer',
|
||||
go_export: 'View packages',
|
||||
view_all: 'View all',
|
||||
recent_activity: 'Recent activity',
|
||||
recent_printers: 'Recent printers',
|
||||
recent_packages: 'Ready to export',
|
||||
no_packages: 'No printers are ready to export yet.',
|
||||
ready_to_export: 'Ready',
|
||||
needs_driver: 'Driver needed',
|
||||
// Printers new
|
||||
add_printer_title: 'Add printer',
|
||||
back_to_printer_library: 'Printers',
|
||||
save_printer: 'Save printer',
|
||||
upload_new_driver: 'Add a new driver',
|
||||
no_driver_option: 'No driver',
|
||||
unassigned_option: 'Unassigned',
|
||||
section_identity: 'Identity',
|
||||
section_connection: 'Network connection',
|
||||
section_driver: 'Driver',
|
||||
section_defaults: 'Print defaults',
|
||||
hint_port: 'Derived from the IP address. Edit it if your naming differs.',
|
||||
hint_name: 'The name the user sees on their workstation.',
|
||||
hint_driver: 'Without a driver the printer is saved, but export stays locked.',
|
||||
hint_ip: 'The printer IPv4 address on the client network.',
|
||||
// Printer list
|
||||
th_name: 'Name',
|
||||
th_ip: 'IP Address',
|
||||
th_ip: 'IP address',
|
||||
th_port: 'Port',
|
||||
th_driver: 'Driver',
|
||||
th_duplex: 'Duplex',
|
||||
@@ -224,71 +322,101 @@
|
||||
th_paper: 'Paper',
|
||||
th_collate: 'Collate',
|
||||
th_actions: 'Actions',
|
||||
th_status: 'Status',
|
||||
th_config: 'Defaults',
|
||||
th_export: 'Export',
|
||||
th_printers: 'Printers',
|
||||
th_used_by: 'Used by',
|
||||
th_size: 'Size',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
no_driver_assigned: '\u2014',
|
||||
// Clients page
|
||||
client_name_label: 'Client Name',
|
||||
add_client_section: 'Add Client',
|
||||
client_list_section: 'Client List',
|
||||
// Client detail page
|
||||
back_to_clients: '\u2190 All Clients',
|
||||
no_driver_assigned: '—',
|
||||
filter_printers: 'Filter by name, IP, or client',
|
||||
no_match: 'No printer matches this filter.',
|
||||
printers_intro: 'A printer with a driver assigned is ready to export.',
|
||||
// Clients
|
||||
client_name_label: 'Client name',
|
||||
add_client_section: 'Add client',
|
||||
client_list_section: 'Client list',
|
||||
clients_intro: 'Clients group printers by site or organization.',
|
||||
open_client: 'Open',
|
||||
// Client detail
|
||||
back_to_clients: 'Clients',
|
||||
printers_section: 'Printers',
|
||||
// Drivers page
|
||||
// Drivers
|
||||
drivers_title: 'Drivers',
|
||||
upload_driver_section: 'Upload Driver Package',
|
||||
driver_library: 'Driver Library',
|
||||
uploading: 'Uploading...',
|
||||
upload_driver_section: 'Add a driver package',
|
||||
driver_library: 'Driver library',
|
||||
uploading: 'Uploading…',
|
||||
upload_btn: 'Upload',
|
||||
driver_filename: 'Filename',
|
||||
driver_names_col: 'Driver Name(s)',
|
||||
driver_filename: 'File',
|
||||
driver_names_col: 'Driver name(s)',
|
||||
architecture: 'Architecture',
|
||||
uploaded_at: 'Uploaded',
|
||||
uploaded_at: 'Added',
|
||||
unknown: 'Unknown',
|
||||
no_drivers: 'No drivers uploaded yet.',
|
||||
// Packages page
|
||||
drivers_intro: 'Drivers are shared — everyone sees the same library.',
|
||||
driver_package_label: 'Driver package (ZIP)',
|
||||
hint_driver_zip: 'The ZIP must contain the .inf file and every file it references.',
|
||||
unused_files: 'file(s) in the ZIP are unused — the .inf never references them.',
|
||||
show_unused: 'Show these files',
|
||||
// Packages
|
||||
packages_title: 'Packages',
|
||||
packages_description: 'Printers with drivers assigned \u2014 ready for deployment package export.',
|
||||
packages_description: 'Printers with a driver assigned — ready for export.',
|
||||
printer_col: 'Printer',
|
||||
client_col: 'Client',
|
||||
driver_col: 'Driver',
|
||||
downloads_col: 'Downloads',
|
||||
no_packages_ready: 'No package-ready printers yet. Assign a driver to a printer to enable package export.',
|
||||
// Printer detail page
|
||||
no_packages_ready: 'No printer is ready yet. Assign a driver to enable export.',
|
||||
format_intune: 'Intune',
|
||||
format_ninja: 'NinjaRMM',
|
||||
hint_intunewin: '.intunewin — upload to Intune as a Win32 app.',
|
||||
hint_ninja: 'ZIP — plain scripts for NinjaRMM or a manual run.',
|
||||
pending_printers: 'printer(s) still need a driver',
|
||||
// Printer detail
|
||||
configuration: 'Configuration',
|
||||
duplex_mode_label: 'Duplex Mode',
|
||||
color_mode_label: 'Color Mode',
|
||||
duplex_mode_label: 'Duplex mode',
|
||||
color_mode_label: 'Color mode',
|
||||
color_value: 'Color',
|
||||
grayscale_value: 'Grayscale',
|
||||
paper_size_label: 'Paper Size',
|
||||
paper_size_label: 'Paper size',
|
||||
collate_label: 'Collate',
|
||||
client_label: 'Client',
|
||||
unassigned: 'Unassigned',
|
||||
driver_section: 'Driver',
|
||||
package_label: 'Package',
|
||||
driver_names_label: 'Driver Name(s)',
|
||||
driver_names_label: 'Driver name(s)',
|
||||
architecture_label: 'Architecture',
|
||||
no_driver_detail: 'No driver assigned',
|
||||
intune_commands: 'Intune Commands',
|
||||
no_driver_detail: 'No driver assigned.',
|
||||
assign_driver_cta: 'Assign a driver',
|
||||
intune_commands: 'Intune commands',
|
||||
hint_intune_cmds: 'Paste these into the Win32 app install and uninstall fields.',
|
||||
install_cmd_label: 'Install command',
|
||||
uninstall_cmd_label: 'Uninstall command',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied!',
|
||||
scripts_section: 'Scripts',
|
||||
download_install: 'Download Install Script',
|
||||
download_uninstall: 'Download Uninstall Script',
|
||||
download_detect: 'Download Detect Script',
|
||||
export_section: 'Export',
|
||||
copied: 'Copied',
|
||||
scripts_section: 'PowerShell scripts',
|
||||
download_install: 'install.ps1',
|
||||
download_uninstall: 'uninstall.ps1',
|
||||
download_detect: 'detect.ps1',
|
||||
export_section: 'Deploy',
|
||||
download_ninja: 'Download NinjaRMM ZIP',
|
||||
download_intunewin: 'Download .intunewin',
|
||||
icon_section: 'Icon',
|
||||
icon_uploaded: 'Icon uploaded',
|
||||
upload_icon: 'Upload Icon',
|
||||
back_to_printers_btn: 'Back to Printers',
|
||||
icon_uploaded: 'Icon saved',
|
||||
upload_icon: 'Upload icon',
|
||||
replace_icon: 'Replace icon',
|
||||
no_icon: 'No icon. Intune will show its default.',
|
||||
hint_icon: 'PNG, exactly 256 × 256, 750 KB max.',
|
||||
back_to_printers_btn: 'Back to printers',
|
||||
export_locked: 'Assign a driver to this printer to unlock scripts and export.',
|
||||
// Edit modal
|
||||
edit_printer_title: 'Edit Printer',
|
||||
// Driver upload section
|
||||
driver_package_label: 'Driver Package (ZIP containing .inf + driver files)'
|
||||
edit_printer_title: 'Edit printer',
|
||||
// Session restore
|
||||
restore_title: 'Restore a backup key',
|
||||
restore_desc: 'Paste the key from imptune-backup-key.txt to get your printers, settings, and clients back in this browser.',
|
||||
backup_key_label: 'Backup key',
|
||||
restore_btn: 'Restore'
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -297,57 +425,107 @@
|
||||
<script defer src="/static/alpine.min.js"></script>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<body x-data="{ nav: false }" :class="nav && 'nav-open'">
|
||||
<div class="layout">
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<strong>ImpTune</strong>
|
||||
<div class="nav-scrim" x-show="nav" @click="nav = false" style="display:none"></div>
|
||||
<nav class="sidebar" @click="nav = false">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">iT</span>
|
||||
<span>
|
||||
<span class="brand-name">ImpTune</span>
|
||||
<span class="brand-sub" x-data x-text="$store.i18n.t('brand_sub')">Print packaging</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-group">
|
||||
<ul class="sidebar-nav">
|
||||
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}>{{ ico.i('gauge') }}<span x-data x-text="$store.i18n.t('dashboard')">Dashboard</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label" x-data x-text="$store.i18n.t('nav_library')">Library</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}>{{ ico.i('driver') }}<span x-data x-text="$store.i18n.t('drivers')">Drivers</span></a></li>
|
||||
<li><a href="/printers" {% if request.url.path.startswith("/printers") %}class="active"{% endif %}>{{ ico.i('printer') }}<span x-data x-text="$store.i18n.t('printers')">Printers</span></a></li>
|
||||
<li><a href="/clients" {% if request.url.path.startswith("/clients") %}class="active"{% endif %}>{{ ico.i('group') }}<span x-data x-text="$store.i18n.t('clients')">Clients</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label" x-data x-text="$store.i18n.t('nav_deploy')">Deploy</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}>{{ ico.i('package') }}<span x-data x-text="$store.i18n.t('packages')">Packages</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<details {% if request.url.path == "/session/restore" %}open{% endif %}>
|
||||
<summary>{{ ico.i('key') }}<span x-data x-text="$store.i18n.t('session_menu')">This session</span></summary>
|
||||
<div class="foot-links">
|
||||
<a href="/session/key/download" x-data x-text="$store.i18n.t('session_download_key')">Download my backup key</a>
|
||||
<a href="/session/restore" x-data x-text="$store.i18n.t('session_restore_key')">Restore from a key</a>
|
||||
<p class="foot-note" x-data x-text="$store.i18n.t('session_note')">Your printers live in this browser.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}
|
||||
x-data x-text="$store.i18n.t('dashboard')">Dashboard</a></li>
|
||||
<li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}
|
||||
x-data x-text="$store.i18n.t('drivers')">Drivers</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 class="sidebar-nav sidebar-nav-footer">
|
||||
<li><a href="/session/key/download">Download backup key</a></li>
|
||||
<li><a href="/session/restore" {% if request.url.path == "/session/restore" %}class="active"{% endif %}>Restore from backup key</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="main-wrapper">
|
||||
<header class="topbar">
|
||||
<div class="topbar-controls" x-data>
|
||||
<button class="icon-btn sidebar-toggle" @click="nav = !nav"
|
||||
x-data :aria-label="$store.i18n.t('open_menu')">{{ ico.i('menu', 18) }}</button>
|
||||
<div class="topbar-title">
|
||||
{% block crumb %}{% endblock %}
|
||||
<h1>{% block page_title %}ImpTune{% endblock %}</h1>
|
||||
</div>
|
||||
<div class="topbar-actions" x-data>
|
||||
{% block page_actions %}{% endblock %}
|
||||
<span class="topbar-sep"></span>
|
||||
<!-- Theme toggle button: cycles Light -> Dark -> System -->
|
||||
<button class="secondary outline"
|
||||
<button class="icon-btn"
|
||||
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"
|
||||
<button class="icon-btn"
|
||||
x-text="$store.i18n.t('lang_label')"
|
||||
@click="$store.i18n.toggle()"
|
||||
title="Toggle language">FR</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
{% if request.state.ephemeral_session %}
|
||||
{# COOKIE_SECURE=false: the owner key is memory-only, so the session
|
||||
dies with the browser window. Say it before work is lost. #}
|
||||
<div class="notice session-ephemeral" id="ephemeral-session-warning">
|
||||
{{ ico.i('key', 16) }}
|
||||
<p x-data x-text="$store.i18n.t('ephemeral_warning')">This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.</p>
|
||||
<a class="btn ghost" href="/session/key/download" x-data x-text="$store.i18n.t('session_download_key')">Download my backup key</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if request.state.is_new_owner %}
|
||||
<dialog open id="session-choice-modal">
|
||||
<article>
|
||||
<h3>Keep your printers?</h3>
|
||||
<p>Your printers, configs, and groups are private to this browser
|
||||
(drivers stay shared with everyone). If you clear your cookies
|
||||
without a backup key, they're gone for good.</p>
|
||||
<footer>
|
||||
<a role="button" href="/session/key/download">Store permanently (download backup key)</a>
|
||||
<button class="secondary" onclick="this.closest('dialog').close()">Keep temporary, this browser only</button>
|
||||
</footer>
|
||||
<div class="dialog-head">
|
||||
<h3>Keep your printers?</h3>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p>Your printers, configs, and clients are private to this browser —
|
||||
drivers stay shared with everyone. Clear your cookies without a backup
|
||||
key and they are gone for good.</p>
|
||||
{% if request.state.ephemeral_session %}
|
||||
<p><strong>This server runs without HTTPS</strong>, so this session is
|
||||
kept in memory only: closing the browser ends it. The backup key is the
|
||||
only way back in.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="dialog-foot">
|
||||
<button class="btn ghost" onclick="this.closest('dialog').close()">Keep in this browser</button>
|
||||
<a role="button" class="btn" href="/session/key/download">Download backup key</a>
|
||||
</div>
|
||||
</article>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}{{ client.name }} · ImpTune{% endblock %}
|
||||
{% block crumb %}
|
||||
<span class="crumb" x-data>
|
||||
<a href="/clients">{{ ico.i('back', 12) }}<span x-text="$store.i18n.t('back_to_clients')">Clients</span></a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
{% block page_title %}{{ client.name }}{% endblock %}
|
||||
|
||||
{% block page_actions %}
|
||||
<a href="/printers/new" class="btn sm">{{ ico.i('plus') }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ client.name }}</h1>
|
||||
<p><a href="/clients" x-data x-text="$store.i18n.t('back_to_clients')">← All Clients</a></p>
|
||||
<div class="toolbar">
|
||||
<h2 class="eyebrow" x-data x-text="$store.i18n.t('printers_section')">Printers</h2>
|
||||
<span class="spacer"></span>
|
||||
{% if printer_count %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}{{ ready_count }} <span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
|
||||
{% if printer_count - ready_count %}
|
||||
<span class="badge warn">{{ ico.i('alert', 12) }}{{ printer_count - ready_count }} <span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('printers_section')">Printers</h2>
|
||||
{% include "partials/printer_list.html" %}
|
||||
</section>
|
||||
{% include "partials/printer_list.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}Clients · ImpTune{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('clients')">Clients</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('clients')">Clients</h1>
|
||||
<p class="page-intro" x-data x-text="$store.i18n.t('clients_intro')">Clients group printers by site or organization.</p>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('add_client_section')">Add Client</h2>
|
||||
<form hx-post="/clients" hx-target="#client-list" hx-swap="outerHTML">
|
||||
<label>
|
||||
<span x-data x-text="$store.i18n.t('client_name_label')">Client Name</span>
|
||||
<input type="text" name="name" placeholder="e.g. Contoso" required>
|
||||
</label>
|
||||
<button type="submit" x-data x-text="$store.i18n.t('add_client')">Add Client</button>
|
||||
</form>
|
||||
</section>
|
||||
<div class="split">
|
||||
<div>
|
||||
{% include "partials/client_list.html" %}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('client_list_section')">Client List</h2>
|
||||
{% include "partials/client_list.html" %}
|
||||
</section>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('plus') }}
|
||||
<h2 x-data x-text="$store.i18n.t('add_client_section')">Add client</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form hx-post="/clients" hx-target="#client-list" hx-swap="outerHTML"
|
||||
hx-on::after-request="if (event.detail.successful) this.reset()">
|
||||
<label>
|
||||
<span class="label-text" x-data x-text="$store.i18n.t('client_name_label')">Client name</span>
|
||||
<input type="text" name="name" placeholder="Contoso" required>
|
||||
</label>
|
||||
<div class="btn-row" style="margin-top:.8rem">
|
||||
<button type="submit" class="btn" x-data>
|
||||
{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_client')">Add client</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,41 +1,147 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}ImpTune{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('dashboard')">Dashboard</span>{% endblock %}
|
||||
|
||||
{% block page_actions %}
|
||||
<a href="/drivers" class="btn ghost sm">{{ ico.i('upload') }}<span x-text="$store.i18n.t('upload_driver')">Add driver</span></a>
|
||||
<a href="/printers/new" class="btn sm">{{ ico.i('plus') }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('dashboard')">Dashboard</h1>
|
||||
<p class="page-intro" x-data x-text="$store.i18n.t('dashboard_intro')">Three steps from driver ZIP to deployable package.</p>
|
||||
|
||||
<div class="quick-actions">
|
||||
<a href="/printers" class="btn-action" x-data x-text="$store.i18n.t('new_printer')">New Printer</a>
|
||||
<a href="/drivers" class="btn-action" x-data x-text="$store.i18n.t('upload_driver_btn')">Upload Driver</a>
|
||||
<a href="/packages" class="btn-action" x-data x-text="$store.i18n.t('export_package')">Export Package</a>
|
||||
{#
|
||||
The rail is the spine of the tool: a printer cannot be exported before it has
|
||||
a driver, so the order is real and the state of each stage is worth showing.
|
||||
#}
|
||||
{% set s1 = 'done' if driver_count else 'next' %}
|
||||
{% set s2 = 'done' if printer_count else ('next' if driver_count else '') %}
|
||||
{% set s3 = 'done' if ready_count else ('next' if printer_count else '') %}
|
||||
<div class="rail">
|
||||
<a class="rail-step {{ s1 }}" href="/drivers">
|
||||
<div class="rail-top">
|
||||
<span class="rail-num">{% if s1 == 'done' %}{{ ico.i('check', 13) }}{% else %}1{% endif %}</span>
|
||||
<span class="rail-title" x-data x-text="$store.i18n.t('step_driver_title')">Add the driver</span>
|
||||
<span class="rail-count">{{ driver_count }}</span>
|
||||
</div>
|
||||
<p class="rail-desc" x-data x-text="$store.i18n.t('step_driver_desc')">A ZIP holding the .inf and its files.</p>
|
||||
<span class="rail-cta" x-data>
|
||||
{% if s1 == 'done' %}<span x-text="$store.i18n.t('drivers')">Drivers</span>{{ ico.i('chevron', 13) }}
|
||||
{% else %}<span x-text="$store.i18n.t('go_upload_driver')">Add a driver</span>{{ ico.i('chevron', 13) }}{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="rail-step {{ s2 }}" href="{% if printer_count %}/printers{% else %}/printers/new{% endif %}">
|
||||
<div class="rail-top">
|
||||
<span class="rail-num">{% if s2 == 'done' %}{{ ico.i('check', 13) }}{% else %}2{% endif %}</span>
|
||||
<span class="rail-title" x-data x-text="$store.i18n.t('step_printer_title')">Configure the printer</span>
|
||||
<span class="rail-count">{{ printer_count }}</span>
|
||||
</div>
|
||||
<p class="rail-desc" x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and defaults.</p>
|
||||
<span class="rail-cta" x-data>
|
||||
{% if s2 == 'done' %}<span x-text="$store.i18n.t('printers')">Printers</span>{{ ico.i('chevron', 13) }}
|
||||
{% else %}<span x-text="$store.i18n.t('go_add_printer')">Add a printer</span>{{ ico.i('chevron', 13) }}{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="rail-step {{ s3 }}" href="/packages">
|
||||
<div class="rail-top">
|
||||
<span class="rail-num">{% if s3 == 'done' %}{{ ico.i('check', 13) }}{% else %}3{% endif %}</span>
|
||||
<span class="rail-title" x-data x-text="$store.i18n.t('step_package_title')">Export the package</span>
|
||||
<span class="rail-count">{{ ready_count }}</span>
|
||||
</div>
|
||||
<p class="rail-desc" x-data x-text="$store.i18n.t('step_package_desc')">.intunewin for Intune, ZIP for NinjaRMM.</p>
|
||||
<span class="rail-cta" x-data>
|
||||
<span x-text="$store.i18n.t('go_export')">View packages</span>{{ ico.i('chevron', 13) }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<section class="recent-activity">
|
||||
<h2 x-data x-text="$store.i18n.t('recent_activity')">Recent Activity</h2>
|
||||
<div class="split">
|
||||
<section class="card card-flush">
|
||||
<div class="card-head">
|
||||
<h2 x-data x-text="$store.i18n.t('recent_printers')">Recent printers</h2>
|
||||
{% if recent_printers %}
|
||||
<span class="head-actions">
|
||||
<a href="/printers" class="btn quiet sm" x-data><span x-text="$store.i18n.t('view_all')">View all</span>{{ ico.i('chevron', 13) }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_printers %}
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
{% for printer in recent_printers %}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="cell-name" href="/printers/{{ printer.id }}">{{ printer.name }}</a>
|
||||
<span class="cell-sub">{{ printer.ip_address }}{% if printer.client_id %} · {{ printer.client.name }}{% endif %}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if printer.driver_id %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
|
||||
{% else %}
|
||||
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('printer', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and defaults.</p>
|
||||
<a href="/printers/new" class="btn sm" x-data>{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="activity-section">
|
||||
<h3 x-data x-text="$store.i18n.t('recent_printers')">Recent Printers</h3>
|
||||
{% if recent_printers %}
|
||||
<ul>
|
||||
{% for printer in recent_printers %}
|
||||
<li><a href="/printers/{{ printer.id }}">{{ printer.name }}</a> — {{ printer.ip_address }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state" x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="activity-section">
|
||||
<h3 x-data x-text="$store.i18n.t('recent_packages')">Recent Packages</h3>
|
||||
{% if recent_packages %}
|
||||
<ul>
|
||||
{% for package in recent_packages %}
|
||||
<li><a href="/printers/{{ package.id }}">{{ package.name }}</a>{% if package.client_id %} — {{ package.client.name }}{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state" x-data x-text="$store.i18n.t('no_packages')">No packages exported yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="card card-flush">
|
||||
<div class="card-head">
|
||||
<h2 x-data x-text="$store.i18n.t('recent_packages')">Ready to export</h2>
|
||||
{% if recent_packages %}
|
||||
<span class="head-actions">
|
||||
<a href="/packages" class="btn quiet sm" x-data><span x-text="$store.i18n.t('view_all')">View all</span>{{ ico.i('chevron', 13) }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_packages %}
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<tbody>
|
||||
{% for package in recent_packages %}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="cell-name" href="/printers/{{ package.id }}">{{ package.name }}</a>
|
||||
<span class="cell-sub">{% if package.client_id %}{{ package.client.name }}{% else %}—{% endif %}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span class="btn-row">
|
||||
<a class="btn quiet sm" href="/printers/{{ package.id }}/packages/intunewin">{{ ico.i('download', 13) }}<span class="mono">.intunewin</span></a>
|
||||
<a class="btn quiet sm" href="/printers/{{ package.id }}/packages/ninja">{{ ico.i('download', 13) }}<span class="mono">.zip</span></a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('package', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_packages')">No printers are ready to export yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('no_packages_ready')">No printer is ready yet. Assign a driver to enable export.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}Drivers · ImpTune{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('drivers_title')">Drivers</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('drivers_title')">Drivers</h1>
|
||||
<p class="page-intro" x-data x-text="$store.i18n.t('drivers_intro')">Drivers are shared — everyone sees the same library.</p>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('upload_driver_section')">Upload Driver Package</h2>
|
||||
<form
|
||||
hx-post="/drivers/upload"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-target="#driver-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#upload-spinner"
|
||||
>
|
||||
<label for="driver-file" x-data x-text="$store.i18n.t('driver_package_label')">Driver Package (ZIP containing .inf + driver files)</label>
|
||||
<input type="file" id="driver-file" name="file" accept=".zip" required>
|
||||
<button type="submit" x-data x-text="$store.i18n.t('upload_btn')">Upload</button>
|
||||
<span id="upload-spinner" class="htmx-indicator" aria-busy="true" x-data x-text="$store.i18n.t('uploading')">Uploading...</span>
|
||||
</form>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('upload') }}
|
||||
<div>
|
||||
<h2 x-data x-text="$store.i18n.t('upload_driver_section')">Add a driver package</h2>
|
||||
<p class="sub" x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form
|
||||
hx-post="/drivers/upload"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-target="#driver-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#upload-spinner"
|
||||
>
|
||||
<div class="uploader">
|
||||
<label for="driver-file" class="label-text" x-data x-text="$store.i18n.t('driver_package_label')">Driver package (ZIP)</label>
|
||||
<input type="file" id="driver-file" name="file" accept=".zip" required>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn" x-data>
|
||||
{{ ico.i('upload', 14) }}<span x-text="$store.i18n.t('upload_btn')">Upload</span>
|
||||
</button>
|
||||
<span id="upload-spinner" class="htmx-indicator" aria-busy="true"
|
||||
x-data x-text="$store.i18n.t('uploading')">Uploading…</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('driver_library')">Driver Library</h2>
|
||||
{% include "partials/driver_list.html" %}
|
||||
</section>
|
||||
<h2 class="eyebrow" style="margin-bottom:.6rem" x-data x-text="$store.i18n.t('driver_library')">Driver library</h2>
|
||||
{% include "partials/driver_list.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,37 +1,91 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}Packages · ImpTune{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('packages_title')">Packages</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('packages_title')">Packages</h1>
|
||||
<p x-data x-text="$store.i18n.t('packages_description')">Printers with drivers assigned — ready for deployment package export.</p>
|
||||
<p class="page-intro" x-data x-text="$store.i18n.t('packages_description')">Printers with a driver assigned — ready for export.</p>
|
||||
|
||||
{% if printers %}
|
||||
<figure>
|
||||
<table role="grid">
|
||||
<div class="card-grid">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('package') }}
|
||||
<h2 class="mono">.intunewin</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="dim" x-data x-text="$store.i18n.t('hint_intunewin')">.intunewin — upload to Intune as a Win32 app.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('script') }}
|
||||
<h2 class="mono">.zip</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="dim" x-data x-text="$store.i18n.t('hint_ninja')">ZIP — plain scripts for NinjaRMM or a manual run.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-flush">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" x-data x-text="$store.i18n.t('printer_col')">Printer</th>
|
||||
<th scope="col" x-data x-text="$store.i18n.t('client_col')">Client</th>
|
||||
<th scope="col" x-data x-text="$store.i18n.t('driver_col')">Driver</th>
|
||||
<th scope="col" x-data x-text="$store.i18n.t('icon_section')">Icon</th>
|
||||
<th scope="col" x-data x-text="$store.i18n.t('downloads_col')">Downloads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for printer in printers %}
|
||||
<tr>
|
||||
<td><a href="/printers/{{ printer.id }}">{{ printer.name }}</a></td>
|
||||
<td>{% if printer.client_id %}{{ printer.client.name }}{% else %}—{% endif %}</td>
|
||||
<td>{{ printer.driver.original_filename }}</td>
|
||||
<td>
|
||||
<a href="/printers/{{ printer.id }}/packages/intunewin">.intunewin</a>
|
||||
|
|
||||
<a href="/printers/{{ printer.id }}/packages/ninja">NinjaRMM ZIP</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a class="cell-name" href="/printers/{{ printer.id }}">{{ printer.name }}</a>
|
||||
<span class="cell-sub">{% if printer.client_id %}{{ printer.client.name }}{% else %}—{% endif %}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">{{ printer.driver.original_filename }}</span>
|
||||
{% if printer.driver.architecture %}<span class="cell-sub">{{ printer.driver.architecture }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if printer.id in icon_printer_ids %}
|
||||
<img src="/printers/{{ printer.id }}/icon" width="28" height="28" alt=""
|
||||
style="border-radius:5px;vertical-align:middle">
|
||||
{% else %}
|
||||
<span class="faint">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span class="btn-row">
|
||||
<a class="btn ghost sm" href="/printers/{{ printer.id }}/packages/intunewin">{{ ico.i('download', 13) }}<span class="mono">.intunewin</span></a>
|
||||
<a class="btn ghost sm" href="/printers/{{ printer.id }}/packages/ninja">{{ ico.i('download', 13) }}<span class="mono">.zip</span></a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if pending_count %}
|
||||
<p class="hint" style="margin-top:.9rem" x-data>
|
||||
<a href="/printers">{{ pending_count }} <span x-text="$store.i18n.t('pending_printers')">printers still need a driver</span></a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="empty-state" x-data x-text="$store.i18n.t('no_packages_ready')">No package-ready printers yet. Assign a driver to a printer to enable package export.</p>
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('package', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_packages_ready')">No printer is ready yet. Assign a driver to enable export.</strong>
|
||||
<p x-data x-text="$store.i18n.t('step_package_desc')">.intunewin for Intune, ZIP for NinjaRMM. Scripts included.</p>
|
||||
<a href="/printers" class="btn sm" x-data>{{ ico.i('printer', 14) }}<span x-text="$store.i18n.t('printers')">Printers</span></a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
{% import "partials/icons.html" as ico %}
|
||||
{% set counts = counts | default({}, true) %}
|
||||
<div id="client-list">
|
||||
{% if not clients %}
|
||||
<p x-data x-text="$store.i18n.t('no_clients')">No clients configured yet.</p>
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('group', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_clients')">No clients configured yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('clients_intro')">Clients group printers by site or organization.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
|
||||
<th x-data x-text="$store.i18n.t('created')">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in clients %}
|
||||
<tr>
|
||||
<td><a href="/clients/{{ c.id }}">{{ c.name }}</a></td>
|
||||
<td>{{ c.created_at.strftime('%Y-%m-%d') if c.created_at else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card card-flush">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
|
||||
<th x-data x-text="$store.i18n.t('th_printers')">Printers</th>
|
||||
<th x-data x-text="$store.i18n.t('created')">Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in clients %}
|
||||
<tr>
|
||||
<td><a class="cell-name" href="/clients/{{ c.id }}">{{ c.name }}</a></td>
|
||||
<td class="num">
|
||||
{% set n = counts.get(c.id, 0) %}
|
||||
{% if n %}{{ n }}{% else %}<span class="faint">0</span>{% endif %}
|
||||
</td>
|
||||
<td class="num">{{ c.created_at.strftime('%Y-%m-%d') if c.created_at else '—' }}</td>
|
||||
<td class="actions">
|
||||
<a class="btn quiet sm" href="/clients/{{ c.id }}" x-data>
|
||||
<span x-text="$store.i18n.t('open_client')">Open</span>{{ ico.i('chevron', 13) }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,50 +1,95 @@
|
||||
{% import "partials/icons.html" as ico %}
|
||||
{% set usage = usage | default({}, true) %}
|
||||
{% set new_driver_id = new_driver_id | default(None, true) %}
|
||||
<div id="driver-list">
|
||||
{% if parsed is defined and parsed.unused_files %}
|
||||
<p class="notice">
|
||||
{{ parsed.unused_files | length }} file(s) may be unused (not referenced by the INF):
|
||||
<div class="notice">
|
||||
<p>
|
||||
{{ parsed.unused_files | length }}
|
||||
<span x-data x-text="$store.i18n.t('unused_files')">file(s) in the ZIP are unused — the .inf never references them.</span>
|
||||
</p>
|
||||
<details>
|
||||
<summary>Show unused files</summary>
|
||||
<summary x-data x-text="$store.i18n.t('show_unused')">Show unused files</summary>
|
||||
<ul>
|
||||
{% for f in parsed.unused_files %}
|
||||
<li>{{ f }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if driver_data %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('driver_filename')">Filename</th>
|
||||
<th x-data x-text="$store.i18n.t('driver_names_col')">Driver Name(s)</th>
|
||||
<th x-data x-text="$store.i18n.t('architecture')">Architecture</th>
|
||||
<th x-data x-text="$store.i18n.t('uploaded_at')">Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in driver_data %}
|
||||
<tr>
|
||||
<td>{{ item.driver.original_filename }}</td>
|
||||
<td>
|
||||
{% if item.names %}
|
||||
<select aria-label="Driver names">
|
||||
{% for name in item.names %}
|
||||
<option>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<em x-data x-text="$store.i18n.t('unknown')">Unknown</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if item.driver.architecture %}{{ item.driver.architecture }}{% else %}<span x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</td>
|
||||
<td>{{ item.driver.uploaded_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card card-flush">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('driver_filename')">File</th>
|
||||
<th x-data x-text="$store.i18n.t('driver_names_col')">Driver name(s)</th>
|
||||
<th class="col-arch" x-data x-text="$store.i18n.t('architecture')">Architecture</th>
|
||||
<th class="col-used" x-data x-text="$store.i18n.t('th_used_by')">Used by</th>
|
||||
<th class="col-added" x-data x-text="$store.i18n.t('uploaded_at')">Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in driver_data %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="cell-name mono">{{ item.driver.original_filename }}</span>
|
||||
<span class="cell-sub">
|
||||
{% if item.driver.size_bytes >= 1048576 %}{{ (item.driver.size_bytes / 1048576) | round(1) }} MB{% elif item.driver.size_bytes >= 1024 %}{{ (item.driver.size_bytes / 1024) | round(0) | int }} KB{% else %}{{ item.driver.size_bytes }} B{% endif %}
|
||||
{% if item.driver.inf_filename %} · {{ item.driver.inf_filename }}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if item.names %}
|
||||
{# Multi-model INFs can carry a dozen names — show three, fold the rest. #}
|
||||
<span class="pill-row">
|
||||
{% for name in item.names[:3] %}<span class="pill">{{ name }}</span>{% endfor %}
|
||||
{% if item.names | length > 3 %}
|
||||
<details class="more-pills">
|
||||
<summary class="pill">+{{ item.names | length - 3 }}</summary>
|
||||
<span class="pill-row">
|
||||
{% for name in item.names[3:] %}<span class="pill">{{ name }}</span>{% endfor %}
|
||||
</span>
|
||||
</details>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-arch">
|
||||
{% if item.driver.architecture %}
|
||||
<span class="badge">{{ item.driver.architecture }}</span>
|
||||
{% else %}
|
||||
<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="num col-used">
|
||||
{% set n = usage.get(item.driver.id, 0) %}
|
||||
{% if n %}<a href="/printers">{{ n }}</a>{% else %}<span class="faint">0</span>{% endif %}
|
||||
</td>
|
||||
<td class="num col-added">
|
||||
{{ item.driver.uploaded_at.strftime('%Y-%m-%d %H:%M') if item.driver.uploaded_at is not string else item.driver.uploaded_at }}
|
||||
{% if new_driver_id and item.driver.id == new_driver_id %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}new</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p x-data x-text="$store.i18n.t('no_drivers')">No drivers uploaded yet.</p>
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('driver', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_drivers')">No drivers uploaded yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{% include "partials/driver_list.html" %}
|
||||
|
||||
{# Out-of-band: refresh the printer form's driver picker with the new driver chosen. #}
|
||||
<select name="driver_id" id="printer-form-driver-select" hx-swap-oob="true">
|
||||
<option value="">-- No driver --</option>
|
||||
<option value="" x-data x-text="$store.i18n.t('no_driver_option')">No driver</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}"
|
||||
{% if item.driver.id == new_driver_id %}selected{% endif %}>
|
||||
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
|
||||
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{#
|
||||
Inline SVG icon set — no external files, no text nodes.
|
||||
|
||||
Text-node-free on purpose: E2E tests read `textContent` of nav links to assert
|
||||
the translated label, so icons must contribute no characters.
|
||||
|
||||
Usage: {% import "partials/icons.html" as ico %} → {{ ico.i('printer') }}
|
||||
#}
|
||||
{% macro i(name, size=16) -%}
|
||||
{%- set s = size -%}
|
||||
<svg class="ico" width="{{ s }}" height="{{ s }}" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
{%- if name == 'gauge' -%}
|
||||
<path d="M12 21a9 9 0 1 0-9-9"/><path d="M3 12h2"/><path d="M19 12h2"/><path d="M12 3v2"/><path d="m12 12 4.5-3.5"/><circle cx="12" cy="12" r="1.6"/>
|
||||
{%- elif name == 'driver' -%}
|
||||
<path d="M3 8.5 12 4l9 4.5-9 4.5-9-4.5Z"/><path d="M3 8.5v7L12 20l9-4.5v-7"/><path d="M12 13v7"/>
|
||||
{%- elif name == 'printer' -%}
|
||||
<path d="M6 9V4h12v5"/><rect x="3" y="9" width="18" height="7" rx="1.5"/><path d="M7 16h10v4H7z"/><circle cx="17.5" cy="12" r=".8" fill="currentColor" stroke="none"/>
|
||||
{%- elif name == 'group' -%}
|
||||
<path d="M4 20v-1.5A3.5 3.5 0 0 1 7.5 15h3A3.5 3.5 0 0 1 14 18.5V20"/><circle cx="9" cy="8.5" r="3"/><path d="M16 15.5h.5A3.5 3.5 0 0 1 20 19v1"/><circle cx="17" cy="9.5" r="2.2"/>
|
||||
{%- elif name == 'package' -%}
|
||||
<path d="M4 7.5 12 4l8 3.5v9L12 20l-8-3.5v-9Z"/><path d="M4 7.5 12 11l8-3.5"/><path d="M12 11v9"/><path d="M8 5.7 16 9.2"/>
|
||||
{%- elif name == 'key' -%}
|
||||
<circle cx="8" cy="15" r="3.5"/><path d="m10.5 12.5 8-8"/><path d="m15.5 7.5 2 2"/><path d="m18 5 2 2"/>
|
||||
{%- elif name == 'upload' -%}
|
||||
<path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/>
|
||||
{%- elif name == 'download' -%}
|
||||
<path d="M12 4v12"/><path d="m7.5 11.5 4.5 4.5 4.5-4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/>
|
||||
{%- elif name == 'plus' -%}
|
||||
<path d="M12 5v14"/><path d="M5 12h14"/>
|
||||
{%- elif name == 'search' -%}
|
||||
<circle cx="11" cy="11" r="6"/><path d="m15.5 15.5 4 4"/>
|
||||
{%- elif name == 'pencil' -%}
|
||||
<path d="M4 20h4l10-10-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/>
|
||||
{%- elif name == 'trash' -%}
|
||||
<path d="M4 7h16"/><path d="M9 7V4.5h6V7"/><path d="M6 7v11.5A1.5 1.5 0 0 0 7.5 20h9a1.5 1.5 0 0 0 1.5-1.5V7"/><path d="M10 11v5"/><path d="M14 11v5"/>
|
||||
{%- elif name == 'check' -%}
|
||||
<path d="m5 12.5 4.5 4.5L19 7.5"/>
|
||||
{%- elif name == 'alert' -%}
|
||||
<path d="M12 4.5 21 19.5H3L12 4.5Z"/><path d="M12 10v4"/><circle cx="12" cy="16.8" r=".9" fill="currentColor" stroke="none"/>
|
||||
{%- elif name == 'copy' -%}
|
||||
<rect x="9" y="9" width="11" height="11" rx="1.5"/><path d="M15 6.5V5.5A1.5 1.5 0 0 0 13.5 4h-8A1.5 1.5 0 0 0 4 5.5v8A1.5 1.5 0 0 0 5.5 15h1"/>
|
||||
{%- elif name == 'terminal' -%}
|
||||
<rect x="3" y="4.5" width="18" height="15" rx="2"/><path d="m7.5 10 2.5 2.5-2.5 2.5"/><path d="M12.5 15h4"/>
|
||||
{%- elif name == 'script' -%}
|
||||
<path d="M7 3.5h7l4 4v13H7z"/><path d="M14 3.5v4h4"/><path d="M10 12h5"/><path d="M10 16h5"/>
|
||||
{%- elif name == 'image' -%}
|
||||
<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="9" cy="10" r="1.8"/><path d="m4.5 18 4.5-4.5 3.5 3.5 3-2.5 4 3.5"/>
|
||||
{%- elif name == 'menu' -%}
|
||||
<path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/>
|
||||
{%- elif name == 'chevron' -%}
|
||||
<path d="m8.5 5.5 6 6.5-6 6.5"/>
|
||||
{%- elif name == 'back' -%}
|
||||
<path d="M19 12H5"/><path d="m11 6-6 6 6 6"/>
|
||||
{%- elif name == 'link' -%}
|
||||
<path d="M10 13.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 0 0-5-5l-1 1"/><path d="M14 10.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5l1-1"/>
|
||||
{%- elif name == 'network' -%}
|
||||
<rect x="3.5" y="14.5" width="6" height="5" rx="1"/><rect x="14.5" y="14.5" width="6" height="5" rx="1"/><rect x="9" y="4.5" width="6" height="5" rx="1"/><path d="M12 9.5v3H6.5v2"/><path d="M12 12.5h5.5v2"/>
|
||||
{%- endif -%}
|
||||
</svg>
|
||||
{%- endmacro %}
|
||||
@@ -1,102 +1,122 @@
|
||||
<!-- Edit trigger button — placed in Actions column -->
|
||||
<button class="secondary outline"
|
||||
{% import "partials/icons.html" as ico %}
|
||||
<!-- Edit trigger — sits in the row's Actions cell -->
|
||||
<button class="btn ghost sm"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').showModal()"
|
||||
x-data x-text="$store.i18n.t('edit')">
|
||||
Edit
|
||||
x-data>
|
||||
{{ ico.i('pencil', 14) }}<span x-text="$store.i18n.t('edit')">Edit</span>
|
||||
</button>
|
||||
|
||||
<!-- Edit dialog — Pico CSS native dialog, no extra library -->
|
||||
<!-- Edit dialog — native <dialog>, no extra library -->
|
||||
<dialog id="edit-modal-{{ p.id }}">
|
||||
<article>
|
||||
<header>
|
||||
<button aria-label="Close" rel="prev"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"></button>
|
||||
<h3 x-data x-text="$store.i18n.t('edit_printer_title')">Edit Printer</h3>
|
||||
</header>
|
||||
<div x-data="{ ip: '{{ p.ip_address }}', port: '{{ p.port_name }}', portEdited: true }">
|
||||
<form hx-patch="/printers/{{ p.id }}"
|
||||
hx-target="#printer-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-request="document.getElementById('edit-modal-{{ p.id }}').close()">
|
||||
|
||||
<label><span x-text="$store.i18n.t('printer_name')">Printer Name</span>
|
||||
<input type="text" name="name" value="{{ p.name }}" required>
|
||||
</label>
|
||||
<div class="dialog-head">
|
||||
<h3 x-text="$store.i18n.t('edit_printer_title')">Edit printer</h3>
|
||||
{# No aria-label="Close": Pico hooks that attribute to draw its own
|
||||
floated X icon, which fights this button's glyph and placement. #}
|
||||
<button type="button" class="icon-btn close"
|
||||
:aria-label="$store.i18n.t('cancel')"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').close()">×</button>
|
||||
</div>
|
||||
|
||||
<label><span x-text="$store.i18n.t('ip_address')">IP Address</span>
|
||||
<input type="text" name="ip_address"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
required>
|
||||
</label>
|
||||
<div class="dialog-body">
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_identity')">Identity</legend>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('printer_name')">Printer name</span>
|
||||
<input type="text" name="name" value="{{ p.name }}" required>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('client')">Client</span>
|
||||
<select name="client_id">
|
||||
<option value="" x-text="$store.i18n.t('unassigned_option')">Unassigned</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}" {% if p.client_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label><span x-text="$store.i18n.t('port_name')">Port Name</span>
|
||||
<input type="text" name="port_name"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true">
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_connection')">Network connection</legend>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('ip_address')">IP address</span>
|
||||
<input type="text" name="ip_address" class="mono"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
required>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('port_name')">Port name</span>
|
||||
<input type="text" name="port_name" class="mono"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true">
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label><span x-text="$store.i18n.t('driver')">Driver</span>
|
||||
<select name="driver_id">
|
||||
<option value="" x-text="$store.i18n.t('no_driver_option')">-- No driver --</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}"
|
||||
{% if p.driver_id == item.driver.id %}selected{% endif %}>
|
||||
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_driver')">Driver</legend>
|
||||
<label>
|
||||
<select name="driver_id">
|
||||
<option value="" x-text="$store.i18n.t('no_driver_option')">No driver</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}"
|
||||
{% if p.driver_id == item.driver.id %}selected{% endif %}>
|
||||
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="hint" x-text="$store.i18n.t('hint_driver')">Without a driver the printer is saved, but export stays locked.</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label><span x-text="$store.i18n.t('duplex_mode')">Duplex Mode</span>
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" {% if p.duplex_mode == 'OneSided' %}selected{% endif %} x-text="$store.i18n.t('one_sided')">One-Sided</option>
|
||||
<option value="LongEdge" {% if p.duplex_mode == 'LongEdge' %}selected{% endif %} x-text="$store.i18n.t('long_edge')">Long Edge</option>
|
||||
<option value="ShortEdge" {% if p.duplex_mode == 'ShortEdge' %}selected{% endif %} x-text="$store.i18n.t('short_edge')">Short Edge</option>
|
||||
</select>
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_defaults')">Print defaults</legend>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('duplex_mode')">Duplex mode</span>
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" {% if p.duplex_mode == 'OneSided' %}selected{% endif %} x-text="$store.i18n.t('one_sided')">One-sided</option>
|
||||
<option value="LongEdge" {% if p.duplex_mode == 'LongEdge' %}selected{% endif %} x-text="$store.i18n.t('long_edge')">Long edge</option>
|
||||
<option value="ShortEdge" {% if p.duplex_mode == 'ShortEdge' %}selected{% endif %} x-text="$store.i18n.t('short_edge')">Short edge</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('paper_size')">Paper size</span>
|
||||
<select name="paper_size">
|
||||
<option value="A4" {% if p.paper_size == 'A4' %}selected{% endif %}>A4</option>
|
||||
<option value="Letter" {% if p.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
|
||||
<option value="Legal" {% if p.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on" {% if p.color_mode %}checked{% endif %}>
|
||||
<span x-text="$store.i18n.t('color_mode')">Color</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on" {% if p.collate %}checked{% endif %}>
|
||||
<span x-text="$store.i18n.t('collate')">Collate</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on"
|
||||
{% if p.color_mode %}checked{% endif %}>
|
||||
<span x-text="$store.i18n.t('color_mode')">Color Mode</span>
|
||||
</label>
|
||||
|
||||
<label><span x-text="$store.i18n.t('paper_size')">Paper Size</span>
|
||||
<select name="paper_size">
|
||||
<option value="A4" {% if p.paper_size == 'A4' %}selected{% endif %}>A4</option>
|
||||
<option value="Letter" {% if p.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
|
||||
<option value="Legal" {% if p.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on"
|
||||
{% if p.collate %}checked{% endif %}>
|
||||
<span x-text="$store.i18n.t('collate')">Collate</span>
|
||||
</label>
|
||||
|
||||
<label><span x-text="$store.i18n.t('client')">Client</span>
|
||||
<select name="client_id">
|
||||
<option value="" x-text="$store.i18n.t('unassigned_option')">-- Unassigned --</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}"
|
||||
{% if p.client_id == c.id %}selected{% endif %}>
|
||||
{{ c.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<footer>
|
||||
<button type="submit" x-text="$store.i18n.t('save')">Save</button>
|
||||
<button type="button" class="secondary"
|
||||
<div class="dialog-foot">
|
||||
<button type="button" class="btn ghost"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"
|
||||
x-text="$store.i18n.t('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
</footer>
|
||||
x-text="$store.i18n.t('cancel')">Cancel</button>
|
||||
<button type="submit" class="btn" x-text="$store.i18n.t('save')">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<div x-data="{ ip: '{{ printer.ip_address if printer else '' }}', port: '{{ printer.port_name if printer else '' }}', portEdited: {{ 'true' if printer else 'false' }} }">
|
||||
<form hx-post="/printers" hx-target="#printer-list" hx-swap="outerHTML">
|
||||
|
||||
<label>
|
||||
Printer Name
|
||||
<input type="text" name="name" placeholder="e.g. HP LaserJet 4050" required
|
||||
value="{{ printer.name if printer else '' }}">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
IP Address
|
||||
<input type="text" name="ip_address"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Port Name
|
||||
<input type="text" name="port_name"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true"
|
||||
placeholder="e.g. IP_192_168_1_100">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Driver
|
||||
<select name="driver_id" id="printer-form-driver-select">
|
||||
<option value="">-- No driver --</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}"
|
||||
{% if printer and printer.driver_id == item.driver.id %}selected{% endif %}>
|
||||
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Duplex Mode
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" {% if not printer or printer.duplex_mode == 'OneSided' %}selected{% endif %}>One-Sided</option>
|
||||
<option value="LongEdge" {% if printer and printer.duplex_mode == 'LongEdge' %}selected{% endif %}>Long Edge</option>
|
||||
<option value="ShortEdge" {% if printer and printer.duplex_mode == 'ShortEdge' %}selected{% endif %}>Short Edge</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on"
|
||||
{% if not printer or printer.color_mode %}checked{% endif %}>
|
||||
Color Mode
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Paper Size
|
||||
<select name="paper_size">
|
||||
<option value="A4" {% if not printer or printer.paper_size == 'A4' %}selected{% endif %}>A4</option>
|
||||
<option value="Letter" {% if printer and printer.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
|
||||
<option value="Legal" {% if printer and printer.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on"
|
||||
{% if not printer or printer.collate %}checked{% endif %}>
|
||||
Collate
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Client
|
||||
<select name="client_id">
|
||||
<option value="">-- Unassigned --</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}"
|
||||
{% if printer and printer.client_id == c.id %}selected{% endif %}>
|
||||
{{ c.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit">Save Printer</button>
|
||||
</form>
|
||||
|
||||
<form hx-post="/drivers/upload"
|
||||
hx-target="#driver-list"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="caller" value="printer_form">
|
||||
<label>
|
||||
Upload New Driver
|
||||
<input type="file" name="file" accept=".zip" required>
|
||||
</label>
|
||||
<button type="submit" class="secondary">Upload Driver</button>
|
||||
</form>
|
||||
<div id="driver-list" style="display:none"></div>
|
||||
</div>
|
||||
@@ -1,57 +1,98 @@
|
||||
{% import "partials/icons.html" as ico %}
|
||||
<div id="printer-list">
|
||||
{% if not grouped %}
|
||||
<p x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</p>
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('printer', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and the defaults the workstation gets.</p>
|
||||
<a href="/printers/new" class="btn sm" x-data>{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for client_name, printers in grouped.items() %}
|
||||
<section>
|
||||
{% set group_client_id = printers[0].client_id if printers else None %}
|
||||
{% if group_client_id %}
|
||||
<h3><a href="/clients/{{ group_client_id }}">{{ client_name }}</a></h3>
|
||||
{% else %}
|
||||
<h3>{{ client_name }}</h3>
|
||||
{% endif %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
|
||||
<th x-data x-text="$store.i18n.t('th_ip')">IP Address</th>
|
||||
<th x-data x-text="$store.i18n.t('th_port')">Port</th>
|
||||
<th x-data x-text="$store.i18n.t('th_driver')">Driver</th>
|
||||
<th x-data x-text="$store.i18n.t('th_duplex')">Duplex</th>
|
||||
<th x-data x-text="$store.i18n.t('th_color')">Color</th>
|
||||
<th x-data x-text="$store.i18n.t('th_paper')">Paper</th>
|
||||
<th x-data x-text="$store.i18n.t('th_collate')">Collate</th>
|
||||
<th x-data x-text="$store.i18n.t('th_actions')">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in printers %}
|
||||
<tr>
|
||||
<td><a href="/printers/{{ p.id }}">{{ p.name }}</a></td>
|
||||
<td>{{ p.ip_address }}</td>
|
||||
<td>{{ p.port_name }}</td>
|
||||
<td>{{ p.driver.original_filename if p.driver_id else '—' }}</td>
|
||||
<td>{{ p.duplex_mode }}</td>
|
||||
<td x-data="{ val: {{ 'true' if p.color_mode else 'false' }} }"
|
||||
x-text="val ? $store.i18n.t('yes') : $store.i18n.t('no')">{{ 'Yes' if p.color_mode else 'No' }}</td>
|
||||
<td>{{ p.paper_size }}</td>
|
||||
<td x-data="{ val: {{ 'true' if p.collate else 'false' }} }"
|
||||
x-text="val ? $store.i18n.t('yes') : $store.i18n.t('no')">{{ 'Yes' if p.collate else 'No' }}</td>
|
||||
<td>
|
||||
{% include "partials/printer_edit_modal.html" %}
|
||||
<button
|
||||
hx-delete="/printers/{{ p.id }}"
|
||||
hx-target="#printer-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete '{{ p.name }}'?"
|
||||
x-data x-text="$store.i18n.t('delete')">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% set group_client_id = printers[0].client_id if printers else None %}
|
||||
{# Group search text must cover everything its rows match on, or a row can
|
||||
match while its group stays hidden. #}
|
||||
{% set gsearch = (printers | map(attribute='name') | join(' ')) ~ ' '
|
||||
~ (printers | map(attribute='ip_address') | join(' ')) ~ ' '
|
||||
~ (printers | map(attribute='port_name') | join(' ')) ~ ' '
|
||||
~ (printers | selectattr('driver_id') | map(attribute='driver.original_filename') | join(' ')) ~ ' '
|
||||
~ client_name %}
|
||||
<section class="card card-flush"
|
||||
data-search="{{ gsearch | lower }}"
|
||||
x-data
|
||||
x-show="!$store.filter.q.trim() || $el.dataset.search.includes($store.filter.q.trim().toLowerCase())">
|
||||
<div class="group-head">
|
||||
{{ ico.i('group') }}
|
||||
{% if group_client_id %}
|
||||
<h3><a href="/clients/{{ group_client_id }}">{{ client_name }}</a></h3>
|
||||
{% else %}
|
||||
<h3 x-data x-text="$store.i18n.t('unassigned')">{{ client_name }}</h3>
|
||||
{% endif %}
|
||||
<span class="count">{{ printers | length }}</span>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
|
||||
<th x-data x-text="$store.i18n.t('th_status')">Status</th>
|
||||
<th class="col-defaults" x-data x-text="$store.i18n.t('th_config')">Defaults</th>
|
||||
<th x-data x-text="$store.i18n.t('th_actions')">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in printers %}
|
||||
<tr data-search="{{ (p.name ~ ' ' ~ p.ip_address ~ ' ' ~ p.port_name ~ ' ' ~ client_name ~ ' ' ~ (p.driver.original_filename if p.driver_id else '')) | lower }}"
|
||||
x-data
|
||||
x-show="!$store.filter.q.trim() || $el.dataset.search.includes($store.filter.q.trim().toLowerCase())">
|
||||
<td>
|
||||
<a class="cell-name" href="/printers/{{ p.id }}">{{ p.name }}</a>
|
||||
<span class="cell-sub">{{ p.ip_address }} · {{ p.port_name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if p.driver_id %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
|
||||
<span class="cell-sub">{{ p.driver.original_filename }}</span>
|
||||
{% else %}
|
||||
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-defaults">
|
||||
<span class="pill-row">
|
||||
<span class="pill" x-data
|
||||
x-text="$store.i18n.t('{{ 'one_sided' if p.duplex_mode == 'OneSided' else ('long_edge' if p.duplex_mode == 'LongEdge' else 'short_edge') }}')">{{ p.duplex_mode }}</span>
|
||||
<span class="pill" x-data
|
||||
x-text="$store.i18n.t('{{ 'color_value' if p.color_mode else 'grayscale_value' }}')">{{ 'Color' if p.color_mode else 'Grayscale' }}</span>
|
||||
<span class="pill">{{ p.paper_size }}</span>
|
||||
{% if p.collate %}
|
||||
<span class="pill" x-data x-text="$store.i18n.t('collate')">Collate</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span class="btn-row">
|
||||
{% if p.driver_id %}
|
||||
<a class="btn quiet sm" href="/printers/{{ p.id }}/packages/intunewin"
|
||||
title=".intunewin">{{ ico.i('download', 14) }}</a>
|
||||
{% endif %}
|
||||
{% include "partials/printer_edit_modal.html" %}
|
||||
<button class="btn danger sm"
|
||||
hx-delete="/printers/{{ p.id }}"
|
||||
hx-target="#printer-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete '{{ p.name }}'?"
|
||||
x-data :title="$store.i18n.t('delete')">
|
||||
{{ ico.i('trash', 14) }}
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,82 +1,209 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ printer.name }}</h1>
|
||||
<article>
|
||||
<h2 x-data x-text="$store.i18n.t('configuration')">Configuration</h2>
|
||||
<dl>
|
||||
<dt x-data x-text="$store.i18n.t('ip_address')">IP Address</dt><dd>{{ printer.ip_address }}</dd>
|
||||
<dt x-data x-text="$store.i18n.t('port_name')">Port Name</dt><dd>{{ printer.port_name }}</dd>
|
||||
<dt x-data x-text="$store.i18n.t('duplex_mode_label')">Duplex Mode</dt><dd>{{ printer.duplex_mode }}</dd>
|
||||
<dt x-data x-text="$store.i18n.t('color_mode_label')">Color Mode</dt>
|
||||
{% if printer.color_mode %}<dd x-data x-text="$store.i18n.t('color_value')">Color</dd>{% else %}<dd x-data x-text="$store.i18n.t('grayscale_value')">Grayscale</dd>{% endif %}
|
||||
<dt x-data x-text="$store.i18n.t('paper_size_label')">Paper Size</dt><dd>{{ printer.paper_size }}</dd>
|
||||
<dt x-data x-text="$store.i18n.t('collate_label')">Collate</dt>
|
||||
{% if printer.collate %}<dd x-data x-text="$store.i18n.t('yes')">Yes</dd>{% else %}<dd x-data x-text="$store.i18n.t('no')">No</dd>{% endif %}
|
||||
<dt x-data x-text="$store.i18n.t('client_label')">Client</dt>
|
||||
{% if printer.client_id %}<dd>{{ printer.client.name }}</dd>{% else %}<dd x-data x-text="$store.i18n.t('unassigned')">Unassigned</dd>{% endif %}
|
||||
</dl>
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
<h2 x-data x-text="$store.i18n.t('driver_section')">Driver</h2>
|
||||
{% if printer.driver_id %}
|
||||
<dl>
|
||||
<dt x-data x-text="$store.i18n.t('package_label')">Package</dt><dd>{{ printer.driver.original_filename }}</dd>
|
||||
<dt x-data x-text="$store.i18n.t('driver_names_label')">Driver Name(s)</dt><dd>{{ driver_names | join(", ") }}</dd>
|
||||
<dt x-data x-text="$store.i18n.t('architecture_label')">Architecture</dt>
|
||||
<dd>{% if printer.driver.architecture %}{{ printer.driver.architecture }}{% else %}<span x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</dd>
|
||||
</dl>
|
||||
{% else %}
|
||||
<p x-data x-text="$store.i18n.t('no_driver_detail')">No driver assigned</p>
|
||||
{% endif %}
|
||||
|
||||
{% if has_driver %}
|
||||
<h2 x-data x-text="$store.i18n.t('intune_commands')">Intune Commands</h2>
|
||||
<div x-data="{ copiedInstall: false }">
|
||||
<label x-text="$store.i18n.t('install_cmd_label')">Install command</label>
|
||||
<code id="install-cmd">{{ install_cmd }}</code>
|
||||
<button @click="
|
||||
const text = document.getElementById('install-cmd').innerText;
|
||||
navigator.clipboard.writeText(text).then(() => { copiedInstall = true; setTimeout(() => copiedInstall = false, 2000) })
|
||||
.catch(() => { /* fallback: text is visible for manual copy */ })
|
||||
" x-text="copiedInstall ? $store.i18n.t('copied') : $store.i18n.t('copy')" class="secondary outline">Copy</button>
|
||||
{% block head_title %}{{ printer.name }} · ImpTune{% endblock %}
|
||||
{% block crumb %}
|
||||
<span class="crumb" x-data>
|
||||
<a href="/printers">{{ ico.i('back', 12) }}<span x-text="$store.i18n.t('back_to_printer_library')">Printers</span></a>
|
||||
{% if printer.client_id %}<span>/</span><a href="/clients/{{ printer.client_id }}">{{ printer.client.name }}</a>{% endif %}
|
||||
</span>
|
||||
{% endblock %}
|
||||
{% block page_title %}{{ printer.name }}{% endblock %}
|
||||
|
||||
{% block page_actions %}
|
||||
{% if has_driver %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}<span x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
|
||||
{% else %}
|
||||
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="split">
|
||||
<!-- Left: what this printer is -->
|
||||
<div>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('network') }}
|
||||
<h2 x-data x-text="$store.i18n.t('configuration')">Configuration</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="kv">
|
||||
<dt x-data x-text="$store.i18n.t('ip_address')">IP address</dt>
|
||||
<dd class="mono-val">{{ printer.ip_address }}</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('port_name')">Port name</dt>
|
||||
<dd class="mono-val">{{ printer.port_name }}</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('client_label')">Client</dt>
|
||||
{% if printer.client_id %}
|
||||
<dd><a href="/clients/{{ printer.client_id }}">{{ printer.client.name }}</a></dd>
|
||||
{% else %}
|
||||
<dd class="faint" x-data x-text="$store.i18n.t('unassigned')">Unassigned</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('duplex_mode_label')">Duplex mode</dt>
|
||||
<dd x-data x-text="$store.i18n.t('{{ 'one_sided' if printer.duplex_mode == 'OneSided' else ('long_edge' if printer.duplex_mode == 'LongEdge' else 'short_edge') }}')">{{ printer.duplex_mode }}</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('color_mode_label')">Color mode</dt>
|
||||
<dd x-data x-text="$store.i18n.t('{{ 'color_value' if printer.color_mode else 'grayscale_value' }}')">{{ 'Color' if printer.color_mode else 'Grayscale' }}</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('paper_size_label')">Paper size</dt>
|
||||
<dd>{{ printer.paper_size }}</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('collate_label')">Collate</dt>
|
||||
<dd x-data x-text="$store.i18n.t('{{ 'yes' if printer.collate else 'no' }}')">{{ 'Yes' if printer.collate else 'No' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('driver') }}
|
||||
<h2 x-data x-text="$store.i18n.t('driver_section')">Driver</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if printer.driver_id %}
|
||||
<dl class="kv">
|
||||
<dt x-data x-text="$store.i18n.t('package_label')">Package</dt>
|
||||
<dd class="mono-val">{{ printer.driver.original_filename }}</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('driver_names_label')">Driver name(s)</dt>
|
||||
<dd>
|
||||
<span class="pill-row">
|
||||
{% for name in driver_names %}<span class="pill">{{ name }}</span>{% endfor %}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt x-data x-text="$store.i18n.t('architecture_label')">Architecture</dt>
|
||||
<dd>{% if printer.driver.architecture %}<span class="badge">{{ printer.driver.architecture }}</span>{% else %}<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</dd>
|
||||
</dl>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('driver', 18) }}</span>
|
||||
<strong>No driver assigned</strong>
|
||||
<p x-data x-text="$store.i18n.t('export_locked')">Assign a driver to this printer to unlock scripts and export.</p>
|
||||
<a href="/printers" class="btn sm" x-data>{{ ico.i('pencil', 14) }}<span x-text="$store.i18n.t('assign_driver_cta')">Assign a driver</span></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if has_driver %}
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('terminal') }}
|
||||
<div>
|
||||
<h2 x-data x-text="$store.i18n.t('intune_commands')">Intune commands</h2>
|
||||
<p class="sub" x-data x-text="$store.i18n.t('hint_intune_cmds')">Paste these into the Win32 app's install and uninstall fields.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body field-stack">
|
||||
<div x-data="{ copiedInstall: false }">
|
||||
<span class="label-text" x-text="$store.i18n.t('install_cmd_label')">Install command</span>
|
||||
<div class="cmd">
|
||||
<code id="install-cmd">{{ install_cmd }}</code>
|
||||
<button type="button" @click="
|
||||
navigator.clipboard.writeText(document.getElementById('install-cmd').innerText)
|
||||
.then(() => { copiedInstall = true; setTimeout(() => copiedInstall = false, 2000) })
|
||||
.catch(() => { /* command stays visible for manual copy */ })
|
||||
" x-text="copiedInstall ? $store.i18n.t('copied') : $store.i18n.t('copy')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-data="{ copiedUninstall: false }">
|
||||
<span class="label-text" x-text="$store.i18n.t('uninstall_cmd_label')">Uninstall command</span>
|
||||
<div class="cmd">
|
||||
<code id="uninstall-cmd">{{ uninstall_cmd }}</code>
|
||||
<button type="button" @click="
|
||||
navigator.clipboard.writeText(document.getElementById('uninstall-cmd').innerText)
|
||||
.then(() => { copiedUninstall = true; setTimeout(() => copiedUninstall = false, 2000) })
|
||||
.catch(() => { /* command stays visible for manual copy */ })
|
||||
" x-text="copiedUninstall ? $store.i18n.t('copied') : $store.i18n.t('copy')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div x-data="{ copiedUninstall: false }">
|
||||
<label x-text="$store.i18n.t('uninstall_cmd_label')">Uninstall command</label>
|
||||
<code id="uninstall-cmd">{{ uninstall_cmd }}</code>
|
||||
<button @click="
|
||||
const text = document.getElementById('uninstall-cmd').innerText;
|
||||
navigator.clipboard.writeText(text).then(() => { copiedUninstall = true; setTimeout(() => copiedUninstall = false, 2000) })
|
||||
.catch(() => { /* fallback: text is visible for manual copy */ })
|
||||
" x-text="copiedUninstall ? $store.i18n.t('copied') : $store.i18n.t('copy')" class="secondary outline">Copy</button>
|
||||
|
||||
<!-- Right: what you can do with it -->
|
||||
<div>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('package') }}
|
||||
<h2 x-data x-text="$store.i18n.t('export_section')">Deploy</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if has_driver %}
|
||||
<div class="field-stack">
|
||||
<div>
|
||||
<a class="btn" href="/printers/{{ printer.id }}/packages/intunewin" x-data>
|
||||
{{ ico.i('download', 14) }}<span x-text="$store.i18n.t('download_intunewin')">Download .intunewin</span>
|
||||
</a>
|
||||
<span class="hint" x-data x-text="$store.i18n.t('hint_intunewin')">.intunewin — upload to Intune as a Win32 app.</span>
|
||||
</div>
|
||||
<div>
|
||||
<a class="btn ghost" href="/printers/{{ printer.id }}/packages/ninja" x-data>
|
||||
{{ ico.i('download', 14) }}<span x-text="$store.i18n.t('download_ninja')">Download NinjaRMM ZIP</span>
|
||||
</a>
|
||||
<span class="hint" x-data x-text="$store.i18n.t('hint_ninja')">ZIP — plain scripts for NinjaRMM or a manual run.</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="dim" x-data x-text="$store.i18n.t('export_locked')">Assign a driver to this printer to unlock scripts and export.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if has_driver %}
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('script') }}
|
||||
<h2 x-data x-text="$store.i18n.t('scripts_section')">PowerShell scripts</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="btn-row">
|
||||
<a class="btn ghost sm mono" href="/printers/{{ printer.id }}/scripts/install.ps1">{{ ico.i('download', 13) }}install.ps1</a>
|
||||
<a class="btn ghost sm mono" href="/printers/{{ printer.id }}/scripts/uninstall.ps1">{{ ico.i('download', 13) }}uninstall.ps1</a>
|
||||
<a class="btn ghost sm mono" href="/printers/{{ printer.id }}/scripts/detect.ps1">{{ ico.i('download', 13) }}detect.ps1</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
{{ ico.i('image') }}
|
||||
<h2 x-data x-text="$store.i18n.t('icon_section')">Icon</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="icon-status">
|
||||
{% if has_icon %}
|
||||
<p class="ok-note">{{ ico.i('check', 14) }}<span x-data x-text="$store.i18n.t('icon_uploaded')">Icon saved</span></p>
|
||||
<div class="icon-preview">
|
||||
<img src="/printers/{{ printer.id }}/icon" width="56" height="56" alt="">
|
||||
<span class="meta">256×256 PNG</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="dim" x-data x-text="$store.i18n.t('no_icon')">No icon. Intune will show its default.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form hx-post="/printers/{{ printer.id }}/icon"
|
||||
hx-target="#icon-status" hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data">
|
||||
<div class="uploader">
|
||||
<input type="file" name="file" accept="image/png" required
|
||||
x-data :aria-label="$store.i18n.t('upload_icon')">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn ghost sm" x-data>
|
||||
{{ ico.i('upload', 13) }}<span x-text="$store.i18n.t('{{ 'replace_icon' if has_icon else 'upload_icon' }}')">Upload icon</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint" x-data x-text="$store.i18n.t('hint_icon')">PNG, exactly 256 × 256, 750 KB max.</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h2 x-data x-text="$store.i18n.t('scripts_section')">Scripts</h2>
|
||||
<a href="/printers/{{ printer.id }}/scripts/install.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_install')">
|
||||
Download Install Script
|
||||
</a>
|
||||
<a href="/printers/{{ printer.id }}/scripts/uninstall.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_uninstall')">
|
||||
Download Uninstall Script
|
||||
</a>
|
||||
<a href="/printers/{{ printer.id }}/scripts/detect.ps1" role="button" class="secondary" x-data x-text="$store.i18n.t('download_detect')">
|
||||
Download Detect Script
|
||||
</a>
|
||||
|
||||
<h2 x-data x-text="$store.i18n.t('export_section')">Export</h2>
|
||||
<a href="/printers/{{ printer.id }}/packages/ninja" role="button" x-data x-text="$store.i18n.t('download_ninja')">Download NinjaRMM ZIP</a>
|
||||
<a href="/printers/{{ printer.id }}/packages/intunewin" role="button" x-data x-text="$store.i18n.t('download_intunewin')">Download .intunewin</a>
|
||||
{% endif %}
|
||||
|
||||
<h2 x-data x-text="$store.i18n.t('icon_section')">Icon</h2>
|
||||
{% if has_icon %}
|
||||
<p x-data x-text="$store.i18n.t('icon_uploaded')">Icon uploaded</p>
|
||||
{% endif %}
|
||||
<form hx-post="/printers/{{ printer.id }}/icon"
|
||||
hx-target="#icon-status" hx-swap="innerHTML"
|
||||
enctype="multipart/form-data">
|
||||
<input type="file" name="file" accept="image/png" required>
|
||||
<button type="submit" x-data x-text="$store.i18n.t('upload_icon')">Upload Icon</button>
|
||||
</form>
|
||||
<div id="icon-status"></div>
|
||||
|
||||
<a href="/printers" role="button" class="secondary" x-data x-text="$store.i18n.t('back_to_printers_btn')">Back to Printers</a>
|
||||
</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}Printers · ImpTune{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('printers')">Printers</span>{% endblock %}
|
||||
|
||||
{% block page_actions %}
|
||||
<a href="/printers/new" class="btn sm">{{ ico.i('plus') }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('printers')">Printers</h1>
|
||||
<p class="page-intro" x-data x-text="$store.i18n.t('printers_intro')">A printer with a driver assigned is ready to export.</p>
|
||||
|
||||
<p><a href="/printers/new" role="button" x-data x-text="$store.i18n.t('add_printer')">Add Printer</a></p>
|
||||
<div x-data="{
|
||||
shown: 0,
|
||||
total: 0,
|
||||
recount() {
|
||||
const rows = Array.from(document.querySelectorAll('#printer-list tr[data-search]'));
|
||||
this.total = rows.length;
|
||||
const q = this.$store.filter.q.trim().toLowerCase();
|
||||
this.shown = q ? rows.filter(r => r.dataset.search.includes(q)).length : rows.length;
|
||||
}
|
||||
}"
|
||||
x-init="recount()"
|
||||
@htmx:after-swap.window="recount()">
|
||||
|
||||
<div class="toolbar">
|
||||
<label class="search">
|
||||
{{ ico.i('search') }}
|
||||
<input type="search" x-model="$store.filter.q" @input="recount()"
|
||||
:placeholder="$store.i18n.t('filter_printers')"
|
||||
:aria-label="$store.i18n.t('filter_printers')">
|
||||
</label>
|
||||
<span class="filter-count" x-show="$store.filter.q.trim()" x-text="shown + ' / ' + total"></span>
|
||||
<span class="spacer"></span>
|
||||
{% if printer_count %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}{{ ready_count }} <span x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
|
||||
{% if printer_count - ready_count %}
|
||||
<span class="badge warn">{{ ico.i('alert', 12) }}{{ printer_count - ready_count }} <span x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 x-data x-text="$store.i18n.t('printer_library')">Printer Library</h2>
|
||||
{% include "partials/printer_list.html" %}
|
||||
</section>
|
||||
|
||||
<p class="empty-state" x-show="$store.filter.q.trim() && shown === 0" style="display:none"
|
||||
x-text="$store.i18n.t('no_match')">No printer matches this filter.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,100 +1,151 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}Add printer · ImpTune{% endblock %}
|
||||
{% block crumb %}
|
||||
<span class="crumb" x-data>
|
||||
<a href="/printers">{{ ico.i('back', 12) }}<span x-text="$store.i18n.t('back_to_printer_library')">Printers</span></a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('add_printer_title')">Add printer</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 x-data x-text="$store.i18n.t('add_printer_title')">Add Printer</h1>
|
||||
<div class="split" x-data="{ ip: '', port: '', portEdited: false }">
|
||||
<section class="card">
|
||||
<div class="card-body">
|
||||
<form action="/printers" method="post">
|
||||
|
||||
<p><a href="/printers" x-data x-text="$store.i18n.t('back_to_printer_library')">← Back to Printer Library</a></p>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_identity')">Identity</legend>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('printer_name')">Printer name</span>
|
||||
<input type="text" name="name" placeholder="HP LaserJet 4050 — Accounting" required>
|
||||
<span class="hint" x-text="$store.i18n.t('hint_name')">The name the user sees on their workstation.</span>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('client')">Client</span>
|
||||
<select name="client_id">
|
||||
<option value="" x-text="$store.i18n.t('unassigned_option')">Unassigned</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div x-data="{ ip: '', port: '', portEdited: false }">
|
||||
<form action="/printers" method="post">
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_connection')">Network connection</legend>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('ip_address')">IP address</span>
|
||||
<input type="text" name="ip_address" class="mono"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
placeholder="192.168.1.100"
|
||||
inputmode="decimal"
|
||||
required>
|
||||
<span class="hint" x-text="$store.i18n.t('hint_ip')">The printer IPv4 address on the client network.</span>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('port_name')">Port name</span>
|
||||
<input type="text" name="port_name" class="mono"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true"
|
||||
placeholder="IP_192_168_1_100">
|
||||
<span class="hint" x-text="$store.i18n.t('hint_port')">Derived from the IP address. Edit it if your naming differs.</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('printer_name')">Printer Name</span>
|
||||
<input type="text" name="name" placeholder="e.g. HP LaserJet 4050" required>
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_driver')">Driver</legend>
|
||||
<label>
|
||||
<select name="driver_id" id="printer-form-driver-select">
|
||||
<option value="" x-text="$store.i18n.t('no_driver_option')">No driver</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}">
|
||||
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="hint" x-text="$store.i18n.t('hint_driver')">Without a driver the printer is saved, but export stays locked.</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('ip_address')">IP Address</span>
|
||||
<input type="text" name="ip_address"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
required>
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_defaults')">Print defaults</legend>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('duplex_mode')">Duplex mode</span>
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" selected x-text="$store.i18n.t('one_sided')">One-sided</option>
|
||||
<option value="LongEdge" x-text="$store.i18n.t('long_edge')">Long edge</option>
|
||||
<option value="ShortEdge" x-text="$store.i18n.t('short_edge')">Short edge</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('paper_size')">Paper size</span>
|
||||
<select name="paper_size">
|
||||
<option value="A4" selected>A4</option>
|
||||
<option value="Letter">Letter</option>
|
||||
<option value="Legal">Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on" checked>
|
||||
<span x-text="$store.i18n.t('color_mode')">Color</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on" checked>
|
||||
<span x-text="$store.i18n.t('collate')">Collate</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('port_name')">Port Name</span>
|
||||
<input type="text" name="port_name"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true"
|
||||
placeholder="e.g. IP_192_168_1_100">
|
||||
</label>
|
||||
<div class="form-foot">
|
||||
<button type="submit" class="btn" x-text="$store.i18n.t('save_printer')">Save printer</button>
|
||||
<a href="/printers" class="btn ghost" x-text="$store.i18n.t('cancel')">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('driver')">Driver</span>
|
||||
<select name="driver_id" id="printer-form-driver-select">
|
||||
<option value="" x-text="$store.i18n.t('no_driver_option')">-- No driver --</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}">
|
||||
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('duplex_mode')">Duplex Mode</span>
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" selected x-text="$store.i18n.t('one_sided')">One-Sided</option>
|
||||
<option value="LongEdge" x-text="$store.i18n.t('long_edge')">Long Edge</option>
|
||||
<option value="ShortEdge" x-text="$store.i18n.t('short_edge')">Short Edge</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on" checked>
|
||||
<span x-text="$store.i18n.t('color_mode')">Color Mode</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('paper_size')">Paper Size</span>
|
||||
<select name="paper_size">
|
||||
<option value="A4" selected>A4</option>
|
||||
<option value="Letter">Letter</option>
|
||||
<option value="Legal">Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on" checked>
|
||||
<span x-text="$store.i18n.t('collate')">Collate</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('client')">Client</span>
|
||||
<select name="client_id">
|
||||
<option value="" x-text="$store.i18n.t('unassigned_option')">-- Unassigned --</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit" x-text="$store.i18n.t('save_printer')">Save Printer</button>
|
||||
</form>
|
||||
|
||||
<form hx-post="/drivers/upload"
|
||||
hx-target="#driver-list"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="caller" value="printer_form">
|
||||
<label>
|
||||
<span x-text="$store.i18n.t('upload_new_driver')">Upload New Driver</span>
|
||||
<input type="file" name="file" accept=".zip" required>
|
||||
</label>
|
||||
<button type="submit" class="secondary" x-text="$store.i18n.t('upload_driver')">Upload Driver</button>
|
||||
</form>
|
||||
<div id="driver-list"></div>
|
||||
{#
|
||||
Separate form on purpose: HTML forbids nesting, and the upload answers a
|
||||
question the technician has *while* filling the form ("my driver isn't in
|
||||
the list"). The response OOB-swaps the select above, new driver selected.
|
||||
#}
|
||||
<section class="card form-aside">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2 x-data x-text="$store.i18n.t('upload_new_driver')">Add a new driver</h2>
|
||||
<p class="sub" x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form hx-post="/drivers/upload"
|
||||
hx-target="#driver-list"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#form-upload-spinner">
|
||||
<input type="hidden" name="caller" value="printer_form">
|
||||
<div class="uploader">
|
||||
<input type="file" name="file" accept=".zip" required
|
||||
x-data :aria-label="$store.i18n.t('driver_package_label')">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn ghost" x-data>
|
||||
{{ ico.i('upload', 14) }}<span x-text="$store.i18n.t('upload_btn')">Upload</span>
|
||||
</button>
|
||||
<span id="form-upload-spinner" class="htmx-indicator" aria-busy="true"
|
||||
x-data x-text="$store.i18n.t('uploading')">Uploading…</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div id="driver-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as ico %}
|
||||
|
||||
{% block head_title %}Restore backup key · ImpTune{% endblock %}
|
||||
{% block page_title %}<span x-data x-text="$store.i18n.t('restore_title')">Restore a backup key</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Restore backup key</h1>
|
||||
<section class="card" style="max-width:34rem">
|
||||
<div class="card-head">
|
||||
{{ ico.i('key') }}
|
||||
<h2 x-data x-text="$store.i18n.t('restore_title')">Restore a backup key</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="dim" x-data x-text="$store.i18n.t('restore_desc')">
|
||||
Paste the key from <code>imptune-backup-key.txt</code> to get your printers,
|
||||
settings, and clients back in this browser.
|
||||
</p>
|
||||
|
||||
<p>Paste the key from your <code>imptune-backup-key.txt</code> backup file to
|
||||
recover your printers, configs, and groups on this browser.</p>
|
||||
{% if error %}
|
||||
<div class="error"><p>{{ error }}</p></div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="error"><p>{{ error }}</p></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/session/restore">
|
||||
<label>
|
||||
Backup key
|
||||
<input type="text" name="key" placeholder="paste your key here" required autofocus>
|
||||
</label>
|
||||
<button type="submit">Restore</button>
|
||||
</form>
|
||||
<form method="post" action="/session/restore">
|
||||
<label>
|
||||
<span class="label-text" x-data x-text="$store.i18n.t('backup_key_label')">Backup key</span>
|
||||
<input type="text" name="key" class="mono" placeholder="paste your key here" required autofocus>
|
||||
</label>
|
||||
<div class="btn-row" style="margin-top:.9rem">
|
||||
<button type="submit" class="btn" x-data x-text="$store.i18n.t('restore_btn')">Restore</button>
|
||||
<a href="/" class="btn ghost" x-data x-text="$store.i18n.t('cancel')">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user