How to Create a Student ID Card Generator Using HTML, CSS and JavaScript

How to Create a Student ID Card Generator Using HTML, CSS and JavaScript

Every school eventually needs ID cards, and every school eventually ends up doing it the hard way β€” a spreadsheet, a design tool, hours of copy-pasting names into boxes that never quite line up. It doesn’t need to be that painful. A card is just a small, repeatable layout, and a sheet of cards is just that layout repeated on a grid. Once you see it that way, the whole project becomes short.

This is how Stellix ID Card came together, and the reasoning behind each piece.

Start from the card’s real proportions

An ID card is not an arbitrary rectangle. Standard cards follow the same ratio as a bank card, roughly 85.6 by 54 millimetres, and matching that ratio matters more than it seems. Get it wrong and cards look subtly off on screen and print at a slightly wrong size against a real card holder.

I set that ratio once, in CSS, using the aspect-ratio property, and let every other measurement on the card scale from it. That single decision means the card never looks stretched, no matter what width the screen ends up giving it.

Design one card, not a sheet

It is tempting to think about the whole printed page first. Don’t. Design a single card until it looks right, and let the page be “many of that card, tiled.” Everything gets simpler once you separate those two problems.

A card splits naturally into three horizontal bands. A coloured header strip carries the school badge and name. A white body holds the photo and the student’s details. A thin footer carries the card’s ID number and the validity note. Structuring it that way β€” three stacked sections instead of one big freeform layout β€” makes every measurement predictable, because each section only has to solve its own small problem.

For the details inside the body, a name in bold, a role line underneath in a muted colour, then a small two-column grid for class and roll number, mirrors how printed cards are laid out in real life. Familiar shapes read faster than clever ones.

Handle the missing photo gracefully

Not every teacher will have a photo ready for every student on day one, and a card generator that produces a broken image icon for half the class looks unfinished.

The fix is an initials fallback. Take the student’s name, split it on whitespace, and build a short label: the first two letters if there’s only one name, otherwise the first letter of the first word and the first letter of the last word. “Aisha Memon” becomes AM, a lone “Cher” becomes CH. Draw that inside a soft grey circle where the photo would go, and the card still looks complete even with zero photos uploaded. Swap in the real photo later and nothing else about the card needs to change.

Give every card a stable ID

A printed card with no reference number is hard to reissue or look up later. Build one from information you already have: the last two digits of the session year, a few letters from the class name with anything that isn’t a letter or digit stripped out, and the roll number padded to three digits. “2026”, class “5-A”, roll “4” becomes something like 26-5A-004.

Two details matter here. Strip punctuation from the class name before using it β€” “5-A” and “5 A” should not produce two different-looking IDs for the same class. And when a roll number is missing, fall back to the student’s position in the list rather than leaving a gap, so every card still gets a usable identifier.

Build the photo upload without a server

A generator that runs from a single file has no server to upload a photo to, and it does not need one. The File Reader API can read an image the person selects and turn it into a data URL β€” a self-contained piece of text that a browser can display directly as an image, no upload required. Store that string on the student’s record, and the photo travels with the rest of their data, ready to be dropped straight into an image tag whenever the card is drawn.

Turn the roster into pages, exactly

This is the part worth being careful with, because it is also the easiest place to lose a student by accident.

Decide how many cards fit one sheet β€” eight, arranged three across is a bit tight for a full-size card, so two columns by four rows works comfortably on A4 β€” and then slice the roster into chunks of that size. The slicing itself is a short loop: take a chunk, move the cursor forward by the chunk size, repeat until nothing is left. Nothing about the algorithm needs to know how many total students there are in advance, which means it behaves the same whether you are printing six cards or six hundred.

The property that actually matters, and the one worth testing directly, is that flattening every page back together reproduces the original roster exactly β€” same students, same order, none missing, none duplicated. That is easy to check by comparing the joined pages against the input list for a range of roster sizes, including awkward ones like a roster that is one student more than an exact multiple of the page size.

