From d6c9ab69e3e2f9c0eba0905e5e226f61bdc223a2 Mon Sep 17 00:00:00 2001 From: cspaier Date: Sat, 5 Sep 2026 12:30:18 +0200 Subject: [PATCH] sauvegarde des tirages --- README.md | 39 +++++++-------------------------------- index.html | 2 +- package-lock.json | 8 ++++---- package.json | 7 +------ src/App.vue | 44 ++++++++++++++++++++------------------------ src/main.js | 4 ++-- src/models.js | 11 +++++++++-- src/storage.js | 20 +++++++++++++------- src/style.css | 2 +- vite.config.js | 6 +++--- 10 files changed, 61 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index fd42e99..83034e3 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,23 @@ -# Tirages de classes — Vue.js / PWA -Tirage aléatoire avec pondération par l'ancienneté du dernier tirage. +# Tirages — Vue 3 / PWA -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. - -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. +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. ## Développement - ```bash npm install npm run dev ``` -Production : - +## Production ```bash npm run build ``` - -Le contenu de `dist/` peut être servi par Caddy, Nginx ou Apache. +Servir `dist/` en HTTPS. Sur iPhone/iPad : Safari → Partager → Sur l'écran d'accueil. ## Dépôt - https://git.spaier.fr/cspaier/tirages-classes diff --git a/index.html b/index.html index e99888d..104b9dc 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Tirages de classes
\ No newline at end of file +Tirages
diff --git a/package-lock.json b/package-lock.json index 5aaa79e..86ea6a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "tirages-classes", - "version": "1.0.0", + "name": "tirages", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tirages-classes", - "version": "1.0.0", + "name": "tirages", + "version": "1.0.1", "dependencies": { "dexie": "^4.0.8", "vue": "^3.5.13" diff --git a/package.json b/package.json index 433682c..a2561fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +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"} -} \ No newline at end of file +{"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"}} diff --git a/src/App.vue b/src/App.vue index 854a85e..65fa63a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,27 +1,23 @@ - \ No newline at end of file + diff --git a/src/main.js b/src/main.js index ebcd743..4387016 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,4 @@ -import {createApp} from 'vue' +import { createApp } from 'vue' import App from './App.vue' import './style.css' -createApp(App).mount('#app') \ No newline at end of file +createApp(App).mount('#app') diff --git a/src/models.js b/src/models.js index 172691d..d69a20a 100644 --- a/src/models.js +++ b/src/models.js @@ -1,2 +1,9 @@ -export 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;it 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;idb.classes.toArray() -export const get=id=>db.classes.get(id) -export const save=c=>db.classes.put(c) -export const remove=id=>db.classes.delete(id) -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 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)})} \ No newline at end of file +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 async function chargerClasses(){return (await db.classes.toArray()).map(c=>new Classe(c))} +export async function obtenirClasse(id){const c=await db.classes.get(id);return c?new Classe(c):null} +export async function sauvegarderClasse(c){await db.transaction('rw',db.classes,async()=>{await db.classes.put(plain(c))});return c} +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 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} diff --git a/src/style.css b/src/style.css index 53fd10b..ece4571 100644 --- a/src/style.css +++ b/src/style.css @@ -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}} diff --git a/vite.config.js b/vite.config.js index beaf83e..a36f036 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,4 +1,4 @@ -import {defineConfig} from 'vite' +import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' -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'}]}})]}) \ No newline at end of file +import { VitePWA } from 'vite-plugin-pwa' +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'}})]})