Download index.html from grappapp/grapp: direct link, hf CLI and curl.
- Browser
- Download file 34.9 kB
-
https://huggingface.co/spaces/grappapp/grapp/resolve/main/index.html
- Command line
-
hf download hf://spaces/grappapp/grapp/index.html
-
curl -L -o index.html https://huggingface.co/spaces/grappapp/grapp/resolve/main/index.html
34.9 kB
| <html lang="fr"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | |
| <title>GRAPP</title> | |
| <meta name="description" content="L'app pour choisir ton vin sans te prendre la grappe."> | |
| <meta name="theme-color" content="#7D1C4F"> | |
| <meta name="apple-mobile-web-app-capable" content="yes"> | |
| <meta name="mobile-web-app-capable" content="yes"> | |
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> | |
| <meta name="apple-mobile-web-app-title" content="GRAPP"> | |
| <link rel="manifest" href="manifest.json"> | |
| <link rel="icon" href="icon-192.png"> | |
| <link rel="apple-touch-icon" href="icon-192.png"> | |
| <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bowlby+One&family=Instrument+Sans:ital,wght@0,400;0,600;0,700;1,400&display=swap"> | |
| <style> | |
| /* Layout : un téléphone au centre (l'app), une colonne de notes à côté sur grand écran. Monde unique rose/prune du dossier PFE. */ | |
| :root{ | |
| --rose:#FF5F8A; --prune:#7D1C4F; --corail:#F5472C; --peche:#F4C4B6; --creme:#FAEFE7; --lilas:#D5B5D2; | |
| --encre:#2A0E1D; --blanc:#FFFFFF; --gris:#6E5563; | |
| --display:"Bowlby One","Arial Black",Impact,sans-serif; | |
| --body:"Instrument Sans",system-ui,-apple-system,"Segoe UI",sans-serif; | |
| color-scheme:light; | |
| } | |
| html,body{background:var(--creme);color:var(--encre)} | |
| body{font-family:var(--body);font-size:15px;line-height:1.45;padding-block:28px 40px; | |
| padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px))} | |
| .page{max-width:980px;margin:0 auto;display:grid;grid-template-columns:minmax(0,400px) minmax(0,1fr);gap:48px;align-items:start} | |
| @media (max-width:820px){.page{grid-template-columns:minmax(0,1fr)}.notes{order:2}} | |
| .phone{background:linear-gradient(170deg,var(--peche),var(--lilas));border:10px solid var(--encre);border-radius:44px; | |
| padding:22px 18px 20px;min-height:720px;box-shadow:10px 10px 0 var(--prune);position:relative;max-width:100%} | |
| .logo{font-family:var(--display);font-size:2.6rem;color:var(--prune);font-style:italic;line-height:1;transform:rotate(-4deg);display:inline-block} | |
| .tag{color:var(--prune);font-weight:700;font-size:.85rem;margin:.3rem 0 1rem} | |
| .tabs{display:flex;gap:6px;margin-bottom:14px} | |
| .tabs button{flex:1;font:700 .78rem var(--body);border:2px solid var(--prune);background:var(--blanc);color:var(--prune); | |
| border-radius:999px;padding:.45rem .2rem;cursor:pointer} | |
| .tabs button[aria-selected="true"]{background:var(--prune);color:var(--blanc)} | |
| button:focus-visible,input:focus-visible{outline:3px solid var(--corail);outline-offset:2px} | |
| h2{font-family:var(--display);font-style:italic;color:var(--prune);font-size:1.55rem;line-height:1.05;margin:.2rem 0 .2rem;text-transform:uppercase;text-wrap:balance} | |
| .sous{font-style:italic;color:var(--prune);margin:0 0 1rem} | |
| .prog{display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem} | |
| .prog b{font-family:var(--display);color:var(--prune);font-size:1.2rem} | |
| .bar{height:6px;background:var(--blanc);border-radius:9px;overflow:hidden;margin-bottom:1rem} | |
| .bar i{display:block;height:100%;background:var(--rose);transition:width .3s} | |
| .opts{display:grid;gap:10px} | |
| .opt{font:700 .95rem var(--body);text-align:left;background:var(--prune);color:var(--blanc);border:none;border-radius:12px; | |
| padding:.8rem 1rem;cursor:pointer;box-shadow:3px 3px 0 var(--rose);transition:transform .1s} | |
| .opt[aria-pressed="true"]{background:var(--rose);box-shadow:3px 3px 0 var(--prune)} | |
| .opt:hover{transform:translate(-1px,-1px)} | |
| .chipsel{display:flex;flex-wrap:wrap;gap:8px} | |
| .chipsel .opt{padding:.5rem .8rem;font-size:.85rem} | |
| label.l{font-weight:700;color:var(--prune);display:block;margin:1rem 0 .4rem} | |
| input[type=text]{width:100%;box-sizing:border-box;font:inherit;border:2px solid var(--prune);border-radius:12px;padding:.6rem .8rem;background:var(--blanc);color:var(--encre)} | |
| input[type=range]{width:100%;accent-color:var(--rose)} | |
| .nav{display:flex;justify-content:space-between;gap:10px;margin-top:1.4rem} | |
| .btn{font:800 .85rem var(--body);text-transform:uppercase;letter-spacing:.04em;border-radius:999px;padding:.6rem 1.1rem;cursor:pointer; | |
| border:2px solid var(--prune);background:var(--blanc);color:var(--prune)} | |
| .btn.go{background:var(--corail);border-color:var(--corail);color:var(--blanc)} | |
| .menu{background:var(--blanc);border-radius:12px;padding:8px;margin:.6rem 0;max-height:230px;overflow:auto} | |
| .menu img{display:block;width:100%} | |
| .ok{background:var(--blanc);border:2px dashed var(--prune);border-radius:12px;padding:.6rem .8rem;font-size:.85rem;margin-bottom:1rem} | |
| .vin{background:var(--blanc);border:3px solid var(--prune);border-radius:16px;padding:.9rem 1rem;box-shadow:4px 4px 0 var(--rose)} | |
| .vins{display:grid;gap:14px} | |
| .vin header{display:flex;justify-content:space-between;align-items:flex-start;gap:8px} | |
| .vin h3{font-family:var(--display);color:var(--prune);font-size:1.05rem;margin:0;text-transform:uppercase;line-height:1.1} | |
| .pct{background:var(--corail);color:var(--blanc);font-weight:800;border-radius:999px;padding:.15rem .6rem;white-space:nowrap;font-variant-numeric:tabular-nums} | |
| .meta{font-size:.82rem;color:var(--gris);margin:.2rem 0} | |
| .pitch{font-style:italic;margin:.35rem 0} | |
| .chips{display:flex;flex-wrap:wrap;gap:5px;margin:.4rem 0} | |
| .chip{background:var(--peche);color:var(--prune);border-radius:999px;padding:.1rem .55rem;font-size:.75rem;font-weight:700} | |
| .row{display:flex;gap:8px;flex-wrap:wrap;margin-top:.5rem} | |
| .row .btn{padding:.35rem .8rem;font-size:.72rem} | |
| .simi{font-size:.82rem;color:var(--prune);margin-top:.4rem} | |
| .empty{background:var(--blanc);border-radius:12px;padding:1rem;color:var(--prune)} | |
| .def h3{font-family:var(--display);color:var(--prune);margin:0 0 .3rem;font-size:1.05rem;text-transform:uppercase} | |
| .def p{margin:.2rem 0} | |
| .notes{padding-top:10px;max-width:62ch} | |
| .notes h1{font-family:var(--display);font-style:italic;color:var(--prune);font-size:2rem;line-height:1.05;margin:0 0 .6rem;text-wrap:balance} | |
| .notes p,.notes li{color:var(--encre)} | |
| .notes .k{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em;font-weight:700;color:var(--rose);margin-top:1.4rem} | |
| .notes ol{padding-left:1.2rem} | |
| .notes code{background:var(--blanc);padding:.05rem .35rem;border-radius:5px;font-size:.88em} | |
| @media (prefers-reduced-motion:reduce){*{transition:none}} | |
| /* Sur téléphone : plein écran, comme une vraie app */ | |
| @media (max-width:520px){ | |
| body{padding:0} | |
| .page{display:block} | |
| .phone{border:0;border-radius:0;box-shadow:none;min-height:100vh;min-height:100dvh; | |
| padding:calc(env(safe-area-inset-top,0px) + 18px) max(16px,env(safe-area-inset-right,0px)) calc(env(safe-area-inset-bottom,0px) + 24px) max(16px,env(safe-area-inset-left,0px))} | |
| .notes{display:none} | |
| } | |
| .src{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:.6rem} | |
| .src .opt{flex:1;min-width:0;text-align:center;padding:.55rem .4rem;font-size:.8rem} | |
| .load{color:var(--prune);font-weight:700;font-size:.9rem} | |
| .err{background:var(--blanc);border:2px solid var(--corail);border-radius:12px;padding:.6rem .8rem;font-size:.85rem;color:var(--encre)} | |
| textarea{width:100%;box-sizing:border-box;font:inherit;border:2px solid var(--prune);border-radius:12px;padding:.6rem .8rem;background:var(--blanc);color:var(--encre);min-height:140px} | |
| /* Sélecteur de langue : discret, en haut à droite du téléphone */ | |
| .lang{position:absolute;top:24px;right:20px;display:flex;align-items:center;gap:2px;font:700 .72rem var(--body);letter-spacing:.06em} | |
| .lang button{background:none;border:0;padding:.25rem .35rem;color:var(--prune);opacity:.45;cursor:pointer;font:inherit;border-radius:6px} | |
| .lang button[aria-pressed="true"]{opacity:1;text-decoration:underline;text-underline-offset:3px;text-decoration-thickness:2px} | |
| .lang span{color:var(--prune);opacity:.35} | |
| @media (max-width:520px){.lang{top:calc(env(safe-area-inset-top,0px) + 20px);right:max(14px,env(safe-area-inset-right,0px))}} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="page"> | |
| <div class="phone" id="phone"> | |
| <div class="lang" role="group" aria-label="Langue / Language"><button data-lang="fr">FR</button><span>·</span><button data-lang="en">EN</button></div> | |
| <div class="logo">GRAPP’</div> | |
| <p class="tag" data-i="tag"></p> | |
| <div class="tabs" role="tablist"> | |
| <button id="t0" role="tab" aria-selected="true" data-t="0" data-i="tab0"></button> | |
| <button id="t1" role="tab" aria-selected="false" data-t="1" data-i="tab1"></button> | |
| <button id="t2" role="tab" aria-selected="false" data-t="2" data-i="tab2"></button> | |
| </div> | |
| <div id="ecran"></div> | |
| </div> | |
| <aside class="notes"> | |
| <h1 data-i="n_h"></h1> | |
| <p data-i="n_p"></p> | |
| <p class="k" data-i="n_k1"></p> | |
| <p data-i="n_p1"></p> | |
| <p class="k" data-i="n_k2"></p> | |
| <p data-i="n_p2"></p> | |
| <p class="k" data-i="n_k3"></p> | |
| <p data-i="n_p3"></p> | |
| <p style="font-size:.8rem;color:var(--gris)" data-i="legal"></p> | |
| </aside> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/tesseract.js@5.1.1/dist/tesseract.min.js"></script> | |
| <script> | |
| /* ---------- Textes FR / EN ---------- */ | |
| const TXT={ | |
| fr:{tag:"L’app pour choisir ton vin sans te prendre la grappe 🍇",tab0:"Tchin Tchin !",tab1:"Mes favoris",tab2:"Dico Vino", | |
| n_h:"GRAPP, ton sommelier de poche",n_p:"Prends en photo la carte des vins du resto, réponds à 7 questions sans jargon, et GRAPP te sort ton top 3 avec un % de match. Uniquement des vins qui sont sur la carte.", | |
| n_k1:"Sur ton téléphone",n_p1:"Ouvre ce lien sur ton téléphone, puis <b>Partager → Sur l'écran d'accueil</b> (iPhone) ou <b>⋮ → Ajouter à l'écran d'accueil</b> (Android) : GRAPP s'ouvre alors en plein écran, comme une app.", | |
| n_k2:"Comment ça marche",n_p2:"Lecture de la photo par OCR (Tesseract.js), reconnaissance des vins tolérante aux fautes, puis recommandation hybride : tes réponses au quiz + ta phrase libre.", | |
| n_k3:"Version Python",n_p3:"Le code Python (Streamlit) est dans les fichiers du projet. Il tourne aussi dans le navigateur : <a href=\"python.html\">version Streamlit</a>.", | |
| legal:"À consommer avec modération. L’abus d’alcool est dangereux pour la santé.", | |
| s1:"Étape 1 · Scanne la carte",s1b:"GRAPP ne te propose que des vins qui sont sur la carte du resto.",photo:"📷 Photo",exemple:"🧾 Exemple",texte:"⌨️ Texte", | |
| phl:"Prends la carte des vins en photo",txl:"Colle ou tape les vins de la carte",imgalt:"Carte des vins d'exemple : bulles, blancs, rosés, rouges et douceurs avec leurs prix", | |
| skip:"Pas de carte",lire:"🔍 Lire la carte",none:"Je n'ai reconnu aucun vin de ma base sur cette carte 🤔 Essaie une photo plus nette, ou tape les noms.", | |
| needphoto:"Ajoute d'abord une photo 😉",ocr:"🔍 L'IA déchiffre la carte… (quelques secondes)",ocrerr:e=>`Lecture impossible (${e}). Tape plutôt les noms des vins.`, | |
| found:n=>`${n} vins repérés`,question:"Question",back:"⬅ Retour",next:"Suivant ➡",find:"🍇 Trouve mon vin !", | |
| flav:"Les saveurs qui te font kiffer",budget:f=>`Budget max pour ${f==="verre"?"le verre":"la bouteille"}`,envie:"Ton envie en une phrase",enviePh:"ex : un truc frais avec de la charcuterie", | |
| res:"Plus qu'à kiffer ✨",top:c=>`Ton top 3${c?" sur la carte du resto":""}`,nores:"Aucun vin ne colle à tes critères. Essaie « Surprends-moi » ou « Peu importe » !",again:"🔄 Recommencer", | |
| smells:"Ça sent :",with:"Avec :",fav:"❤️ Mes fav",unfav:"💔 Retirer",same:"🔁 Dans le même style :",ifyou:"🔁 Si tu aimes, essaie :", | |
| favh:"Mes vins pépites",nofav:"Pas encore de favori. Fais le quiz et clique sur ❤️ !",remove:"Retirer", | |
| dicoh:"Je décrypte le vin !",dicob:"Un mot bizarre sur la carte ? Tape-le.",dicoph:"ex : tanins, brut, carafer…",dicono:"Rien trouvé. Essaie « sec », « robe » ou « crémant ».", | |
| loading:"🍇 GRAPP se prépare…",loaderr:e=>`Impossible de charger les données (${e}). Recharge la page.`, | |
| r:{sucre:["Sec, comme tu aimes","Presque sec","Une pointe de douceur","Doux comme demandé","Bien sucré, team gourmand"],light:"Léger et facile",mid:"Équilibré",bold:"Costaud, du caractère", | |
| prof:"Profil ",occ:"Parfait pour : ",bud:(p,u)=>`Dans ton budget (${p} € ${u==="verre"?"le verre":"la bouteille"})`,envie:"Colle à ton envie du moment"}, | |
| fam:{fruite:"Fruité 🍓",floral:"Floral 🌸",epice:"Épicé 🌶️",boise:"Boisé / vanillé 🪵",mineral:"Frais et minéral 🌊",gourmand:"Gourmand / sucré 🍯"}, | |
| occ:{apero:"Apéro entre potes",repas:"Avec un bon repas",dessert:"Pour le dessert",grande_occasion:"Grosse occasion"}, | |
| Q:[["Alors, on trinque ?","C'est quoi le plan du jour ?"],["Verre ou bouteille ?","Tu la joues solo ou on partage ?"],["La vie en couleur !","T'es dans quelle team ?"], | |
| ["Plus ça pétille…","Un peu de pep's dans ton verre ?"],["Sucré ou pas ?","Sois honnête, personne ne juge."],["Léger ou costaud ?","Plutôt plume ou plutôt haltère ?"], | |
| ["C'est parfait !","Dernière ligne droite : tes goûts et ton budget."]], | |
| O:{verre:"Juste un p'tit verre (ou 2)",bouteille:"On prend une bouteille",rouge:"Rouge",blanc:"Blanc",rose:"Rosé",peu_importe:"Surprends-moi", | |
| oui:"J'adore le pétillant",non:"Sans bulles",bpi:"Peu importe",s1:"Sec",s2:"Presque sec",s3:"Une pointe de douceur",s4:"Doux",s5:"Team dessert", | |
| c1:"Tout léger",c2:"Léger",c3:"Entre les deux",c4:"Costaud",c5:"Ultra costaud"}, | |
| ios:{h:"Mets GRAPP sur ton iPhone 🍇",p:"En 3 secondes, GRAPP s'ouvre en plein écran comme une vraie app.", | |
| s:['Touche <b>Partager</b> ICO en bas de Safari (ou dans le menu <b>•••</b>)','Choisis <b>Sur l\'écran d\'accueil</b>','Touche <b>Ajouter</b> : c\'est fait !'],ok:"Compris",close:"Fermer"}}, | |
| en:{tag:"The app to pick your wine without the headache 🍇",tab0:"Cheers!",tab1:"My favourites",tab2:"Wine Dico", | |
| n_h:"GRAPP, your pocket sommelier",n_p:"Snap the restaurant’s wine list, answer 7 jargon-free questions, and GRAPP gives you your top 3 with a match score. Only wines that are actually on the list.", | |
| n_k1:"On your phone",n_p1:"Open this link on your phone, then <b>Share → Add to Home Screen</b> (iPhone) or <b>⋮ → Add to Home screen</b> (Android): GRAPP then opens full screen, like a real app.", | |
| n_k2:"How it works",n_p2:"The photo is read by OCR (Tesseract.js), wines are matched despite reading errors, then a hybrid recommendation combines your quiz answers and your free-text craving.", | |
| n_k3:"Python version",n_p3:"The Python code (Streamlit) is in the project files. It also runs in the browser: <a href=\"python.html\">Streamlit version</a>.", | |
| legal:"Please drink responsibly. Alcohol abuse is dangerous for your health.", | |
| s1:"Step 1 · Scan the wine list",s1b:"GRAPP only suggests wines that are on the restaurant’s list.",photo:"📷 Photo",exemple:"🧾 Sample",texte:"⌨️ Text", | |
| phl:"Take a photo of the wine list",txl:"Paste or type the wines on the list",imgalt:"Sample wine list: sparkling, whites, rosés, reds and sweet wines with prices", | |
| skip:"No list",lire:"🔍 Read the list",none:"I couldn't recognise any wine from my database on this list 🤔 Try a sharper photo, or type the names.", | |
| needphoto:"Add a photo first 😉",ocr:"🔍 The AI is reading the list… (a few seconds)",ocrerr:e=>`Couldn't read it (${e}). Type the wine names instead.`, | |
| found:n=>`${n} wines found`,question:"Question",back:"⬅ Back",next:"Next ➡",find:"🍇 Find my wine!", | |
| flav:"Flavours you love",budget:f=>`Max budget per ${f==="verre"?"glass":"bottle"}`,envie:"Your craving in one sentence",enviePh:"e.g. something fresh with charcuterie", | |
| res:"Time to enjoy ✨",top:c=>`Your top 3${c?" from the restaurant’s list":""}`,nores:"No wine matches your criteria. Try “Surprise me” or “Don’t mind”!",again:"🔄 Start over", | |
| smells:"Smells like:",with:"Goes with:",fav:"❤️ Save",unfav:"💔 Remove",same:"🔁 Same style:",ifyou:"🔁 If you like it, try:", | |
| favh:"My gem wines",nofav:"No favourites yet. Take the quiz and tap ❤️!",remove:"Remove", | |
| dicoh:"Wine, decoded!",dicob:"A weird word on the list? Type it.",dicoph:"e.g. tannins, brut, decanting…",dicono:"Nothing found. Try “dry”, “body” or “crémant”.", | |
| loading:"🍇 GRAPP is getting ready…",loaderr:e=>`Couldn't load the data (${e}). Please reload.`, | |
| r:{sucre:["Dry, just how you like it","Almost dry","A touch of sweetness","Sweet, as requested","Very sweet, dessert team"],light:"Light and easy",mid:"Balanced",bold:"Bold, full of character", | |
| prof:"Profile: ",occ:"Perfect for: ",bud:(p,u)=>`Within your budget (€${p} per ${u==="verre"?"glass":"bottle"})`,envie:"Matches your craving"}, | |
| fam:{fruite:"Fruity 🍓",floral:"Floral 🌸",epice:"Spicy 🌶️",boise:"Oaky / vanilla 🪵",mineral:"Fresh & mineral 🌊",gourmand:"Indulgent / sweet 🍯"}, | |
| occ:{apero:"Drinks with friends",repas:"With a good meal",dessert:"With dessert",grande_occasion:"Big occasion"}, | |
| Q:[["So, let’s toast?","What’s the plan today?"],["Glass or bottle?","Going solo or sharing?"],["Life in colour!","Which team are you?"], | |
| ["The more bubbles…","A little fizz in your glass?"],["Sweet or not?","Be honest, nobody’s judging."],["Light or bold?","Feather or dumbbell?"], | |
| ["Perfect!","Last step: your tastes and your budget."]], | |
| O:{verre:"Just a glass (or two)",bouteille:"Let’s get a bottle",rouge:"Red",blanc:"White",rose:"Rosé",peu_importe:"Surprise me", | |
| oui:"I love bubbles",non:"No bubbles",bpi:"Don’t mind",s1:"Dry",s2:"Almost dry",s3:"A touch of sweetness",s4:"Sweet",s5:"Dessert team", | |
| c1:"Very light",c2:"Light",c3:"In between",c4:"Bold",c5:"Very bold"}, | |
| ios:{h:"Put GRAPP on your iPhone 🍇",p:"In 3 seconds, GRAPP opens full screen like a real app.", | |
| s:['Tap <b>Share</b> ICO at the bottom of Safari (or in the <b>•••</b> menu)','Choose <b>Add to Home Screen</b>','Tap <b>Add</b>: done!'],ok:"Got it",close:"Close"}}}; | |
| let LANG="fr";try{const l=localStorage.getItem("grapp_lang");if(l==="en"||l==="fr")LANG=l}catch(e){} | |
| const T=()=>TXT[LANG]; | |
| /* ---------- Données ---------- */ | |
| const D={vins:[],en:{},dico:{fr:[],en:[]},carteTexte:"",img:"assets/carte_exemple.png"}; | |
| function parseCSV(txt){const rows=[];let row=[],f="",q=false; | |
| for(let i=0;i<txt.length;i++){const c=txt[i]; | |
| if(q){if(c=='"'){if(txt[i+1]=='"'){f+='"';i++}else q=false}else f+=c} | |
| else if(c=='"')q=true;else if(c==','){row.push(f);f=""} | |
| else if(c=='\n'){row.push(f);rows.push(row);row=[];f=""}else if(c!='\r')f+=c} | |
| if(f||row.length){row.push(f);rows.push(row)} | |
| const h=rows.shift();return rows.filter(r=>r.length===h.length).map(r=>Object.fromEntries(h.map((k,i)=>[k,isNaN(r[i])||r[i]===""?r[i]:+r[i]])));} | |
| /* texte d'un vin dans la langue choisie (le nom de l'appellation ne se traduit pas) */ | |
| const V=(v,k)=>LANG==="en"&&D.en[v.nom]?D.en[v.nom][k]:v[k]; | |
| /* ---------- Lecture de la carte : même logique que grapp/carte.py ---------- */ | |
| const norm=t=>String(t).normalize("NFD").replace(/[\u0300-\u036f]/g,"").toLowerCase().replace(/[^a-z0-9]+/g," ").trim(); | |
| const esc=t=>String(t).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); | |
| const VIDES=new Set("vin de du des la le d rouge blanc rose brut sec pays oc aop igp cotes villages".split(" ")); | |
| function ratio(a,b){if(!a.length&&!b.length)return 1;const m=Array.from({length:a.length+1},()=>new Array(b.length+1).fill(0)); | |
| for(let i=1;i<=a.length;i++)for(let j=1;j<=b.length;j++)m[i][j]=a[i-1]===b[j-1]?m[i-1][j-1]+1:Math.max(m[i-1][j],m[i][j-1]); | |
| return 2*m[a.length][b.length]/(a.length+b.length);} | |
| function scoreLigne(l,nom){const n=norm(nom);if(n&&l.includes(n))return 1; | |
| const mots=n.split(" ").filter(m=>!VIDES.has(m)&&m.length>2);if(!mots.length)return 0; | |
| const toks=l.split(" ");return mots.filter(m=>Math.max(0,...toks.map(t=>ratio(m,t)))>=.8).length/mots.length;} | |
| function trouverVins(texte){const res={}; | |
| for(const brute of texte.split("\n")){const l=norm(brute);if(l.length<4)continue; | |
| let best=null;for(const v of D.vins){const s=scoreLigne(l,v.nom),r=ratio(norm(v.nom),l); | |
| if(!best||s>best.s||(s===best.s&&r>best.r))best={s,r,nom:v.nom}} | |
| if(best&&best.s>=.99&&!(best.nom in res)){const m=brute.match(/(\d{1,3}(?:[.,]\d{1,2})?)\s*(?:€|eur)/i)||brute.trim().match(/(\d{1,3}(?:[.,]\d{1,2})?)\s*$/); | |
| res[best.nom]=m?parseFloat(m[1].replace(",",".")):null}} | |
| return res;} | |
| /* ---------- Quiz : les réponses sont stockées par identifiant, pas par texte ---------- */ | |
| const QK=[["occasion",["apero","repas","dessert","grande_occasion"]],["format",["verre","bouteille"]],["couleur",["rouge","blanc","rose","peu_importe"]], | |
| ["bulles",["oui","non","bpi"]],["sucre",["s1","s2","s3","s4","s5"]],["corps",["c1","c2","c3","c4","c5"]],["final",null]]; | |
| const lab=(cle,id)=>cle==="occasion"?T().occ[id]:T().O[id]; | |
| let S={tab:0,etape:0,rep:{},carte:null,src:"exemple",fav:[]}; | |
| try{S.fav=JSON.parse(localStorage.getItem("grapp_fav")||"[]")}catch(e){} | |
| const saveFav=()=>{try{localStorage.setItem("grapp_fav",JSON.stringify(S.fav))}catch(e){}}; | |
| const fiche=v=>`${v.nom} ${v.couleur} ${v.region} ${v.aromes} ${v.accords} ${v.pitch} `+(D.en[v.nom]?`${D.en[v.nom].region} ${D.en[v.nom].aromes} ${D.en[v.nom].accords} ${D.en[v.nom].pitch}`:""); | |
| function prefs(r){ | |
| return {occasion:r.occasion||"apero",format:r.format||"verre",couleur:r.couleur||"rouge",bulles:r.bulles==="bpi"?"peu_importe":(r.bulles||"oui"), | |
| sucre:+(r.sucre||"s1").slice(1),corps:+(r.corps||"c1").slice(1),familles:r.familles||[],budget:+(r.budget||30),envie:r.envie||""}; | |
| } | |
| const P={sucre:.18,corps:.18,famille:.17,occasion:.12,budget:.15,semantique:.20}; | |
| function recommander(p,k,carte){ | |
| let df=D.vins.filter(v=>(p.couleur==="peu_importe"||v.couleur===p.couleur)&&(p.bulles==="peu_importe"||v.bulles===(p.bulles==="oui"?1:0))&&(!carte||v.nom in carte)); | |
| const mots=new Set(norm(p.envie).split(" ").filter(w=>w.length>3)); | |
| let sims=df.map(v=>{const f=new Set(norm(fiche(v)).split(" "));let n=0;mots.forEach(w=>{if([...f].some(x=>x.startsWith(w.slice(0,5))))n++});return n}); | |
| const lo=Math.min(...sims),hi=Math.max(...sims); | |
| sims=sims.map(s=>hi>lo?(s-lo)/(hi-lo):1); | |
| const R=T().r; | |
| return df.map((v,i)=>{ | |
| let prix=p.format==="verre"?v.prix_verre:v.prix_bouteille; if(carte&&carte[v.nom])prix=carte[v.nom]; | |
| const s={sucre:1-Math.abs(v.sucre-p.sucre)/4,corps:1-Math.abs(v.corps-p.corps)/4, | |
| famille:!p.familles.length||p.familles.includes(v.famille)?1:.25,occasion:v.occasion===p.occasion?1:.4, | |
| budget:prix<=p.budget?1:Math.max(0,1-(prix-p.budget)/p.budget),semantique:p.envie.trim()?sims[i]:.5}; | |
| const r=[]; | |
| if(s.sucre>=.75)r.push(R.sucre[v.sucre-1]); | |
| if(s.corps>=.75)r.push(v.corps<=2?R.light:v.corps===3?R.mid:R.bold); | |
| if(p.familles.includes(v.famille))r.push(R.prof+T().fam[v.famille]); | |
| if(v.occasion===p.occasion)r.push(R.occ+T().occ[v.occasion].toLowerCase()); | |
| if(prix<=p.budget)r.push(R.bud(Math.round(prix),p.format)); | |
| if(p.envie.trim()&&s.semantique>=.7)r.push(R.envie); | |
| return {v,prix,raisons:r,match:Math.round(100*Object.keys(P).reduce((a,c)=>a+P[c]*s[c],0))}; | |
| }).sort((a,b)=>b.match-a.match).slice(0,k); | |
| } | |
| function similaires(nom){ | |
| const v=D.vins.find(x=>x.nom===nom); const vec=x=>[x.sucre,x.corps,x.acidite,x.tanins,x.bulles*3]; | |
| const a=vec(v); | |
| return D.vins.filter(x=>x.nom!==nom).map(x=>({x,d:vec(x).reduce((s,t,i)=>s+(t-a[i])**2,0)+(x.famille===v.famille?0:2)+(x.couleur===v.couleur?0:4)})).sort((p,q)=>p.d-q.d).slice(0,3).map(o=>o.x.nom); | |
| } | |
| /* ---------- Affichage ---------- */ | |
| const E=document.getElementById("ecran"); | |
| const EMO={rouge:"🍷",blanc:"🥂",rose:"🌸"}; | |
| function carteVin(r){ | |
| const v=r.v,fav=S.fav.includes(v.nom),t=T(); | |
| return `<article class="vin"><header><h3>${EMO[v.couleur]} ${esc(v.nom)}</h3><span class="pct">${r.match}% match</span></header> | |
| <div class="meta">${esc(V(v,"region"))} · ~${LANG==="en"?"€"+Math.round(r.prix):Math.round(r.prix)+" €"}</div><p class="pitch">${LANG==="en"?"“":"« "}${esc(V(v,"pitch"))}${LANG==="en"?"”":" »"}</p> | |
| <div class="meta"><b>${t.smells}</b> ${esc(V(v,"aromes"))} · <b>${t.with}</b> ${esc(V(v,"accords"))}</div> | |
| <div class="chips">${r.raisons.map(x=>`<span class="chip">${esc(x)}</span>`).join("")}</div> | |
| <div class="row"><button class="btn" data-fav="${esc(v.nom)}">${fav?t.unfav:t.fav}</button></div> | |
| <div class="simi">${t.same} ${similaires(v.nom).map(esc).join(", ")}</div></article>`; | |
| } | |
| function statique(){ | |
| document.documentElement.lang=LANG; | |
| document.querySelectorAll("[data-i]").forEach(el=>{el.innerHTML=T()[el.dataset.i]}); | |
| document.querySelectorAll("[data-lang]").forEach(b=>b.setAttribute("aria-pressed",b.dataset.lang===LANG)); | |
| if(window.iosTextes)iosTextes(); | |
| } | |
| function render(){ | |
| const t=T(); | |
| document.querySelectorAll(".tabs button").forEach(b=>b.setAttribute("aria-selected",+b.dataset.t===S.tab)); | |
| if(S.tab===1){ | |
| E.innerHTML=`<h2>${t.favh}</h2>`+(S.fav.length?`<div class="vins">${S.fav.map(n=>{const v=D.vins.find(x=>x.nom===n);if(!v)return""; | |
| return `<article class="vin"><header><h3>${EMO[v.couleur]} ${esc(n)}</h3><button class="btn" data-fav="${esc(n)}" aria-label="${t.remove}">🗑️</button></header> | |
| <div class="meta">${esc(V(v,"region"))} · ~${LANG==="en"?"€"+v.prix_bouteille:v.prix_bouteille+" €"}</div><p class="pitch">${esc(V(v,"pitch"))}</p> | |
| <div class="simi">${t.ifyou} ${similaires(n).map(esc).join(", ")}</div></article>`}).join("")}</div>` | |
| :`<p class="empty">${t.nofav}</p>`); | |
| return; | |
| } | |
| if(S.tab===2){ | |
| E.innerHTML=`<h2>${t.dicoh}</h2><p class="sous">${t.dicob}</p> | |
| <input type="text" id="dq" placeholder="${esc(t.dicoph)}" value="${esc(S.dq||"")}"><div class="vins" id="dres" style="margin-top:12px"></div>`; | |
| const dico=D.dico[LANG]; | |
| const go=()=>{S.dq=dq.value;const q=norm(dq.value);const res=q?dico.map(d=>{const tt=norm(d.terme),f=norm(d.definition);let s=tt.includes(q)||q.includes(tt)?10:0;q.split(" ").filter(w=>w.length>3).forEach(w=>{if(f.includes(w.slice(0,5)))s++});return {d,s}}).filter(o=>o.s>0).sort((a,b)=>b.s-a.s).slice(0,3):dico.slice(0,3).map(d=>({d})); | |
| dres.innerHTML=res.length?res.map(({d})=>`<article class="vin def"><h3>${esc(d.terme)}</h3><p>${esc(d.definition)}</p><p class="pitch">👉 ${esc(d.exemple)}</p></article>`).join(""):`<p class="empty">${t.dicono}</p>`}; | |
| const dq=document.getElementById("dq"),dres=document.getElementById("dres");dq.oninput=go;go();return; | |
| } | |
| const e=S.etape; | |
| if(e===0){ | |
| const src=S.src||"exemple"; | |
| let h=`<h2>${t.s1}</h2><p class="sous">${t.s1b}</p> | |
| <div class="src"><button class="opt" data-s="photo" aria-pressed="${src==="photo"}">${t.photo}</button><button class="opt" data-s="exemple" aria-pressed="${src==="exemple"}">${t.exemple}</button><button class="opt" data-s="texte" aria-pressed="${src==="texte"}">${t.texte}</button></div>`; | |
| if(src==="photo")h+=`<label class="l" for="ph">${t.phl}</label><input type="file" id="ph" accept="image/*" capture="environment"><div id="prev"></div>`; | |
| else if(src==="exemple")h+=`<div class="menu"><img src="${D.img}" alt="${esc(t.imgalt)}"></div>`; | |
| else h+=`<label class="l" for="tx">${t.txl}</label><textarea id="tx" placeholder="Chinon 2020 29 € Sancerre 42 €">${esc(S.tx||"")}</textarea>`; | |
| h+=`<div id="msg"></div><div class="nav"><button class="btn" id="skip">${t.skip}</button><button class="btn go" id="lire">${t.lire}</button></div>`; | |
| E.innerHTML=h; | |
| E.querySelectorAll("[data-s]").forEach(b=>b.onclick=()=>{S.src=b.dataset.s;render()}); | |
| const ph=document.getElementById("ph");let fichier=null; | |
| if(ph)ph.onchange=()=>{fichier=ph.files[0];if(fichier)prev.innerHTML=`<div class="menu"><img src="${URL.createObjectURL(fichier)}" alt=""></div>`}; | |
| const tx=document.getElementById("tx");if(tx)tx.oninput=()=>{S.tx=tx.value}; | |
| const msg=document.getElementById("msg"); | |
| const fini=c=>{if(Object.keys(c).length){S.carte=c;S.etape=1;render()}else msg.innerHTML=`<p class="err">${t.none}</p>`}; | |
| lire.onclick=async()=>{ | |
| if(src==="exemple"){fini(trouverVins(D.carteTexte));return} | |
| if(src==="texte"){fini(trouverVins(S.tx||""));return} | |
| if(!fichier){msg.innerHTML=`<p class="err">${t.needphoto}</p>`;return} | |
| msg.innerHTML=`<p class="load">${t.ocr}</p>`;lire.disabled=true; | |
| try{const r=await Tesseract.recognize(fichier,"fra");fini(trouverVins(r.data.text))} | |
| catch(err){msg.innerHTML=`<p class="err">${esc(t.ocrerr(err.message||err))}</p>`} | |
| finally{lire.disabled=false}}; | |
| skip.onclick=()=>{S.carte=null;S.etape=1;render()};return; | |
| } | |
| if(e<=7){ | |
| const [cle,ids]=QK[e-1],[titre,sous]=t.Q[e-1]; | |
| let h=(S.carte?`<div class="ok">🧾 <b>${t.found(Object.keys(S.carte).length)}</b>${LANG==="en"?":":" :"} ${Object.keys(S.carte).map(esc).join(", ")}</div>`:"")+ | |
| `<div class="prog"><span class="sous" style="margin:0">${t.question}</span><b>${e}/7</b></div><div class="bar"><i style="width:${e/7*100}%"></i></div><h2>${titre}</h2><p class="sous">${sous}</p>`; | |
| if(ids){h+=`<div class="opts">${ids.map(id=>`<button class="opt" aria-pressed="${(S.rep[cle]||ids[0])===id}" data-o="${id}">${esc(lab(cle,id))}</button>`).join("")}</div>`} | |
| else{const fmt=S.rep.format||"verre";S.rep.budget=S.rep.budget||(fmt==="verre"?8:30);S.rep.familles=S.rep.familles||[]; | |
| h+=`<label class="l">${t.flav}</label><div class="chipsel">${Object.keys(t.fam).map(f=>`<button class="opt" aria-pressed="${S.rep.familles.includes(f)}" data-f="${f}">${t.fam[f]}</button>`).join("")}</div> | |
| <label class="l" for="bud">${t.budget(fmt)}${LANG==="en"?": €":" : "}<span id="bv">${S.rep.budget}</span>${LANG==="en"?"":" €"}</label><input type="range" id="bud" min="3" max="80" value="${S.rep.budget}"> | |
| <label class="l" for="env">${t.envie}</label><input type="text" id="env" value="${esc(S.rep.envie||"")}" placeholder="${esc(t.enviePh)}">`} | |
| h+=`<div class="nav"><button class="btn" id="ret">${t.back}</button><button class="btn go" id="suiv">${e<7?t.next:t.find}</button></div>`; | |
| E.innerHTML=h; | |
| E.querySelectorAll("[data-o]").forEach(b=>b.onclick=()=>{S.rep[cle]=b.dataset.o;render()}); | |
| E.querySelectorAll("[data-f]").forEach(b=>b.onclick=()=>{const f=b.dataset.f,L=S.rep.familles;S.rep.familles=L.includes(f)?L.filter(x=>x!==f):[...L,f];render()}); | |
| const bud=document.getElementById("bud");if(bud){bud.oninput=()=>{S.rep.budget=+bud.value;bv.textContent=bud.value};env.oninput=()=>{S.rep.envie=env.value}} | |
| ret.onclick=()=>{S.etape--;render()}; | |
| suiv.onclick=()=>{if(ids&&!S.rep[cle])S.rep[cle]=ids[0];S.etape++;render()};return; | |
| } | |
| const r=recommander(prefs(S.rep),3,S.carte); | |
| E.innerHTML=`<h2>${t.res}</h2><p class="sous">${t.top(!!S.carte)}</p>`+ | |
| (r.length?`<div class="vins">${r.map(carteVin).join("")}</div>`:`<p class="empty">${t.nores}</p>`)+ | |
| `<div class="nav"><button class="btn" id="re">${t.again}</button></div>`; | |
| re.onclick=()=>{S.etape=0;S.rep={};S.carte=null;render()}; | |
| } | |
| document.querySelector(".tabs").onclick=ev=>{const b=ev.target.closest("[data-t]");if(b){S.tab=+b.dataset.t;render()}}; | |
| document.querySelector(".lang").onclick=ev=>{const b=ev.target.closest("[data-lang]");if(!b)return;LANG=b.dataset.lang;try{localStorage.setItem("grapp_lang",LANG)}catch(e){} | |
| S.dq="";statique();if(D.vins.length)render()}; | |
| E.addEventListener("click",ev=>{const b=ev.target.closest("[data-fav]");if(!b)return;const n=b.dataset.fav;S.fav=S.fav.includes(n)?S.fav.filter(x=>x!==n):[...S.fav,n];saveFav();render()}); | |
| statique(); | |
| E.innerHTML=`<p class="load">${T().loading}</p>`; | |
| Promise.all(["data/vins.csv","data/vins_en.csv","data/dico.csv","data/dico_en.csv","data/carte_exemple.txt"].map(u=>fetch(u).then(r=>{if(!r.ok)throw new Error(u);return r.text()}))) | |
| .then(([v,ve,d,de,c])=>{D.vins=parseCSV(v);parseCSV(ve).forEach(x=>D.en[x.nom]=x);D.dico.fr=parseCSV(d);D.dico.en=parseCSV(de);D.carteTexte=c;render()}) | |
| .catch(err=>{E.innerHTML=`<p class="err">${esc(T().loaderr(err.message))}</p>`}); | |
| </script> | |
| <!-- Aide à l'installation : iPhone uniquement, dans le navigateur (pas en mode app) --> | |
| <style> | |
| .ios-voile{position:fixed;inset:0;background:rgba(42,14,29,.35);z-index:50} | |
| .ios-pop{position:fixed;left:max(12px,env(safe-area-inset-left,0px));right:max(12px,env(safe-area-inset-right,0px)); | |
| bottom:calc(env(safe-area-inset-bottom,0px) + 12px);z-index:51;background:var(--creme);border:3px solid var(--prune); | |
| border-radius:22px;box-shadow:5px 5px 0 var(--rose);padding:18px 18px 16px;animation:iosIn .35s ease-out} | |
| @keyframes iosIn{from{transform:translateY(30px);opacity:0}to{transform:none;opacity:1}} | |
| @media (prefers-reduced-motion:reduce){.ios-pop{animation:none}} | |
| .ios-pop h3{font-family:var(--display);font-style:italic;text-transform:uppercase;color:var(--prune);font-size:1.15rem;margin:0 0 .3rem;line-height:1.1} | |
| .ios-pop p{margin:0 0 .7rem;font-size:.9rem;color:var(--encre)} | |
| .ios-pop ol{margin:0 0 .9rem;padding-left:1.3rem;font-size:.9rem;color:var(--encre);display:grid;gap:.35rem} | |
| .ios-pop b{color:var(--prune)} | |
| .ios-ico{display:inline-block;vertical-align:-3px;width:16px;height:18px} | |
| .ios-pop .btn{width:100%} | |
| .ios-x{position:absolute;top:8px;right:10px;background:none;border:0;font-size:1.4rem;color:var(--prune);cursor:pointer;line-height:1;padding:4px} | |
| </style> | |
| <div id="iosAide" hidden> | |
| <div class="ios-voile" data-ferme></div> | |
| <div class="ios-pop" role="dialog" aria-labelledby="iosTitre"> | |
| <button class="ios-x" data-ferme id="iosX">×</button> | |
| <h3 id="iosTitre"></h3> | |
| <p id="iosP"></p> | |
| <ol id="iosOl"></ol> | |
| <button class="btn go" data-ferme id="iosOk"></button> | |
| </div> | |
| </div> | |
| <script> | |
| (function(){ | |
| var ICO='<svg class="ios-ico" viewBox="0 0 16 18" aria-hidden="true"><path d="M8 1v10M4.5 4.5 8 1l3.5 3.5M3 8H2v9h12V8h-1" fill="none" stroke="#7D1C4F" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>'; | |
| window.iosTextes=function(){var t=TXT[LANG].ios;document.getElementById("iosTitre").textContent=t.h;document.getElementById("iosP").textContent=t.p; | |
| document.getElementById("iosOl").innerHTML=t.s.map(function(x){return "<li>"+x.replace("ICO",ICO)+"</li>"}).join(""); | |
| document.getElementById("iosOk").textContent=t.ok;document.getElementById("iosX").setAttribute("aria-label",t.close)}; | |
| iosTextes(); | |
| var ua=navigator.userAgent||""; | |
| var iphone=/iPhone|iPod/.test(ua); | |
| var appli=window.navigator.standalone===true||(window.matchMedia&&matchMedia("(display-mode: standalone)").matches); | |
| var vu=false;try{vu=localStorage.getItem("grapp_ios_aide")==="1"}catch(e){} | |
| if(!iphone||appli||vu)return; | |
| var el=document.getElementById("iosAide"); | |
| setTimeout(function(){el.hidden=false},1200); | |
| el.addEventListener("click",function(e){ | |
| if(e.target.closest("[data-ferme]")){el.hidden=true;try{localStorage.setItem("grapp_ios_aide","1")}catch(x){}} | |
| }); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |