Created
March 7, 2026 04:48
-
-
Save EncodeTheCode/fb3418e2e1377bec0b46c19df854525e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="sv"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Ekonomi Uträkning - Notebook Strict Lines (Y offset)</title> | |
| <style> | |
| /* --- Exact notebook-style A4 with strict rule: ALL invoice text must sit between ruled lines --- */ | |
| :root{ | |
| --page-width:210mm; | |
| --page-height:297mm; | |
| --line-spacing:8mm; /* distance between ruled lines */ | |
| --left-margin:25mm; /* left red margin distance from paper edge */ | |
| --left-line-width:2px; /* thickness of the red margin line */ | |
| --line-color: rgba(6,95,70,0.18); /* teal ruled line color */ | |
| --left-line-color: rgba(220,38,38,0.18); /* red margin color */ | |
| --paper-bg: #fffdf9; /* slightly warm paper */ | |
| --ink: #05202b; | |
| --notebook-top-offset:36mm; /* where the ruled area starts below header */ | |
| --text-y-offset:4px; /* requested vertical nudge for text baseline */ | |
| } | |
| *{box-sizing:border-box} | |
| html,body{height:100%;margin:0;background:#f0f4f7;font-family:Segoe UI, Roboto, Inter, system-ui, -apple-system} | |
| .app{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px} | |
| .page{ | |
| width:var(--page-width); | |
| min-height:var(--page-height); | |
| background:var(--paper-bg); | |
| position:relative; | |
| box-shadow:0 24px 48px rgba(2,6,23,0.28); | |
| border-radius:6px; | |
| overflow:hidden; | |
| padding:18mm 18mm 14mm; /* inner margins */ | |
| display:block; | |
| } | |
| /* Header sits above the notebook ruled area so it does not overlap lines */ | |
| .header-bar{display:flex;align-items:center;justify-content:space-between;margin-bottom:8mm} | |
| .title{display:flex;align-items:center;gap:12px;color:var(--ink)} | |
| .title .app-icon{font-size:22px} | |
| .title h1{font-size:18px;margin:0} | |
| .controls{display:flex;gap:8px;align-items:center} | |
| .select, .button, .file-input-label{font-size:13px;padding:8px 10px;border-radius:6px;border:1px solid rgba(2,6,23,0.06);background:rgba(255,255,255,0.9);cursor:pointer} | |
| .select{appearance:none} | |
| .button{background:linear-gradient(180deg,#0ea5a4,#0a8e85);color:#fff;border:none;box-shadow:0 8px 22px rgba(10,130,120,0.12)} | |
| .button.secondary{background:#fff;color:var(--ink);border:1px solid rgba(2,6,23,0.06)} | |
| .file-input-label{display:inline-flex;align-items:center;gap:8px} | |
| /* NOTEBOOK AREA: this block contains the exact ruled lines and is where invoices must appear */ | |
| .notebook{ | |
| position:relative; | |
| padding:12px 14px 18px 18px; | |
| /* draw vertical left margin and horizontal repeated lines starting here */ | |
| background-image: | |
| linear-gradient(to right, var(--left-line-color) var(--left-line-width), transparent var(--left-line-width)), | |
| linear-gradient(to bottom, var(--line-color) 1px, transparent 1px); | |
| background-size: var(--left-margin) 100%, 100% var(--line-spacing); | |
| background-position: left top, left top; | |
| background-repeat: no-repeat, repeat-y; | |
| border-radius:6px; | |
| margin-bottom:8mm; | |
| } | |
| /* Hole-punch marks inside the notebook area */ | |
| .notebook::before{ | |
| content:"";position:absolute;left:6px;top:12mm;bottom:12mm;width:20px; pointer-events:none; | |
| background-image: radial-gradient(circle at 50% 50%, rgba(2,6,23,0.12) 0 3px, transparent 4px); | |
| background-size: 20px 72mm; background-repeat: repeat-y;opacity:0.6; | |
| } | |
| .table-wrap{width:100%;margin-top:6px} | |
| table{width:100%;border-collapse:collapse;background:transparent} | |
| thead th{color:var(--ink);opacity:0.95;text-align:left;padding:6px 6px;font-size:13px} | |
| /* KEY: each row's height must match the line spacing so text baseline is between the ruled lines */ | |
| tbody tr{height:var(--line-spacing);vertical-align:middle} | |
| /* remove td vertical padding so content doesn't climb onto the lines */ | |
| tbody td{padding:0 6px;border:0;vertical-align:middle} | |
| /* center inputs vertically and enforce their height so text sits strictly between lines */ | |
| .row-cell{display:flex;align-items:center;height:100%;overflow:hidden} | |
| .input{width:100%;border:none;background:transparent;color:var(--ink);font-size:14px;padding:0 6px;height:calc(var(--line-spacing) - 6px);line-height:calc(var(--line-spacing) - 6px);transform:translateY(var(--text-y-offset));} | |
| /* Number inputs aligned to the right but also nudged */ | |
| .input.number{max-width:160px;text-align:right} | |
| .input:focus{outline:none;box-shadow:0 4px 14px rgba(10,130,120,0.06)} | |
| .smallbtn{padding:6px 8px;border-radius:6px;border:1px solid rgba(2,6,23,0.06);background:rgba(255,255,255,0.9);cursor:pointer} | |
| .total{display:flex;justify-content:space-between;align-items:center;margin-top:18px;padding:12px;border-radius:8px;background:linear-gradient(90deg,#fff,#fbfdff);font-weight:700;color:var(--ink)} | |
| .footer{margin-top:14px;color:rgba(2,6,23,0.54);font-size:12px} | |
| @media print{ | |
| body{background:white} | |
| .app{padding:0} | |
| .page{box-shadow:none;border-radius:0;padding:18mm} | |
| } | |
| @media(max-width:900px){ | |
| .page{transform:scale(0.9);transform-origin:top center;width:190mm} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <div class="page" role="document" aria-label="Notebook strict lines A4 page with Y offset"> | |
| <div class="header-bar"> | |
| <div class="title"> | |
| <div class="app-icon">🧾</div> | |
| <div> | |
| <h1 id="titleText">Ekonomi – Faktura Lista</h1> | |
| <div style="font-size:11px;color:rgba(2,6,23,0.5)">All invoices are placed strictly between the ruled lines. Text baseline is nudged by 4px downward for correct placement.</div> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <select id="langSelect" class="select" aria-label="language select"> | |
| <option value="sv">Svenska (sv)</option> | |
| <option value="en">English (en)</option> | |
| </select> | |
| <button id="addBtn" class="button">➕ Lägg till faktura</button> | |
| <button id="exportBtn" class="button secondary">📤 Exportera</button> | |
| <label class="file-input-label" title="Importera JSON"> | |
| 📥 <span id="importBtnText">Importera</span> | |
| <input id="importFile" type="file" accept="application/json" style="display:none"> | |
| </label> | |
| <button id="resetBtn" class="button secondary">♻️ Reset</button> | |
| </div> | |
| </div> | |
| <!-- NOTEBOOK AREA: ONLY here are ruled lines present and ONLY here can invoices be placed --> | |
| <div class="notebook"> | |
| <div class="table-wrap"> | |
| <table id="invoiceTable" aria-label="Invoice table"> | |
| <thead> | |
| <tr> | |
| <th style="width:44px"></th> | |
| <th id="thDesc">Beskrivning</th> | |
| <th id="thPrice" style="width:160px;text-align:right">Pris (SEK)</th> | |
| <th style="width:64px"></th> | |
| </tr> | |
| </thead> | |
| <tbody></tbody> | |
| </table> | |
| </div> | |
| <div class="total" aria-live="polite"> | |
| <div id="totalLabel">Total:</div> | |
| <div><span id="total">0.00</span> <span id="currency">SEK</span></div> | |
| </div> | |
| <div class="footer"> | |
| <small id="helpText">Endast fälten i anteckningsområdet kan innehålla fakturor — text placeras alltid mellan linjerna.</small> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // --- Translations --- | |
| const translations = { | |
| sv: { | |
| title: 'Ekonomi – Faktura Lista', | |
| add: '➕ Lägg till faktura', | |
| export: '📤 Exportera', | |
| import: 'Importera', | |
| reset: '♻️ Reset', | |
| desc: 'Beskrivning', | |
| price: 'Pris (SEK)', | |
| total: 'Total:', | |
| help: 'Du kan exportera/importera hela listan som en JSON-fil.', | |
| invoiceEmoji: '🧾', | |
| remove: 'Ta bort' | |
| }, | |
| en: { | |
| title: 'Finance – Invoice List', | |
| add: '➕ Add invoice', | |
| export: '📤 Export', | |
| import: 'Import', | |
| reset: '♻️ Reset', | |
| desc: 'Description', | |
| price: 'Price (SEK)', | |
| total: 'Total:', | |
| help: 'You can export/import the whole list as a JSON file.', | |
| invoiceEmoji: '🧾', | |
| remove: 'Remove' | |
| } | |
| }; | |
| // --- State --- | |
| let data = []; | |
| let lang = localStorage.getItem('ekonomi_lang') || 'sv'; | |
| const saveKey = 'ekonomi_data_v1'; | |
| // --- Elements --- | |
| const tbody = document.querySelector('#invoiceTable tbody'); | |
| const totalEl = document.getElementById('total'); | |
| const totalLabelEl = document.getElementById('totalLabel'); | |
| const titleText = document.getElementById('titleText'); | |
| const thDesc = document.getElementById('thDesc'); | |
| const thPrice = document.getElementById('thPrice'); | |
| const helpText = document.getElementById('helpText'); | |
| const langSelect = document.getElementById('langSelect'); | |
| const addBtn = document.getElementById('addBtn'); | |
| const exportBtn = document.getElementById('exportBtn'); | |
| const importFile = document.getElementById('importFile'); | |
| const importBtnText = document.getElementById('importBtnText'); | |
| const resetBtn = document.getElementById('resetBtn'); | |
| // --- Helpers --- | |
| function t(k){ return translations[lang][k] || k } | |
| function fmtNumber(n){ | |
| try{ | |
| const locale = lang === 'sv' ? 'sv-SE' : 'en-US'; | |
| return new Intl.NumberFormat(locale,{minimumFractionDigits:2,maximumFractionDigits:2}).format(Number(n || 0)); | |
| }catch(e){ return Number(n || 0).toFixed(2) } | |
| } | |
| function save(){ | |
| try{ localStorage.setItem(saveKey, JSON.stringify(data)); localStorage.setItem('ekonomi_lang', lang);}catch(e){} | |
| } | |
| function load(){ | |
| const raw = localStorage.getItem(saveKey); | |
| if(raw){ | |
| try{ data = JSON.parse(raw) }catch(e){ data = [] } | |
| } else { data = [] } | |
| } | |
| // --- Render --- | |
| function render(){ | |
| tbody.innerHTML = ''; | |
| let sum = 0; | |
| data.forEach((item, i)=>{ | |
| const tr = document.createElement('tr'); | |
| // place the inputs inside a flex container (.row-cell) that vertically centers them | |
| // inputs have a translateY Y-offset so text baseline sits correctly between lines | |
| const descInput = `<div class="row-cell"><input class="input" value="${escapeHtml(item.desc)}" oninput="updateDesc(${i}, this.value)" placeholder="${escapeHtml(t('desc'))}"></div>`; | |
| const priceInput = `<div class="row-cell"><input class="input number" type="number" step="0.01" value="${Number(item.price).toFixed(2)}" oninput="updatePrice(${i}, this.value)" aria-label="price"></div>`; | |
| tr.innerHTML = ` | |
| <td style="width:48px;display:flex;align-items:center;justify-content:center">${t('invoiceEmoji')}</td> | |
| <td>${descInput}</td> | |
| <td style="text-align:right">${priceInput}</td> | |
| <td style="width:64px;text-align:center;vertical-align:middle"><button class="smallbtn" onclick="removeRow(${i})">${t('remove')}</button></td> | |
| `; | |
| tbody.appendChild(tr); | |
| sum += Number(item.price || 0); | |
| }); | |
| totalEl.textContent = fmtNumber(sum); | |
| save(); | |
| } | |
| // safe escape for values inserted into value="..." | |
| function escapeHtml(s){ return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>') } | |
| // --- Actions (exposed to inline handlers) --- | |
| window.updateDesc = function(i, v){ data[i].desc = v; save(); } | |
| window.updatePrice = function(i, v){ data[i].price = Number(v || 0); render(); } | |
| window.removeRow = function(i){ data.splice(i,1); render(); } | |
| function addRow(){ | |
| // create a blank entry; it will be rendered in the next available ruled row and will be vertically centered | |
| data.push({desc: '', price: 0}); | |
| render(); | |
| setTimeout(()=>{ | |
| const inputs = tbody.querySelectorAll('input'); | |
| if(inputs.length) inputs[inputs.length-2].focus(); | |
| },60); | |
| } | |
| function exportData(){ | |
| const json = JSON.stringify(data, null, 2); | |
| const blob = new Blob([json], {type:'application/json'}); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = 'ekonomi.json'; | |
| a.click(); | |
| } | |
| function importDataFromFile(file){ | |
| const reader = new FileReader(); | |
| reader.onload = (e)=>{ | |
| try{ | |
| const parsed = JSON.parse(e.target.result); | |
| if(Array.isArray(parsed)){ | |
| data = parsed.map(x=>({desc:x.desc||'',price: Number(x.price||0)})); | |
| render(); | |
| } else { | |
| alert('Invalid file format (expected an array)'); | |
| } | |
| }catch(err){ alert('Could not parse file: ' + err.message) } | |
| } | |
| reader.readAsText(file); | |
| } | |
| function importData(){ | |
| const file = importFile.files[0]; | |
| if(file) importDataFromFile(file); | |
| } | |
| function resetAll(){ | |
| if(confirm('Reset all data?')){ | |
| data = []; save(); render(); | |
| } | |
| } | |
| // --- Language --- | |
| function applyTranslations(){ | |
| titleText.textContent = t('title'); | |
| addBtn.textContent = t('add'); | |
| exportBtn.textContent = t('export'); | |
| importBtnText.textContent = t('import'); | |
| resetBtn.textContent = t('reset'); | |
| thDesc.textContent = t('desc'); | |
| thPrice.textContent = t('price'); | |
| totalLabelEl.textContent = t('total'); | |
| helpText.textContent = t('help'); | |
| } | |
| langSelect.value = lang; | |
| langSelect.addEventListener('change', ()=>{ lang = langSelect.value; applyTranslations(); render(); save(); }); | |
| addBtn.addEventListener('click', addRow); | |
| exportBtn.addEventListener('click', exportData); | |
| importFile.addEventListener('change', importData); | |
| resetBtn.addEventListener('click', resetAll); | |
| // load saved and apply | |
| load(); applyTranslations(); render(); | |
| document.querySelector('.file-input-label').addEventListener('click', ()=> importFile.click()); | |
| window.addEventListener('keydown',(e)=>{ if((e.ctrlKey||e.metaKey) && e.key.toLowerCase()==='n'){ e.preventDefault(); addRow(); } }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment