sauvegarde des tirages

This commit is contained in:
2026-09-05 12:30:18 +02:00
parent afece8c70f
commit d6c9ab69e3
10 changed files with 61 additions and 82 deletions
+7 -32
View File
@@ -1,48 +1,23 @@
# Tirages de classes — Vue.js / PWA # Tirages — Vue 3 / PWA
Tirage aléatoire avec pondération par l'ancienneté du dernier tirage.
Application 100% locale et hors-ligne (aucun compte, aucun serveur). Migration web de `tirages2` : Vue 3 + Vite + PWA + IndexedDB (Dexie).
## Données et confidentialité ## Données
Les classes et historiques sont stockés dans IndexedDB du navigateur. Rien n'est envoyé au serveur : le serveur sert uniquement les fichiers de l'application.
Les classes et l’historique sont stockés **localement dans le navigateur**, dans IndexedDB. Rien n’est envoyé au serveur par l’application. Utiliser **Exporter les données** pour créer une sauvegarde JSON ou transférer les données vers un autre appareil. **Importer** remplace les données présentes.
Le serveur sert uniquement les fichiers statiques de l’application. Après le premier chargement, la PWA peut fonctionner hors connexion.
## Export / import
Le bouton **Exporter les données** crée un fichier JSON contenant les classes et leur historique. Il est utile comme sauvegarde avant de changer de téléphone, de navigateur ou d’effacer les données du site. Le fichier peut ensuite être réimporté avec **Importer des données**.
> Attention : si les données du site sont effacées dans le navigateur et qu’aucun export n’a été conservé, les données locales peuvent être perdues.
## Installation mobile
### iPhone / iPad
1. Ouvrir l’application dans Safari.
2. Toucher **Partager**.
3. Choisir **Sur l’écran d’accueil**.
4. Valider.
### Android
Dans Chrome, choisir **Installer l’application** ou **Ajouter à l’écran d’accueil**.
L’application doit être servie en **HTTPS** pour que le service worker PWA fonctionne.
## Développement ## Développement
```bash ```bash
npm install npm install
npm run dev npm run dev
``` ```
Production : ## Production
```bash ```bash
npm run build npm run build
``` ```
Servir `dist/` en HTTPS. Sur iPhone/iPad : Safari → Partager → Sur l'écran d'accueil.
Le contenu de `dist/` peut être servi par Caddy, Nginx ou Apache.
## Dépôt ## Dépôt
https://git.spaier.fr/cspaier/tirages-classes https://git.spaier.fr/cspaier/tirages-classes
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="fr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#2563eb"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="default"><title>Tirages de classes</title></head><body><div id="app"></div><script type="module" src="/src/main.js"></script></body></html> <!doctype html><html lang="fr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#2563eb"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Tirages"><title>Tirages</title></head><body><div id="app"></div><script type="module" src="/src/main.js"></script></body></html>
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "tirages-classes", "name": "tirages",
"version": "1.0.0", "version": "1.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tirages-classes", "name": "tirages",
"version": "1.0.0", "version": "1.0.1",
"dependencies": { "dependencies": {
"dexie": "^4.0.8", "dexie": "^4.0.8",
"vue": "^3.5.13" "vue": "^3.5.13"
+1 -6
View File
@@ -1,6 +1 @@
{ {"name":"tirages","version":"1.0.1","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build","preview":"vite preview"},"dependencies":{"dexie":"^4.0.8","vue":"^3.5.13"},"devDependencies":{"@vitejs/plugin-vue":"^5.2.1","vite":"^6.0.0","vite-plugin-pwa":"^0.21.1"}}
"name":"tirages-classes","version":"1.0.0","private":true,"type":"module",
"scripts":{"dev":"vite","build":"vite build","preview":"vite preview"},
"dependencies":{"dexie":"^4.0.8","vue":"^3.5.13"},
"devDependencies":{"@vitejs/plugin-vue":"^5.2.1","vite":"^6.0.0","vite-plugin-pwa":"^0.21.1"}
}
+20 -24
View File
@@ -1,27 +1,23 @@
<script setup> <script setup>
import {ref,computed,onMounted} from 'vue' import {ref,onMounted,computed} from 'vue'
import {load,get,save,remove,exportData,importData} from './storage' import {chargerClasses,obtenirClasse,creerClasse,modifierClasse,supprimerClasse,effectuerTirage,reTirer,exporter,importer} from './storage'
import {genererId,tirer} from './models' const classes=ref([]),view=ref('list'),active=ref(null),editId=ref(null),help=ref(false),busy=ref(false)
const classes=ref([]),view=ref('list'),active=ref(null),editId=ref(null),showHelp=ref(false),fileInput=ref(null) const form=ref({nom:'',nombreEleves:24,ponderationMax:2}), sorted=computed(()=>[...classes.value].sort((a,b)=>a.nom.localeCompare(b.nom,'fr')))
const form=ref({nom:'',nombreEleves:24,ponderationMax:2}) async function refresh(){classes.value=await chargerClasses()}
const sorted=computed(()=>[...classes.value].sort((a,b)=>a.nom.localeCompare(b.nom,'fr'))) function nouveau(){editId.value=null;form.value={nom:'',nombreEleves:24,ponderationMax:2};view.value='form'}
async function refresh(){classes.value=await load()} function edit(c){editId.value=c.id;form.value={nom:c.nom,nombreEleves:c.nombreEleves,ponderationMax:c.ponderationMax};view.value='form'}
function newClass(){editId.value=null;form.value={nom:'',nombreEleves:24,ponderationMax:2};view.value='form'} async function submit(){if(!form.value.nom.trim())return alert('Le nom est obligatoire.');if(!Number.isInteger(+form.value.nombreEleves)||+form.value.nombreEleves<1)return alert('Le nombre d’élèves doit être un entier positif.');if(+form.value.ponderationMax<1)return alert('La pondération doit être supérieure ou égale à 1.');if(editId.value)await modifierClasse(editId.value,form.value);else await creerClasse(form.value);await refresh();view.value='list'}
async function edit(c){editId.value=c.id;form.value={nom:c.nom,nombreEleves:c.nombreEleves,ponderationMax:c.ponderationMax};view.value='form'} async function open(c){active.value=await obtenirClasse(c.id);view.value='detail'}
async function submit(){if(!form.value.nom.trim())return alert('Nom obligatoire');if(!Number.isInteger(Number(form.value.nombreEleves))||Number(form.value.nombreEleves)<1)return alert('Nombre d’élèves invalide');if(Number(form.value.ponderationMax)<1)return alert('Pondération invalide');const old=editId.value?await get(editId.value):null;const c={id:editId.value||genererId(),nom:form.value.nom.trim(),nombreEleves:Number(form.value.nombreEleves),ponderationMax:Number(form.value.ponderationMax),dateCreation:old?.dateCreation||new Date().toISOString(),tirages:old?.tirages||[]};await save(c);await refresh();view.value='list'} async function del(c){if(confirm(`Supprimer « ${c.nom} » ?`)){await supprimerClasse(c.id);await refresh()}}
async function del(c){if(confirm(`Supprimer « ${c.nom} » ? Cette action est irréversible.`)){await remove(c.id);await refresh()}} async function draw(){if(busy.value)return;busy.value=true;try{active.value=await effectuerTirage(active.value.id);await refresh()}finally{busy.value=false}}
async function open(c){active.value=await get(c.id);view.value='detail'} async function redraw(){if(busy.value)return;busy.value=true;try{active.value=await reTirer(active.value.id);await refresh()}finally{busy.value=false}}
async function draw(){tirer(active.value);await save(active.value);active.value=await get(active.value.id)} async function doExport(){const json=await exporter();const blob=new Blob([json],{type:'application/json'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=`tirages-${new Date().toISOString().slice(0,10)}.json`;a.click();URL.revokeObjectURL(url)}
async function redraw(){if(!active.value.tirages.length)return;active.value.tirages.shift();await save(active.value);await draw()} function chooseImport(){document.getElementById('importFile').click()}
async function doExport(){const text=await exportData();const blob=new Blob([text],{type:'application/json'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=`tirages-export-${new Date().toISOString().slice(0,10)}.json`;a.click();URL.revokeObjectURL(url)} async function doImport(e){const f=e.target.files?.[0];if(!f)return;try{await importer(await f.text());await refresh();alert('Import terminé.')}catch(err){alert(`Import impossible : ${err.message}`)}e.target.value=''}
function chooseImport(){fileInput.value?.click()}
async function onImport(e){const f=e.target.files?.[0];if(!f)return;try{await importData(await f.text());await refresh();alert('Import terminé. Les données du navigateur ont été remplacées par celles du fichier.')}catch(err){alert(err.message||'Import impossible.')}e.target.value=''}
onMounted(refresh) onMounted(refresh)
</script> </script>
<template><div class="app"><header><button v-if="view!=='list'" class="icon" @click="view='list';active=null">‹</button><h1>{{view==='list'?'Tirages':view==='form'?(editId?'Modifier la classe':'Nouvelle classe'):active?.nom}}</h1><div class="actions"><button v-if="view==='list'" class="icon" @click="showHelp=true">?</button><button v-if="view==='list'" class="icon" @click="newClass">+</button></div></header> <template><div class="app"><header><button v-if="view!=='list'" @click="view='list';active=null">‹</button><h1>{{view==='list'?'Mes classes':view==='form'?(editId?'Modifier la classe':'Nouvelle classe'):active?.nom}}</h1><button v-if="view==='list'" @click="help=true">?</button></header><main>
<main> <section v-if="view==='list'"><div v-if="!sorted.length" class="empty"><b>Aucune classe</b><span>Créez une classe pour commencer.</span><button class="primary" @click="nouveau">Créer une classe</button></div><div v-else class="cards"><article v-for="c in sorted" :key="c.id"><button class="main" @click="open(c)"><b>{{c.nom}}</b><span>{{c.nombreEleves}} élèves</span><i>›</i></button><footer><button @click="edit(c)">Modifier</button><button @click="del(c)">Supprimer</button></footer></article></div><button class="primary add" @click="nouveau">+ Nouvelle classe</button><div class="data"><button @click="doExport">Exporter les données</button><button @click="chooseImport">Importer des données</button><input id="importFile" type="file" accept="application/json" hidden @change="doImport"></div></section>
<section v-if="view==='list'"><div v-if="!sorted.length" class="empty"><b>Aucune classe</b><span>Créez une classe pour commencer.</span><button class="primary" @click="newClass">Créer une classe</button></div><div v-else class="cards"><article v-for="c in sorted" :key="c.id"><button class="main" @click="open(c)"><span><b>{{c.nom}}</b><small>{{c.nombreEleves}} élèves</small></span><strong>›</strong></button><footer><button @click="edit(c)">Modifier</button><button @click="del(c)">Supprimer</button></footer></article></div><div class="data-actions"><button @click="doExport">Exporter les données</button><button @click="chooseImport">Importer des données</button><input ref="fileInput" type="file" accept="application/json,.json" hidden @change="onImport"></div></section> <section v-else-if="view==='form'" class="form"><label>Nom de la classe<input v-model="form.nom" placeholder="ex. 3A" autofocus></label><label>Nombre d'élèves<input v-model.number="form.nombreEleves" type="number" min="1"></label><label>Pondération maximale<input v-model.number="form.ponderationMax" type="number" min="1" step=".1"><small>1 = équiprobable. Une valeur supérieure favorise les élèves qui n'ont pas été tirés récemment.</small></label><button class="primary" @click="submit">Enregistrer</button></section>
<section v-else-if="view==='form'" class="form"><label>Nom de la classe<input v-model="form.nom" placeholder="ex. 3A" autofocus></label><label>Nombre d’élèves<input v-model.number="form.nombreEleves" type="number" min="1" step="1"></label><label>Pondération maximale<input v-model.number="form.ponderationMax" type="number" min="1" step="0.1"><small>1 = équiprobable. Plus la valeur est élevée, plus les élèves qui n’ont pas été tirés récemment sont favorisés.</small></label><button class="primary" @click="submit">Enregistrer</button></section> <section v-else><p class="muted">{{active.nombreEleves}} élèves · pondération max ×{{active.ponderationMax}}</p><div class="draw"><button class="primary" :disabled="busy" @click="draw">🎲 Tirer un élève au sort</button><strong v-if="active.tirages.length">n°{{active.tirages[0].numeroEleve}}</strong><button v-if="active.tirages.length" :disabled="busy" @click="redraw">🔁 Re-tirer (annule et retire)</button></div><h2>Historique des tirages</h2><div v-if="!active.tirages.length" class="muted">Aucun tirage pour le moment.</div><div v-else class="history"><div v-for="(t,i) in active.tirages" :key="t.id"><b>{{t.numeroEleve}}</b><span>{{new Date(t.dateTirage).toLocaleString('fr-FR')}}</span><em v-if="i===0">dernier</em></div></div></section>
<section v-else><p class="muted">{{active.nombreEleves}} élèves · pondération max ×{{active.ponderationMax}}</p><div class="draw"><button class="primary" @click="draw">🎲 Tirer un élève au sort</button><strong v-if="active.tirages.length">n°{{active.tirages[0].numeroEleve}}</strong><button v-if="active.tirages.length" @click="redraw">🔁 Re-tirer (annule et retire)</button></div><h2>Historique des tirages</h2><div v-if="!active.tirages.length" class="empty small">Aucun tirage pour le moment.</div><div v-else class="history"><div v-for="(t,i) in active.tirages" :key="t.id"><b>{{t.numeroEleve}}</b><span>{{new Date(t.dateTirage).toLocaleString('fr-FR')}}</span><em v-if="i===0">dernier</em></div></div></section> </main><div v-if="help" class="overlay" @click.self="help=false"><div class="modal"><button class="close" @click="help=false">×</button><h2>À propos de Tirages</h2><h3>Données locales</h3><p>Les classes et les tirages sont stockés dans <b>IndexedDB du navigateur</b>, sur l’appareil utilisé.</p><p><b>Rien n’est envoyé au serveur.</b> Le serveur sert uniquement les fichiers de l’application. Les tirages restent dans votre navigateur.</p><h3>Exporter les données</h3><p>L’export crée un fichier JSON contenant vos classes et votre historique. Il sert notamment à faire une copie de sécurité ou à transférer vos données vers un autre appareil.</p><p>Pour restaurer une copie, utilisez <b>Importer des données</b>. L’import remplace les données actuellement présentes.</p><h3>Ajouter à l’écran d’accueil</h3><p><b>iPhone / iPad :</b> ouvrez Tirages dans Safari → bouton Partager → <b>Sur l’écran d’accueil</b> → Ajouter.</p><p><b>Android :</b> ouvrez Tirages dans Chrome → menu ⋮ → <b>Ajouter à l’écran d’accueil</b> ou <b>Installer l’application</b>.</p><p class="repo">Code source : <a href="https://git.spaier.fr/cspaier/tirages-classes" target="_blank" rel="noreferrer">git.spaier.fr/cspaier/tirages-classes</a></p></div></div></div></template>
</main>
<div v-if="showHelp" class="overlay" @click.self="showHelp=false"><section class="help"><button class="close" @click="showHelp=false">×</button><h2>À propos et aide</h2><h3>Données privées et locales</h3><p>Les classes et l’historique sont stockés dans <b>le navigateur de votre appareil</b>, dans une base de données locale IndexedDB. Les données de tirage ne sont envoyées à aucun serveur.</p><p>Le serveur ne sert qu’à fournir les fichiers de l’application. Une fois la PWA installée et mise en cache, elle peut fonctionner hors connexion.</p><h3>Exporter les données</h3><p><b>Exporter</b> crée un fichier JSON contenant vos classes et leurs historiques. Gardez-le comme sauvegarde, notamment avant de changer de téléphone, de navigateur ou de supprimer les données du site.</p><p>L’export permet aussi de transférer vos données vers une autre installation grâce à <b>Importer des données</b>.</p><h3>Installer sur un mobile</h3><p><b>iPhone / iPad :</b> ouvrez l’application dans Safari, touchez <b>Partager</b>, puis <b>Sur l’écran d’accueil</b> et validez.</p><p><b>Android :</b> ouvrez l’application dans Chrome puis utilisez <b>Installer l’application</b> ou <b>Ajouter à l’écran d’accueil</b>, selon la version.</p><p class="note">L’installation nécessite une connexion HTTPS. Le nom et les menus peuvent légèrement varier selon le navigateur.</p><h3>Code source</h3><p><a href="https://git.spaier.fr/cspaier/tirages-classes" target="_blank" rel="noopener">Dépôt Git : git.spaier.fr/cspaier/tirages-classes</a></p></section></div></div></template>
+9 -2
View File
@@ -1,2 +1,9 @@
export function genererId(){return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`} function genererId(){return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`}
export function tirer(classe){const nums=Array.from({length:classe.nombreEleves},(_,i)=>i+1);const ages=nums.map(n=>{const t=classe.tirages.find(x=>x.numeroEleve===n);return (Date.now()-new Date(t?.dateTirage||classe.dateCreation).getTime())/1000});const min=Math.min(...ages),max=Math.max(...ages);const poids=ages.map(a=>max===min?1:1+((a-min)/(max-min))*(classe.ponderationMax-1));let r=Math.random()*poids.reduce((a,b)=>a+b,0);let n=nums.at(-1);for(let i=0;i<nums.length;i++){r-=poids[i];if(r<=0){n=nums[i];break}}classe.tirages.unshift({id:genererId(),numeroEleve:n,dateTirage:new Date().toISOString()});return n} export class Tirage{constructor({id,numeroEleve,dateTirage}={}){this.id=id??genererId();this.numeroEleve=Number(numeroEleve);this.dateTirage=dateTirage??new Date().toISOString()}}
export class Classe{constructor({id,nom,nombreEleves,ponderationMax,dateCreation,tirages}={}){this.id=id??genererId();this.nom=nom;this.nombreEleves=Number(nombreEleves);this.ponderationMax=Math.max(Number(ponderationMax)||2,1);this.dateCreation=dateCreation??new Date().toISOString();this.tirages=(tirages||[]).map(t=>t instanceof Tirage?t:new Tirage(t))}
numerosEleves(){return Array.from({length:this.nombreEleves},(_,i)=>i+1)}
calculerPoids(maintenant=new Date()){const derniers={};for(const t of this.tirages)if(!(t.numeroEleve in derniers))derniers[t.numeroEleve]=new Date(t.dateTirage);const nums=this.numerosEleves();const ages=nums.map(n=>(maintenant.getTime()-(derniers[n]||new Date(this.dateCreation)).getTime())/1000);const min=Math.min(...ages),max=Math.max(...ages);const poids=max===min?nums.map(()=>1):ages.map(a=>1+((a-min)/(max-min))*(this.ponderationMax-1));return{numeros:nums,poids}}
choisirNumeroPondere(){const{numeros,poids}=this.calculerPoids();const total=poids.reduce((a,b)=>a+b,0);let r=Math.random()*total;for(let i=0;i<numeros.length;i++){r-=poids[i];if(r<=0)return numeros[i]}return numeros[numeros.length-1]}
tirer(){const t=new Tirage({numeroEleve:this.choisirNumeroPondere()});this.tirages.unshift(t);return t}
reTirer(){if(!this.tirages.length)throw new Error('Aucun tirage à annuler');this.tirages.shift();return this.tirer()}
}
+13 -7
View File
@@ -1,9 +1,15 @@
import Dexie from 'dexie' import Dexie from 'dexie'
export const db=new Dexie('tirages-classes') import {Classe} from './models'
export const db=new Dexie('tirages')
db.version(1).stores({classes:'id,nom'}) db.version(1).stores({classes:'id,nom'})
export const load=()=>db.classes.toArray() function plain(c){return{id:c.id,nom:c.nom,nombreEleves:c.nombreEleves,ponderationMax:c.ponderationMax,dateCreation:c.dateCreation,tirages:c.tirages.map(t=>({id:t.id,numeroEleve:t.numeroEleve,dateTirage:t.dateTirage}))}}
export const get=id=>db.classes.get(id) export async function chargerClasses(){return (await db.classes.toArray()).map(c=>new Classe(c))}
export const save=c=>db.classes.put(c) export async function obtenirClasse(id){const c=await db.classes.get(id);return c?new Classe(c):null}
export const remove=id=>db.classes.delete(id) export async function sauvegarderClasse(c){await db.transaction('rw',db.classes,async()=>{await db.classes.put(plain(c))});return c}
export async function exportData(){const classes=await load();return JSON.stringify({format:'tirages-classes',version:1,exportedAt:new Date().toISOString(),classes},null,2)} export async function creerClasse(data){const c=new Classe({nom:data.nom.trim(),nombreEleves:data.nombreEleves,ponderationMax:data.ponderationMax});await sauvegarderClasse(c);return c}
export async function importData(text){const data=JSON.parse(text);if(!data||data.format!=='tirages-classes'||!Array.isArray(data.classes))throw new Error('Fichier d’export invalide.');for(const c of data.classes){if(!c.id||!c.nom||!Number.isInteger(Number(c.nombreEleves))||Number(c.nombreEleves)<1||!Array.isArray(c.tirages))throw new Error('Le fichier contient des données invalides.')}await db.transaction('rw',db.classes,async()=>{await db.classes.clear();await db.classes.bulkPut(data.classes)})} export async function modifierClasse(id,data){const c=await obtenirClasse(id);if(!c)throw Error('Classe introuvable');c.nom=data.nom.trim();c.nombreEleves=Number(data.nombreEleves);c.ponderationMax=Math.max(Number(data.ponderationMax)||2,1);await sauvegarderClasse(c);return c}
export async function supprimerClasse(id){await db.classes.delete(id)}
export async function effectuerTirage(id){const c=await obtenirClasse(id);if(!c)throw Error('Classe introuvable');c.tirer();await sauvegarderClasse(c);return c}
export async function reTirer(id){const c=await obtenirClasse(id);if(!c)throw Error('Classe introuvable');c.reTirer();await sauvegarderClasse(c);return c}
export async function exporter(){const data=await chargerClasses();return JSON.stringify(data.map(c=>({...c,tirages:c.tirages.map(t=>({...t}))})),null,2)}
export async function importer(json){const parsed=JSON.parse(json);if(!Array.isArray(parsed))throw Error('Format invalide');const classes=parsed.map(c=>new Classe(c));await db.transaction('rw',db.classes,async()=>{await db.classes.clear();await db.classes.bulkPut(classes.map(plain))});return classes}
+1 -1
View File
@@ -1 +1 @@
*{box-sizing:border-box}html,body,#app{margin:0;min-height:100%;background:#f7f7fb;color:#1c1c1e;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input{font:inherit}button{cursor:pointer}.app{min-height:100vh}header{height:60px;position:sticky;top:0;background:#fff;border-bottom:1px solid #e5e7eb;display:grid;grid-template-columns:48px 1fr 96px;align-items:center;z-index:5}header h1{text-align:center;font-size:19px;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.actions{display:flex;justify-content:flex-end}.icon{border:0;background:transparent;color:#2563eb;font-size:29px;height:60px;min-width:44px}.icon:first-letter{font-size:32px}main{max-width:760px;margin:auto;padding:18px 16px 32px}.cards{display:grid;gap:12px}.cards article,.draw,.history{background:#fff;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden}.main{width:100%;border:0;background:#fff;padding:16px;display:flex;align-items:center;text-align:left}.main span{display:grid;gap:4px;flex:1}.main b{font-size:17px}.main small{font-size:14px;color:#6b7280}.main strong{font-size:28px;color:#2563eb;font-weight:400}article footer{display:flex;border-top:1px solid #eee}article footer button{flex:1;padding:11px;border:0;background:#fff;color:#2563eb}article footer button+button{border-left:1px solid #eee;color:#dc2626}.empty{min-height:55vh;display:flex;align-items:center;justify-content:center;gap:9px;flex-direction:column;color:#6b7280;text-align:center}.empty b{color:#1c1c1e;font-size:19px}.empty.small{min-height:120px}.primary{background:#2563eb;color:#fff;border:0;border-radius:10px;padding:13px 18px;font-weight:650}.form{display:grid;gap:20px}.form label{display:grid;gap:7px;font-weight:650}.form input{padding:13px;border:1px solid #ccc;border-radius:10px;background:#fff}.form small{font-weight:400;color:#6b7280;line-height:1.45}.muted{color:#6b7280}.draw{padding:20px;display:grid;gap:16px;text-align:center}.draw strong{font-size:54px;color:#2563eb}.draw button:not(.primary){padding:12px;border:1px solid #ddd;border-radius:10px;background:#fff}.history div{display:flex;align-items:center;gap:12px;padding:11px 13px;border-bottom:1px solid #eee}.history div:last-child{border:0}.history b{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:#2563eb;color:#fff}.history span{flex:1;color:#6b7280;font-size:14px}.history em{font-size:11px;color:#16a34a;font-style:normal;font-weight:700}h2{font-size:18px;margin:25px 0 12px}.data-actions{display:flex;justify-content:center;gap:12px;margin:22px 0;flex-wrap:wrap}.data-actions button{background:transparent;border:0;color:#2563eb;padding:8px}.overlay{position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:20;padding:20px;overflow:auto}.help{position:relative;max-width:650px;margin:5vh auto;background:#fff;border-radius:16px;padding:24px;box-shadow:0 10px 40px rgba(0,0,0,.2);line-height:1.5}.help h2{margin-top:0}.help h3{font-size:16px;margin:22px 0 6px}.help p{margin:7px 0}.help a{color:#2563eb;word-break:break-word}.close{position:absolute;right:10px;top:8px;border:0;background:transparent;font-size:30px;color:#666}.note{font-size:13px;color:#6b7280}@media(max-width:480px){main{padding:14px 12px 28px}.help{padding:20px 18px}.data-actions{display:grid;gap:2px}} *{box-sizing:border-box}body{margin:0;background:#f7f7fb;color:#1c1c1e;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.app{min-height:100vh}header{height:58px;position:sticky;top:0;background:#fff;display:grid;grid-template-columns:48px 1fr 48px;align-items:center;border-bottom:1px solid #e5e7eb;z-index:2}header h1{text-align:center;font-size:19px;margin:0;overflow:hidden;text-overflow:ellipsis}header button{height:100%;border:0;background:transparent;color:#2563eb;font-size:28px}main{max-width:760px;margin:auto;padding:18px 16px}.cards{display:grid;gap:12px}.cards article,.draw,.history{background:#fff;border:1px solid #e3e3e8;border-radius:14px;overflow:hidden}.main{width:100%;padding:17px;border:0;background:#fff;display:grid;grid-template-columns:1fr auto auto;text-align:left}.main b{font-size:17px}.main span{grid-row:2;color:#777;font-size:14px}.main i{grid-column:3;grid-row:1/3;align-self:center;font-size:28px;color:#999;font-style:normal}article footer{display:flex;border-top:1px solid #eee}article footer button{flex:1;padding:11px;border:0;background:#fff;color:#2563eb}article footer button+button{border-left:1px solid #eee;color:#dc2626}.primary{background:#2563eb;color:#fff;border:0;border-radius:10px;padding:13px 18px;font-weight:650}.add{width:100%;margin-top:16px}.data{display:flex;gap:8px;margin-top:14px}.data button{flex:1;padding:10px;border:1px solid #ddd;background:#fff;border-radius:10px}.empty{min-height:55vh;display:flex;align-items:center;justify-content:center;gap:10px;flex-direction:column;color:#777}.empty b{color:#222;font-size:19px}.form{display:grid;gap:20px}.form label{display:grid;gap:7px;font-weight:650}.form input{padding:13px;border:1px solid #ccc;border-radius:10px;background:#fff}.form small{font-weight:400;color:#777}.draw{padding:20px;display:grid;gap:16px;text-align:center}.draw strong{font-size:54px;color:#2563eb}.draw button:not(.primary){padding:12px;border:1px solid #ddd;border-radius:10px;background:#fff}.draw button:disabled{opacity:.55}.muted{color:#777}h2{font-size:17px;margin-top:25px}.history div{display:flex;align-items:center;gap:12px;padding:11px 13px;border-bottom:1px solid #eee}.history div:last-child{border:0}.history b{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;background:#2563eb;color:#fff}.history span{flex:1;color:#777;font-size:14px}.history em{font-size:11px;color:#16a34a;font-style:normal;font-weight:bold}.overlay{position:fixed;inset:0;background:#0007;display:flex;align-items:flex-end;justify-content:center;z-index:10}.modal{background:#fff;width:min(760px,100%);max-height:90vh;overflow:auto;border-radius:18px 18px 0 0;padding:22px 20px 30px;position:relative}.modal h2{margin-top:0}.modal h3{margin-bottom:5px}.modal p{line-height:1.45}.close{position:absolute;right:12px;top:8px;border:0;background:transparent;font-size:30px;color:#666}.repo a{color:#2563eb;overflow-wrap:anywhere}@media(min-width:700px){.overlay{align-items:center}.modal{border-radius:18px}}
+1 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({plugins:[vue(),VitePWA({registerType:'autoUpdate',manifest:{name:'Tirages de classes',short_name:'Tirages',description:'Tirage aléatoire pondéré par l’ancienneté',lang:'fr',start_url:'/',display:'standalone',background_color:'#f7f7fb',theme_color:'#2563eb',icons:[{src:'/icon.svg',sizes:'any',type:'image/svg+xml',purpose:'any maskable'}]}})]}) export default defineConfig({plugins:[vue(),VitePWA({registerType:'autoUpdate',manifest:{name:'Tirages',short_name:'Tirages',description:'Tirage aléatoire pondéré',lang:'fr',start_url:'/',display:'standalone',background_color:'#f7f7fb',theme_color:'#2563eb'}})]})