Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf67396f94 | ||
|
|
3e6314523d | ||
|
|
07e7bf25c6 | ||
|
|
9f8e130d41 | ||
|
|
405170a90c | ||
|
|
f5632f4f49 | ||
|
|
390bff74c2 |
@@ -0,0 +1,2 @@
|
|||||||
|
100.127.13.115 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC7xaGuqQN2FIGM/dhnulSiqFVUfsfulIUY1yTYoJrhB
|
||||||
|
10.10.2.80 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDd7LYWCbMt1hl7f4ttNL3J8Ich3v/SRWXc9X8inn5Sv
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
name: CI and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-build-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Repository auschecken
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Regressionstests ausführen
|
||||||
|
run: |
|
||||||
|
for test in tests/*.test.js; do
|
||||||
|
node "$test"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: JavaScript-Syntax prüfen
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
html = Path('index.html').read_text(encoding='utf-8')
|
||||||
|
start = html.index('<script>') + len('<script>')
|
||||||
|
end = html.index('</script>', start)
|
||||||
|
Path('/tmp/teig-terminal.js').write_text(html[start:end], encoding='utf-8')
|
||||||
|
PY
|
||||||
|
node --check /tmp/teig-terminal.js
|
||||||
|
|
||||||
|
- name: Statisches Release bauen
|
||||||
|
run: |
|
||||||
|
rm -rf dist
|
||||||
|
install -d -m 0755 dist
|
||||||
|
install -m 0644 index.html dist/index.html
|
||||||
|
test -s dist/index.html
|
||||||
|
grep -q 'TEIG//TERMINAL' dist/index.html
|
||||||
|
sha256sum dist/index.html
|
||||||
|
|
||||||
|
- name: Deploymentzugang vorbereiten
|
||||||
|
if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||||
|
env:
|
||||||
|
TEIG_DEPLOY_KEY: ${{ secrets.TEIG_DEPLOY_KEY }}
|
||||||
|
run: |
|
||||||
|
install -d -m 0700 "$HOME/.ssh"
|
||||||
|
printf '%s\n' "$TEIG_DEPLOY_KEY" > "$HOME/.ssh/teig_deploy"
|
||||||
|
chmod 0600 "$HOME/.ssh/teig_deploy"
|
||||||
|
install -m 0644 .gitea/known_hosts "$HOME/.ssh/known_hosts"
|
||||||
|
cat > "$HOME/.ssh/config" <<'EOF'
|
||||||
|
Host deploy-jump
|
||||||
|
HostName 100.127.13.115
|
||||||
|
User gitea-jump
|
||||||
|
IdentityFile ~/.ssh/teig_deploy
|
||||||
|
IdentitiesOnly yes
|
||||||
|
StrictHostKeyChecking yes
|
||||||
|
UserKnownHostsFile ~/.ssh/known_hosts
|
||||||
|
RequestTTY no
|
||||||
|
|
||||||
|
Host teig-production
|
||||||
|
HostName 10.10.2.80
|
||||||
|
User teig-deploy
|
||||||
|
IdentityFile ~/.ssh/teig_deploy
|
||||||
|
IdentitiesOnly yes
|
||||||
|
ProxyJump deploy-jump
|
||||||
|
StrictHostKeyChecking yes
|
||||||
|
UserKnownHostsFile ~/.ssh/known_hosts
|
||||||
|
RequestTTY no
|
||||||
|
EOF
|
||||||
|
chmod 0600 "$HOME/.ssh/config"
|
||||||
|
|
||||||
|
- name: Nach VM220 deployen
|
||||||
|
if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
tar -C dist -czf - index.html | ssh teig-production "deploy $GITHUB_SHA"
|
||||||
|
|
||||||
|
- name: Live-Deployment verifizieren
|
||||||
|
if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||||
|
run: |
|
||||||
|
expected="$(sha256sum dist/index.html | cut -d' ' -f1)"
|
||||||
|
for attempt in $(seq 1 12); do
|
||||||
|
if curl -fsSL --max-time 20 https://teig.deploybar.de/ -o /tmp/teig-live.html; then
|
||||||
|
actual="$(sha256sum /tmp/teig-live.html | cut -d' ' -f1)"
|
||||||
|
if [ "$actual" = "$expected" ]; then
|
||||||
|
printf 'Live-Deployment verifiziert: %s\n' "$actual"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
printf 'Erwarteter SHA-256: %s\n' "$expected" >&2
|
||||||
|
printf 'Live SHA-256: %s\n' "${actual:-nicht abrufbar}" >&2
|
||||||
|
exit 1
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# TEIG//TERMINAL
|
# TEIG//TERMINAL
|
||||||
|
|
||||||
|
[](https://git.deploybar.de/philipp/teig-terminal/actions?workflow=ci.yml)
|
||||||
|
|
||||||
Pizzateig-Kalkulator als statische Single-Page-App.
|
Pizzateig-Kalkulator als statische Single-Page-App.
|
||||||
|
|
||||||
Aktuelle Version: **v1.1**
|
Aktuelle Version: **v1.1**
|
||||||
@@ -9,5 +11,16 @@ Aktuelle Version: **v1.1**
|
|||||||
- Vorteig
|
- Vorteig
|
||||||
- Rückwärts-Zeitplan
|
- Rückwärts-Zeitplan
|
||||||
- Rezept-Link über URL-Hash
|
- Rezept-Link über URL-Hash
|
||||||
|
- Rezept direkt als einseitige DIN-A4-PDF im Cyberpunk-Stil herunterladen
|
||||||
|
|
||||||
Live: https://teig.deploybar.de/
|
Live: https://teig.deploybar.de/
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Gitea Actions prüft bei jedem Push und Pull Request automatisch:
|
||||||
|
|
||||||
|
- Regressionstests der Hefekalibrierung
|
||||||
|
- JavaScript-Syntax der Single-File-App
|
||||||
|
- Erstellung des statischen Release-Artefakts
|
||||||
|
|
||||||
|
Pushes auf `main` werden nach erfolgreichen Prüfungen automatisch nach VM220 deployt. Der Webserver erhält ausschließlich das geprüfte `dist/index.html`; anschließend vergleicht die Pipeline den SHA-256-Hash der Live-Seite mit dem gebauten Artefakt.
|
||||||
|
|||||||
+285
-3
@@ -258,12 +258,50 @@ tr.tot td.n{color:var(--cyan);text-shadow:0 0 12px rgba(0,240,255,.3);font-size:
|
|||||||
footer{color:var(--muted);font-size:11.5px;line-height:1.6;margin-top:22px;border-top:1px solid var(--line);padding-top:16px}
|
footer{color:var(--muted);font-size:11.5px;line-height:1.6;margin-top:22px;border-top:1px solid var(--line);padding-top:16px}
|
||||||
footer b{color:var(--txt)}
|
footer b{color:var(--txt)}
|
||||||
|
|
||||||
|
/* ---------- Responsive Desktop-Arbeitsbereich ---------- */
|
||||||
|
.workspace{display:block}
|
||||||
|
.input-stack,.output-stack{min-width:0}
|
||||||
|
@media(max-width:1049px){
|
||||||
|
.workspace{display:block}
|
||||||
|
}
|
||||||
|
@media(min-width:1050px){
|
||||||
|
.wrap{max-width:1320px;padding:0 24px}
|
||||||
|
header{
|
||||||
|
display:grid;grid-template-columns:minmax(0,1fr) auto;
|
||||||
|
grid-template-areas:"brand status" "tag status";
|
||||||
|
column-gap:32px;align-items:end;padding:30px 0 20px
|
||||||
|
}
|
||||||
|
.brand{grid-area:brand;font-size:48px}
|
||||||
|
.tag{grid-area:tag}
|
||||||
|
.status{grid-area:status;align-self:center;justify-self:end;margin-top:0}
|
||||||
|
.quick-summary{
|
||||||
|
display:grid;grid-template-columns:repeat(5,minmax(0,1fr));
|
||||||
|
gap:10px;overflow:visible;margin:0 0 18px;padding:10px;
|
||||||
|
border:1px solid var(--line);background:rgba(7,8,11,.94)
|
||||||
|
}
|
||||||
|
body[data-ux="simple"] .quick-summary{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||||
|
.quick-summary span{min-width:0;white-space:normal;text-align:center;line-height:1.3}
|
||||||
|
.workspace{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:minmax(0,1fr) minmax(430px,.95fr);
|
||||||
|
gap:18px;align-items:start
|
||||||
|
}
|
||||||
|
body[data-ux="simple"] .workspace{
|
||||||
|
grid-template-columns:minmax(0,.9fr) minmax(480px,1.1fr)
|
||||||
|
}
|
||||||
|
.workspace .panel{padding:19px 18px 21px;margin-bottom:18px}
|
||||||
|
.output-stack .hero{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
|
||||||
|
.output-stack .actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
|
.output-stack .btn{width:100%}
|
||||||
|
}
|
||||||
|
|
||||||
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||||
.printonly{display:none}
|
.printonly{display:none}
|
||||||
@media print{
|
@media print{
|
||||||
@page{size:A4;margin:15mm}
|
@page{size:A4;margin:15mm}
|
||||||
body{background:#fff;color:#000;background-image:none;font-size:11pt;padding:0}
|
body{background:#fff;color:#000;background-image:none;font-size:11pt;padding:0}
|
||||||
.wrap{max-width:none;padding:0}
|
.wrap{max-width:none;padding:0}
|
||||||
|
.workspace{display:block}
|
||||||
header,.actions,.noprint,#countdown{display:none!important}
|
header,.actions,.noprint,#countdown{display:none!important}
|
||||||
.printonly{display:block}
|
.printonly{display:block}
|
||||||
#printhead{margin:0 0 8mm;border-bottom:2px solid #000;padding-bottom:4mm}
|
#printhead{margin:0 0 8mm;border-bottom:2px solid #000;padding-bottom:4mm}
|
||||||
@@ -309,6 +347,9 @@ footer b{color:var(--txt)}
|
|||||||
|
|
||||||
<div class="quick-summary noprint" id="quickSummary" aria-label="Kurzüberblick"></div>
|
<div class="quick-summary noprint" id="quickSummary" aria-label="Kurzüberblick"></div>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<div class="input-stack">
|
||||||
|
|
||||||
<section class="panel noprint">
|
<section class="panel noprint">
|
||||||
<p class="ph"><b>00 // Schnellstart</b><span>Einfach oder Profi</span></p>
|
<p class="ph"><b>00 // Schnellstart</b><span>Einfach oder Profi</span></p>
|
||||||
<div class="ctl">
|
<div class="ctl">
|
||||||
@@ -473,6 +514,9 @@ footer b{color:var(--txt)}
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="output-stack">
|
||||||
|
|
||||||
<!-- WARNUNGEN -->
|
<!-- WARNUNGEN -->
|
||||||
<div class="warns" id="warns"></div>
|
<div class="warns" id="warns"></div>
|
||||||
|
|
||||||
@@ -502,11 +546,15 @@ footer b{color:var(--txt)}
|
|||||||
|
|
||||||
<div class="actions noprint">
|
<div class="actions noprint">
|
||||||
<button type="button" class="btn pri" id="share">Rezept teilen</button>
|
<button type="button" class="btn pri" id="share">Rezept teilen</button>
|
||||||
<button type="button" class="btn" id="pdf">Druck/PDF</button>
|
<button type="button" class="btn" id="downloadPdf">PDF herunterladen</button>
|
||||||
|
<button type="button" class="btn" id="pdf">Drucken</button>
|
||||||
<button type="button" class="btn" id="copyTxt">Als Text kopieren</button>
|
<button type="button" class="btn" id="copyTxt">Als Text kopieren</button>
|
||||||
<button type="button" class="btn" id="reset">Zurücksetzen</button>
|
<button type="button" class="btn" id="reset">Zurücksetzen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<b>Hinweis:</b> Die Hefemenge wird über ein Q10-Modell aus Zeit und Temperatur geschätzt.
|
<b>Hinweis:</b> Die Hefemenge wird über ein Q10-Modell aus Zeit und Temperatur geschätzt.
|
||||||
Teigtemperatur, Hefevitalität und Schwankungen im Raum lassen sich nicht in eine Formel pressen —
|
Teigtemperatur, Hefevitalität und Schwankungen im Raum lassen sich nicht in eine Formel pressen —
|
||||||
@@ -929,6 +977,7 @@ function initUI(){
|
|||||||
|
|
||||||
$("share").onclick=share;
|
$("share").onclick=share;
|
||||||
$("copyTxt").onclick=copyText;
|
$("copyTxt").onclick=copyText;
|
||||||
|
$("downloadPdf").onclick=downloadPdf;
|
||||||
$("pdf").onclick=savePdf;
|
$("pdf").onclick=savePdf;
|
||||||
$("reset").onclick=()=>{ if(!confirm("Alle Werte zurücksetzen?")) return; S=Object.assign({},DEFAULT); S.bake=defaultBake(); location.hash=""; render(); };
|
$("reset").onclick=()=>{ if(!confirm("Alle Werte zurücksetzen?")) return; S=Object.assign({},DEFAULT); S.bake=defaultBake(); location.hash=""; render(); };
|
||||||
}
|
}
|
||||||
@@ -1176,6 +1225,236 @@ async function toClipboard(text, btn, label){
|
|||||||
}
|
}
|
||||||
function share(){ toClipboard(location.href, $("share"), "Link kopiert ✓"); }
|
function share(){ toClipboard(location.href, $("share"), "Link kopiert ✓"); }
|
||||||
|
|
||||||
|
/* PDF-DOWNLOAD */
|
||||||
|
function pdfFilename(styleName, balls, ballWeight, planName){
|
||||||
|
return `Pizzateig-${styleName}-${balls}x${Math.round(ballWeight)}g-${planName}.pdf`
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g,"")
|
||||||
|
.replace(/[\/\\:*?"<>|]+/g,"-")
|
||||||
|
.replace(/\s+/g,"-")
|
||||||
|
.replace(/-+/g,"-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function winAnsiHex(text){
|
||||||
|
const special={"€":128,"‚":130,"ƒ":131,"„":132,"…":133,"†":134,"‡":135,"ˆ":136,"‰":137,"Š":138,"‹":139,"Œ":140,"Ž":142,"‘":145,"’":146,"“":147,"”":148,"•":149,"–":150,"—":151,"˜":152,"™":153,"š":154,"›":155,"œ":156,"ž":158,"Ÿ":159};
|
||||||
|
let out="";
|
||||||
|
for(const char of String(text)){
|
||||||
|
const point=char.codePointAt(0);
|
||||||
|
const byte=special[char] ?? (point<=255 ? point : 63);
|
||||||
|
out+=byte.toString(16).padStart(2,"0").toUpperCase();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapPdfLines(lines, width=92){
|
||||||
|
const out=[];
|
||||||
|
lines.forEach(raw=>{
|
||||||
|
const line=String(raw);
|
||||||
|
if(!line){ out.push(""); return; }
|
||||||
|
const indent=(line.match(/^\s*/)||[""])[0];
|
||||||
|
let rest=line.trim();
|
||||||
|
while(rest.length>width){
|
||||||
|
let cut=rest.lastIndexOf(" ",width);
|
||||||
|
if(cut<20) cut=width;
|
||||||
|
out.push(indent+rest.slice(0,cut));
|
||||||
|
rest=rest.slice(cut).trimStart();
|
||||||
|
}
|
||||||
|
out.push(indent+rest);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePdfRecipe(lines){
|
||||||
|
const source=lines.map(line=>String(line));
|
||||||
|
const title=source[0]||"PIZZATEIG";
|
||||||
|
const overview=[];
|
||||||
|
const sections=[];
|
||||||
|
let current=null, link="";
|
||||||
|
source.slice(1).forEach(raw=>{
|
||||||
|
const line=raw.trim();
|
||||||
|
if(!line) return;
|
||||||
|
if(/^https?:\/\//.test(line)){ link=line; return; }
|
||||||
|
if(/^(ZUTATEN|HAUPTTEIG|ZEITPLAN|VORTEIG\b)/.test(line)){
|
||||||
|
current={title:line,items:[]};
|
||||||
|
sections.push(current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(current) current.items.push(line);
|
||||||
|
else overview.push(...line.split(" · ").map(part=>part.trim()).filter(Boolean));
|
||||||
|
});
|
||||||
|
if(overview.length>4) sections.unshift({title:"WEITERE DATEN",items:overview.slice(4)});
|
||||||
|
return {title,overview:overview.slice(0,4),sections,link};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPdfBytes(lines){
|
||||||
|
const encoder=new TextEncoder();
|
||||||
|
const data=parsePdfRecipe(lines);
|
||||||
|
const ingredients=data.sections.filter(section=>section.title!=="ZEITPLAN");
|
||||||
|
const timeline=data.sections.find(section=>section.title==="ZEITPLAN")||{title:"ZEITPLAN",items:[]};
|
||||||
|
const capItems=(items,max)=>items.length>max?[...items.slice(0,max-1),"... weitere Angaben gekürzt"]:items;
|
||||||
|
|
||||||
|
let stream="";
|
||||||
|
const rect=(x,y,w,h,fill,stroke="",lineWidth=.7)=>{
|
||||||
|
if(fill) stream+=`${fill} rg ${x} ${y} ${w} ${h} re f\n`;
|
||||||
|
if(stroke) stream+=`${stroke} RG ${lineWidth} w ${x} ${y} ${w} ${h} re S\n`;
|
||||||
|
};
|
||||||
|
const line=(x1,y1,x2,y2,color,width=.7)=>{ stream+=`${color} RG ${width} w ${x1} ${y1} m ${x2} ${y2} l S\n`; };
|
||||||
|
const text=(value,x,y,size,font="F1",color="0.886 0.925 0.961")=>{
|
||||||
|
stream+=`BT\n/${font} ${size} Tf\n${color} rg\n${x} ${y} Td\n<${winAnsiHex(value)}> Tj\nET\n`;
|
||||||
|
};
|
||||||
|
const wrapped=(value,width)=>wrapPdfLines([String(value)],width);
|
||||||
|
|
||||||
|
// Vollflächige Cyberpunk-Bühne mit Raster, Scanlines und Neonrahmen.
|
||||||
|
rect(0,0,595,842,"0.027 0.031 0.043");
|
||||||
|
rect(0,704,595,138,"0.039 0.051 0.078");
|
||||||
|
stream+="0.047 0.071 0.102 RG 0.25 w\n";
|
||||||
|
for(let x=18;x<595;x+=24) stream+=`${x} 0 m ${x} 842 l S\n`;
|
||||||
|
for(let y=8;y<842;y+=24) stream+=`0 ${y} m 595 ${y} l S\n`;
|
||||||
|
stream+="0.075 0.094 0.133 RG 0.18 w\n";
|
||||||
|
for(let y=3;y<842;y+=6) stream+=`0 ${y} m 595 ${y} l S\n`;
|
||||||
|
rect(18,18,559,806,"","0.12 0.23 0.30",.6);
|
||||||
|
line(18,823,577,823,"0 0.941 1",2.2);
|
||||||
|
line(18,816,170,816,"1 0.176 0.471",4.5);
|
||||||
|
line(424,26,577,26,"1 0.176 0.471",3);
|
||||||
|
|
||||||
|
// Kopf: doppelte Neon-Typografie, Statusblock und technische Marker.
|
||||||
|
text("TEIG//TERMINAL",35,778,29,"F2","0.23 0.02 0.19");
|
||||||
|
text("TEIG//TERMINAL",33,780,29,"F2","0 0.941 1");
|
||||||
|
text("PIZZA FORMULATION SYSTEM",35,765,7.5,"F1","1 0.176 0.471");
|
||||||
|
rect(414,770,145,28,"0.055 0.071 0.106","1 0.176 0.471",.8);
|
||||||
|
rect(425,781,5,5,"0.545 1 0.231");
|
||||||
|
text("RECIPE // READY",438,780,8.2,"F2","0.922 0.965 1");
|
||||||
|
text("06",507,716,42,"F2","0.10 0.19 0.24");
|
||||||
|
text(data.title,34,724,16,"F2","0.922 0.965 1");
|
||||||
|
text("FORMULA EXPORT // DIN A4 // ONE PAGE",35,709,6.8,"F1","0.53 0.64 0.74");
|
||||||
|
|
||||||
|
// Vier Dashboard-Kacheln nutzen die gesamte Breite für die Kerndaten.
|
||||||
|
const cards=[34,167,300,433];
|
||||||
|
cards.forEach((x,index)=>{
|
||||||
|
rect(x,634,126,60,index%2?"0.055 0.071 0.106":"0.063 0.082 0.122",index===3?"1 0.176 0.471":"0 0.941 1",.65);
|
||||||
|
text(`DATA // 0${index+1}`,x+9,678,6.3,"F2",index===3?"1 0.176 0.471":"0 0.941 1");
|
||||||
|
const value=data.overview[index]||"—";
|
||||||
|
const parts=wrapped(value,23).slice(0,2);
|
||||||
|
parts.forEach((part,row)=>text(part,x+9,659-row*11,8.2,"F2","0.922 0.965 1"));
|
||||||
|
rect(x+113,637,10,3,index===3?"1 0.176 0.471":"0 0.941 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zwei große Module: Zutaten/Formel links, Prozess-Timeline rechts.
|
||||||
|
rect(34,112,238,500,"0.043 0.059 0.086","0 0.941 1",.75);
|
||||||
|
rect(286,112,273,500,"0.043 0.059 0.086","1 0.176 0.471",.75);
|
||||||
|
rect(34,568,238,44,"0.00 0.22 0.26");
|
||||||
|
rect(286,568,273,44,"0.25 0.035 0.15");
|
||||||
|
text("01",47,580,17,"F2","0 0.941 1");
|
||||||
|
text("FORMULA // ZUTATEN",79,584,10.5,"F2","0.922 0.965 1");
|
||||||
|
text("02",299,580,17,"F2","1 0.176 0.471");
|
||||||
|
text("PROCESS // ZEITPLAN",332,584,10.5,"F2","0.922 0.965 1");
|
||||||
|
|
||||||
|
let leftY=545, itemNo=1;
|
||||||
|
const ingredientRows=[];
|
||||||
|
ingredients.forEach(section=>{
|
||||||
|
ingredientRows.push({heading:section.title});
|
||||||
|
capItems(section.items,12).forEach(item=>ingredientRows.push({item}));
|
||||||
|
});
|
||||||
|
if(!ingredientRows.length) ingredientRows.push({heading:"ZUTATEN"},{item:"Keine Angaben"});
|
||||||
|
const ingredientCount=Math.max(1,ingredientRows.filter(row=>row.item).length);
|
||||||
|
const headingCount=ingredientRows.filter(row=>row.heading).length;
|
||||||
|
const leftStep=Math.max(28,Math.min(62,(390-headingCount*24)/ingredientCount));
|
||||||
|
ingredientRows.slice(0,15).forEach((row,index)=>{
|
||||||
|
if(row.heading){
|
||||||
|
text(row.heading,48,leftY,8.8,"F2","1 0.176 0.471");
|
||||||
|
line(48,leftY-7,255,leftY-7,"0.18 0.30 0.36",.45);
|
||||||
|
leftY-=24;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rowH=Math.max(25,leftStep-4);
|
||||||
|
rect(46,leftY-rowH+7,214,rowH,index%2?"0.055 0.071 0.106":"0.063 0.082 0.122");
|
||||||
|
rect(46,leftY-rowH+7,4,rowH,itemNo%2?"0 0.941 1":"1 0.176 0.471");
|
||||||
|
text(String(itemNo).padStart(2,"0"),57,leftY-8,8,"F2","0.35 0.52 0.61");
|
||||||
|
const amount=row.item.match(/^(.*\S)\s+([0-9][0-9.,]*\s*(?:g|%|ml))$/i);
|
||||||
|
if(amount){
|
||||||
|
const labelLines=wrapped(amount[1],17).slice(0,2);
|
||||||
|
labelLines.forEach((part,i)=>text(part,82,leftY-7-i*12,10.2,"F1","0.922 0.965 1"));
|
||||||
|
const amountX=Math.max(184,248-amount[2].length*7.2);
|
||||||
|
text(amount[2],amountX,leftY-8,12,"F2",itemNo%2?"0 0.941 1":"1 0.176 0.471");
|
||||||
|
}else{
|
||||||
|
const itemLines=wrapped(row.item,28).slice(0,2);
|
||||||
|
itemLines.forEach((part,i)=>text(part,78,leftY-6-i*12,9.8,"F1","0.922 0.965 1"));
|
||||||
|
}
|
||||||
|
leftY-=leftStep; itemNo++;
|
||||||
|
});
|
||||||
|
text("BAKER'S PERCENTAGE // MASS CONTROL",48,130,6.5,"F1","0.36 0.49 0.58");
|
||||||
|
for(let x=48;x<255;x+=9) rect(x,121,(x%27===0)?4:2,5,"0 0.941 1");
|
||||||
|
|
||||||
|
const steps=capItems(timeline.items,10);
|
||||||
|
const timelineTop=546, timelineBottom=150;
|
||||||
|
const slot=(timelineTop-timelineBottom)/Math.max(1,steps.length);
|
||||||
|
line(309,timelineBottom,309,timelineTop,"0 0.941 1",1.1);
|
||||||
|
steps.forEach((raw,index)=>{
|
||||||
|
const y=timelineTop-index*slot;
|
||||||
|
const parts=raw.split(/\s+—\s+/);
|
||||||
|
const when=parts.length>1?parts.shift():String(index+1).padStart(2,"0");
|
||||||
|
const action=parts.length?parts.join(" — "):raw;
|
||||||
|
rect(304,y-2,10,10,"0.043 0.059 0.086","0 0.941 1",1);
|
||||||
|
rect(307,y+1,4,4,index===steps.length-1?"1 0.176 0.471":"0 0.941 1");
|
||||||
|
text(when,324,y+1,10.5,"F2",index===steps.length-1?"1 0.176 0.471":"0 0.941 1");
|
||||||
|
const actionLines=wrapped(action,31).slice(0,2);
|
||||||
|
actionLines.forEach((part,row)=>text(part,324,y-13-row*12,9.3,"F1","0.922 0.965 1"));
|
||||||
|
if(index<steps.length-1) line(324,y-slot+9,545,y-slot+9,"0.12 0.19 0.24",.35);
|
||||||
|
});
|
||||||
|
if(!steps.length) text("Kein Zeitplan vorhanden",324,530,8.5,"F1","0.53 0.64 0.74");
|
||||||
|
text("FERMENTATION SEQUENCE // REVERSE SCHEDULE",304,130,6.5,"F1","0.45 0.39 0.57");
|
||||||
|
|
||||||
|
// Link-Zeile und Footer schließen die Seite wie ein System-Dashboard ab.
|
||||||
|
rect(34,61,525,35,"0.039 0.051 0.078","0.17 0.31 0.37",.5);
|
||||||
|
text("RECIPE LINK",46,82,6.3,"F2","1 0.176 0.471");
|
||||||
|
const linkLines=wrapped(data.link||"Lokales Rezept ohne Link",102).slice(0,2);
|
||||||
|
linkLines.forEach((part,index)=>text(part,111,80-index*8,5.7,"F1","0 0.941 1"));
|
||||||
|
text("SYS.ONLINE",34,38,6.5,"F2","0.545 1 0.231");
|
||||||
|
text("NO CLOUD // NO TRACKING // GENERATED LOCALLY",112,38,6.5,"F1","0.53 0.64 0.74");
|
||||||
|
text("A4 // 1 OF 1",484,38,6.5,"F2","1 0.176 0.471");
|
||||||
|
|
||||||
|
const objects=[];
|
||||||
|
objects[1]="<< /Type /Catalog /Pages 2 0 R >>";
|
||||||
|
objects[2]="<< /Type /Pages /Kids [6 0 R] /Count 1 >>";
|
||||||
|
objects[3]="<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>";
|
||||||
|
objects[4]="<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>";
|
||||||
|
objects[5]=`<< /Length ${encoder.encode(stream).length} >>\nstream\n${stream}endstream`;
|
||||||
|
objects[6]="<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents 5 0 R >>";
|
||||||
|
|
||||||
|
const chunks=[encoder.encode("%PDF-1.4\n")], offsets=[0];
|
||||||
|
let length=chunks[0].length;
|
||||||
|
for(let id=1;id<objects.length;id++){
|
||||||
|
offsets[id]=length;
|
||||||
|
const chunk=encoder.encode(`${id} 0 obj\n${objects[id]}\nendobj\n`);
|
||||||
|
chunks.push(chunk); length+=chunk.length;
|
||||||
|
}
|
||||||
|
const xrefAt=length;
|
||||||
|
let tail=`xref\n0 ${objects.length}\n0000000000 65535 f \n`;
|
||||||
|
for(let id=1;id<objects.length;id++) tail+=`${String(offsets[id]).padStart(10,"0")} 00000 n \n`;
|
||||||
|
tail+=`trailer\n<< /Size ${objects.length} /Root 1 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`;
|
||||||
|
chunks.push(encoder.encode(tail)); length+=chunks[chunks.length-1].length;
|
||||||
|
|
||||||
|
const pdf=new Uint8Array(length);
|
||||||
|
let at=0;
|
||||||
|
chunks.forEach(chunk=>{ pdf.set(chunk,at); at+=chunk.length; });
|
||||||
|
return pdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadPdf(){
|
||||||
|
const r=lastR, st=STYLES[S.style], btn=$("downloadPdf");
|
||||||
|
const blob=new Blob([buildPdfBytes(recipeText().split("\n"))],{type:"application/pdf"});
|
||||||
|
const url=URL.createObjectURL(blob), link=document.createElement("a");
|
||||||
|
link.href=url;
|
||||||
|
link.download=pdfFilename(st.n,S.balls,r.ballW,PLANS[S.plan].n);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||||
|
const old=btn.textContent;
|
||||||
|
btn.textContent="PDF geladen ✓";
|
||||||
|
setTimeout(()=>btn.textContent=old,1800);
|
||||||
|
}
|
||||||
|
|
||||||
/* PDF = Druckdialog mit sprechendem Dateinamen. Browser und Handys
|
/* PDF = Druckdialog mit sprechendem Dateinamen. Browser und Handys
|
||||||
bieten dort „Als PDF sichern" an — ganz ohne externe Bibliothek. */
|
bieten dort „Als PDF sichern" an — ganz ohne externe Bibliothek. */
|
||||||
const ORIG_TITLE=document.title;
|
const ORIG_TITLE=document.title;
|
||||||
@@ -1205,7 +1484,7 @@ function renderPrint(r){
|
|||||||
`Erstellt am ${d.getDate()}.${d.getMonth()+1}.${d.getFullYear()} mit TEIG//TERMINAL · Rezept erneut öffnen: ${location.href}`;
|
`Erstellt am ${d.getDate()}.${d.getMonth()+1}.${d.getFullYear()} mit TEIG//TERMINAL · Rezept erneut öffnen: ${location.href}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyText(){
|
function recipeText(){
|
||||||
const r=lastR, tl=lastTl, st=STYLES[S.style], f=FLOURS.find(x=>x.id===S.flour);
|
const r=lastR, tl=lastTl, st=STYLES[S.style], f=FLOURS.find(x=>x.id===S.flour);
|
||||||
const L=[];
|
const L=[];
|
||||||
L.push(`PIZZATEIG — ${st.n}`);
|
L.push(`PIZZATEIG — ${st.n}`);
|
||||||
@@ -1230,7 +1509,10 @@ function copyText(){
|
|||||||
tl.steps.forEach(s=>L.push(` ${fmtT(s.at)} ${fmtDay(s.at)} — ${s.a}${s.d?` (${dur(s.d)})`:""}`));
|
tl.steps.forEach(s=>L.push(` ${fmtT(s.at)} ${fmtDay(s.at)} — ${s.a}${s.d?` (${dur(s.d)})`:""}`));
|
||||||
L.push("");
|
L.push("");
|
||||||
L.push(location.href);
|
L.push(location.href);
|
||||||
toClipboard(L.join("\n"), $("copyTxt"), "Text kopiert ✓");
|
return L.join("\n");
|
||||||
|
}
|
||||||
|
function copyText(){
|
||||||
|
toClipboard(recipeText(), $("copyTxt"), "Text kopiert ✓");
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("hashchange",()=>{ if(!hashLock){ readHash(); render(); }});
|
window.addEventListener("hashchange",()=>{ if(!hashLock){ readHash(); render(); }});
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(html, /id="downloadPdf"[^>]*>PDF herunterladen<\/button>/, 'PDF-Download-Button fehlt');
|
||||||
|
|
||||||
|
const start = html.indexOf('/* PDF-DOWNLOAD */');
|
||||||
|
const end = html.indexOf('/* PDF = Druckdialog', start);
|
||||||
|
assert.ok(start >= 0 && end > start, 'PDF-Download-Implementierung fehlt');
|
||||||
|
|
||||||
|
const sandbox = { Uint8Array, TextEncoder, Blob };
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(`${html.slice(start, end)}\nglobalThis.api={pdfFilename,parsePdfRecipe,buildPdfBytes};`, sandbox);
|
||||||
|
|
||||||
|
const { pdfFilename, parsePdfRecipe, buildPdfBytes } = sandbox.api;
|
||||||
|
assert.equal(
|
||||||
|
pdfFilename('Napoletana', 4, 250, '24 Stunden'),
|
||||||
|
'Pizzateig-Napoletana-4x250g-24-Stunden.pdf'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
pdfFilename('Frei/Test', 2, 199.6, 'Same:Day'),
|
||||||
|
'Pizzateig-Frei-Test-2x200g-Same-Day.pdf'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
pdfFilename('Römisch tonda', 4, 200, 'Über Nacht'),
|
||||||
|
'Pizzateig-Romisch-tonda-4x200g-Uber-Nacht.pdf'
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipeLines = [
|
||||||
|
'PIZZATEIG — Napoletana',
|
||||||
|
'4 Teiglinge à 250 g · 1.000 g Teig',
|
||||||
|
'Mehl: Caputo Pizzeria · Hydration 62 %',
|
||||||
|
'',
|
||||||
|
'ZUTATEN',
|
||||||
|
' Mehl 605 g',
|
||||||
|
' Wasser 375 g',
|
||||||
|
' Frischhefe 2,3 g',
|
||||||
|
' Salz 17 g',
|
||||||
|
'',
|
||||||
|
'ZEITPLAN',
|
||||||
|
' 19:00 Mo — Teig kneten und ruhen lassen (30 Min.)',
|
||||||
|
' 19:30 Mo — Ballen formen und bei Raumtemperatur reifen lassen',
|
||||||
|
' 23:00 Mo — Ballen in den Kühlschrank stellen',
|
||||||
|
' 15:00 Di — Ballen aus dem Kühlschrank nehmen',
|
||||||
|
' 19:00 Di — Pizza backen',
|
||||||
|
'',
|
||||||
|
'https://teig.deploybar.de/#style=napo&balls=4&ball=250&plan=d24',
|
||||||
|
];
|
||||||
|
const parsed = JSON.parse(JSON.stringify(parsePdfRecipe(recipeLines)));
|
||||||
|
assert.equal(parsed.title, 'PIZZATEIG — Napoletana');
|
||||||
|
assert.deepEqual(parsed.overview, ['4 Teiglinge à 250 g', '1.000 g Teig', 'Mehl: Caputo Pizzeria', 'Hydration 62 %']);
|
||||||
|
assert.deepEqual(parsed.sections.map(section => section.title), ['ZUTATEN', 'ZEITPLAN']);
|
||||||
|
assert.equal(parsed.sections[0].items.length, 4);
|
||||||
|
assert.equal(parsed.sections[1].items.length, 5);
|
||||||
|
assert.match(parsed.link, /^https:\/\/teig\.deploybar\.de\//);
|
||||||
|
|
||||||
|
const bytes = buildPdfBytes(recipeLines);
|
||||||
|
assert.ok(bytes instanceof Uint8Array);
|
||||||
|
const ascii = Buffer.from(bytes).toString('latin1');
|
||||||
|
assert.ok(ascii.startsWith('%PDF-1.4'));
|
||||||
|
assert.match(ascii, /\/Type \/Catalog/);
|
||||||
|
assert.match(ascii, /\/Type \/Page\b/);
|
||||||
|
assert.match(ascii, /\/Encoding \/WinAnsiEncoding/);
|
||||||
|
assert.match(ascii, /0\.027 0\.031 0\.043 rg/); // dunkler Cyberpunk-Hintergrund
|
||||||
|
assert.match(ascii, /0 0\.941 1 rg/); // Cyan-Akzent
|
||||||
|
assert.match(ascii, /1 0\.176 0\.471 rg/); // Magenta-Akzent
|
||||||
|
assert.match(ascii, /xref\n0 \d+/);
|
||||||
|
assert.match(ascii, /%%EOF\n$/);
|
||||||
|
assert.match(ascii, /\/Count 1\b/);
|
||||||
|
|
||||||
|
const longPdf = Buffer.from(buildPdfBytes([
|
||||||
|
'PIZZATEIG — Belastungstest',
|
||||||
|
...Array.from({ length: 120 }, (_, i) => `ZEILE ${i + 1} — Rezeptinformation`),
|
||||||
|
])).toString('latin1');
|
||||||
|
assert.match(longPdf, /\/Count 1\b/, 'PDF muss auf genau eine Seite begrenzt bleiben');
|
||||||
|
assert.equal((longPdf.match(/\/Type \/Page\b/g) || []).length, 1, 'PDF enthält mehr als eine A4-Seite');
|
||||||
|
|
||||||
|
fs.writeFileSync('/tmp/teig-terminal-download-test.pdf', Buffer.from(bytes));
|
||||||
|
console.log('pdf download tests: OK');
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(html, /<main class="workspace">[\s\S]*<div class="input-stack">/,
|
||||||
|
'Desktop-Arbeitsbereich mit Eingabespalte fehlt');
|
||||||
|
assert.match(html, /<div class="output-stack">[\s\S]*06 \/\/ Rezept[\s\S]*07 \/\/ Zeitplan/,
|
||||||
|
'Rezept und Zeitplan müssen in der Ausgabespalte liegen');
|
||||||
|
assert.match(html, /@media\s*\(min-width:\s*1050px\)[\s\S]*?\.wrap\s*\{[^}]*max-width:\s*1320px/,
|
||||||
|
'Breiter Desktop-Container fehlt');
|
||||||
|
assert.match(html, /@media\s*\(min-width:\s*1050px\)[\s\S]*?\.workspace\s*\{[^}]*display:\s*grid[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+minmax\(430px,\s*\.95fr\)/,
|
||||||
|
'Responsive Zweispaltenstruktur für Desktop fehlt');
|
||||||
|
assert.match(html, /@media\s*\(min-width:\s*1050px\)[\s\S]*?\.quick-summary\s*\{[^}]*grid-template-columns:\s*repeat\(5,\s*minmax\(0,\s*1fr\)\)/,
|
||||||
|
'Desktop-Profiübersicht muss die Breite in fünf Spalten nutzen');
|
||||||
|
assert.match(html, /body\[data-ux="simple"\]\s+\.quick-summary\s*\{[^}]*grid-template-columns:\s*repeat\(4,\s*minmax\(0,\s*1fr\)\)/,
|
||||||
|
'Desktop-Einfachübersicht muss ihre vier Werte gleichmäßig verteilen');
|
||||||
|
assert.match(html, /@media\s*\(max-width:\s*1049px\)[\s\S]*?\.workspace\s*\{[^}]*display:\s*block/,
|
||||||
|
'Tablet und Mobile müssen einspaltig bleiben');
|
||||||
|
|
||||||
|
console.log('responsive desktop layout tests: OK');
|
||||||
Reference in New Issue
Block a user