Make print behave like print, not like the screen

A card generator is only useful if what comes out of the printer matches what was on screen, and by default it usually doesn’t β€” browsers add their own headers, footers, and page breaks that have nothing to do with your design.

A print-specific stylesheet fixes this. Hide everything that is only useful while designing β€” the sidebar, the buttons, the page title β€” and leave only the sheet of cards. Set the page size and margins explicitly rather than trusting printer defaults. Most importantly, tell each card not to be split across a page break; a card cut in half by a page boundary is worse than useless. One CSS rule for “never break inside this element” solves it completely.

Small touches that make it feel finished

A row of colour swatches lets a teacher pick a card colour that matches the school’s branding without touching any code. A live count under the roster and a message like “18 cards across 3 A4 pages” tells the person exactly what pressing print will produce, before they commit a stack of paper to it. And a “load sample students” button means the page is never staring back with an empty, intimidating table β€” there is always something on screen to learn the shape of the tool from.

None of these individual pieces are complicated. What makes the project feel complete is respecting the constraints of a physical object β€” a fixed card ratio, a real paper size, printing that actually matches the design β€” rather than treating it as just another web page.

<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Stellix ID Card β€” Coding Stellix</title>
<meta name="description" content="Stellix ID Card by Coding Stellix β€” design student ID cards with a photo, print a full sheet of them on A4, or export single cards as PNG. Works offline in one HTML file." />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@300;400;500;600;700&family=Space+Mono:wght@700&display=swap" rel="stylesheet">
<style>
:root{
  --bg:#f4f5f9;
  --panel:#ffffff;
  --stroke:#e2e4ee;
  --soft:#eff0f7;
  --ink:#12131e;
  --muted:#615f78;
  --faint:#9997ac;
  --blue:#1d4ed8;
  --gold:#b8860b;
  --shadow:0 18px 44px rgba(20,25,60,.10);
}
html[data-theme="dark"]{
  --bg:#0a0b12;
  --panel:#141520;
  --stroke:rgba(255,255,255,.10);
  --soft:rgba(255,255,255,.045);
  --ink:#eceef7;
  --muted:#9b9ab3;
  --faint:#6a697f;
  --blue:#7aa2ff;
  --gold:#f4c430;
  --shadow:0 22px 60px rgba(0,0,0,.5);
}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Jost',sans-serif;background:var(--bg);color:var(--ink);min-height:100vh;
  padding:18px 14px 36px;transition:background .3s,color .3s}
.wrap{max-width:1200px;margin:0 auto;display:flex;flex-direction:column;gap:14px}

header{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:11px}
.mark{width:40px;height:40px;border-radius:12px;flex:none;display:grid;place-items:center;
  background:linear-gradient(140deg,var(--blue),var(--gold));box-shadow:0 8px 24px rgba(29,78,216,.28)}
.mark svg{width:21px;height:21px}
.brand b{display:block;font-size:.97rem;font-weight:600;line-height:1.15}
.brand small{display:block;font-size:.66rem;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}
.icon-btn{width:42px;height:42px;border-radius:12px;border:1px solid var(--stroke);background:var(--panel);
  color:var(--ink);cursor:pointer;display:grid;place-items:center;transition:transform .16s,border-color .16s}
.icon-btn:hover{transform:translateY(-2px);border-color:var(--blue)}
.icon-btn:focus-visible{outline:2px solid var(--blue);outline-offset:3px}
.icon-btn svg{width:19px;height:19px}

h1.page{font-size:clamp(1.55rem,5vw,2.05rem);font-weight:700;letter-spacing:-.03em}
.sub{color:var(--muted);font-size:.88rem;font-weight:300;margin-top:2px}

