Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created March 7, 2026 04:48
Show Gist options
  • Select an option

  • Save EncodeTheCode/fb3418e2e1377bec0b46c19df854525e to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/fb3418e2e1377bec0b46c19df854525e to your computer and use it in GitHub Desktop.
<!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,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
// --- 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