deploy: f6ac0722e9
1275
index.html
Normal file
33
recipe/cuisses_poulet_four.cook
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
title: Cuisses de poulet au four (cuisson lente)
|
||||||
|
description: Peau croustillante, chair qui se détache toute seule. Cuisson lente, non supervisée.
|
||||||
|
servings: 4
|
||||||
|
prep time: 5 minutes
|
||||||
|
cook time: 2 hours
|
||||||
|
course: plat
|
||||||
|
locale: fr
|
||||||
|
tags:
|
||||||
|
- batch
|
||||||
|
- four
|
||||||
|
- poulet
|
||||||
|
---
|
||||||
|
|
||||||
|
Préchauffer le #four{} à 150°C.
|
||||||
|
|
||||||
|
Poser les @cuisses de poulet entières{4} dans un #plat à four{}, peau sur le dessus, sans qu'elles se touchent (serrées = peau à la vapeur).
|
||||||
|
|
||||||
|
Arroser d'@huile d'olive{} et masser, puis @sel{} et @poivre{} généreux et les @épices{}(piment d'Espelette, cumin, paprika, coriandre, origan…).
|
||||||
|
|
||||||
|
Enfourner ~{2%heures} minimum. Ni retourner, ni arroser, ni couvrir, ni liquide dans le plat.
|
||||||
|
|
||||||
|
La chair doit se détacher de l'os et la peau être dorée. Sinon ~{5%minutes} sous le gril.
|
||||||
|
|
||||||
|
== Notes ==
|
||||||
|
|
||||||
|
> 2 h 30 – 3 h sans problème, ça cale avec les 2 h de mijotage de la ratatouille.
|
||||||
|
|
||||||
|
> Garder la graisse du plat pour faire revenir des légumes ou des patates plus tard.
|
||||||
|
|
||||||
|
> Combos : cumin + coriandre + paprika · paprika fumé + origan · Espelette seul.
|
||||||
|
|
||||||
|
> 3-4 jours au frigo, se réchauffe bien.
|
||||||
1351
recipe/cuisses_poulet_four.html
Normal file
23
recipe/galette_lentilles.cook
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
title: Galettes de lentilles
|
||||||
|
servings: 4
|
||||||
|
prep time: 10 minutes
|
||||||
|
cook time: 10 minutes
|
||||||
|
course: plat
|
||||||
|
locale: fr
|
||||||
|
tags:
|
||||||
|
- végétarien
|
||||||
|
- poêle
|
||||||
|
---
|
||||||
|
|
||||||
|
Râper finement les @carottes{2} et émincer l'@échalote{1}.
|
||||||
|
|
||||||
|
Mélanger avec les @lentilles{200%g}(ou du maïs), @sel{2%g}, @cumin{2%g}, @coriandre{à volonté}, @farine{15%g}(ou Maïzena), @œuf{1} et @chapelure de pain{15%g}, jusqu'à obtenir une sorte de pâte grumeleuse.
|
||||||
|
|
||||||
|
Filmer au contact et laisser reposer au frigo ~repos{1%heure} minimum. La pâte doit être assez compacte pour faire des boules type falafels ou des galettes.
|
||||||
|
|
||||||
|
Cuire à la #poêle{} dans de l'@huile d'olive{}.
|
||||||
|
|
||||||
|
== Notes ==
|
||||||
|
|
||||||
|
> Option : une fois les boules faites, les paner et les frire ou les passer au four.
|
||||||
1397
recipe/galette_lentilles.html
Normal file
35
recipe/legumes_rotis.cook
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
title: Légumes rôtis au four
|
||||||
|
description: Bords caramélisés, cœur fondant. Une grande plaque pour la semaine.
|
||||||
|
servings: 6
|
||||||
|
prep time: 10 minutes
|
||||||
|
cook time: 1 hour
|
||||||
|
course: accompagnement
|
||||||
|
locale: fr
|
||||||
|
tags:
|
||||||
|
- batch
|
||||||
|
- four
|
||||||
|
- légumes
|
||||||
|
---
|
||||||
|
|
||||||
|
Préchauffer le #four{} à 200°C.
|
||||||
|
|
||||||
|
Couper en gros morceaux réguliers de 3-4 cm les légumes qui tiennent : @pommes de terre{}, @patate douce{}, @carottes{}, @panais{}, @courge{}, @betterave{}, @chou-fleur{}, @oignons rouges{}. Réguliers = tout cuit à la même vitesse.
|
||||||
|
|
||||||
|
Mélanger dans un #saladier{} avec beaucoup d'@huile d'olive{}, @sel{}, @poivre{}, les @épices{}(cumin, paprika, curry, herbes de Provence, romarin) et l'@ail{1%tête}(gousses entières, en chemise). Chaque morceau doit être luisant.
|
||||||
|
|
||||||
|
Étaler en une seule couche sur une #plaque{}, sans que ça se chevauche.
|
||||||
|
|
||||||
|
Enfourner ~{45%minutes} à 1 h, en remuant une fois à mi-cuisson. Fini quand les bords sont dorés et qu'un couteau traverse sans résistance.
|
||||||
|
|
||||||
|
== Notes ==
|
||||||
|
|
||||||
|
> Le seul vrai piège : la plaque surchargée. Les légumes rendent leur eau, ça cuit à la vapeur et ça ne dore jamais. Deux plaques plutôt qu'une trop pleine.
|
||||||
|
|
||||||
|
> Les tendres (courgette, brocoli, poivron, tomates cerises) cuisent 2× plus vite : les ajouter 20-25 min avant la fin.
|
||||||
|
|
||||||
|
> Bon chaud, bon froid en salade avec une vinaigrette ou du yaourt-citron.
|
||||||
|
|
||||||
|
> Sur la même plaque que des cuisses de poulet, ça marche aussi, mais compter 150°C et 2 h.
|
||||||
|
|
||||||
|
> 4-5 jours au frigo. Se congèle mal (ça devient mou).
|
||||||
1525
recipe/legumes_rotis.html
Normal file
36
recipe/mi-cuit_chocolat.cook
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: Le gâteau mi-cuit au chocolat de Suzy Palatin
|
||||||
|
description: Gâteau au chocolat orgiaque, riche et gourmand, mais très facile à faire. Proposé par François-Régis Gaudry, et c'est Pierre Hermé qui dit que c'est le meilleur gâteau au chocolat du monde.
|
||||||
|
author: Suzy Palatin
|
||||||
|
source: https://www.youtube.com/watch?v=Vpa58PdBuoQ
|
||||||
|
servings: 8
|
||||||
|
prep time: 15 minutes
|
||||||
|
cook time: 30 minutes
|
||||||
|
course: dessert
|
||||||
|
locale: fr
|
||||||
|
tags:
|
||||||
|
- chocolat
|
||||||
|
- gâteau
|
||||||
|
---
|
||||||
|
|
||||||
|
Préchauffer le #four{} à 200°C.
|
||||||
|
|
||||||
|
Faire fondre le @chocolat pâtissier à 60 ou 70 %{250%g} au bain-marie ou au micro-ondes.
|
||||||
|
|
||||||
|
Ajouter le @beurre{250%g}(pommade, coupé en morceaux) et bien mélanger à l'aide d'un #fouet{}, pour obtenir une texture lisse.
|
||||||
|
|
||||||
|
Débarrasser dans un #cul de poule{}, ajouter le @sucre{250%g} et mélanger.
|
||||||
|
|
||||||
|
Ajouter la @farine{70%g} en trois fois, en mélangeant énergiquement pour éviter les grumeaux.
|
||||||
|
|
||||||
|
Casser les @œufs{4} dans un bol et les battre en omelette. Les ajouter à l'appareil chocolaté et bien mélanger.
|
||||||
|
|
||||||
|
Verser la pâte dans un #moule à manqué de 24-25 cm{}, préalablement beurré et fariné, idéalement en silicone.
|
||||||
|
|
||||||
|
Baisser le thermostat à 150°C et enfourner ~{30%minutes}.
|
||||||
|
|
||||||
|
Laisser refroidir ~{10%minutes} et démouler. Le gâteau doit avoir une légère croûte et un cœur moelleux, fondant et crémeux à souhait.
|
||||||
|
|
||||||
|
== Notes ==
|
||||||
|
|
||||||
|
> Prévoir un peu de beurre et de farine en plus des 250 g / 70 g, pour le moule.
|
||||||
1424
recipe/mi-cuit_chocolat.html
Normal file
40
recipe/poisson_papillote.cook
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: Poisson en papillote
|
||||||
|
description: Zéro vaisselle, zéro surveillance.
|
||||||
|
servings: 4
|
||||||
|
prep time: 10 minutes
|
||||||
|
cook time: 25 minutes
|
||||||
|
course: plat
|
||||||
|
locale: fr
|
||||||
|
tags:
|
||||||
|
- four
|
||||||
|
- poisson
|
||||||
|
---
|
||||||
|
|
||||||
|
Préchauffer le #four{} à 200°C.
|
||||||
|
|
||||||
|
Couper fin les légumes : @courgette{1}(en rondelles), @poireau{1}(émincé), @tomates cerises{1%poignée}. Vraiment fin, sinon ils restent crus.
|
||||||
|
|
||||||
|
Sur 4 grandes feuilles de #papier cuisson{} : un lit de légumes, puis un @pavé de poisson{4}(cabillaud, lieu, saumon, dorade) sur chacune.
|
||||||
|
|
||||||
|
Ajouter un filet d'@huile d'olive{}, 2-3 rondelles de @citron{1}, @sel{}, @poivre{} et les @herbes{}(aneth, thym, origan). Optionnel : @vin blanc{1%c. à s.} par papillote, @ail{}, @piment{}.
|
||||||
|
|
||||||
|
Fermer hermétiquement en roulant les bords : la papillote doit gonfler à la cuisson.
|
||||||
|
|
||||||
|
Enfourner ~{20%minutes} pour des pavés de 2 cm, ~{25%minutes} pour 3-4 cm. Ne pas ouvrir en cours de route.
|
||||||
|
|
||||||
|
La chair doit se détacher en lamelles à la fourchette.
|
||||||
|
|
||||||
|
== Notes ==
|
||||||
|
|
||||||
|
> Les légumes cuisent moins vite que le poisson. Les durs (carotte, pomme de terre) sont à éviter, ou à précuire.
|
||||||
|
|
||||||
|
> Poisson surgelé : direct sans décongeler, +8-10 min.
|
||||||
|
|
||||||
|
> Papier cuisson plutôt qu'alu (le citron et la tomate attaquent l'alu).
|
||||||
|
|
||||||
|
> Trop cuit = sec et cotonneux, c'est la seule vraie façon de rater.
|
||||||
|
|
||||||
|
> Le jus au fond de la papillote est la sauce, le verser sur l'assiette.
|
||||||
|
|
||||||
|
> Moins « batch » que le reste : se mange le jour même. Les restes sont bons froids, émiettés en salade.
|
||||||
1552
recipe/poisson_papillote.html
Normal file
37
recipe/porc_effiloche.cook
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Porc effiloché au four
|
||||||
|
description: Le four fait tout, on n'ouvre pas.
|
||||||
|
servings: 8
|
||||||
|
prep time: 10 minutes
|
||||||
|
cook time: 6 hours
|
||||||
|
course: plat
|
||||||
|
locale: fr
|
||||||
|
tags:
|
||||||
|
- batch
|
||||||
|
- four
|
||||||
|
- porc
|
||||||
|
---
|
||||||
|
|
||||||
|
Préchauffer le #four{} à 150°C.
|
||||||
|
|
||||||
|
Frotter l'@échine de porc{2%kg}(ou palette, garder le gras) d'@huile d'olive{} ou de @moutarde{}, puis masser toutes les faces avec le rub : @sel{}, @poivre{}, @paprika fumé{}, @cumin{}, @ail en poudre{}, @origan{}, @cassonade{15%g}.
|
||||||
|
|
||||||
|
Émincer les @oignons{2} et les étaler au fond d'une #cocotte{}. Poser la viande dessus, gras vers le haut, puis verser la @bière{1%verre}(ou cidre, bouillon, eau) à côté de la viande, pas dessus (ça rincerait le rub).
|
||||||
|
|
||||||
|
Couvrir (couvercle ou alu bien serré) et cuire ~{5%heures} sans ouvrir.
|
||||||
|
|
||||||
|
Prêt quand une fourchette s'enfonce sans résistance. Laisser reposer ~repos{30%minutes}, puis effilocher à deux fourchettes et remélanger avec le jus et les oignons.
|
||||||
|
|
||||||
|
== Notes ==
|
||||||
|
|
||||||
|
> Compter ~3 h par kilo à 150°C. Trop cuit n'existe pas ici, pas assez cuit si.
|
||||||
|
|
||||||
|
> Si c'est encore ferme à 5 h, c'est que le collagène n'a pas fondu : rallonger, pas monter la température.
|
||||||
|
|
||||||
|
> Dernière heure à découvert pour une croûte, mais garder le jus pour remouiller après.
|
||||||
|
|
||||||
|
> Le jus est gras : le laisser figer au frigo et retirer la couche du dessus si besoin.
|
||||||
|
|
||||||
|
> Buns, tacos, riz, patates, ou juste avec des légumes rôtis.
|
||||||
|
|
||||||
|
> 4-5 jours au frigo, se congèle très bien en portions (avec son jus, sinon ça sèche).
|
||||||
1510
recipe/porc_effiloche.html
Normal file
35
recipe/ratatouille.cook
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
title: « Ratatouille » du frigo
|
||||||
|
description: Pas une vraie ratatouille - légumes cuits ensemble, départ à froid, 2 h de mijotage.
|
||||||
|
servings: 6
|
||||||
|
prep time: 15 minutes
|
||||||
|
cook time: 2 hours 15 minutes
|
||||||
|
course: plat
|
||||||
|
locale: fr
|
||||||
|
tags:
|
||||||
|
- batch
|
||||||
|
- mijoté
|
||||||
|
- légumes
|
||||||
|
---
|
||||||
|
|
||||||
|
Couper en gros morceaux les @oignons{2} et, au choix, @tomates{}, @courgettes{}, @aubergines{}, @poivrons{}, @piments{}. Vraiment gros : 2 h de cuisson, coupé fin ça finit en purée.
|
||||||
|
|
||||||
|
Tout mettre à froid dans une #marmite{} avec le @concentré de tomate{généreusement}, beaucoup d'@huile d'olive{}, @sel{} et @poivre{}. Allumer à feu bas-moyen.
|
||||||
|
|
||||||
|
Laisser revenir à découvert ~{15%minutes} en remuant régulièrement : le concentré doit foncer et perdre son goût cru.
|
||||||
|
|
||||||
|
Couvrir, feu minimum, ~{2%heures} minimum. Remuer de temps en temps.
|
||||||
|
|
||||||
|
Goûter, rectifier le sel.
|
||||||
|
|
||||||
|
== Notes ==
|
||||||
|
|
||||||
|
> Départ à froid = pas de saisie, les légumes confisent dans l'huile au lieu de griller. Ne pas lésiner sur l'huile, c'est elle qui fait la texture.
|
||||||
|
|
||||||
|
> Pas d'eau : les légumes en rendent assez. Si ça attache, fond d'eau + baisser le feu.
|
||||||
|
|
||||||
|
> Trop liquide à la fin : 15-20 min à découvert.
|
||||||
|
|
||||||
|
> Ajouts : ail en fin de cuisson, herbes de Provence, laurier, pincée de sucre si les tomates sont acides.
|
||||||
|
|
||||||
|
> Meilleur le lendemain. 4-5 jours au frigo, se congèle.
|
||||||
1453
recipe/ratatouille.html
Normal file
23
recipe/saucisses.cook
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
title: Saucisses maison
|
||||||
|
servings: 4
|
||||||
|
prep time: 15 minutes
|
||||||
|
cook time: 15 minutes
|
||||||
|
course: plat
|
||||||
|
locale: fr
|
||||||
|
tags:
|
||||||
|
- saucisse
|
||||||
|
- poêle
|
||||||
|
---
|
||||||
|
|
||||||
|
Dans un #saladier{}, mélanger la @chair à saucisse bœuf et porc{450%g}, le @sel{3%g}, une pincée de @poivre{}, du @persil plat{}(ciselé) et le @paprika{7%g}.
|
||||||
|
|
||||||
|
Couper une #bouteille en plastique d'1,5 L{} en deux : c'est le poussoir à saucisses.
|
||||||
|
|
||||||
|
Transvaser la farce dans la moitié haute de la bouteille. Planter une #pique à brochette{} dans le goulot.
|
||||||
|
|
||||||
|
Retourner la moitié basse de la bouteille et l'enfoncer dans la partie ouverte, pour pousser la farce vers le goulot.
|
||||||
|
|
||||||
|
Chauffer une #sauteuse{} à feu moyen et cuire les saucisses à cœur.
|
||||||
|
|
||||||
|
Garnir un @pain à hot-dog{} de @salade{}, ajouter de la @salsa{}, puis la saucisse, et enfin @ketchup{} et @moutarde{} selon l'envie.
|
||||||
1419
recipe/saucisses.html
Normal file
38
sitemap.xml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://pub.sqrt.fr/vincent/recipes/</loc>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://pub.sqrt.fr/vincent/recipes/recipe/cuisses_poulet_four.html</loc>
|
||||||
|
<lastmod>2026-08-17</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://pub.sqrt.fr/vincent/recipes/recipe/galette_lentilles.html</loc>
|
||||||
|
<lastmod>2026-08-17</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://pub.sqrt.fr/vincent/recipes/recipe/legumes_rotis.html</loc>
|
||||||
|
<lastmod>2026-08-17</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://pub.sqrt.fr/vincent/recipes/recipe/mi-cuit_chocolat.html</loc>
|
||||||
|
<lastmod>2026-08-17</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://pub.sqrt.fr/vincent/recipes/recipe/poisson_papillote.html</loc>
|
||||||
|
<lastmod>2026-08-17</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://pub.sqrt.fr/vincent/recipes/recipe/porc_effiloche.html</loc>
|
||||||
|
<lastmod>2026-08-17</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://pub.sqrt.fr/vincent/recipes/recipe/ratatouille.html</loc>
|
||||||
|
<lastmod>2026-08-17</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://pub.sqrt.fr/vincent/recipes/recipe/saucisses.html</loc>
|
||||||
|
<lastmod>2026-08-17</lastmod>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
BIN
static/android-chrome-128x128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
static/android-chrome-144x144.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
static/android-chrome-192x192-maskable.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
static/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
static/android-chrome-256x256.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
static/android-chrome-384x384.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
static/android-chrome-512x512-maskable.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
static/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
429
static/css/cooking-mode.css
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
/* Cooking Mode Overlay */
|
||||||
|
.cooking-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header bar */
|
||||||
|
.cooking-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-header-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-header-sections {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-header-sections::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-section-pill {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-section-pill:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-section-pill.active {
|
||||||
|
background: linear-gradient(135deg, #f97316, #eab308);
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-close-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-close-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Carousel container */
|
||||||
|
.cooking-carousel {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card positioning */
|
||||||
|
.cooking-card-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card {
|
||||||
|
position: absolute;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px;
|
||||||
|
transition: transform 0.3s ease-out, opacity 0.3s ease-out;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255,255,255,0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card.current {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
z-index: 1;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card.prev {
|
||||||
|
opacity: 0.25;
|
||||||
|
transform: translateY(-75%) scale(0.85);
|
||||||
|
z-index: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card.next {
|
||||||
|
opacity: 0.25;
|
||||||
|
transform: translateY(75%) scale(0.85);
|
||||||
|
z-index: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card.hidden-card {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%) scale(0.8);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Swipe animation */
|
||||||
|
.cooking-card.swiping {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section header card */
|
||||||
|
.cooking-card-section {
|
||||||
|
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card-section h2 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: linear-gradient(135deg, #f97316, #eab308);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-mise-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-mise-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-mise-item.checked {
|
||||||
|
opacity: 0.35;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-mise-name {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-mise-note {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-mise-qty {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f97316;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step card */
|
||||||
|
.cooking-card-step {
|
||||||
|
background: linear-gradient(135deg, #1e293b, #162032);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-number {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #f97316, #eab308);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-right: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-image {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-text {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override badge styles within cooking mode for readability */
|
||||||
|
.cooking-step-text .ingredient-badge {
|
||||||
|
font-size: 22px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-text .cookware-badge {
|
||||||
|
font-size: 22px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-text .timer-badge {
|
||||||
|
font-size: 22px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-ingredients {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-ingredients span {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Done card */
|
||||||
|
.cooking-card-done {
|
||||||
|
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card-done h2 {
|
||||||
|
font-size: 36px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card-done p {
|
||||||
|
font-size: 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-done-close-btn {
|
||||||
|
margin-top: 28px;
|
||||||
|
padding: 12px 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #f97316, #eab308);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-done-close-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.cooking-progress {
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #f97316, #eab308);
|
||||||
|
transition: width 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cooking-header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-header-sections {
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-section-pill {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-header-title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card {
|
||||||
|
width: calc(100% - 24px);
|
||||||
|
padding: 20px 16px;
|
||||||
|
max-height: 75vh;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card-section h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-text {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-text .ingredient-badge,
|
||||||
|
.cooking-step-text .cookware-badge,
|
||||||
|
.cooking-step-text .timer-badge {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-ingredients {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-mise-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-mise-name {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-mise-qty {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-mise-item {
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-card-done h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-done-close-btn {
|
||||||
|
padding: 10px 32px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-close-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.cooking-step-text {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
146
static/css/custom-styles.css
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/* Custom styles to enhance Tailwind */
|
||||||
|
:root {
|
||||||
|
--primary-orange: #ff6b35;
|
||||||
|
--primary-green: #4ade80;
|
||||||
|
--light-orange: #fed7aa;
|
||||||
|
--light-blue: #dbeafe;
|
||||||
|
--light-green: #dcfce7;
|
||||||
|
--light-yellow: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe cards with colorful borders */
|
||||||
|
.recipe-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, #ff6b35, #fbbf24, #4ade80);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ingredient badges */
|
||||||
|
.ingredient-badge {
|
||||||
|
background: linear-gradient(135deg, #fef3c7, #fed7aa);
|
||||||
|
border: 1px solid #fbbf24;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookware-badge {
|
||||||
|
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
|
||||||
|
border: 1px solid #4ade80;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-badge {
|
||||||
|
background: linear-gradient(135deg, #fee2e2, #fecaca);
|
||||||
|
border: 1px solid #f87171;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced buttons */
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #ff6b35, #f97316);
|
||||||
|
box-shadow: 0 4px 14px 0 rgba(255, 107, 53, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px 0 rgba(255, 107, 53, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search input with colorful focus */
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: #ff6b35;
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe image placeholder with gradient */
|
||||||
|
.recipe-image-placeholder {
|
||||||
|
background: linear-gradient(135deg, #ff6b35, #fbbf24);
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step numbers with colorful circles */
|
||||||
|
.step-number {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: linear-gradient(135deg, #ff6b35, #f97316);
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags styling */
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
background: linear-gradient(135deg, #fbbf24, #fb923c);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(251, 146, 60, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation pills */
|
||||||
|
.nav-pill {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pill:hover {
|
||||||
|
background: linear-gradient(135deg, #fef3c7, #fed7aa);
|
||||||
|
color: #ea580c;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 6px rgba(234, 88, 12, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pill.active {
|
||||||
|
background: linear-gradient(135deg, #ff6b35, #f97316);
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe metadata pills */
|
||||||
|
.metadata-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: linear-gradient(135deg, #f9fafb, #f3f4f6);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-pill svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
217
static/css/input.css
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Tailwind v4 is CSS-first and no longer auto-discovers tailwind.config.js.
|
||||||
|
@config keeps the existing JS config (custom colours, gradient keyframes,
|
||||||
|
darkMode: 'class' and the content globs) authoritative. */
|
||||||
|
@config "../../tailwind.config.js";
|
||||||
|
|
||||||
|
/* Custom component classes */
|
||||||
|
@layer components {
|
||||||
|
/* Recipe cards with colorful borders */
|
||||||
|
.recipe-card {
|
||||||
|
@apply relative overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card::before {
|
||||||
|
content: '';
|
||||||
|
@apply absolute top-0 left-0 right-0 h-1;
|
||||||
|
background: linear-gradient(90deg, #ff6b35, #fbbf24, #4ade80);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ingredient badges */
|
||||||
|
.ingredient-badge {
|
||||||
|
@apply inline-block align-middle px-2 py-0.5 rounded-full text-sm font-medium whitespace-nowrap;
|
||||||
|
background: linear-gradient(135deg, #fef3c7, #fed7aa);
|
||||||
|
@apply text-orange-700 border border-orange-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookware-badge {
|
||||||
|
@apply inline-block align-middle px-2 py-0.5 rounded-full text-sm font-medium whitespace-nowrap;
|
||||||
|
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
|
||||||
|
@apply text-green-900 border border-green-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-badge {
|
||||||
|
@apply inline-block align-middle px-2 py-0.5 rounded-full text-sm font-medium whitespace-nowrap;
|
||||||
|
background: linear-gradient(135deg, #fee2e2, #fecaca);
|
||||||
|
@apply text-red-900 border border-red-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced buttons */
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #ff6b35, #f97316);
|
||||||
|
@apply shadow-lg hover:-translate-y-0.5 transition-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
box-shadow: 0 6px 20px 0 rgba(255, 107, 53, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step numbers with colorful circles */
|
||||||
|
.step-number {
|
||||||
|
@apply w-8 h-8 flex items-center justify-center rounded-full text-white font-bold shrink-0;
|
||||||
|
background: linear-gradient(135deg, #ff6b35, #f97316);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags styling */
|
||||||
|
.tag {
|
||||||
|
@apply inline-block px-3 py-1 rounded-full text-xs font-medium mr-2;
|
||||||
|
background: linear-gradient(135deg, #fbbf24, #fb923c);
|
||||||
|
@apply text-white shadow-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation pills */
|
||||||
|
.nav-pill {
|
||||||
|
@apply px-5 py-2 rounded-full transition-all font-medium text-gray-500 relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pill:hover {
|
||||||
|
background: linear-gradient(135deg, #fef3c7, #fed7aa);
|
||||||
|
@apply text-orange-700 -translate-y-0.5 shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pill.active {
|
||||||
|
background: linear-gradient(135deg, #ff6b35, #f97316);
|
||||||
|
@apply text-white font-bold shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe metadata pills - simple outline style */
|
||||||
|
.metadata-pill {
|
||||||
|
@apply inline-flex items-center px-3 py-1 rounded-full text-sm font-medium whitespace-nowrap border bg-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-servings {
|
||||||
|
@apply text-gray-600 border-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-time {
|
||||||
|
@apply text-blue-600 border-blue-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-difficulty {
|
||||||
|
@apply text-yellow-600 border-yellow-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-course {
|
||||||
|
@apply text-orange-600 border-orange-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-prep {
|
||||||
|
@apply text-indigo-600 border-indigo-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-cook {
|
||||||
|
@apply text-red-600 border-red-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-cuisine {
|
||||||
|
@apply text-green-600 border-green-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diet {
|
||||||
|
@apply text-lime-600 border-lime-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-author {
|
||||||
|
@apply text-pink-600 border-pink-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-custom {
|
||||||
|
@apply text-purple-600 border-purple-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search input with colorful focus */
|
||||||
|
.search-input:focus {
|
||||||
|
@apply border-primary-orange;
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step images in recipes */
|
||||||
|
.image-step {
|
||||||
|
@apply w-full max-h-80 object-contain rounded-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe image placeholder with gradient */
|
||||||
|
.recipe-image-placeholder {
|
||||||
|
background: linear-gradient(135deg, #ff6b35, #fbbf24);
|
||||||
|
@apply w-30 h-30 rounded-full flex items-center justify-center text-white text-5xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pantry item styles with improved dark mode support */
|
||||||
|
.pantry-item {
|
||||||
|
@apply bg-gray-50 dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pantry-item:hover {
|
||||||
|
@apply bg-gray-100 dark:bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Out-of-stock pantry items with better dark mode contrast */
|
||||||
|
.pantry-item.out-of-stock {
|
||||||
|
@apply bg-red-50 border border-red-200 dark:bg-red-950/30 dark:border-red-900/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pantry-item.out-of-stock:hover {
|
||||||
|
@apply bg-red-100 dark:bg-red-950/40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pantry text colors for dark mode */
|
||||||
|
.pantry-item .text-gray-900 {
|
||||||
|
@apply dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pantry-item .text-gray-600 {
|
||||||
|
@apply dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pantry-item .text-gray-500 {
|
||||||
|
@apply dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Out-of-stock text in dark mode */
|
||||||
|
.pantry-item.out-of-stock .quantity-display {
|
||||||
|
@apply text-red-600 dark:text-red-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status dots with dark mode support */
|
||||||
|
.item-status-dot.in-stock {
|
||||||
|
@apply bg-green-500 dark:bg-green-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-status-dot.out-of-stock {
|
||||||
|
@apply bg-red-500 dark:bg-red-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CodeMirror editor dark mode support */
|
||||||
|
.dark .cm-editor {
|
||||||
|
@apply bg-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .cm-gutters {
|
||||||
|
@apply bg-gray-800 border-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .cm-lineNumbers .cm-gutterElement {
|
||||||
|
@apply text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .cm-activeLineGutter {
|
||||||
|
@apply bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .cm-activeLine {
|
||||||
|
@apply bg-gray-800/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .cm-content {
|
||||||
|
caret-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .cm-cursor {
|
||||||
|
border-left-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .cm-selectionBackground {
|
||||||
|
@apply bg-blue-800/50!;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
static/css/output.css
Normal file
445
static/css/styles.css
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
/* Base styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #111827;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.viewport {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1024px;
|
||||||
|
margin: 3rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
nav {
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav > div {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav .flex {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #4b5563;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a.font-semibold {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
main {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"] {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="number"]:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 16rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button,
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.recipe-card {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card:hover {
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-step {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ingredients and tags */
|
||||||
|
.ingredient,
|
||||||
|
.cookware,
|
||||||
|
.timer {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookware {
|
||||||
|
background-color: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search results dropdown */
|
||||||
|
.search-results {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results a {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results a:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-x-2 > * + * {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-x-3 > * + * {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-x-6 > * + * {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-1 > * + * {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-2 > * + * {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-4 > * + * {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-3 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-6 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-2 {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-4 {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-2 {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-3 {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.px-3 {
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-1 {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-2 {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-lg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-2xl {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-3xl {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-medium {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-semibold {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-bold {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-400 {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-500 {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-600 {
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-700 {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-800 {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-900 {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-red-600 {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-red-800 {
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gray-50 {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-white {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-lg {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-sm {
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-lg {
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-gray-200 {
|
||||||
|
border-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-gray-300 {
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-gray-900:hover {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-red-800:hover {
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:bg-gray-50:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:shadow-lg:hover {
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breadcrumbs */
|
||||||
|
ol.flex {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.md\:grid-cols-3 {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.md\:col-span-1 {
|
||||||
|
grid-column: span 1 / span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md\:col-span-2 {
|
||||||
|
grid-column: span 2 / span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
static/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 409 B |
BIN
static/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 761 B |
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
607
static/js/cooking-mode.js
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
// Cooking Mode for CookCLI
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ─── Constants ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const WHEEL_THRESHOLD = 80;
|
||||||
|
const WHEEL_RESET_MS = 200;
|
||||||
|
const SWIPE_THRESHOLD = 50;
|
||||||
|
const SWIPE_CLAMP_MAX = 150;
|
||||||
|
const SWIPE_SCALE_FACTOR = 0.001;
|
||||||
|
|
||||||
|
// ─── State ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
cards: [],
|
||||||
|
cardEls: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
wakeLock: null,
|
||||||
|
overlay: null,
|
||||||
|
touchStartY: 0,
|
||||||
|
touchCurrentY: 0,
|
||||||
|
isSwiping: false,
|
||||||
|
scrolling: false,
|
||||||
|
canScrollCard: false,
|
||||||
|
startScrollTop: 0,
|
||||||
|
wheelAccumulator: 0,
|
||||||
|
wheelResetTimeout: null,
|
||||||
|
triggerElement: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Data Loading ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadRecipeData() {
|
||||||
|
const el = document.getElementById('cooking-mode-data');
|
||||||
|
if (!el) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(el.textContent);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse cooking mode data:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureStepHTML() {
|
||||||
|
const stepElements = [];
|
||||||
|
const sectionEls = document.querySelectorAll('.md\\:col-span-2 ol');
|
||||||
|
sectionEls.forEach(function(ol) {
|
||||||
|
ol.querySelectorAll(':scope > li').forEach(function(li) {
|
||||||
|
const textDiv = li.querySelector('.leading-8');
|
||||||
|
if (textDiv) {
|
||||||
|
stepElements.push(textDiv.innerHTML);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return stepElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card Building ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildCards(data, stepHTMLs) {
|
||||||
|
const cards = [];
|
||||||
|
let stepHTMLIndex = 0;
|
||||||
|
|
||||||
|
data.sections.forEach(function(section, sectionIndex) {
|
||||||
|
const sectionName = section.name || (data.sections.length > 1 ? 'Main' : null);
|
||||||
|
|
||||||
|
if (section.ingredients.length > 0 || sectionName) {
|
||||||
|
cards.push({
|
||||||
|
type: 'section',
|
||||||
|
sectionIndex: sectionIndex,
|
||||||
|
name: sectionName || data.name,
|
||||||
|
ingredients: section.ingredients
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
section.steps.forEach(function(step) {
|
||||||
|
cards.push({
|
||||||
|
type: 'step',
|
||||||
|
sectionIndex: sectionIndex,
|
||||||
|
sectionName: sectionName,
|
||||||
|
number: step.number,
|
||||||
|
totalInSection: section.steps.length,
|
||||||
|
image: step.image,
|
||||||
|
html: stepHTMLs[stepHTMLIndex] || '',
|
||||||
|
ingredients: step.ingredients
|
||||||
|
});
|
||||||
|
stepHTMLIndex++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cards.push({
|
||||||
|
type: 'done',
|
||||||
|
sectionIndex: data.sections.length - 1,
|
||||||
|
name: data.name
|
||||||
|
});
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rendering ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeHTML(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(card) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'cooking-card hidden-card';
|
||||||
|
|
||||||
|
if (card.type === 'section') {
|
||||||
|
div.classList.add('cooking-card-section');
|
||||||
|
let ingredientsHTML = '';
|
||||||
|
if (card.ingredients.length > 0) {
|
||||||
|
const items = card.ingredients.map(function(ing) {
|
||||||
|
const qty = [ing.quantity, ing.unit].filter(Boolean).join(' ');
|
||||||
|
const note = ing.note ? '<span class="cooking-mise-note">(' + escapeHTML(ing.note) + ')</span>' : '';
|
||||||
|
return '<div class="cooking-mise-item">' +
|
||||||
|
'<span class="cooking-mise-name">' + escapeHTML(ing.name) + ' ' + note + '</span>' +
|
||||||
|
(qty ? '<span class="cooking-mise-qty">' + escapeHTML(qty) + '</span>' : '') +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
ingredientsHTML = '<div class="cooking-mise-grid">' + items + '</div>';
|
||||||
|
}
|
||||||
|
div.innerHTML = '<h2>' + escapeHTML(card.name) + '</h2>' + ingredientsHTML;
|
||||||
|
div.querySelectorAll('.cooking-mise-item').forEach(function(item) {
|
||||||
|
item.addEventListener('click', function() {
|
||||||
|
item.classList.toggle('checked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (card.type === 'step') {
|
||||||
|
div.classList.add('cooking-card-step');
|
||||||
|
const imageHTML = card.image
|
||||||
|
? '<img class="cooking-step-image" src="' + escapeHTML(card.image) + '" alt="Step ' + card.number + '" />'
|
||||||
|
: '';
|
||||||
|
let ingredientsHTML = '';
|
||||||
|
if (card.ingredients.length > 0) {
|
||||||
|
ingredientsHTML = '<div class="cooking-step-ingredients">' +
|
||||||
|
card.ingredients.map(function(ing) {
|
||||||
|
const qty = [ing.quantity, ing.unit].filter(Boolean).join(' ');
|
||||||
|
const note = ing.note ? ' (' + escapeHTML(ing.note) + ')' : '';
|
||||||
|
return '<span>' + escapeHTML(ing.name) + (qty ? ': ' + escapeHTML(qty) : '') + note + '</span>';
|
||||||
|
}).join('') + '</div>';
|
||||||
|
}
|
||||||
|
div.innerHTML =
|
||||||
|
imageHTML +
|
||||||
|
'<div class="cooking-step-text"><span class="cooking-step-number">' + card.number + '</span>' + card.html + '</div>' +
|
||||||
|
ingredientsHTML;
|
||||||
|
}
|
||||||
|
else if (card.type === 'done') {
|
||||||
|
div.classList.add('cooking-card-done');
|
||||||
|
div.innerHTML = '<h2>Bon Appetit!</h2>' +
|
||||||
|
'<button class="cooking-done-close-btn">Close</button>';
|
||||||
|
div.querySelector('.cooking-done-close-btn').addEventListener('click', closeCookingMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverlay(data, cards) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'cooking-overlay';
|
||||||
|
overlay.id = 'cooking-overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-label', 'Cooking mode: ' + data.name);
|
||||||
|
|
||||||
|
// ARIA live region for step announcements
|
||||||
|
const liveRegion = document.createElement('div');
|
||||||
|
liveRegion.setAttribute('aria-live', 'polite');
|
||||||
|
liveRegion.setAttribute('aria-atomic', 'true');
|
||||||
|
liveRegion.className = 'sr-only';
|
||||||
|
liveRegion.id = 'cooking-status';
|
||||||
|
overlay.appendChild(liveRegion);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'cooking-header';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'cooking-header-title';
|
||||||
|
title.textContent = data.name;
|
||||||
|
|
||||||
|
const sectionsNav = document.createElement('div');
|
||||||
|
sectionsNav.className = 'cooking-header-sections';
|
||||||
|
sectionsNav.id = 'cooking-sections-nav';
|
||||||
|
|
||||||
|
data.sections.forEach(function(section, i) {
|
||||||
|
const pill = document.createElement('button');
|
||||||
|
pill.className = 'cooking-section-pill';
|
||||||
|
pill.textContent = section.name || (data.sections.length > 1 ? 'Main' : 'Steps');
|
||||||
|
pill.dataset.sectionIndex = i;
|
||||||
|
pill.addEventListener('click', function() { navigateToSection(i); });
|
||||||
|
sectionsNav.appendChild(pill);
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'cooking-close-btn';
|
||||||
|
closeBtn.setAttribute('aria-label', 'Close cooking mode');
|
||||||
|
closeBtn.innerHTML = '×';
|
||||||
|
closeBtn.addEventListener('click', closeCookingMode);
|
||||||
|
|
||||||
|
header.appendChild(title);
|
||||||
|
if (data.sections.length > 1 || (data.sections[0] && data.sections[0].name)) {
|
||||||
|
header.appendChild(sectionsNav);
|
||||||
|
}
|
||||||
|
header.appendChild(closeBtn);
|
||||||
|
|
||||||
|
// Carousel
|
||||||
|
const carousel = document.createElement('div');
|
||||||
|
carousel.className = 'cooking-carousel';
|
||||||
|
carousel.id = 'cooking-carousel';
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'cooking-card-container';
|
||||||
|
container.id = 'cooking-card-container';
|
||||||
|
|
||||||
|
cards.forEach(function(card) {
|
||||||
|
container.appendChild(renderCard(card));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click on prev/next card to navigate
|
||||||
|
container.addEventListener('click', function(e) {
|
||||||
|
const card = e.target.closest('.cooking-card');
|
||||||
|
if (!card) return;
|
||||||
|
if (card.classList.contains('prev')) {
|
||||||
|
navigateTo(state.currentIndex - 1);
|
||||||
|
} else if (card.classList.contains('next')) {
|
||||||
|
navigateTo(state.currentIndex + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
carousel.appendChild(container);
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
const progress = document.createElement('div');
|
||||||
|
progress.className = 'cooking-progress';
|
||||||
|
progress.innerHTML = '<div class="cooking-progress-bar" id="cooking-progress-bar"></div>';
|
||||||
|
|
||||||
|
overlay.appendChild(header);
|
||||||
|
overlay.appendChild(carousel);
|
||||||
|
overlay.appendChild(progress);
|
||||||
|
|
||||||
|
// Cache card element references
|
||||||
|
state.cardEls = Array.from(container.querySelectorAll('.cooking-card'));
|
||||||
|
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Navigation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function updateCards() {
|
||||||
|
const cardEls = state.cardEls;
|
||||||
|
if (cardEls.length === 0) return;
|
||||||
|
|
||||||
|
// Only update cards that need changing: previous current +-1 and new current +-1
|
||||||
|
const indicesToUpdate = new Set();
|
||||||
|
for (let i = 0; i < cardEls.length; i++) {
|
||||||
|
const el = cardEls[i];
|
||||||
|
if (el.classList.contains('current') || el.classList.contains('prev') || el.classList.contains('next')) {
|
||||||
|
indicesToUpdate.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
indicesToUpdate.add(state.currentIndex);
|
||||||
|
if (state.currentIndex > 0) indicesToUpdate.add(state.currentIndex - 1);
|
||||||
|
if (state.currentIndex < cardEls.length - 1) indicesToUpdate.add(state.currentIndex + 1);
|
||||||
|
|
||||||
|
indicesToUpdate.forEach(function(i) {
|
||||||
|
const el = cardEls[i];
|
||||||
|
el.classList.remove('current', 'prev', 'next', 'hidden-card', 'swiping');
|
||||||
|
el.style.transform = '';
|
||||||
|
if (i === state.currentIndex) {
|
||||||
|
el.classList.add('current');
|
||||||
|
} else if (i === state.currentIndex - 1) {
|
||||||
|
el.classList.add('prev');
|
||||||
|
} else if (i === state.currentIndex + 1) {
|
||||||
|
el.classList.add('next');
|
||||||
|
} else {
|
||||||
|
el.classList.add('hidden-card');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update progress bar
|
||||||
|
const progressBar = document.getElementById('cooking-progress-bar');
|
||||||
|
if (progressBar) {
|
||||||
|
const pct = state.cards.length > 1
|
||||||
|
? (state.currentIndex / (state.cards.length - 1)) * 100
|
||||||
|
: 100;
|
||||||
|
progressBar.style.width = pct + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update section pills
|
||||||
|
const currentCard = state.cards[state.currentIndex];
|
||||||
|
if (currentCard) {
|
||||||
|
const pills = document.querySelectorAll('.cooking-section-pill');
|
||||||
|
for (let j = 0; j < pills.length; j++) {
|
||||||
|
const isActive = parseInt(pills[j].dataset.sectionIndex) === currentCard.sectionIndex;
|
||||||
|
pills[j].classList.toggle('active', isActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ARIA live region
|
||||||
|
updateAriaStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAriaStatus() {
|
||||||
|
const statusEl = document.getElementById('cooking-status');
|
||||||
|
if (!statusEl) return;
|
||||||
|
|
||||||
|
const currentCard = state.cards[state.currentIndex];
|
||||||
|
if (!currentCard) return;
|
||||||
|
|
||||||
|
let message;
|
||||||
|
if (currentCard.type === 'section') {
|
||||||
|
message = 'Ingredients for ' + currentCard.name;
|
||||||
|
} else if (currentCard.type === 'step') {
|
||||||
|
message = 'Step ' + currentCard.number + ' of ' + currentCard.totalInSection;
|
||||||
|
} else if (currentCard.type === 'done') {
|
||||||
|
message = 'Recipe complete. Bon Appetit!';
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(index) {
|
||||||
|
if (index < 0 || index >= state.cards.length) return;
|
||||||
|
state.currentIndex = index;
|
||||||
|
updateCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToSection(sectionIndex) {
|
||||||
|
for (let i = 0; i < state.cards.length; i++) {
|
||||||
|
if (state.cards[i].sectionIndex === sectionIndex) {
|
||||||
|
navigateTo(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Touch Handling ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function getCurrentCardEl() {
|
||||||
|
return state.cardEls[state.currentIndex] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAtScrollTop(el) {
|
||||||
|
return el.scrollTop <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAtScrollBottom(el) {
|
||||||
|
return el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardCanScroll(el) {
|
||||||
|
return el.scrollHeight > el.clientHeight + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(e) {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
state.touchStartY = e.touches[0].clientY;
|
||||||
|
state.touchCurrentY = state.touchStartY;
|
||||||
|
state.isSwiping = false;
|
||||||
|
state.scrolling = false;
|
||||||
|
|
||||||
|
const currentEl = getCurrentCardEl();
|
||||||
|
if (currentEl) {
|
||||||
|
state.startScrollTop = currentEl.scrollTop;
|
||||||
|
state.canScrollCard = cardCanScroll(currentEl);
|
||||||
|
} else {
|
||||||
|
state.canScrollCard = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e) {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
state.touchCurrentY = e.touches[0].clientY;
|
||||||
|
const delta = state.touchCurrentY - state.touchStartY;
|
||||||
|
const currentEl = getCurrentCardEl();
|
||||||
|
if (!currentEl) return;
|
||||||
|
|
||||||
|
// If card has scrollable content, let it scroll until at boundary
|
||||||
|
if (state.canScrollCard && !state.isSwiping) {
|
||||||
|
const swipingUp = delta < 0;
|
||||||
|
const swipingDown = delta > 0;
|
||||||
|
const atTop = isAtScrollTop(currentEl);
|
||||||
|
const atBottom = isAtScrollBottom(currentEl);
|
||||||
|
|
||||||
|
// Allow native scroll if not at boundary, or scrolling into content
|
||||||
|
if ((swipingUp && !atBottom) || (swipingDown && !atTop)) {
|
||||||
|
state.scrolling = true;
|
||||||
|
return; // let browser handle scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
// At boundary and swiping away from content — start card swipe
|
||||||
|
if (!state.scrolling) {
|
||||||
|
state.isSwiping = true;
|
||||||
|
state.touchStartY = state.touchCurrentY; // reset start to boundary
|
||||||
|
} else {
|
||||||
|
// Was scrolling, now at boundary — wait for a new gesture
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.isSwiping = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isSwiping) {
|
||||||
|
const swipeDelta = state.touchCurrentY - state.touchStartY;
|
||||||
|
const clampedDelta = Math.max(-SWIPE_CLAMP_MAX, Math.min(SWIPE_CLAMP_MAX, swipeDelta));
|
||||||
|
currentEl.classList.add('swiping');
|
||||||
|
currentEl.style.transform = 'translateY(' + clampedDelta + 'px) scale(' + (1 - Math.abs(clampedDelta) * SWIPE_SCALE_FACTOR) + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
try {
|
||||||
|
const currentEl = getCurrentCardEl();
|
||||||
|
|
||||||
|
if (state.isSwiping) {
|
||||||
|
const delta = state.touchCurrentY - state.touchStartY;
|
||||||
|
|
||||||
|
if (currentEl) {
|
||||||
|
currentEl.classList.remove('swiping');
|
||||||
|
currentEl.style.transform = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta < -SWIPE_THRESHOLD && state.currentIndex < state.cards.length - 1) {
|
||||||
|
navigateTo(state.currentIndex + 1);
|
||||||
|
} else if (delta > SWIPE_THRESHOLD && state.currentIndex > 0) {
|
||||||
|
navigateTo(state.currentIndex - 1);
|
||||||
|
} else {
|
||||||
|
updateCards();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
state.isSwiping = false;
|
||||||
|
state.scrolling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Keyboard Handling ────────────────────────────────────────
|
||||||
|
|
||||||
|
function onKeyDown(e) {
|
||||||
|
if (!state.overlay) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
navigateTo(state.currentIndex + 1);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
navigateTo(state.currentIndex - 1);
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
closeCookingMode();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wheel Handling ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onWheel(e) {
|
||||||
|
if (!state.overlay) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Accumulate wheel delta (trackpads send many small events)
|
||||||
|
state.wheelAccumulator += e.deltaY;
|
||||||
|
|
||||||
|
// Reset accumulator after a pause in scrolling
|
||||||
|
if (state.wheelResetTimeout) clearTimeout(state.wheelResetTimeout);
|
||||||
|
state.wheelResetTimeout = setTimeout(function() {
|
||||||
|
state.wheelAccumulator = 0;
|
||||||
|
state.wheelResetTimeout = null;
|
||||||
|
}, WHEEL_RESET_MS);
|
||||||
|
|
||||||
|
// Navigate only when accumulated delta exceeds threshold
|
||||||
|
if (state.wheelAccumulator >= WHEEL_THRESHOLD) {
|
||||||
|
navigateTo(state.currentIndex + 1);
|
||||||
|
state.wheelAccumulator = 0;
|
||||||
|
} else if (state.wheelAccumulator <= -WHEEL_THRESHOLD) {
|
||||||
|
navigateTo(state.currentIndex - 1);
|
||||||
|
state.wheelAccumulator = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wake Lock ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function acquireWakeLock() {
|
||||||
|
if (!('wakeLock' in navigator)) return;
|
||||||
|
navigator.wakeLock.request('screen').then(function(lock) {
|
||||||
|
state.wakeLock = lock;
|
||||||
|
lock.addEventListener('release', function() {
|
||||||
|
state.wakeLock = null;
|
||||||
|
});
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.warn('Wake lock request failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseWakeLock() {
|
||||||
|
if (state.wakeLock) {
|
||||||
|
state.wakeLock.release().catch(function() {});
|
||||||
|
state.wakeLock = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (state.overlay && document.visibilityState === 'visible' && !state.wakeLock) {
|
||||||
|
acquireWakeLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBeforeUnload() {
|
||||||
|
releaseWakeLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Open / Close ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function startCookingMode() {
|
||||||
|
const data = loadRecipeData();
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
// Save trigger element for focus restoration
|
||||||
|
state.triggerElement = document.activeElement;
|
||||||
|
|
||||||
|
const stepHTMLs = captureStepHTML();
|
||||||
|
state.cards = buildCards(data, stepHTMLs);
|
||||||
|
if (state.cards.length === 0) return;
|
||||||
|
|
||||||
|
state.currentIndex = 0;
|
||||||
|
|
||||||
|
state.overlay = renderOverlay(data, state.cards);
|
||||||
|
document.body.appendChild(state.overlay);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
updateCards();
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
const carousel = document.getElementById('cooking-carousel');
|
||||||
|
carousel.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||||
|
carousel.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||||
|
carousel.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||||
|
carousel.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
window.addEventListener('beforeunload', onBeforeUnload);
|
||||||
|
|
||||||
|
acquireWakeLock();
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
|
||||||
|
// Move focus to overlay for accessibility
|
||||||
|
state.overlay.setAttribute('tabindex', '-1');
|
||||||
|
state.overlay.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCookingMode() {
|
||||||
|
if (!state.overlay) return;
|
||||||
|
|
||||||
|
// Remove carousel event listeners
|
||||||
|
const carousel = document.getElementById('cooking-carousel');
|
||||||
|
if (carousel) {
|
||||||
|
carousel.removeEventListener('touchstart', onTouchStart);
|
||||||
|
carousel.removeEventListener('touchmove', onTouchMove);
|
||||||
|
carousel.removeEventListener('touchend', onTouchEnd);
|
||||||
|
carousel.removeEventListener('wheel', onWheel);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.overlay.remove();
|
||||||
|
state.overlay = null;
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||||
|
|
||||||
|
releaseWakeLock();
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
|
||||||
|
// Clear wheel state
|
||||||
|
if (state.wheelResetTimeout) {
|
||||||
|
clearTimeout(state.wheelResetTimeout);
|
||||||
|
state.wheelResetTimeout = null;
|
||||||
|
}
|
||||||
|
state.wheelAccumulator = 0;
|
||||||
|
|
||||||
|
// Restore focus to trigger element
|
||||||
|
if (state.triggerElement && state.triggerElement.focus) {
|
||||||
|
state.triggerElement.focus();
|
||||||
|
state.triggerElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.cards = [];
|
||||||
|
state.cardEls = [];
|
||||||
|
state.currentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Global API ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
window.startCookingMode = startCookingMode;
|
||||||
|
window.closeCookingMode = closeCookingMode;
|
||||||
|
|
||||||
|
})();
|
||||||
13
static/js/editor.bundle.js
Normal file
408
static/js/keyboard-shortcuts.js
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* Keyboard shortcuts for Cook CLI web interface
|
||||||
|
*
|
||||||
|
* Global shortcuts (available on all pages):
|
||||||
|
* - / Focus search
|
||||||
|
* - g h Go to home/recipes
|
||||||
|
* - g s Go to shopping list
|
||||||
|
* - g p Go to pantry
|
||||||
|
* - g x Go to preferences
|
||||||
|
* - ? Show keyboard shortcuts help
|
||||||
|
* - Escape Close modals/dropdowns
|
||||||
|
* - t Toggle theme (dark/light)
|
||||||
|
*
|
||||||
|
* Recipe page shortcuts:
|
||||||
|
* - c Start cooking mode
|
||||||
|
* - e Edit recipe
|
||||||
|
* - a Add to shopping list
|
||||||
|
* - p Print recipe
|
||||||
|
* - +/- Increase/decrease scale
|
||||||
|
* - [/] Decrease/increase scale by 0.5
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Track pending key sequences (for multi-key shortcuts like "g h")
|
||||||
|
let pendingKey = null;
|
||||||
|
let pendingTimeout = null;
|
||||||
|
|
||||||
|
// Check if user is typing in a text-entry input field
|
||||||
|
function isTyping(event) {
|
||||||
|
const target = event.target;
|
||||||
|
const tagName = target.tagName.toLowerCase();
|
||||||
|
|
||||||
|
if (tagName === 'textarea' || tagName === 'select') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only block for text-entry inputs, not checkboxes/radios/etc.
|
||||||
|
if (tagName === 'input') {
|
||||||
|
const type = (target.type || 'text').toLowerCase();
|
||||||
|
const textTypes = ['text', 'password', 'email', 'number', 'search', 'url', 'tel', 'date', 'time', 'datetime-local'];
|
||||||
|
return textTypes.includes(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for contenteditable elements
|
||||||
|
if (target.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for CodeMirror editor
|
||||||
|
if (target.closest('.cm-editor')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear pending key sequence
|
||||||
|
function clearPendingKey() {
|
||||||
|
pendingKey = null;
|
||||||
|
if (pendingTimeout) {
|
||||||
|
clearTimeout(pendingTimeout);
|
||||||
|
pendingTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show keyboard shortcuts modal
|
||||||
|
window.showShortcutsHelp = function() {
|
||||||
|
const existingModal = document.getElementById('keyboard-shortcuts-modal');
|
||||||
|
if (existingModal) {
|
||||||
|
existingModal.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticMode = window.__STATIC_MODE__ === true;
|
||||||
|
const kbd = 'class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono"';
|
||||||
|
const row = (label, keys) => `
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">${label}</span>
|
||||||
|
<span>${keys}</span>
|
||||||
|
</div>`;
|
||||||
|
const k = (s) => `<kbd ${kbd}>${s}</kbd>`;
|
||||||
|
|
||||||
|
const navRows = [
|
||||||
|
row('Focus search', k('/')),
|
||||||
|
row('Navigate search results', `${k('↑')} ${k('↓')} ${k('Enter')}`),
|
||||||
|
row('Go to recipes', `${k('g')} ${k('h')}`),
|
||||||
|
];
|
||||||
|
if (!staticMode) {
|
||||||
|
navRows.push(
|
||||||
|
row('Go to shopping list', `${k('g')} ${k('s')}`),
|
||||||
|
row('Go to pantry', `${k('g')} ${k('p')}`),
|
||||||
|
row('Go to preferences', `${k('g')} ${k('x')}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeRows = [row('Start cooking mode', k('c'))];
|
||||||
|
if (!staticMode) {
|
||||||
|
recipeRows.push(
|
||||||
|
row('Edit recipe', k('e')),
|
||||||
|
row('Add to shopping list', k('a'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
recipeRows.push(row('Print recipe', k('p')));
|
||||||
|
if (!staticMode) {
|
||||||
|
recipeRows.push(
|
||||||
|
row('Increase scale', k('+')),
|
||||||
|
row('Decrease scale', k('-'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shoppingSection = staticMode ? '' : `
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-3">Shopping List</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
${row('Clear all items', k('c'))}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'keyboard-shortcuts-modal';
|
||||||
|
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Keyboard Shortcuts</h2>
|
||||||
|
<button onclick="closeShortcutsHelp()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 overflow-y-auto max-h-[60vh]">
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-3">Navigation</h3>
|
||||||
|
<div class="space-y-2">${navRows.join('')}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-3">General</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
${row('Toggle theme', k('t'))}
|
||||||
|
${row('Show shortcuts', k('?'))}
|
||||||
|
${row('Close modal', k('Esc'))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-3">Recipe Page</h3>
|
||||||
|
<div class="space-y-2">${recipeRows.join('')}</div>
|
||||||
|
</div>
|
||||||
|
${shoppingSection}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Press <kbd class="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs font-mono">Esc</kbd> to close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Close on backdrop click
|
||||||
|
modal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeShortcutsHelp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close keyboard shortcuts modal
|
||||||
|
window.closeShortcutsHelp = function() {
|
||||||
|
const modal = document.getElementById('keyboard-shortcuts-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle keyboard events
|
||||||
|
function handleKeydown(event) {
|
||||||
|
// Don't handle if user is typing
|
||||||
|
if (isTyping(event)) {
|
||||||
|
// But still allow Escape to blur inputs
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.target.blur();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't handle if modifier keys are pressed (except Shift for ? and +)
|
||||||
|
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = event.key;
|
||||||
|
|
||||||
|
// Handle pending key sequences (like "g h")
|
||||||
|
if (pendingKey === 'g') {
|
||||||
|
clearPendingKey();
|
||||||
|
const pfx = window.__PREFIX__ || '';
|
||||||
|
const staticMode = window.__STATIC_MODE__ === true;
|
||||||
|
switch (key) {
|
||||||
|
case 'h':
|
||||||
|
case 'r':
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.href = pfx + '/';
|
||||||
|
return;
|
||||||
|
case 's':
|
||||||
|
if (staticMode) break;
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.href = pfx + '/shopping-list';
|
||||||
|
return;
|
||||||
|
case 'p':
|
||||||
|
if (staticMode) break;
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.href = pfx + '/pantry';
|
||||||
|
return;
|
||||||
|
case 'x':
|
||||||
|
if (staticMode) break;
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.href = pfx + '/preferences';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If no valid second key, fall through to handle as new key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global shortcuts
|
||||||
|
switch (key) {
|
||||||
|
case '/':
|
||||||
|
event.preventDefault();
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.focus();
|
||||||
|
searchInput.select();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'g':
|
||||||
|
event.preventDefault();
|
||||||
|
pendingKey = 'g';
|
||||||
|
// Clear pending after 1.5 seconds
|
||||||
|
pendingTimeout = setTimeout(clearPendingKey, 1500);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case '?':
|
||||||
|
event.preventDefault();
|
||||||
|
showShortcutsHelp();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault();
|
||||||
|
// Close shortcuts modal if open
|
||||||
|
const shortcutsModal = document.getElementById('keyboard-shortcuts-modal');
|
||||||
|
if (shortcutsModal && !shortcutsModal.classList.contains('hidden')) {
|
||||||
|
closeShortcutsHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Close search results if open
|
||||||
|
const searchResults = document.getElementById('search-results');
|
||||||
|
if (searchResults && !searchResults.classList.contains('hidden')) {
|
||||||
|
searchResults.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Close any other modals
|
||||||
|
const modals = document.querySelectorAll('[data-modal]');
|
||||||
|
modals.forEach(modal => modal.classList.add('hidden'));
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 't':
|
||||||
|
event.preventDefault();
|
||||||
|
if (typeof toggleTheme === 'function') {
|
||||||
|
toggleTheme();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-specific shortcuts
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const pfx2 = window.__PREFIX__ || '';
|
||||||
|
|
||||||
|
// Recipe page shortcuts
|
||||||
|
if (path.startsWith(pfx2 + '/recipe/')) {
|
||||||
|
handleRecipeShortcuts(event, key);
|
||||||
|
}
|
||||||
|
// Shopping list page shortcuts
|
||||||
|
else if (path === pfx2 + '/shopping-list') {
|
||||||
|
handleShoppingListShortcuts(event, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipe page specific shortcuts
|
||||||
|
function handleRecipeShortcuts(event, key) {
|
||||||
|
const staticMode = window.__STATIC_MODE__ === true;
|
||||||
|
switch (key) {
|
||||||
|
case 'c':
|
||||||
|
event.preventDefault();
|
||||||
|
if (typeof startCookingMode === 'function') {
|
||||||
|
startCookingMode();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'e':
|
||||||
|
if (staticMode) return;
|
||||||
|
event.preventDefault();
|
||||||
|
// Find and click the edit link
|
||||||
|
const editPfx = (window.__PREFIX__ || '') + '/edit/';
|
||||||
|
const editLink = document.querySelector(`a[href^="${editPfx}"]`);
|
||||||
|
if (editLink) {
|
||||||
|
editLink.click();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'a':
|
||||||
|
if (staticMode) return;
|
||||||
|
event.preventDefault();
|
||||||
|
// Find and click the add to shopping list button
|
||||||
|
const addButton = document.querySelector('button[onclick^="addToShoppingList"]');
|
||||||
|
if (addButton) {
|
||||||
|
addButton.click();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'p':
|
||||||
|
event.preventDefault();
|
||||||
|
window.print();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case '+':
|
||||||
|
case '=': // = is on the same key as + without shift
|
||||||
|
if (staticMode) return;
|
||||||
|
event.preventDefault();
|
||||||
|
adjustScale(0.5);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case '-':
|
||||||
|
case '_':
|
||||||
|
if (staticMode) return;
|
||||||
|
event.preventDefault();
|
||||||
|
adjustScale(-0.5);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case ']':
|
||||||
|
if (staticMode) return;
|
||||||
|
event.preventDefault();
|
||||||
|
adjustScale(1);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case '[':
|
||||||
|
if (staticMode) return;
|
||||||
|
event.preventDefault();
|
||||||
|
adjustScale(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust recipe scale
|
||||||
|
function adjustScale(delta) {
|
||||||
|
const scaleInput = document.getElementById('scale');
|
||||||
|
if (!scaleInput) return;
|
||||||
|
|
||||||
|
let newValue = parseFloat(scaleInput.value) + delta;
|
||||||
|
const min = parseFloat(scaleInput.min) || 0.5;
|
||||||
|
const max = parseFloat(scaleInput.max) || 200;
|
||||||
|
|
||||||
|
// Clamp to valid range
|
||||||
|
newValue = Math.max(min, Math.min(max, newValue));
|
||||||
|
|
||||||
|
// Round to avoid floating point issues
|
||||||
|
newValue = Math.round(newValue * 10) / 10;
|
||||||
|
|
||||||
|
if (newValue !== parseFloat(scaleInput.value)) {
|
||||||
|
scaleInput.value = newValue;
|
||||||
|
// Trigger the onchange event
|
||||||
|
scaleInput.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shopping list page specific shortcuts
|
||||||
|
function handleShoppingListShortcuts(event, key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'c':
|
||||||
|
event.preventDefault();
|
||||||
|
// Clear the list (if the function exists)
|
||||||
|
if (typeof clearList === 'function') {
|
||||||
|
if (confirm('Clear all items from shopping list?')) {
|
||||||
|
clearList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
|
// Add visual indicator for keyboard navigation
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Add a small hint in the search placeholder about the shortcut
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
if (searchInput) {
|
||||||
|
const currentPlaceholder = searchInput.getAttribute('placeholder');
|
||||||
|
if (currentPlaceholder && !currentPlaceholder.includes('/')) {
|
||||||
|
// Don't modify placeholder - keep it clean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
110
static/js/search.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
(function () {
|
||||||
|
var prefix = window.__PREFIX__ || ".";
|
||||||
|
var input = document.getElementById("search-input");
|
||||||
|
var results = document.getElementById("search-results");
|
||||||
|
if (!input || !results) return;
|
||||||
|
|
||||||
|
var index = null;
|
||||||
|
var selectedIndex = -1;
|
||||||
|
|
||||||
|
function loadIndex() {
|
||||||
|
if (index !== null) return Promise.resolve(index);
|
||||||
|
return fetch(prefix + "/static/search-index.json")
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
index = data;
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
console.error("search-index load failed", e);
|
||||||
|
index = [];
|
||||||
|
return index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function score(entry, q) {
|
||||||
|
var ql = q.toLowerCase();
|
||||||
|
if (entry.title.toLowerCase().indexOf(ql) !== -1) return 3;
|
||||||
|
if (entry.tags.some(function (t) { return t.toLowerCase().indexOf(ql) !== -1; })) return 2;
|
||||||
|
if (entry.ingredients.some(function (i) { return i.toLowerCase().indexOf(ql) !== -1; })) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(matches) {
|
||||||
|
if (matches.length === 0) {
|
||||||
|
results.innerHTML = '<div class="p-4 text-gray-500 text-center">No recipes found</div>';
|
||||||
|
} else {
|
||||||
|
results.innerHTML = matches.map(function (m) {
|
||||||
|
var href = prefix + "/" + m.path;
|
||||||
|
return '<a href="' + escapeHtml(href) + '" class="search-result block px-4 py-3 hover:bg-gradient-to-r hover:from-purple-50 hover:to-pink-50 transition-colors border-b border-gray-100 last:border-b-0">' +
|
||||||
|
'<div class="font-medium text-gray-800">' + escapeHtml(m.title) + '</div>' +
|
||||||
|
'</a>';
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
results.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSearchSelection() {
|
||||||
|
var items = results.querySelectorAll("a");
|
||||||
|
items.forEach(function (item, i) {
|
||||||
|
if (i === selectedIndex) {
|
||||||
|
item.classList.add("search-selected");
|
||||||
|
item.scrollIntoView({ block: "nearest" });
|
||||||
|
} else {
|
||||||
|
item.classList.remove("search-selected");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeout;
|
||||||
|
input.addEventListener("input", function () {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
var q = this.value.trim();
|
||||||
|
selectedIndex = -1;
|
||||||
|
if (q.length < 2) {
|
||||||
|
results.classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timeout = setTimeout(function () {
|
||||||
|
loadIndex().then(function (idx) {
|
||||||
|
var matches = idx
|
||||||
|
.map(function (e) { return { e: e, s: score(e, q) }; })
|
||||||
|
.filter(function (x) { return x.s > 0; })
|
||||||
|
.sort(function (a, b) { return b.s - a.s; })
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(function (x) { return x.e; });
|
||||||
|
render(matches);
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener("keydown", function (e) {
|
||||||
|
var items = results.querySelectorAll("a");
|
||||||
|
if (items.length === 0 || results.classList.contains("hidden")) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
|
||||||
|
updateSearchSelection();
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = Math.max(selectedIndex - 1, -1);
|
||||||
|
updateSearchSelection();
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
if (selectedIndex >= 0 && selectedIndex < items.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
items[selectedIndex].click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
if (!input.contains(e.target) && !results.contains(e.target)) {
|
||||||
|
results.classList.add("hidden");
|
||||||
|
selectedIndex = -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
176
static/js/src/cooklang-mode.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { StreamLanguage } from "@codemirror/language";
|
||||||
|
|
||||||
|
// Cooklang syntax highlighting mode for CodeMirror 6
|
||||||
|
// Ported from cooklang-obsidian/src/mode/cook/cook.ts
|
||||||
|
export const cooklang = StreamLanguage.define({
|
||||||
|
name: "cooklang",
|
||||||
|
|
||||||
|
startState() {
|
||||||
|
return {
|
||||||
|
formatting: false,
|
||||||
|
nextMultiline: false,
|
||||||
|
inMultiline: false,
|
||||||
|
afterSection: false,
|
||||||
|
position: null,
|
||||||
|
inFrontmatter: false,
|
||||||
|
inMetadata: false,
|
||||||
|
inNote: false,
|
||||||
|
inComment: false,
|
||||||
|
afterAmount: false // Track if we just closed a {} to look for (prep)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
token(stream, state) {
|
||||||
|
const sol = stream.sol() || state.afterSection;
|
||||||
|
const eol = stream.eol();
|
||||||
|
|
||||||
|
state.afterSection = false;
|
||||||
|
|
||||||
|
if (sol) {
|
||||||
|
// Reset single-line states at start of new line
|
||||||
|
state.inNote = false;
|
||||||
|
state.inMetadata = false;
|
||||||
|
|
||||||
|
if (state.nextMultiline) {
|
||||||
|
state.inMultiline = true;
|
||||||
|
state.nextMultiline = false;
|
||||||
|
} else {
|
||||||
|
state.position = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eol && !state.nextMultiline) {
|
||||||
|
state.inMultiline = false;
|
||||||
|
state.position = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sol) {
|
||||||
|
while (stream.eatSpace()) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontmatter delimiters (---)
|
||||||
|
if (sol && stream.match(/^---\s*$/)) {
|
||||||
|
state.inFrontmatter = !state.inFrontmatter;
|
||||||
|
return "meta";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside frontmatter
|
||||||
|
if (state.inFrontmatter) {
|
||||||
|
stream.skipToEnd();
|
||||||
|
return "meta";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sections (= Section Name, == Section ==, etc.)
|
||||||
|
if (sol && stream.match(/^=+/)) {
|
||||||
|
stream.skipToEnd();
|
||||||
|
return "heading";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line comments (-- comment)
|
||||||
|
if (sol && stream.match(/^--/)) {
|
||||||
|
stream.skipToEnd();
|
||||||
|
return "comment";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block comments ([- comment -])
|
||||||
|
if (stream.match(/^\[-/)) {
|
||||||
|
state.inComment = true;
|
||||||
|
return "comment";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.inComment) {
|
||||||
|
if (stream.match(/-]/)) {
|
||||||
|
state.inComment = false;
|
||||||
|
return "comment";
|
||||||
|
}
|
||||||
|
stream.skipToEnd();
|
||||||
|
return "comment";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata (>> key: value)
|
||||||
|
if (sol && stream.match(/^>>/)) {
|
||||||
|
state.inMetadata = true;
|
||||||
|
state.position = "metadata-key";
|
||||||
|
return "meta";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.inMetadata) {
|
||||||
|
if (state.position === "metadata-key") {
|
||||||
|
if (stream.match(/^[^:]+:/)) {
|
||||||
|
state.position = "metadata-value";
|
||||||
|
return "meta";
|
||||||
|
}
|
||||||
|
stream.skipToEnd();
|
||||||
|
return "meta";
|
||||||
|
} else if (state.position === "metadata-value") {
|
||||||
|
stream.skipToEnd();
|
||||||
|
return "meta";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes (lines starting with >)
|
||||||
|
if (sol && stream.match(/^>/)) {
|
||||||
|
state.inNote = true;
|
||||||
|
return "comment";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.inNote) {
|
||||||
|
stream.skipToEnd();
|
||||||
|
return "comment";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorthand preparations (prep) after ingredient amounts like @onion{1}(chopped)
|
||||||
|
if (state.afterAmount && stream.match(/^\([^)]*\)/)) {
|
||||||
|
state.afterAmount = false;
|
||||||
|
return "string"; // Use string style for prep instructions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset afterAmount if we see anything else
|
||||||
|
if (state.afterAmount && !stream.match(/^\s*(?=\()/, false)) {
|
||||||
|
state.afterAmount = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingredients (@ingredient{amount})
|
||||||
|
if (stream.match(/^@([^@#~]+?(?={))/)) {
|
||||||
|
return "variableName";
|
||||||
|
} else if (stream.match(/^@(.+?\b)/)) {
|
||||||
|
return "variableName";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookware (#cookware{amount})
|
||||||
|
if (stream.match(/^#([^@#~]+?(?={))/)) {
|
||||||
|
return "keyword";
|
||||||
|
} else if (stream.match(/^#(.+?\b)/)) {
|
||||||
|
return "keyword";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timers (~timer{amount})
|
||||||
|
if (stream.match(/^~([^@#~]+?(?={))/)) {
|
||||||
|
return "number";
|
||||||
|
} else if (stream.match(/^~(.+?\b)/)) {
|
||||||
|
return "number";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amounts in curly braces
|
||||||
|
const ch = stream.next();
|
||||||
|
if (!ch) return null;
|
||||||
|
|
||||||
|
if (ch === '{') {
|
||||||
|
if (state.position !== "timer") state.position = "measurement";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '}') {
|
||||||
|
state.position = null;
|
||||||
|
state.afterAmount = true; // Look for (prep) after closing brace
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '%' && (state.position === "measurement" || state.position === "timer")) {
|
||||||
|
state.position = "unit";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.position;
|
||||||
|
}
|
||||||
|
});
|
||||||
209
static/js/src/editor.js
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { EditorState } from "@codemirror/state";
|
||||||
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from "@codemirror/view";
|
||||||
|
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||||
|
import { syntaxHighlighting, HighlightStyle, bracketMatching } from "@codemirror/language";
|
||||||
|
import { searchKeymap, highlightSelectionMatches } from "@codemirror/search";
|
||||||
|
import { linter } from "@codemirror/lint";
|
||||||
|
import { autocompletion, snippet } from "@codemirror/autocomplete";
|
||||||
|
import { tags as t } from "@lezer/highlight";
|
||||||
|
import { cooklang } from "./cooklang-mode.js";
|
||||||
|
|
||||||
|
// Diagnostics support
|
||||||
|
let currentDiagnostics = [];
|
||||||
|
|
||||||
|
export function setDiagnostics(view, diagnostics) {
|
||||||
|
currentDiagnostics = diagnostics;
|
||||||
|
// Trigger a state update to rerun the linter
|
||||||
|
if (view) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linter that returns current diagnostics
|
||||||
|
const cooklangLinter = linter((view) => currentDiagnostics, { delay: 0 });
|
||||||
|
|
||||||
|
// Completion support
|
||||||
|
let completionResolver = null;
|
||||||
|
|
||||||
|
export function setCompletionResolver(resolver) {
|
||||||
|
completionResolver = resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async completion source for Cooklang
|
||||||
|
async function cooklangCompletions(context) {
|
||||||
|
if (!completionResolver) return null;
|
||||||
|
|
||||||
|
const pos = context.pos;
|
||||||
|
const line = context.state.doc.lineAt(pos);
|
||||||
|
const textBefore = line.text.slice(0, pos - line.from);
|
||||||
|
|
||||||
|
// Check for trigger characters (@, #, ~)
|
||||||
|
// Include . / - in character class so @./path file references trigger completion
|
||||||
|
const match = textBefore.match(/[@#~]([a-zA-Z0-9_./\-]*)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const prefix = match[1];
|
||||||
|
const from = pos - prefix.length;
|
||||||
|
const triggerChar = match[0][0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await completionResolver(line.number - 1, pos - line.from);
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: from,
|
||||||
|
// Keep completion open while user types path chars; CodeMirror's
|
||||||
|
// built-in FuzzyMatcher handles client-side narrowing.
|
||||||
|
validFor: /^[a-zA-Z0-9_./\-]*$/,
|
||||||
|
options: items.map(item => {
|
||||||
|
const type = item.kind === 17 ? 'file' : item.kind === 6 ? 'variable' : item.kind === 14 ? 'keyword' : 'text';
|
||||||
|
// Prefer textEdit.newText (used by recipe references with explicit ranges),
|
||||||
|
// then insertText, then label
|
||||||
|
const text = item.textEdit?.newText || item.insertText || item.label;
|
||||||
|
|
||||||
|
// Check if LSP sent a snippet (insertTextFormat === 2)
|
||||||
|
if (item.insertTextFormat === 2 && text.includes('$')) {
|
||||||
|
// Convert LSP snippet syntax to CodeMirror: $0 -> ${}, $1 -> ${1}
|
||||||
|
const cmSnippet = text.replace(/\$0/g, '${}').replace(/\$(\d+)/g, '${$1}');
|
||||||
|
return {
|
||||||
|
label: item.label,
|
||||||
|
type: type,
|
||||||
|
detail: item.detail || '',
|
||||||
|
apply: snippet(cmSnippet)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: item.label,
|
||||||
|
type: type,
|
||||||
|
detail: item.detail || '',
|
||||||
|
apply: text
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Completion error:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom highlight style for Cooklang syntax
|
||||||
|
const cooklangHighlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: t.variableName, color: "#ea580c", fontWeight: "600" }, // Ingredients (orange)
|
||||||
|
{ tag: t.keyword, color: "#16a34a", fontWeight: "600" }, // Cookware (green)
|
||||||
|
{ tag: t.number, color: "#dc2626", fontWeight: "600" }, // Timers (red)
|
||||||
|
{ tag: t.comment, color: "#9ca3af", fontStyle: "italic" }, // Comments
|
||||||
|
{ tag: t.meta, color: "#8b5cf6" }, // Metadata
|
||||||
|
{ tag: t.unit, color: "#6366f1" }, // Units
|
||||||
|
{ tag: t.heading, color: "#0891b2", fontWeight: "700", fontSize: "1.1em" }, // Sections (cyan)
|
||||||
|
{ tag: t.string, color: "#d97706", fontStyle: "italic" } // Prep instructions (amber italic)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Editor base theme for layout
|
||||||
|
const cooklangBaseTheme = EditorView.theme({
|
||||||
|
"&": {
|
||||||
|
height: "100%",
|
||||||
|
fontSize: "14px"
|
||||||
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||||
|
overflow: "auto"
|
||||||
|
},
|
||||||
|
".cm-content": {
|
||||||
|
padding: "1rem"
|
||||||
|
},
|
||||||
|
".cm-line": {
|
||||||
|
padding: "0 0.5rem"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cursor position callback
|
||||||
|
let cursorPositionCallback = null;
|
||||||
|
|
||||||
|
export function setCursorPositionCallback(callback) {
|
||||||
|
cursorPositionCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize editor
|
||||||
|
export function initEditor(container, initialContent, onChange) {
|
||||||
|
const updateListener = EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged && onChange) {
|
||||||
|
onChange(update.state.doc.toString());
|
||||||
|
}
|
||||||
|
// Always update cursor position on any update
|
||||||
|
const pos = update.state.selection.main.head;
|
||||||
|
const line = update.state.doc.lineAt(pos);
|
||||||
|
const col = pos - line.from + 1;
|
||||||
|
if (cursorPositionCallback) {
|
||||||
|
cursorPositionCallback(line.number, col);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: initialContent,
|
||||||
|
extensions: [
|
||||||
|
lineNumbers(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
highlightActiveLineGutter(),
|
||||||
|
history(),
|
||||||
|
bracketMatching(),
|
||||||
|
highlightSelectionMatches(),
|
||||||
|
cooklang,
|
||||||
|
syntaxHighlighting(cooklangHighlightStyle),
|
||||||
|
cooklangBaseTheme,
|
||||||
|
cooklangLinter,
|
||||||
|
autocompletion({
|
||||||
|
override: [cooklangCompletions],
|
||||||
|
activateOnTyping: true
|
||||||
|
}),
|
||||||
|
keymap.of([
|
||||||
|
...defaultKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
...searchKeymap
|
||||||
|
]),
|
||||||
|
updateListener,
|
||||||
|
EditorView.lineWrapping
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = new EditorView({
|
||||||
|
state,
|
||||||
|
parent: container
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set cursor to beginning of document (line 1, col 1)
|
||||||
|
view.dispatch({
|
||||||
|
selection: { anchor: 0 }
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get editor content
|
||||||
|
export function getContent(view) {
|
||||||
|
return view.state.doc.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set editor content
|
||||||
|
export function setContent(view, content) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: view.state.doc.length,
|
||||||
|
insert: content
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for global access
|
||||||
|
window.CooklangEditor = {
|
||||||
|
initEditor,
|
||||||
|
getContent,
|
||||||
|
setContent,
|
||||||
|
setDiagnostics,
|
||||||
|
setCompletionResolver,
|
||||||
|
setCursorPositionCallback
|
||||||
|
};
|
||||||
1
static/search-index.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[{"title":"Légumes rôtis au four","path":"recipe/legumes_rotis.html","tags":["batch","four","légumes"],"ingredients":["pommes de terre","patate douce","carottes","panais","courge","betterave","chou-fleur","oignons rouges","huile d'olive","sel","poivre","épices","ail"]},{"title":"Porc effiloché au four","path":"recipe/porc_effiloche.html","tags":["batch","four","porc"],"ingredients":["échine de porc","huile d'olive","moutarde","sel","poivre","paprika fumé","cumin","ail en poudre","origan","cassonade","oignons","bière"]},{"title":"Galettes de lentilles","path":"recipe/galette_lentilles.html","tags":["végétarien","poêle"],"ingredients":["carottes","échalote","lentilles","sel","cumin","coriandre","farine","œuf","chapelure de pain","huile d'olive"]},{"title":"Le gâteau mi-cuit au chocolat de Suzy Palatin","path":"recipe/mi-cuit_chocolat.html","tags":["chocolat","gâteau"],"ingredients":["chocolat pâtissier à 60 ou 70 %","beurre","sucre","farine","œufs"]},{"title":"Cuisses de poulet au four (cuisson lente)","path":"recipe/cuisses_poulet_four.html","tags":["batch","four","poulet"],"ingredients":["cuisses de poulet entières","huile d'olive","sel","poivre","épices"]},{"title":"Poisson en papillote","path":"recipe/poisson_papillote.html","tags":["four","poisson"],"ingredients":["courgette","poireau","tomates cerises","pavé de poisson","huile d'olive","citron","sel","poivre","herbes","vin blanc","ail","piment"]},{"title":"Saucisses maison","path":"recipe/saucisses.html","tags":["saucisse","poêle"],"ingredients":["chair à saucisse bœuf et porc","sel","poivre","persil plat","paprika","pain à hot-dog","salade","salsa","ketchup","moutarde"]},{"title":"« Ratatouille » du frigo","path":"recipe/ratatouille.html","tags":["batch","mijoté","légumes"],"ingredients":["oignons","tomates","courgettes","aubergines","poivrons","piments","concentré de tomate","huile d'olive","sel","poivre"]}]
|
||||||
60
static/site.webmanifest
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "Cook",
|
||||||
|
"short_name": "Cook",
|
||||||
|
"description": "Cooklang recipe manager and shopping list",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/android-chrome-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/android-chrome-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/android-chrome-192x192-maskable.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/android-chrome-256x256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/android-chrome-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/android-chrome-512x512-maskable.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#f97316",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/"
|
||||||
|
}
|
||||||