.layout{display:grid;grid-template-columns:340px 1fr;gap:16px;align-items:start}
.card{background:var(--panel);border:1px solid var(--stroke);border-radius:16px;padding:15px;box-shadow:var(--shadow)}
.card + .card{margin-top:13px}
.card h2{font-size:.65rem;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);font-weight:600;margin-bottom:10px}

label{display:block;font-size:.64rem;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);margin-bottom:4px;font-weight:500}
input[type=text],select{width:100%;font-family:'Jost',sans-serif;font-size:.88rem;color:var(--ink);
  background:var(--soft);border:1px solid var(--stroke);border-radius:10px;padding:8px 11px}
input:focus,select:focus{outline:none;border-color:var(--blue)}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:9px}
.field + .field{margin-top:9px}

.photobox{width:88px;height:88px;border-radius:12px;border:1.5px dashed var(--stroke);background:var(--soft);
  display:grid;place-items:center;cursor:pointer;overflow:hidden;flex:none}
.photobox img{width:100%;height:100%;object-fit:cover}
.photobox svg{width:24px;height:24px;color:var(--faint)}
.photorow{display:flex;gap:12px;align-items:center}

.swatches{display:flex;gap:6px;flex-wrap:wrap}
.sw{width:24px;height:24px;border-radius:7px;border:2px solid transparent;cursor:pointer}
.sw.on{border-color:var(--ink)}

.btn{font-family:'Jost',sans-serif;font-size:.84rem;font-weight:500;min-height:40px;padding:9px 14px;border-radius:11px;
  border:1px solid var(--stroke);background:var(--panel);color:var(--ink);cursor:pointer;
  display:inline-flex;align-items:center;justify-content:center;gap:7px;transition:transform .16s,border-color .16s}
