feat(bank-project): Add Dark Mode toggle

pull/1566/head
hitheswar77 1 month ago
parent 627f67f409
commit 1b36207979

@ -2,312 +2,231 @@
// Constants
// ---------------------------------------------------------------------------
const serverUrl = 'http://localhost:5000/api'; // reserved for future server swap
const serverUrl = 'http://localhost:5000/api';
const storageKey = 'savedAccount';
const accountsKey = 'accounts';
const schemaKey = 'schemaVersion';
const schemaVersion = 1;
const accountsKey = 'accounts'; // New key for all accounts
const themeKey = 'appTheme'; // <--- NEW: Constant for theme storage
// ---------------------------------------------------------------------------
// Intl helpers
// Theme Toggle Logic <--- NEW SECTION
// ---------------------------------------------------------------------------
const userLocale = navigator.language || 'en-IN';
function isIsoCurrency(code) {
if (!code) return false;
const c = String(code).trim().toUpperCase();
if (!/^[A-Z]{3}$/.test(c)) return false;
// Verify against supported values when available
try {
if (typeof Intl.supportedValuesOf === 'function') {
return Intl.supportedValuesOf('currency').includes(c);
}
} catch {}
return true; // fallback accept 3-letter code
window.toggleTheme = function() {
const html = document.documentElement;
const isDark = html.classList.toggle('dark-mode');
// Toggle visibility of sun/moon icons based on the active class
const sun = document.getElementById('sun-icon');
const moon = document.getElementById('moon-icon');
if (sun && moon) {
// If dark mode is active (isDark is true), hide the moon and show the sun
moon.classList.toggle('hidden', isDark);
sun.classList.toggle('hidden', !isDark);
}
// Save preference to local storage
localStorage.setItem(themeKey, isDark ? 'dark' : 'light');
}
function toCurrency(amount, currency) {
const n = Number(amount);
if (!Number.isFinite(n)) return String(amount);
const c = String(currency || '').trim();
if (isIsoCurrency(c)) {
// Accounting style shows negatives in parentheses if supported
const fmt = new Intl.NumberFormat(userLocale, {
style: 'currency',
currency: c.toUpperCase(),
currencySign: 'accounting',
maximumFractionDigits: 2
});
return fmt.format(n);
function applyThemeOnLoad() {
const html = document.documentElement;
const savedTheme = localStorage.getItem(themeKey);
// Default to system preference if no saved theme is found
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
let activateDark;
if (savedTheme) {
activateDark = savedTheme === 'dark';
} else {
activateDark = prefersDark;
}
// Fallback: symbol + localized number
const num = new Intl.NumberFormat(userLocale, {
maximumFractionDigits: 2
}).format(n);
return c ? `${c} ${num}` : num;
}
function toDate(dateStr) {
// Expect yyyy-mm-dd; fallback to today if invalid
const d = dateStr ? new Date(dateStr) : new Date();
if (Number.isNaN(d.getTime())) return new Date();
return d;
}
if (activateDark) {
html.classList.add('dark-mode');
} else {
html.classList.remove('dark-mode');
}
function formatDate(dateStr) {
const d = toDate(dateStr);
const fmt = new Intl.DateTimeFormat(userLocale, { dateStyle: 'medium' });
return fmt.format(d);
// Ensure correct icon is visible on load (needs to wait for DOM elements)
// This logic runs again in updateRoute or dashboard refresh to ensure icons are set.
}
// Attach the function to run after the DOM content is loaded
document.addEventListener('DOMContentLoaded', applyThemeOnLoad);
// ---------------------------------------------------------------------------
// Storage and state
// Router
// ---------------------------------------------------------------------------
function safeParse(json, fallback) {
try { return JSON.parse(json); } catch { return fallback; }
const routes = {
'/dashboard': { title: 'My Account', templateId: 'dashboard', init: refresh },
'/login': { title: 'Login', templateId: 'login' }
};
function navigate(path) {
window.history.pushState({}, path, window.location.origin + path);
updateRoute();
}
function updateRoute() {
const path = window.location.pathname;
const route = routes[path];
if (!route) {
return navigate('/dashboard');
}
const template = document.getElementById(route.templateId);
const view = template.content.cloneNode(true);
const app = document.getElementById('app');
app.innerHTML = '';
app.appendChild(view);
if (typeof route.init === 'function') {
route.init();
}
// Re-run icon setup after template is rendered
applyThemeOnLoad(); // <--- UPDATED: Call applyThemeOnLoad here too, to set icon state
document.title = route.title;
}
// ---------------------------------------------------------------------------
// API interactions (replaced with localStorage logic)
// ---------------------------------------------------------------------------
function getAccounts() {
return safeParse(localStorage.getItem(accountsKey), []);
return JSON.parse(localStorage.getItem(accountsKey) || '[]');
}
function saveAccounts(accounts) {
localStorage.setItem(accountsKey, JSON.stringify(accounts));
}
function migrateSchema() {
const v = Number(localStorage.getItem(schemaKey) || 0);
if (v >= schemaVersion) return;
let accounts = getAccounts();
// Example migration scaffolding:
// if (v < 1) { /* future migrations */ }
saveAccounts(accounts);
localStorage.setItem(schemaKey, String(schemaVersion));
}
function findAccount(user) {
const accounts = getAccounts();
return accounts.find(acc => acc.user === user) || null;
}
async function getAccount(user) {
// Simulate async
return new Promise(resolve => {
setTimeout(() => {
const acc = findAccount(user);
resolve(acc || { error: 'Account not found' });
}, 60);
if (!acc) resolve({ error: 'Account not found' });
else resolve(acc);
}, 100);
});
}
function uuid() {
// Works on HTTPS and localhost; fallback otherwise
if (globalThis.crypto && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'tx-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
}
async function createAccount(accountJson) {
return new Promise(resolve => {
setTimeout(() => {
const data = safeParse(accountJson, null);
if (!data) return resolve({ error: 'Malformed account data' });
const user = String(data.user || '').trim();
if (!user) return resolve({ error: 'Username required' });
if (findAccount(user)) return resolve({ error: 'User already exists' });
const currency = String(data.currency || 'INR').trim();
let data;
try {
data = JSON.parse(accountJson);
} catch (e) {
return resolve({ error: 'Malformed account data' });
}
if (!data.user) return resolve({ error: 'Username required' });
if (findAccount(data.user)) return resolve({ error: 'User already exists' });
// Set up initial account structure
const newAcc = {
user,
description: String(data.description || ''),
balance: Number(data.balance || 0) || 0,
currency: isIsoCurrency(currency) ? currency.toUpperCase() : currency,
user: data.user,
description: data.description || '',
balance: 0,
currency: data.currency || 'USD',
transactions: []
};
const accounts = getAccounts();
accounts.push(newAcc);
saveAccounts(accounts);
resolve(newAcc);
}, 80);
}, 100);
});
}
async function createTransaction(user, transactionJson) {
return new Promise(resolve => {
setTimeout(() => {
const tx = safeParse(transactionJson, null);
if (!tx) return resolve({ error: 'Malformed transaction data' });
const amount = Number(tx.amount);
const object = String(tx.object || '').trim();
const dateStr = tx.date || new Date().toISOString().slice(0, 10);
if (!Number.isFinite(amount)) return resolve({ error: 'Amount must be a valid number' });
if (!object) return resolve({ error: 'Object is required' });
const accounts = getAccounts();
const idx = accounts.findIndex(acc => acc.user === user);
if (idx === -1) return resolve({ error: 'Account not found' });
const newTx = {
id: uuid(),
date: dateStr,
object,
amount
};
// Update balance and push transaction
accounts[idx].balance = (Number(accounts[idx].balance) || 0) + amount;
accounts[idx].transactions = (accounts[idx].transactions || []);
accounts[idx].transactions.push(newTx);
const tx = JSON.parse(transactionJson);
tx.amount = parseFloat(tx.amount);
tx.date = tx.date || new Date().toISOString().slice(0, 10);
accounts[idx].balance += tx.amount;
accounts[idx].transactions.push(tx);
saveAccounts(accounts);
resolve(newTx);
}, 80);
resolve(tx);
}, 100);
});
}
// Keep a frozen state object to avoid accidental mutations
// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
let state = Object.freeze({
account: null
});
function updateState(property, newData) {
state = Object.freeze({ ...state, [property]: newData });
// Persist active account only
state = Object.freeze({
...state,
[property]: newData
});
localStorage.setItem(storageKey, JSON.stringify(state.account));
}
// Cross-tab sync: refresh when accounts or active account change in another tab
window.addEventListener('storage', (e) => {
if (e.key === accountsKey || e.key === storageKey) {
if (state.account?.user) {
refresh().catch(() => {});
}
}
});
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
function qs(id) {
return document.getElementById(id);
}
function updateElement(id, textOrNode) {
const el = qs(id);
if (!el) return;
while (el.firstChild) el.removeChild(el.firstChild);
el.append(textOrNode);
}
// Resolve multiple possible IDs used by older markup
function setTextByAnyId(ids, text) {
for (const id of ids) {
const el = qs(id);
if (el) { el.textContent = text; return true; }
}
return false;
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
const routes = {
'/dashboard': { title: 'My Account', templateId: 'dashboard', init: refresh },
'/login': { title: 'Login', templateId: 'login', init: attachAuthHandlers }
};
function navigate(path) {
// Store path in history state and URL
history.pushState({ path }, '', path);
updateRoute();
}
function updateRoute() {
const path = history.state?.path || window.location.pathname;
const route = routes[path] || routes['/dashboard'];
const template = document.getElementById(route.templateId);
const view = template.content.cloneNode(true);
const app = document.getElementById('app');
app.innerHTML = '';
app.appendChild(view);
// Attach handlers after DOM is rendered
attachGlobalHandlers();
if (typeof route.init === 'function') {
Promise.resolve(route.init()).catch(err => console.error(err));
}
document.title = route.title;
}
// Browser back/forward
window.addEventListener('popstate', () => updateRoute());
// ---------------------------------------------------------------------------
// Auth
// Login/register
// ---------------------------------------------------------------------------
async function login() {
const form = qs('loginForm');
if (!form) return;
if (!form.checkValidity()) { form.reportValidity(); return; }
const user = String(form.user.value || '').trim();
const loginForm = document.getElementById('loginForm')
const user = loginForm.user.value;
const data = await getAccount(user);
if (data.error) return updateElement('loginError', data.error);
if (data.error) {
return updateElement('loginError', data.error);
}
updateState('account', data);
navigate('/dashboard');
}
async function register() {
const form = qs('registerForm');
if (!form) return;
if (!form.checkValidity()) { form.reportValidity(); return; }
const data = Object.fromEntries(new FormData(form));
data.user = String(data.user || '').trim();
data.currency = String(data.currency || '').trim();
data.description = String(data.description || '').trim();
data.balance = Number(data.balance || 0) || 0;
const registerForm = document.getElementById('registerForm');
const formData = new FormData(registerForm);
const data = Object.fromEntries(formData);
const jsonData = JSON.stringify(data);
const result = await createAccount(jsonData);
if (result.error) return updateElement('registerError', result.error);
if (result.error) {
return updateElement('registerError', result.error);
}
updateState('account', result);
navigate('/dashboard');
}
function attachAuthHandlers() {
const loginForm = qs('loginForm');
if (loginForm) {
loginForm.addEventListener('submit', (e) => { e.preventDefault(); login(); });
}
const registerForm = qs('registerForm');
if (registerForm) {
registerForm.addEventListener('submit', (e) => { e.preventDefault(); register(); });
}
}
// ---------------------------------------------------------------------------
// Dashboard
// ---------------------------------------------------------------------------
async function updateAccountData() {
const account = state.account;
if (!account) return logout();
if (!account) {
return logout();
}
const data = await getAccount(account.user);
if (data.error) return logout();
if (data.error) {
return logout();
}
updateState('account', data);
}
@ -319,133 +238,89 @@ async function refresh() {
function updateDashboard() {
const account = state.account;
if (!account) return logout();
// Description (support both #description and #transactions-description subtitles)
setTextByAnyId(['description', 'transactions-description'], account.description || 'Transactions');
// Balance and currency
const balanceText = toCurrency(account.balance, account.currency);
setTextByAnyId(['balance', 'balance-value'], balanceText);
if (!account) {
return logout();
}
// Some markups use a separate currency span; keep it empty when using formatted output
setTextByAnyId(['balance-currency', 'currency'], '');
updateElement('description', account.description);
updateElement('balance', account.balance.toFixed(2));
updateElement('currency', account.currency);
// Transactions (sorted by date desc, then by insertion order)
const tbody = qs('transactions');
if (!tbody) return;
// Update transactions
const transactionsRows = document.createDocumentFragment();
for (const transaction of account.transactions) {
const transactionRow = createTransactionRow(transaction);
transactionsRows.appendChild(transactionRow);
}
updateElement('transactions', transactionsRows);
}
const frag = document.createDocumentFragment();
function createTransactionRow(transaction) {
const template = document.getElementById('transaction');
const sorted = [...(account.transactions || [])]
.sort((a, b) => String(b.date).localeCompare(String(a.date)));
for (const tx of sorted) {
const row = template.content.cloneNode(true);
const tr = row.querySelector('tr');
const tds = tr.children;
tds[0].textContent = formatDate(tx.date);
tds[1].textContent = tx.object;
tds[2].textContent = toCurrency(tx.amount, account.currency);
if (tx.amount < 0) tr.classList.add('debit');
if (tx.amount > 0) tr.classList.add('credit');
frag.appendChild(row);
const transactionRow = template.content.cloneNode(true);
const tr = transactionRow.querySelector('tr');
// START: FIX - FORMAT DATE
// The date is typically stored as 'YYYY-MM-DD'. We convert it to 'MM/DD/YYYY' for display.
const dateParts = transaction.date.split('-');
const displayDate = dateParts[1] + '/' + dateParts[2] + '/' + dateParts[0];
// END: FIX
tr.children[0].textContent = displayDate; // Use displayDate
tr.children[1].textContent = transaction.object;
tr.children[2].textContent = transaction.amount.toFixed(2);
// Optionally highlight positive/negative transactions
if (transaction.amount < 0) {
tr.classList.add('negative');
} else if (transaction.amount > 0) {
tr.classList.add('positive');
}
tbody.innerHTML = '';
tbody.appendChild(frag);
return transactionRow;
}
function addTransaction() {
const dialog = qs('transactionDialog');
if (!dialog) return;
const dialog = document.getElementById('transactionDialog');
dialog.classList.add('show');
// Reset form and set today
const form = qs('transactionForm');
if (form) {
form.reset();
form.date.valueAsDate = new Date();
// Move focus to first field
form.date.focus();
}
// Close handlers
const backdrop = dialog.querySelector('[data-dismiss]') || dialog;
backdrop.addEventListener('click', onDialogDismissClick);
dialog.addEventListener('keydown', onDialogKeydown);
}
// Reset form
const transactionForm = document.getElementById('transactionForm');
transactionForm.reset();
function onDialogDismissClick(e) {
if (e.target?.hasAttribute?.('data-dismiss')) {
cancelTransaction();
}
}
function onDialogKeydown(e) {
if (e.key === 'Escape') {
cancelTransaction();
}
// Set date to today
transactionForm.date.valueAsDate = new Date();
}
async function confirmTransaction() {
const form = qs('transactionForm');
if (!form) return;
// Inline validation
const amountVal = Number(form.amount.value);
const objectVal = String(form.object.value || '').trim();
if (!Number.isFinite(amountVal)) {
setFormError('transactionError', 'Amount must be a valid number');
return;
}
if (!objectVal) {
setFormError('transactionError', 'Object is required');
return;
}
const dialog = document.getElementById('transactionDialog');
dialog.classList.remove('show');
clearFormError('transactionError');
const transactionForm = document.getElementById('transactionForm');
const jsonData = JSON.stringify(Object.fromEntries(new FormData(form)));
const formData = new FormData(transactionForm);
const jsonData = JSON.stringify(Object.fromEntries(formData));
const data = await createTransaction(state.account.user, jsonData);
if (data.error) {
setFormError('transactionError', data.error);
return;
return updateElement('transactionError', data.error);
}
// Update local state
// Update local state with new transaction
const newAccount = {
...state.account,
balance: (Number(state.account.balance) || 0) + data.amount,
transactions: [...(state.account.transactions || []), data]
};
balance: state.account.balance + data.amount,
transactions: [...state.account.transactions, data]
}
updateState('account', newAccount);
// Close dialog and update view
cancelTransaction();
// Update display
updateDashboard();
}
function setFormError(id, message) {
updateElement(id, message);
const el = qs(id);
if (el) el.focus();
}
function clearFormError(id) {
const el = qs(id);
if (el) el.textContent = '';
}
function cancelTransaction() {
const dialog = qs('transactionDialog');
if (!dialog) return;
async function cancelTransaction() {
const dialog = document.getElementById('transactionDialog');
dialog.classList.remove('show');
dialog.removeEventListener('keydown', onDialogKeydown);
const opener = document.querySelector('button[onclick="addTransaction()"]');
if (opener) opener.focus();
}
function logout() {
@ -454,31 +329,22 @@ function logout() {
}
// ---------------------------------------------------------------------------
// Global listeners
// Utils
// ---------------------------------------------------------------------------
function attachGlobalHandlers() {
// Intercept form submissions for inline handlers in markup
const loginForm = qs('loginForm');
if (loginForm && !loginForm.__wired) {
loginForm.__wired = true;
loginForm.addEventListener('submit', (e) => { e.preventDefault(); login(); });
}
const registerForm = qs('registerForm');
if (registerForm && !registerForm.__wired) {
registerForm.__wired = true;
registerForm.addEventListener('submit', (e) => { e.preventDefault(); register(); });
}
const txForm = qs('transactionForm');
if (txForm && !txForm.__wired) {
txForm.__wired = true;
txForm.addEventListener('submit', (e) => { e.preventDefault(); confirmTransaction(); });
}
const cancelBtn = document.querySelector('#transactionDialog [data-dismiss]');
if (cancelBtn && !cancelBtn.__wired) {
cancelBtn.__wired = true;
cancelBtn.addEventListener('click', cancelTransaction);
function updateElement(id, textOrNode) {
const element = document.getElementById(id);
element.textContent = ''; // Removes all children
// START: FIX - Handle Text vs. Node/Fragment Appending Robustly
// When dealing with complex DOM nodes (like the DocumentFragment holding transactions),
// we must use appendChild. For simple text (like balance/error), textContent is fine.
if (textOrNode instanceof Node) {
element.appendChild(textOrNode);
} else {
element.textContent = textOrNode;
}
// END: FIX
}
// ---------------------------------------------------------------------------
@ -486,20 +352,16 @@ function attachGlobalHandlers() {
// ---------------------------------------------------------------------------
function init() {
// Schema migration
migrateSchema();
// Restore active account
const saved = safeParse(localStorage.getItem(storageKey), null);
if (saved) updateState('account', saved);
// Seed history state if missing
if (!history.state || !history.state.path) {
const initialPath = state.account ? '/dashboard' : '/login';
history.replaceState({ path: initialPath }, '', initialPath);
// Restore state
const savedState = localStorage.getItem(storageKey);
if (savedState) {
updateState('account', JSON.parse(savedState));
}
// Initial route render
// applyThemeOnLoad() is now called via DOMContentLoaded and updateRoute
// Update route for browser back/next buttons
window.onpopstate = () => updateRoute();
updateRoute();
}

@ -1,96 +1,47 @@
<!DOCTYPE html>
<html lang="en" data-theme="auto">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#0a7cff">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Squirrel Banking</title>
<!-- Local Font Awesome (ensure this path exists locally) -->
<link rel="stylesheet" href="assets/fontawesome/css/all.min.css">
<!-- App styles -->
<link rel="stylesheet" href="styles.css">
<!-- App logic -->
<script src="app.js" defer></script>
</head>
<body>
<noscript>Please enable JavaScript to use this app.</noscript>
<!-- SPA mount point -->
<div id="app" aria-live="polite">Loading…</div>
<!-- Placeholder where we will insert our app HTML based on route -->
<div id="app">Loading...</div>
<!-- Login page template -->
<template id="login">
<section class="page page-auth" aria-labelledby="loginTitle">
<div class="auth-card">
<div class="brand">
<i class="fa-solid fa-squirrel brand-icon" aria-hidden="true"></i>
<h1 id="loginTitle" class="brand-title">
<span class="hide-xs">Squirrel</span>
<img class="brand-logo" src="logo.svg" alt="Squirrel Banking Logo">
<span class="hide-xs">Banking</span>
</h1>
<section class="login-page">
<div class="login-container">
<div class="login-title text-center">
<span class="hide-xs">Squirrel</span>
<img class="login-logo" src="logo.svg" alt="Squirrel Banking Logo">
<span class="hide-xs">Banking</span>
</div>
<div class="auth-panels">
<section class="auth-panel" aria-labelledby="loginHeading">
<h2 id="loginHeading" class="panel-title">
<i class="fa-solid fa-right-to-bracket" aria-hidden="true"></i> Login
</h2>
<form id="loginForm" action="javascript:void(0)" novalidate>
<label for="username" class="field-label">Username</label>
<div class="field">
<i class="fa-solid fa-user" aria-hidden="true"></i>
<input id="username" name="user" type="text" maxlength="20" autocomplete="username" required>
</div>
<div id="loginError" class="error" role="alert" aria-live="polite"></div>
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-arrow-right-to-bracket" aria-hidden="true"></i>
Login
</button>
</form>
</section>
<div class="auth-sep" role="separator" aria-label="Or">OR</div>
<section class="auth-panel" aria-labelledby="registerHeading">
<h2 id="registerHeading" class="panel-title">
<i class="fa-solid fa-user-plus" aria-hidden="true"></i> Register
</h2>
<form id="registerForm" action="javascript:void(0)" novalidate>
<label for="user" class="field-label">Username (required)</label>
<div class="field">
<i class="fa-solid fa-id-badge" aria-hidden="true"></i>
<input id="user" name="user" type="text" maxlength="20" autocomplete="username" required>
</div>
<label for="currency" class="field-label">Currency (required)</label>
<div class="field">
<i class="fa-solid fa-coins" aria-hidden="true"></i>
<input id="currency" name="currency" type="text" maxlength="5" placeholder="INR or ₹" required>
</div>
<label for="description" class="field-label">Description</label>
<div class="field">
<i class="fa-solid fa-note-sticky" aria-hidden="true"></i>
<input id="description" name="description" type="text" maxlength="100" placeholder="About the account">
</div>
<label for="current-balance" class="field-label">Current balance</label>
<div class="field">
<i class="fa-solid fa-wallet" aria-hidden="true"></i>
<input id="current-balance" name="balance" type="number" value="0" step="any" inputmode="decimal">
</div>
<div id="registerError" class="error" role="alert" aria-live="polite"></div>
<button class="btn btn-ghost" type="submit">
<i class="fa-solid fa-user-check" aria-hidden="true"></i>
Register
</button>
</form>
</section>
<div class="login-content">
<h2 class="text-center">Login</h2>
<form id="loginForm" action="javascript:login()">
<label for="username">Username</label>
<input id="username" name="user" type="text" maxlength="20" required>
<div id="loginError" class="error" role="alert"></div>
<button>Login</button>
</form>
<p class="login-separator text-center"><span>OR</span></p>
<h2 class="text-center">Register</h2>
<form id="registerForm" action="javascript:register()">
<label for="user">Username (required)</label>
<input id="user" name="user" type="text" maxlength="20" required>
<label for="currency">Currency (required)</label>
<input id="currency" name="currency" type="text" maxlength="5" value="$" required>
<label for="description">Description</label>
<input id="description" name="description" type="text" maxlength="100">
<label for="current-balance">Current balance</label>
<input id="current-balance" name="balance" type="number" value="0">
<div id="registerError" class="error" role="alert"></div>
<button>Register</button>
</form>
</div>
</div>
</section>
@ -98,109 +49,73 @@
<!-- Dashboard page template -->
<template id="dashboard">
<section class="page page-dash">
<header class="dash-header">
<div class="brand compact">
<img class="brand-logo" src="logo.svg" alt="">
<h1 class="brand-title hide-xs">Squirrel Banking</h1>
</div>
<div class="header-actions">
<button class="btn btn-icon" type="button" id="themeToggle" aria-label="Toggle theme">
<i class="fa-solid fa-moon" aria-hidden="true"></i>
</button>
<button class="btn btn-icon" type="button" onclick="logout()" aria-label="Logout">
<i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i>
</button>
</div>
<section class="dashboard-page">
<header class="dashboard-header">
<img class="dashboard-logo" src="logo.svg" alt="Squirrel Banking Logo">
<h1 class="dashboard-title hide-xs">Squirrel Banking</h1>
<button id="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark/light theme">
<!-- Moon Icon (for Light mode) -->
<svg id="moon-icon" class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
<!-- Sun Icon (for Dark mode, hidden by default) -->
<svg id="sun-icon" class="w-6 h-6 hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</button>
<button onclick="logout()">Logout</button>
</header>
<section class="summary">
<div class="balance" aria-live="polite">
<div class="balance-label">
<i class="fa-solid fa-chart-line" aria-hidden="true"></i>
Balance
</div>
<div class="balance-value">
<span id="balance">0</span>
<span id="balance-currency"></span>
</div>
</div>
<div class="account-meta">
<i class="fa-solid fa-circle-info" aria-hidden="true"></i>
<span id="account-info" class="muted"></span>
<div class="balance">
<div>Balance</div>
<span id="balance"></span>
<span id="balance-currency"></span>
</div>
<!-- START: CHANGED SECTION for Theme Toggle and Logout -->
<div class="header-actions">
<button id="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark/light theme">
<!-- Moon Icon (for Light mode) -->
<svg id="moon-icon" class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
<!-- Sun Icon (for Dark mode, hidden by default) -->
<svg id="sun-icon" class="w-6 h-6 hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</button>
<button onclick="logout()">Logout</button>
</div>
</section>
<section class="dash-content">
<!-- END: CHANGED SECTION -->
<div class="dashboard-content">
<div class="transactions-title">
<h2 id="transactions-description">
<i class="fa-solid fa-receipt" aria-hidden="true"></i>
Transactions
</h2>
<button class="btn btn-primary" type="button" onclick="addTransaction()">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
Add transaction
</button>
<h2 id="transactions-description"></h2>
<button onclick="addTransaction()">Add transaction</button>
</div>
<table class="transactions-table" aria-label="Transactions">
<thead>
<tr>
<th scope="col"><i class="fa-solid fa-calendar-day" aria-hidden="true"></i> Date</th>
<th scope="col"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Object</th>
<th scope="col" class="num"><i class="fa-solid fa-indian-rupee-sign" aria-hidden="true"></i> Amount</th>
<th>Date</th>
<th>Object</th>
<th>Amount</th>
</tr>
</thead>
<tbody id="transactions"></tbody>
</table>
</section>
</div>
</section>
<!-- Modal dialog: Add transaction -->
<section id="transactionDialog" class="dialog" hidden>
<div class="dialog-backdrop" data-dismiss></div>
<div class="dialog-content"
role="dialog"
aria-modal="true"
aria-labelledby="txDialogTitle"
aria-describedby="txDialogDesc">
<h2 id="txDialogTitle" class="text-center">
<i class="fa-solid fa-square-plus" aria-hidden="true"></i>
Add transaction
</h2>
<p id="txDialogDesc" class="muted text-center">
Use negative amount for debit and positive for credit; Esc closes [web guideline aligned].
</p>
<form id="transactionForm" action="javascript:void(0)" novalidate>
<label for="date">Date</label>
<div class="field">
<i class="fa-solid fa-calendar-days" aria-hidden="true"></i>
<input id="date" name="date" type="date" required>
</div>
<label for="object">Object</label>
<div class="field">
<i class="fa-solid fa-tag" aria-hidden="true"></i>
<input id="object" name="object" type="text" maxlength="50" required>
</div>
<label for="amount">Amount (negative for debit)</label>
<div class="field">
<i class="fa-solid fa-money-bill-transfer" aria-hidden="true"></i>
<input id="amount" name="amount" type="number" value="0" step="any" inputmode="decimal" required>
</div>
<div id="transactionError" class="error" role="alert" aria-live="polite"></div>
<section id="transactionDialog" class="dialog">
<div class="dialog-content">
<h2 class="text-center">Add transaction</h2>
<form id="transactionForm" action="javascript:void(0)">
<label for="date">Date</label>
<input id="date" name="date" type="date" required>
<label for="object">Object</label>
<input id="object" name="object" type="text" maxlength="50" required>
<label for="amount">Amount (use negative value for debit)</label>
<input id="amount" name="amount" type="number" value="0" step="any" required>
<div id="transactionError" class="error" role="alert"></div>
<div class="dialog-buttons">
<button type="button" class="btn btn-ghost" data-dismiss>
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
Cancel
</button>
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-check" aria-hidden="true"></i>
OK
</button>
<button type="button" class="button-alt" formaction="javascript:cancelTransaction()" formnovalidate>Cancel</button>
<button formaction="javascript:confirmTransaction()">OK</button>
</div>
</form>
</div>
@ -212,8 +127,8 @@
<tr>
<td></td>
<td></td>
<td class="num"></td>
<td></td>
</tr>
</template>
</body>
</html>
</html>

@ -1,83 +1,41 @@
/* ==========================================================================
Design Tokens and Theming
========================================================================== */
:root {
/* Brand */
/* Colors */
--primary: #0091ea;
--primary-600: #007ccc;
--primary-700: #0066a8;
/* Neutrals */
--text: #1b1f24;
--muted: #5a6370;
--white: #ffffff;
--surface: #ffffff;
--surface-2: #f5f7fb;
--border: #c7cdd8;
/* Accents */
--primary-light: #7bbde6;
--accent: #546e7a;
--error: #ff5522;
/* Backgrounds */
--grey: #445;
--error: #f52;
--background: #f5f5f6;
--background-accent: #e8f2fb;
/* Effects */
--shadow-sm: 0 1px 2px rgba(16, 24, 40, .06);
--shadow-md: 0 6px 16px rgba(16, 24, 40, .12);
--focus: 3px solid rgba(0, 145, 234, .4);
/* Radius and Spacing */
--radius: 12px;
--radius-sm: 8px;
--space-2xs: 4px;
--space-xs: 6px;
--background-accent: #cfe5f2;
--white: #fff;
--border: #99a;
/* Sizes */
--radius: 10px;
--space-xs: 5px;
--space-sm: 10px;
--space-md: 20px;
--space-lg: 28px;
--space-xl: 40px;
/* Typography */
--font-ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
--font-size: 16px;
/* Opt into user color schemes */
color-scheme: light dark;
}
/* Dark theme via user preference */
@media (prefers-color-scheme: dark) {
:root {
--text: #e9edf4;
--muted: #aab4c3;
--surface: #131720;
--surface-2: #0f131b;
--background: #0b0e14;
--background-accent: #0f1822;
--border: #2a3443;
--shadow-sm: 0 1px 2px rgba(0,0,0,.45);
--shadow-md: 0 8px 24px rgba(0,0,0,.5);
--focus: 3px solid rgba(0, 145, 234, .55);
}
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
/* START: Dark Mode Variables */
:root.dark-mode {
--primary: #5cb3ff; /* Lighter primary for contrast */
--primary-light: #004d80;
--grey: #a0aec0; /* Lighter grey for text */
--background: #1a202c; /* Dark background */
--background-accent: #2d3748; /* Slightly lighter dark background */
--white: #1f2937; /* Dark equivalent of 'white' for containers */
--border: #4a5568;
}
/* END: Dark Mode Variables */
/* ==========================================================================
Base and Reset
========================================================================== */
/* ------------------------------------------------------------------------ */
/* Micro reset */
/* ------------------------------------------------------------------------ */
* { box-sizing: border-box; }
* {
box-sizing: border-box;
}
html, body, #app {
margin: 0;
@ -85,183 +43,125 @@ html, body, #app {
height: 100%;
}
html {
font-size: 100%;
}
/* ------------------------------------------------------------------------ */
/* General styles */
/* ------------------------------------------------------------------------ */
body {
font-family: var(--font-ui);
font-size: var(--font-size);
color: var(--text);
background: var(--background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
accent-color: var(--primary);
}
/* Utilities */
.text-center { text-align: center; }
.hide-xs { display: none; }
@media (min-width: 480px) { .hide-xs { display: initial; } }
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 1px, 1px);
white-space: nowrap; border: 0;
}
/* Focus states (WCAG-friendly) */
:where(a, button, [role="button"], input, select, textarea):focus-visible {
outline: var(--focus);
outline-offset: 2px;
border-radius: 6px;
font-family: Arial, Helvetica, sans-serif;
font-size: 16px;
/* Apply background and foreground colors from variables */
background-color: var(--background);
color: var(--grey);
transition: background-color 0.3s, color 0.3s; /* Smooth transition */
}
/* ==========================================================================
Headings and Text
========================================================================== */
h2 {
color: var(--primary);
text-transform: uppercase;
font-weight: 700;
font-weight: bold;
font-size: 1.5rem;
margin: var(--space-md) 0;
}
/* Muted text helper */
.muted { color: var(--muted); }
/* ==========================================================================
Forms
========================================================================== */
form {
display: flex;
flex-direction: column;
margin: var(--space-sm) var(--space-md);
gap: var(--space-xs);
}
label {
color: var(--muted);
text-transform: uppercase;
font-size: 80%;
letter-spacing: .02em;
}
input, select, textarea {
height: 44px;
padding: 0 var(--space-sm);
input {
margin-top: var(--space-xs);
margin-bottom: var(--space-sm);
height: 45px;
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-sm);
transition: border-color .18s ease, box-shadow .18s ease, background-color .18s ease;
border-radius: var(--radius);
}
input::placeholder,
textarea::placeholder { color: color-mix(in oklab, var(--muted) 80%, var(--text)); }
input:focus {
border-color: var(--primary-600);
border-color: var(--primary);
outline: 0;
box-shadow: 0 0 0 4px color-mix(in oklab, var(--primary) 20%, transparent);
}
.error {
color: var(--error);
margin: var(--space-2xs) 0;
label {
color: var(--grey);
text-transform: uppercase;
font-size: 80%;
}
.error:empty { display: none; }
/* Native UI controls pick up brand via accent-color */
input[type="checkbox"],
input[type="radio"],
input[type="range"],
progress { accent-color: var(--primary); }
/* ==========================================================================
Buttons
========================================================================== */
button,
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 700;
button {
font-weight: bold;
background-color: var(--primary);
color: var(--white);
height: 40px;
padding: 0 var(--space-md);
padding: var(--space-xs);
border: 0;
border-radius: var(--radius-sm);
border-radius: var(--radius);
text-transform: uppercase;
min-width: 110px;
min-width: 100px;
margin: var(--space-sm) 0;
box-shadow: var(--shadow-sm);
transition: filter .12s ease, transform .12s ease, background-color .12s ease;
}
button:hover,
.btn:hover { filter: brightness(110%); cursor: pointer; }
button:active,
.btn:active { transform: translateY(1px); }
button:focus-visible,
.btn:focus-visible { outline: var(--focus); }
.button-alt,
.btn-ghost {
.button-alt {
background-color: transparent;
color: var(--primary);
border: 1px solid color-mix(in oklab, var(--primary) 40%, var(--border));
box-shadow: none;
}
.btn-icon {
min-width: auto;
width: 40px; height: 40px;
padding: 0;
border-radius: 50%;
button:hover {
filter: brightness(115%);
cursor: pointer;
}
/* ==========================================================================
Login Page
========================================================================== */
button:focus {
outline: none;
border: 3px solid var(--grey);
}
.login-page {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
background:
radial-gradient(800px 500px at 20% -10%, color-mix(in oklab, var(--primary) 30%, transparent), transparent 60%),
radial-gradient(800px 500px at 120% 10%, color-mix(in oklab, var(--primary-600) 20%, transparent), transparent 55%),
linear-gradient(var(--primary), var(--primary-600));
.error {
color: var(--error);
margin: var(--space-xs) 0;
}
.login-container {
flex: auto;
max-width: 520px;
max-height: 100%;
overflow: auto;
padding: var(--space-sm);
.error:empty {
display: none;
}
/* START: Dark Mode specific button style */
.dashboard-header button#theme-toggle {
background: transparent;
color: var(--grey);
padding: var(--space-xs);
margin: 0;
border: none;
height: 40px;
width: 40px;
display: inline-flex;
justify-content: center;
align-items: center;
}
.dashboard-header button#theme-toggle:hover {
filter: none;
background-color: rgba(255, 255, 255, 0.1);
}
.dark-mode .dashboard-header {
background-color: var(--background-accent);
}
.dark-mode .dashboard-header button {
border-color: var(--primary);
}
/* END: Dark Mode specific button style */
/* ------------------------------------------------------------------------ */
/* Login page */
/* ------------------------------------------------------------------------ */
.login-title {
font-size: 2rem;
font-weight: 800;
font-weight: bold;
color: var(--white);
margin: var(--space-md);
text-align: center;
}
.login-logo {
@ -269,14 +169,27 @@ button:focus-visible,
vertical-align: middle;
}
.login-page {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
background: linear-gradient(var(--primary), var(--primary-light));
}
.login-container {
flex: auto;
max-width: 480px;
max-height: 100%;
overflow: auto;
}
.login-content {
background-color: var(--surface);
padding: var(--space-md);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
border: 1px solid var(--border);
background-color: var(--white); /* Now uses variable for easy dark mode switch */
padding: var(--space-sm);
}
.login-separator {
position: relative;
top: 0.5em;
@ -287,15 +200,14 @@ button:focus-visible,
.login-separator > span {
position: relative;
top: -0.6em;
background-color: var(--surface);
padding: 0 var(--space-sm);
color: var(--muted);
top: -0.5em;
background-color: var(--white); /* Use var(--white) here as well */
padding: var(--space-sm);
}
/* ==========================================================================
Dashboard
========================================================================== */
/* ------------------------------------------------------------------------ */
/* Dashboard page */
/* ------------------------------------------------------------------------ */
.dashboard-page {
display: flex;
@ -303,35 +215,24 @@ button:focus-visible,
flex-direction: column;
}
/* Make content responsive to its own width (container queries) */
.dashboard-content {
width: 100%;
max-width: 960px;
align-self: center;
padding: var(--space-sm);
container-type: inline-size;
container-name: dash;
}
.dashboard-header {
background-color: var(--accent);
padding: var(--space-xs) var(--space-sm);
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--accent); /* Used to be --grey, switched to --accent for a better variable fit */
padding: 0 var(--space-sm)
}
.dashboard-header button {
border: 1px solid color-mix(in oklab, var(--white) 35%, transparent);
float: right;
border: 1px solid;
background-color: transparent;
color: var(--white);
color: var(--white); /* Ensure buttons are visible in dark header */
}
.dashboard-title {
font-size: 1.5rem;
font-weight: 800;
font-weight: bold;
color: var(--white);
margin: 0 var(--space-sm);
vertical-align: middle;
margin: 0 var(--space-sm)
}
.dashboard-logo {
@ -341,22 +242,19 @@ button:focus-visible,
}
.balance {
background: radial-gradient(circle at center, var(--primary), var(--primary-600));
background: radial-gradient(circle at center, var(--primary), var(--primary-light));
text-align: center;
padding: var(--space-md) var(--space-sm);
}
.balance > div {
color: var(--white);
padding-top: var(--space-2xs);
padding-top: var(--space-xs);
text-transform: uppercase;
letter-spacing: .08em;
}
.balance > span {
color: var(--white);
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 800;
font-size: 3rem;
}
.transactions-title {
@ -365,9 +263,8 @@ button:focus-visible,
align-items: center;
padding: 0 var(--space-sm);
color: var(--accent);
font-weight: 800;
font-size: 1.25rem;
gap: var(--space-sm);
font-weight: bold;
font-size: 1.5rem;
}
.transactions-title > div {
@ -376,30 +273,21 @@ button:focus-visible,
white-space: nowrap;
}
/* ==========================================================================
Transactions Table
========================================================================== */
.transactions-table {
width: 100%;
font-size: 1.05rem;
font-size: 1.2rem;
padding: var(--space-sm);
margin: 0;
border-spacing: 0;
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
background-color: var(--background);
}
.transactions-table thead th {
border-bottom: 1px solid var(--border);
color: var(--muted);
font-weight: 700;
}
.transactions-table tr:nth-child(even) {
background-color: var(--surface-2);
background-color: var(--background-accent);
}
.transactions-table td,
@ -410,6 +298,7 @@ button:focus-visible,
.transactions-table td:first-child,
.transactions-table th:first-child {
/* Make first column use the minimum width */
width: 1%;
white-space: nowrap;
}
@ -419,90 +308,88 @@ button:focus-visible,
text-align: right;
}
/* Condense table in narrow containers */
@container dash (width < 520px) {
.transactions-table {
font-size: 1rem;
border-radius: var(--radius-sm);
padding: var(--space-xs);
}
}
/* ==========================================================================
Dialog (Modal)
========================================================================== */
.dialog {
display: none;
position: fixed;
inset: 0;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
left: 0;
top: 0;
overflow: auto;
background-color: rgba(0,0,0,.45);
background-color: rgba(0,0,0,0.4);
animation: slideFromTop 0.3s ease-in-out;
justify-content: center;
align-items: flex-start;
padding: var(--space-sm);
z-index: 1000;
}
.dialog.show { display: flex; }
.dialog.show {
display: flex;
}
@keyframes slideFromTop {
from {
top: -300px;
opacity: 0;
}
to {
top: 0;
opacity: 1;
}
}
.dialog-content {
flex: auto;
background-color: var(--surface);
color: var(--text);
max-width: 520px;
background-color: var(--white);
max-width: 480px;
max-height: 100%;
padding: var(--space-md);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--shadow-md);
margin-top: var(--space-xl);
transform: translateY(-8px);
animation: dialogIn .25s ease-out both;
padding: var(--space-sm);
}
@keyframes dialogIn {
from { opacity: 0; transform: translateY(-14px); }
to { opacity: 1; transform: translateY(0); }
.dialog-buttons {
text-align: right;
}
.dialog-buttons {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
/* ------------------------------------------------------------------------ */
/* Utilities */
/* ------------------------------------------------------------------------ */
.text-center {
text-align: center;
}
/* ==========================================================================
Responsive
========================================================================== */
.hide-xs {
display: none;
}
/* ------------------------------------------------------------------------ */
/* Responsive adaptations */
/* ------------------------------------------------------------------------ */
@media only screen and (min-width: 480px) {
.hide-xs {
display: initial;
}
@media (min-width: 480px) {
.login-content,
.dialog-content {
border-radius: var(--radius);
}
}
@media (min-width: 768px) {
.dashboard-content {
max-width: 900px;
.dialog-content {
margin-top: var(--space-xl);
}
}
/* ==========================================================================
Legacy compatibility mappings (existing class names)
========================================================================== */
.button-alt { /* keep existing alias */
background-color: transparent;
color: var(--primary);
border: 1px solid color-mix(in oklab, var(--primary) 40%, var(--border));
}
@media only screen and (min-width: 768px) {
.transactions-table {
border-radius: var(--radius);
}
/* Keep original variable names for backward compatibility where possible */
:root {
--space-xs: var(--space-xs);
--space-sm: var(--space-sm);
--space-md: var(--space-md);
--space-xl: var(--space-xl);
.dashboard-content {
width: 100%;
max-width: 768px;
align-self: center;
}
}

Loading…
Cancel
Save