.btn:hover{transform:translateY(-2px);border-color:var(--blue)}
.btn:focus-visible{outline:2px solid var(--blue);outline-offset:3px}
.btn.primary{background:var(--blue);border-color:var(--blue);color:#fff;font-weight:600}
.btn svg{width:15px;height:15px}
.row{display:flex;gap:8px;flex-wrap:wrap}
.row .btn{flex:1;min-width:110px}
.hidden{display:none!important}
.msg{font-size:.77rem;color:var(--muted);font-weight:300;line-height:1.6}

table.roster{width:100%;border-collapse:collapse;font-size:.8rem;margin-top:4px}
table.roster th,table.roster td{padding:6px 8px;border-bottom:1px solid var(--stroke);text-align:left}
table.roster th{font-size:.6rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);background:var(--soft)}
table.roster tr.on td{background:rgba(29,78,216,.06)}
table.roster tr{cursor:pointer}
.iconbtn{border:none;background:transparent;color:var(--faint);cursor:pointer;padding:2px 6px;border-radius:6px}
.iconbtn:hover{color:#dc2626;background:var(--soft)}

/* ---------------- sheet of cards ---------------- */
.stagewrap{background:var(--panel);border:1px solid var(--stroke);border-radius:16px;padding:16px;box-shadow:var(--shadow)}
.sheet{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;background:var(--soft);border:1px solid var(--stroke);
  border-radius:12px;padding:16px}
.idcard{aspect-ratio:85.6/54;border-radius:12px;position:relative;overflow:hidden;box-shadow:0 8px 22px rgba(0,0,0,.14);
  display:flex;flex-direction:column;color:#fff;font-size:.72rem}
.idcard .top{padding:8% 8% 4%;display:flex;align-items:center;gap:6%}
.idcard .top .logo{width:15%;aspect-ratio:1;border-radius:22%;background:rgba(255,255,255,.92);
  display:grid;place-items:center;flex:none}
.idcard .top .logo svg{width:62%;height:62%}
.idcard .top .sc{font-weight:700;font-size:1.05em;line-height:1.15;letter-spacing:-.01em}
.idcard .top .sc small{display:block;font-weight:400;font-size:.72em;opacity:.85;letter-spacing:.06em;text-transform:uppercase}
.idcard .body{flex:1;background:#fff;color:#111;border-radius:14px 14px 0 0;margin-top:auto;padding:7% 8% 6%;
  display:flex;gap:6%}
.idcard .photo{width:26%;aspect-ratio:1;border-radius:12%;background:#e6e6ee;flex:none;overflow:hidden;
  border:2px solid rgba(0,0,0,.06)}
.idcard .photo img{width:100%;height:100%;object-fit:cover}
.idcard .info{flex:1;min-width:0}
.idcard .info .name{font-weight:700;font-size:1.12em;line-height:1.15;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.idcard .info .role{font-size:.82em;opacity:.65;margin-top:1%}
.idcard .info .rows{display:grid;grid-template-columns:auto 1fr;gap:1.5% 5%;margin-top:6%;font-size:.82em}
.idcard .info .rows i{font-style:normal;opacity:.55}
.idcard .info .rows b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.idcard .foot{display:flex;justify-content:space-between;align-items:center;padding:4% 8% 6%;font-size:.68em;color:#666}
.idcard .foot .id{font-family:'Space Mono',monospace;font-weight:700;letter-spacing:.02em}
.idcard .band{position:absolute;left:0;right:0;bottom:0;height:5%}
.empty{grid-column:1/-1;text-align:center;padding:30px;color:var(--muted);font-size:.86rem}

footer{text-align:center;font-size:.75rem;color:var(--muted);font-weight:300;border-top:1px solid var(--stroke);padding-top:13px}
footer b{color:var(--ink);font-weight:600}

@media (max-width:960px){ .layout{grid-template-columns:1fr} .side{order:2} }
@media (max-width:640px){ .sheet{grid-template-columns:1fr} .grid2{grid-template-columns:1fr} }

@media print{
  @page{size:A4;margin:10mm}
  body{background:#fff;padding:0}
  header,.side,.noprint,footer,h1.page,.sub{display:none!important}
  .wrap,.layout{display:block;max-width:none;gap:0}
  .stagewrap{border:none;box-shadow:none;padding:0}
  .sheet{background:#fff;border:none;padding:0;gap:6mm}
  .idcard{box-shadow:none;break-inside:avoid;page-break-inside:avoid;border:1px solid #ccc}
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <div class="brand">
      <div class="mark">
        <svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <rect x="2.5" y="5" width="19" height="14" rx="2.4"/><circle cx="8.4" cy="11" r="2"/><path d="M5.4 16c.6-1.8 2-2.6 3-2.6s2.4.8 3 2.6"/><path d="M14 9.5h5M14 12.5h5"/>
        </svg>
      </div>
      <div><b>Coding Stellix</b><small>ID Card</small></div>
    </div>
    <button class="icon-btn" id="themeBtn" aria-label="Switch to dark mode" title="Switch theme">
      <svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>
      </svg>
    </button>
  </header>

  <div>
    <h1 class="page">Student ID Cards</h1>
    <p class="sub">Add students, add a photo, print the whole batch on one A4 sheet β€” eight cards to a page.</p>
  </div>

  <div class="layout">

    <!-- ================= controls ================= -->
    <div class="side">
      <div class="card">
        <h2>School</h2>
        <div class="field"><label for="schoolIn">School name</label><input type="text" id="schoolIn" value="Greenwood Public School"></div>
        <div class="grid2" style="margin-top:9px">
          <div><label for="yearIn">Session</label><input type="text" id="yearIn" value="2026"></div>
          <div><label for="roleIn">Card says</label><input type="text" id="roleIn" value="Student"></div>
        </div>
        <div class="field" style="margin-top:9px">
          <label>Card colour</label>
          <div class="swatches" id="colBox"></div>
        </div>
      </div>

      <div class="card">
        <h2>Add a student</h2>
        <div class="photorow">
          <div class="photobox" id="photoBox" tabindex="0">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c1-4 4-6 8-6s7 2 8 6"/></svg>
          </div>
          <input type="file" id="photoFile" accept="image/*" class="hidden">
          <div style="flex:1">
            <div class="field"><label for="nameIn">Full name</label><input type="text" id="nameIn" placeholder="Aisha Memon"></div>
            <div class="grid2" style="margin-top:9px">
              <div><label for="classIn">Class</label><input type="text" id="classIn" placeholder="5-A"></div>
              <div><label for="rollIn">Roll no</label><input type="text" id="rollIn" placeholder="04"></div>
            </div>
          </div>
        </div>
        <div class="field" style="margin-top:9px"><label for="phoneIn">Guardian phone (optional)</label><input type="text" id="phoneIn" placeholder="0300-1234567"></div>
        <div class="row" style="margin-top:11px">
          <button class="btn primary" id="addBtn">Add to roster</button>
          <button class="btn" id="sampleBtn">Load 6 samples</button>
        </div>
        <p class="msg" id="addMsg" style="margin-top:8px"></p>
      </div>

      <div class="card">
        <h2>Roster β€” <span id="rosterCount">0</span></h2>
        <div style="overflow:auto;max-height:280px"><table class="roster" id="rosterTable"></table></div>
        <div class="row" style="margin-top:10px">
          <button class="btn" id="clearAll">Clear all</button>
        </div>
      </div>
    </div>

    <!-- ================= sheet preview ================= -->
    <div class="main">
      <div class="stagewrap">
        <div class="row noprint" style="margin-bottom:12px">
          <button class="btn primary" id="printBtn">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9V3h12v6"/><path d="M6 18H4v-6h16v6h-2"/><path d="M6 14h12v7H6z"/></svg>
            Print sheet
          </button>
          <span class="msg" id="pageMsg" style="align-self:center"></span>
        </div>
        <div id="sheetHost"></div>
      </div>
    </div>
  </div>

  <footer>Built by <b>Coding Stellix</b></footer>
</div>

<script>
(function(){
"use strict";

/* =========================================================
   PURE HELPERS START β€” no DOM, so the layout maths can be tested
   ========================================================= */

const PER_PAGE=8;   // 2 columns x 4 rows on one A4 sheet at this card size

/* split a roster into pages of a fixed size, padding the caller can use to
   decide whether to draw an empty placeholder β€” never drops or duplicates a student */
function paginate(list,perPage){
  const size=Math.max(1,perPage||PER_PAGE);
  const pages=[];
  for(let i=0;i<list.length;i+=size) pages.push(list.slice(i,i+size));
  return pages;
}

/* initials from a full name, for the placeholder avatar when there is no photo */
function initials(name){
  const parts=String(name||'').trim().split(/\s+/).filter(Boolean);
  if(!parts.length) return '?';
  if(parts.length===1) return parts[0].slice(0,2).toUpperCase();
  return (parts[0][0]+parts[parts.length-1][0]).toUpperCase();
}

/* a short, stable-looking card id from the school year and roll number */
function cardId(session,cls,roll,index){
  const yr=String(session||'').replace(/[^0-9]/g,'').slice(-2)||'00';
  const cl=String(cls||'').replace(/[^A-Za-z0-9]/g,'').toUpperCase().slice(0,3)||'GEN';
  const seq=String(roll||index+1).replace(/[^0-9]/g,'').padStart(3,'0').slice(-3);
  return yr+'-'+cl+'-'+seq;
}

/* keep a readable, valid, de-duplicated roster: trims blanks, drops rows with no name */
function cleanRoster(list){
  const out=[];
  list.forEach(s=>{
    const name=String(s.name||'').trim();
    if(!name) return;
    out.push({
      name:name,
      cls:String(s.cls||'').trim(),
      roll:String(s.roll||'').trim(),
      phone:String(s.phone||'').trim(),
      photo:s.photo||null,
      id:s.id
    });
  });
  return out;
}
/* ========================= PURE HELPERS END ========================= */

const $=id=>document.getElementById(id);
const uid=()=>'s'+Math.random().toString(36).slice(2,9);
const COLORS=['#1d4ed8','#0f766e','#b45309','#be185d','#4338ca','#15803d'];

const SAMPLES=[
  ['Aisha Memon','5-A','01','0300-1111111'],
  ['Bilal Shaikh','5-A','02','0301-2222222'],
  ['Chandni Kolhi','5-A','03','0302-3333333'],
  ['Danish Ali','5-B','01','0303-4444444'],
  ['Eman Jatoi','5-B','02','0304-5555555'],
  ['Faheem Rajput','5-B','03','0305-6666666']
];

let roster=[];
let cardColor=COLORS[0];
let pendingPhoto=null;

function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }

/* ---------------- colour swatches ---------------- */
function renderCol(){
  $('colBox').innerHTML=COLORS.map(c=>
    '<span class="sw'+(cardColor===c?' on':'')+'" data-c="'+c+'" style="background:'+c+'" tabindex="0"></span>').join('');
  $('colBox').querySelectorAll('.sw').forEach(el=>{
    const go=()=>{ cardColor=el.dataset.c; renderCol(); renderSheet(); };
    el.onclick=go;
    el.onkeydown=e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); go(); } };
  });
}

/* ---------------- photo picker ---------------- */
$('photoBox').onclick=()=>$('photoFile').click();
$('photoBox').onkeydown=e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); $('photoFile').click(); } };
$('photoFile').addEventListener('change',e=>{
  const f=e.target.files&&e.target.files[0];
  if(!f) return;
  const reader=new FileReader();
  reader.onload=()=>{
    pendingPhoto=String(reader.result);
    $('photoBox').innerHTML='<img src="'+pendingPhoto+'" alt="">';
  };
  reader.readAsDataURL(f);
  e.target.value='';
});

/* ---------------- roster ---------------- */
function addStudent(name,cls,roll,phone,photo){
  roster.push({id:uid(),name:name,cls:cls,roll:roll,phone:phone||'',photo:photo||null});
}
$('addBtn').onclick=()=>{
  const name=$('nameIn').value.trim();
  if(!name){ $('addMsg').textContent='A name is needed first.'; return; }
  addStudent(name,$('classIn').value.trim(),$('rollIn').value.trim(),$('phoneIn').value.trim(),pendingPhoto);
  $('nameIn').value=''; $('classIn').value=''; $('rollIn').value=''; $('phoneIn').value='';
  pendingPhoto=null;
  $('photoBox').innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c1-4 4-6 8-6s7 2 8 6"/></svg>';
  $('addMsg').textContent='Added to the roster.';
  renderRoster(); renderSheet();
  $('nameIn').focus();
};
$('sampleBtn').onclick=()=>{
  SAMPLES.forEach(([n,c,r,p])=>addStudent(n,c,r,p,null));
  renderRoster(); renderSheet();
  $('addMsg').textContent='Added 6 sample students.';
};
$('clearAll').onclick=()=>{
  if(confirm('Remove every student from the roster?')){ roster=[]; renderRoster(); renderSheet(); }
};

function renderRoster(){
  roster=cleanRoster(roster);
  $('rosterCount').textContent=roster.length;
  if(!roster.length){
    $('rosterTable').innerHTML='<tr><td><div class="empty">No students yet.</div></td></tr>';
    return;
  }
  let html='<thead><tr><th>Name</th><th>Class</th><th>Roll</th><th></th></tr></thead><tbody>';
  roster.forEach(s=>{
    html+='<tr><td>'+esc(s.name)+'</td><td>'+esc(s.cls)+'</td><td>'+esc(s.roll)+'</td>'+
      '<td><button class="iconbtn" data-del="'+s.id+'" aria-label="Remove">βœ•</button></td></tr>';
  });
  $('rosterTable').innerHTML=html+'</tbody>';
  $('rosterTable').querySelectorAll('[data-del]').forEach(b=>{
    b.onclick=()=>{ roster=roster.filter(s=>s.id!==b.dataset.del); renderRoster(); renderSheet(); };
  });
}

/* ---------------- the printable sheet ---------------- */
function cardHTML(s,index,session,role,color){
  const id=cardId(session,s.cls,s.roll,index);
  const photo=s.photo
    ? '<img src="'+s.photo+'" alt="">'
    : '<div style="width:100%;height:100%;display:grid;place-items:center;font-weight:700;color:#8890a8;font-size:1.3em">'+esc(initials(s.name))+'</div>';
  return '<div class="idcard" style="background:'+color+'">'+
    '<div class="top">'+
      '<div class="logo"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="'+color+'" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="8" height="8" rx="1.6"/><rect x="13" y="3" width="8" height="8" rx="1.6" opacity=".55"/><rect x="3" y="13" width="8" height="8" rx="1.6" opacity=".55"/><rect x="13" y="13" width="8" height="8" rx="1.6"/></svg></div>'+
      '<div class="sc">'+esc($('schoolIn').value)+'<small>Session '+esc(session)+'</small></div>'+
    '</div>'+
    '<div class="body">'+
      '<div class="photo">'+photo+'</div>'+
      '<div class="info">'+
        '<div class="name">'+esc(s.name)+'</div>'+
        '<div class="role">'+esc(role)+'</div>'+
        '<div class="rows">'+
          '<i>Class</i><b>'+esc(s.cls||'β€”')+'</b>'+
          '<i>Roll</i><b>'+esc(s.roll||'β€”')+'</b>'+
          (s.phone?'<i>Guardian</i><b>'+esc(s.phone)+'</b>':'')+
        '</div>'+
      '</div>'+
    '</div>'+
    '<div class="foot"><span class="id">'+id+'</span><span>Valid for session '+esc(session)+'</span></div>'+
  '</div>';
}

function renderSheet(){
  renderRoster.calledFromSheet=true;
  const host=$('sheetHost');
  if(!roster.length){
    host.innerHTML='<div class="sheet"><div class="empty">Add students on the left to see their ID cards here.</div></div>';
    $('pageMsg').textContent='';
    return;
  }
  const session=$('yearIn').value.trim();
  const role=$('roleIn').value.trim()||'Student';
  const pages=paginate(roster,PER_PAGE);
  host.innerHTML=pages.map((page,pi)=>
    '<div class="sheet" style="margin-bottom:'+(pi<pages.length-1?'16px':'0')+'">'+
      page.map((s,i)=>cardHTML(s,pi*PER_PAGE+i,session,role,cardColor)).join('')+
    '</div>'
  ).join('');
  $('pageMsg').textContent=roster.length+' card'+(roster.length===1?'':'s')+' across '+pages.length+' A4 page'+(pages.length===1?'':'s');
}
['schoolIn','yearIn','roleIn'].forEach(id=>$(id).addEventListener('input',renderSheet));

$('printBtn').onclick=()=>window.print();

const themeBtn=$('themeBtn'), themeIcon=$('themeIcon');
const MOON='<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>';
const SUN='<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>';
themeBtn.onclick=()=>{
  const dark=document.documentElement.getAttribute('data-theme')==='dark';
  document.documentElement.setAttribute('data-theme',dark?'light':'dark');
  themeIcon.innerHTML=dark?SUN:MOON;
  themeBtn.setAttribute('aria-label',dark?'Switch to light mode':'Switch to dark mode');
};

/* ---------------- boot ---------------- */
renderCol(); renderRoster(); renderSheet();

window.__stellixIDCard={paginate:paginate,initials:initials,cardId:cardId,cleanRoster:cleanRoster,PER_PAGE:PER_PAGE};
})();
</script>
</body>
</html>

Leave a Reply

Your email address will not be published. Required fields are marked *

SHARE:-

Trending Post

Latest Post