Added world clocks

This commit is contained in:
Jon
2026-04-29 22:03:38 +01:00
parent 9dcd4fedc5
commit ce8cd74bcf
2 changed files with 755 additions and 0 deletions

738
dashboard.html Normal file
View File

@@ -0,0 +1,738 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Dashboard</title>
<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=Roboto+Mono:wght@100;200;300&display=swap" rel="stylesheet">
<script src="https://widgets.coingecko.com/gecko-coin-list-widget.js" async></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #000000;
--text: #ffffff;
--subtext: #8e8e93;
--divider: #1c1c1e;
--accent: #ff9f0a;
--modal-bg: #1c1c1e;
--modal-hover: #2c2c2e;
--tab-bar-h: 54px;
}
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, 'Helvetica Neue', sans-serif;
overflow: hidden;
}
/* ─── TAB BAR ─── */
#tab-bar {
position: fixed;
bottom: 0; left: 0; right: 0;
height: var(--tab-bar-h);
background: rgba(8,8,8,0.94);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-top: 1px solid var(--divider);
display: flex;
align-items: center;
padding: 0 6px;
z-index: 300;
gap: 1px;
}
.tab-btn {
flex: 1;
height: 42px;
background: none;
border: none;
cursor: pointer;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
color: var(--subtext);
transition: background 0.15s, color 0.15s;
position: relative;
}
.tab-btn:hover { background: rgba(255,255,255,0.05); }
.tab-btn.active { color: var(--accent); }
.tab-icon { font-size: 14px; line-height: 1; }
.tab-label { font-size: 8px; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase; }
.tab-key {
position: absolute;
top: 3px; right: 5px;
font-size: 6.5px;
font-family: 'Roboto Mono', monospace;
opacity: 0.35;
}
.tab-btn.active .tab-key { opacity: 0.55; }
/* ─── PANELS ─── */
.panel {
position: fixed;
inset: 0;
bottom: var(--tab-bar-h);
overflow-y: auto;
display: none;
flex-direction: column;
background: var(--bg);
}
.panel.active { display: flex; }
/* ─── PANEL HEADER ─── */
.panel-header {
position: sticky;
top: 0; z-index: 100;
padding: 20px 28px 12px;
background: rgba(0,0,0,0.88);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--divider);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.panel-header h1 {
font-size: 34px;
font-weight: 700;
letter-spacing: -0.5px;
}
.header-actions { display: flex; gap: 12px; align-items: center; }
.icon-btn {
background: var(--modal-bg);
border: none;
color: var(--accent);
width: 36px; height: 36px;
border-radius: 50%;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, transform 0.1s;
}
.icon-btn:hover { background: var(--modal-hover); transform: scale(1.08); }
.icon-btn:active { transform: scale(0.94); }
/* ─── CLOCK LIST ─── */
#clock-list { flex: 1; padding: 0; list-style: none; }
.clock-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 28px;
border-bottom: 1px solid var(--divider);
animation: fadeIn 0.35s ease both;
position: relative;
overflow: hidden;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
.clock-location { font-size: 13px; color: var(--subtext); letter-spacing: 0.04em; text-transform: uppercase; margin-bottom: 2px; }
.clock-city { font-size: 22px; font-weight: 600; letter-spacing: -0.3px; margin-bottom: 4px; }
.clock-day-info { font-size: 13px; color: var(--subtext); }
.clock-right {
text-align: right;
display: flex; flex-direction: column; align-items: flex-end; gap: 4px;
}
.clock-digital {
font-family: 'Roboto Mono', monospace;
font-size: 52px;
font-weight: 100;
letter-spacing: -1px;
line-height: 1;
text-shadow: 0 0 30px rgba(255,255,255,0.07);
}
.clock-digital .colon { animation: blink 1s step-start infinite; }
@keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0.15; } }
.clock-ampm { font-size: 16px; font-weight: 300; color: var(--subtext); font-family: inherit; margin-left: 4px; }
.clock-seconds { font-family: 'Roboto Mono', monospace; font-size: 13px; color: var(--subtext); }
.delete-btn {
position: absolute; left: 0; top: 0;
height: 100%; width: 36px;
background: #ff3b30; border: none; color: #fff; font-size: 20px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transform: translateX(-100%);
transition: transform 0.22s ease;
}
.clock-item:hover .delete-btn { transform: translateX(0); }
.clock-item:hover { padding-left: 64px; transition: padding 0.22s ease; }
#clock-empty {
display: none;
flex-direction: column; align-items: center; justify-content: center;
flex: 1; gap: 12px; color: var(--subtext); padding: 60px 28px;
}
#clock-empty svg { opacity: 0.25; }
#clock-empty p { font-size: 17px; text-align: center; line-height: 1.5; }
/* ─── CRYPTO PANEL ─── */
.crypto-body {
flex: 1;
padding: 20px 16px 20px;
display: flex;
flex-direction: column;
}
gecko-coin-list-widget {
width: 100% !important;
display: block;
}
/* ─── PLACEHOLDER PANELS ─── */
.placeholder-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
color: var(--subtext);
padding: 40px;
}
.ph-key {
font-family: 'Roboto Mono', monospace;
font-size: 64px;
font-weight: 100;
color: #1a1a1a;
letter-spacing: -4px;
}
.placeholder-body p { font-size: 15px; text-align: center; line-height: 1.6; max-width: 260px; }
.ph-hint {
font-size: 11px;
background: var(--modal-bg);
border-radius: 8px;
padding: 7px 14px;
font-family: 'Roboto Mono', monospace;
}
/* ─── MODAL ─── */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
z-index: 400;
display: flex; align-items: flex-end; justify-content: center;
opacity: 0; pointer-events: none;
transition: opacity 0.25s;
}
.modal-overlay.open { opacity: 1; pointer-events: all; }
.modal {
background: var(--modal-bg);
border-radius: 16px 16px 0 0;
width: 100%; max-width: 540px; max-height: 80vh;
display: flex; flex-direction: column;
transform: translateY(100%);
transition: transform 0.32s cubic-bezier(0.32,0.72,0,1);
margin-bottom: var(--tab-bar-h);
}
.modal-overlay.open .modal { transform: translateY(0); }
.modal-handle { width: 36px; height: 4px; background: var(--subtext); border-radius: 2px; margin: 12px auto 0; opacity: 0.4; }
.modal-header {
padding: 16px 20px 12px;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid #333;
}
.modal-header h2 { font-size: 18px; font-weight: 600; }
.modal-close {
background: #3a3a3c; border: none; color: var(--subtext);
width: 28px; height: 28px; border-radius: 50%; font-size: 14px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.modal-close:hover { background: #48484a; }
.modal-search {
margin: 12px 16px;
background: #2c2c2e; border: none; border-radius: 10px;
color: var(--text); font-size: 15px; padding: 10px 14px;
width: calc(100% - 32px); outline: none;
}
.modal-search::placeholder { color: var(--subtext); }
.tz-list { overflow-y: auto; flex: 1; list-style: none; }
.tz-list::-webkit-scrollbar { width: 4px; }
.tz-list::-webkit-scrollbar-thumb { background: #3a3a3c; border-radius: 2px; }
.tz-group-label {
font-size: 12px; font-weight: 600; color: var(--subtext);
text-transform: uppercase; letter-spacing: 0.08em;
padding: 10px 20px 4px;
background: var(--modal-bg); position: sticky; top: 0;
}
.tz-item {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; cursor: pointer;
border-bottom: 1px solid #2c2c2e;
transition: background 0.12s;
}
.tz-item:hover { background: var(--modal-hover); }
.tz-city-name { font-size: 16px; font-weight: 500; }
.tz-tz-name { font-size: 12px; color: var(--subtext); margin-top: 2px; }
.tz-current-time { font-family: 'Roboto Mono', monospace; font-size: 15px; font-weight: 300; color: var(--subtext); }
.tz-item.added .tz-city-name { color: var(--accent); }
.tz-added-badge { font-size: 11px; background: var(--accent); color: #000; border-radius: 4px; padding: 2px 6px; font-weight: 600; margin-left: 8px; }
/* ─── TOAST ─── */
#key-toast {
position: fixed;
top: 16px; left: 50%;
transform: translateX(-50%) translateY(-50px);
background: rgba(28,28,30,0.96);
border: 1px solid #2c2c2e;
border-radius: 10px;
padding: 7px 16px;
font-size: 12px;
font-family: 'Roboto Mono', monospace;
color: var(--subtext);
z-index: 500;
pointer-events: none;
opacity: 0;
transition: transform 0.28s cubic-bezier(0.32,0.72,0,1), opacity 0.28s;
white-space: nowrap;
}
#key-toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
#key-toast strong { color: var(--accent); }
</style>
</head>
<body>
<!-- ══════════════ PANEL 1: WORLD CLOCK ══════════════ -->
<div class="panel active" id="panel-1">
<header class="panel-header">
<h1>World Clock</h1>
<div class="header-actions">
<button class="icon-btn" id="fs-btn" title="Toggle fullscreen">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 6V1h5M10 1h5v5M15 10v5h-5M6 15H1v-5"/>
</svg>
</button>
<button class="icon-btn" id="add-btn" title="Add clock">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="8" y1="1" x2="8" y2="15"/><line x1="1" y1="8" x2="15" y2="8"/>
</svg>
</button>
</div>
</header>
<ul id="clock-list"></ul>
<div id="clock-empty">
<svg width="52" height="52" viewBox="0 0 52 52" fill="none" stroke="white" stroke-width="1.5" stroke-linecap="round">
<circle cx="26" cy="26" r="22"/>
<line x1="26" y1="14" x2="26" y2="26"/><line x1="26" y1="26" x2="35" y2="32"/>
<circle cx="26" cy="26" r="2" fill="white"/>
</svg>
<p>No clocks added yet.<br/>Tap <strong style="color:var(--accent)">+</strong> to add a city.</p>
</div>
</div>
<!-- ══════════════ PANEL 2: CRYPTO ══════════════ -->
<div class="panel" id="panel-2">
<header class="panel-header">
<h1>Crypto</h1>
<div class="header-actions">
<button class="icon-btn fs-all" title="Toggle fullscreen">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 6V1h5M10 1h5v5M15 10v5h-5M6 15H1v-5"/>
</svg>
</button>
</div>
</header>
<div class="crypto-body">
<gecko-coin-list-widget
locale="en"
dark-mode="true"
outlined="true"
coin-ids="bitcoin,ethereum,tether,pax-gold,solana,binancecoin,ripple,cardano,avalanche-2,chainlink,polkadot,dogecoin,shiba-inu,uniswap,litecoin"
initial-currency="usd">
</gecko-coin-list-widget>
</div>
</div>
<!-- ══════════════ PANELS 310: PLACEHOLDERS ══════════════ -->
<div class="panel" id="panel-3">
<header class="panel-header"><h1>Tab 3</h1><div class="header-actions"><button class="icon-btn fs-all" title="Toggle fullscreen"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6V1h5M10 1h5v5M15 10v5h-5M6 15H1v-5"/></svg></button></div></header>
<div class="placeholder-body"><div class="ph-key">3</div><p>Empty tab — press <strong style="color:var(--accent)">3</strong> to jump here.</p><span class="ph-hint">Keys 19, 0 switch tabs</span></div>
</div>
<div class="panel" id="panel-4">
<header class="panel-header"><h1>Tab 4</h1><div class="header-actions"><button class="icon-btn fs-all" title="Toggle fullscreen"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6V1h5M10 1h5v5M15 10v5h-5M6 15H1v-5"/></svg></button></div></header>
<div class="placeholder-body"><div class="ph-key">4</div><p>Empty tab — press <strong style="color:var(--accent)">4</strong> to jump here.</p><span class="ph-hint">Keys 19, 0 switch tabs</span></div>
</div>
<div class="panel" id="panel-5">
<header class="panel-header"><h1>Tab 5</h1><div class="header-actions"><button class="icon-btn fs-all" title="Toggle fullscreen"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6V1h5M10 1h5v5M15 10v5h-5M6 15H1v-5"/></svg></button></div></header>
<div class="placeholder-body"><div class="ph-key">5</div><p>Empty tab — press <strong style="color:var(--accent)">5</strong> to jump here.</p><span class="ph-hint">Keys 19, 0 switch tabs</span></div>
</div>
<div class="panel" id="panel-6">
<header class="panel-header"><h1>Tab 6</h1><div class="header-actions"><button class="icon-btn fs-all" title="Toggle fullscreen"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6V1h5M10 1h5v5M15 10v5h-5M6 15H1v-5"/></svg></button></div></header>
<div class="placeholder-body"><div class="ph-key">6</div><p>Empty tab — press <strong style="color:var(--accent)">6</strong> to jump here.</p><span class="ph-hint">Keys 19, 0 switch tabs</span></div>
</div>
<div class="panel" id="panel-7">
<header class="panel-header"><h1>Tab 7</h1><div class="header-actions"><button class="icon-btn fs-all" title="Toggle fullscreen"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6V1h5M10 1h5v5M15 10v5h-5M6 15H1v-5"/></svg></button></div></header>
<div class="placeholder-body"><div class="ph-key">7</div><p>Empty tab — press <strong style="color:var(--accent)">7</strong> to jump here.</p><span class="ph-hint">Keys 19, 0 switch tabs</span></div>
</div>
<div class="panel" id="panel-8">
<header class="panel-header"><h1>Tab 8</h1><div class="header-actions"><button class="icon-btn fs-all" title="Toggle fullscreen"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6V1h5M10 1h5v5M15 10v5h-5M6 15H1v-5"/></svg></button></div></header>
<div class="placeholder-body"><div class="ph-key">8</div><p>Empty tab — press <strong style="color:var(--accent)">8</strong> to jump here.</p><span class="ph-hint">Keys 19, 0 switch tabs</span></div>
</div>
<div class="panel" id="panel-9">
<header class="panel-header"><h1>Tab 9</h1><div class="header-actions"><button class="icon-btn fs-all" title="Toggle fullscreen"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6V1h5M10 1h5v5M15 10v5h-5M6 15H1v-5"/></svg></button></div></header>
<div class="placeholder-body"><div class="ph-key">9</div><p>Empty tab — press <strong style="color:var(--accent)">9</strong> to jump here.</p><span class="ph-hint">Keys 19, 0 switch tabs</span></div>
</div>
<div class="panel" id="panel-10">
<header class="panel-header"><h1>Tab 10</h1><div class="header-actions"><button class="icon-btn fs-all" title="Toggle fullscreen"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6V1h5M10 1h5v5M15 10v5h-5M6 15H1v-5"/></svg></button></div></header>
<div class="placeholder-body"><div class="ph-key">0</div><p>Empty tab — press <strong style="color:var(--accent)">0</strong> to jump here.</p><span class="ph-hint">Keys 19, 0 switch tabs</span></div>
</div>
<!-- ══════════════ TAB BAR ══════════════ -->
<nav id="tab-bar">
<button class="tab-btn active" data-tab="1"><span class="tab-key">1</span><span class="tab-icon">🕐</span><span class="tab-label">Clock</span></button>
<button class="tab-btn" data-tab="2"><span class="tab-key">2</span><span class="tab-icon"></span><span class="tab-label">Crypto</span></button>
<button class="tab-btn" data-tab="3"><span class="tab-key">3</span><span class="tab-icon"></span><span class="tab-label">Tab 3</span></button>
<button class="tab-btn" data-tab="4"><span class="tab-key">4</span><span class="tab-icon"></span><span class="tab-label">Tab 4</span></button>
<button class="tab-btn" data-tab="5"><span class="tab-key">5</span><span class="tab-icon"></span><span class="tab-label">Tab 5</span></button>
<button class="tab-btn" data-tab="6"><span class="tab-key">6</span><span class="tab-icon"></span><span class="tab-label">Tab 6</span></button>
<button class="tab-btn" data-tab="7"><span class="tab-key">7</span><span class="tab-icon"></span><span class="tab-label">Tab 7</span></button>
<button class="tab-btn" data-tab="8"><span class="tab-key">8</span><span class="tab-icon"></span><span class="tab-label">Tab 8</span></button>
<button class="tab-btn" data-tab="9"><span class="tab-key">9</span><span class="tab-icon"></span><span class="tab-label">Tab 9</span></button>
<button class="tab-btn" data-tab="10"><span class="tab-key">0</span><span class="tab-icon"></span><span class="tab-label">Tab 10</span></button>
</nav>
<!-- CLOCK MODAL -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal" role="dialog">
<div class="modal-handle"></div>
<div class="modal-header">
<h2>Choose a City</h2>
<button class="modal-close" id="modal-close"></button>
</div>
<input class="modal-search" id="modal-search" type="text" placeholder="Search city or timezone…" autocomplete="off" />
<ul class="tz-list" id="tz-list"></ul>
</div>
</div>
<div id="key-toast"></div>
<script>
// ─── TAB SYSTEM ───────────────────────────────────────────
let activeTab = 1;
const TAB_NAMES = {
1:'World Clock', 2:'Crypto', 3:'Tab 3', 4:'Tab 4', 5:'Tab 5',
6:'Tab 6', 7:'Tab 7', 8:'Tab 8', 9:'Tab 9', 10:'Tab 10'
};
function switchTab(n) {
if (n < 1 || n > 10 || n === activeTab) return;
document.getElementById(`panel-${activeTab}`)?.classList.remove('active');
document.querySelector(`.tab-btn[data-tab="${activeTab}"]`)?.classList.remove('active');
activeTab = n;
document.getElementById(`panel-${n}`)?.classList.add('active');
document.querySelector(`.tab-btn[data-tab="${n}"]`)?.classList.add('active');
showToast(`<strong>${n === 10 ? '0' : n}</strong> &nbsp;·&nbsp; ${TAB_NAMES[n]}`);
}
document.querySelectorAll('.tab-btn').forEach(btn =>
btn.addEventListener('click', () => switchTab(+btn.dataset.tab))
);
// ─── KEYBOARD ─────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const modalOpen = document.getElementById('modal-overlay').classList.contains('open');
if (e.key === 'Escape' && modalOpen) { closeModal(); return; }
if (modalOpen) return;
if (e.key >= '1' && e.key <= '9') { switchTab(+e.key); return; }
if (e.key === '0') { switchTab(10); return; }
if (e.key === 'f' || e.key === 'F') toggleFullscreen();
});
// ─── TOAST ────────────────────────────────────────────────
let toastTimer;
function showToast(msg) {
const t = document.getElementById('key-toast');
t.innerHTML = msg;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 1500);
}
// ─── FULLSCREEN ───────────────────────────────────────────
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}
const FS_ICON_OUT = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6V1h5M10 1h5v5M15 10v5h-5M6 15H1v-5"/></svg>`;
const FS_ICON_IN = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 1H1v5M10 1h5v5M15 10h-5v5M6 15H1v-5"/></svg>`;
document.addEventListener('fullscreenchange', () => {
const inFS = !!document.fullscreenElement;
document.querySelectorAll('#fs-btn, .fs-all').forEach(b => {
b.innerHTML = inFS ? FS_ICON_IN : FS_ICON_OUT;
b.title = inFS ? 'Exit fullscreen' : 'Toggle fullscreen';
});
});
document.getElementById('fs-btn').addEventListener('click', toggleFullscreen);
document.querySelectorAll('.fs-all').forEach(b => b.addEventListener('click', toggleFullscreen));
// ─── CLOCK DATA ───────────────────────────────────────────
const ALL_TZ = [
{ city:"New York", tz:"America/New_York", region:"Americas" },
{ city:"Los Angeles", tz:"America/Los_Angeles", region:"Americas" },
{ city:"Chicago", tz:"America/Chicago", region:"Americas" },
{ city:"Denver", tz:"America/Denver", region:"Americas" },
{ city:"Phoenix", tz:"America/Phoenix", region:"Americas" },
{ city:"Toronto", tz:"America/Toronto", region:"Americas" },
{ city:"Vancouver", tz:"America/Vancouver", region:"Americas" },
{ city:"Mexico City", tz:"America/Mexico_City", region:"Americas" },
{ city:"São Paulo", tz:"America/Sao_Paulo", region:"Americas" },
{ city:"Buenos Aires", tz:"America/Argentina/Buenos_Aires", region:"Americas" },
{ city:"Bogotá", tz:"America/Bogota", region:"Americas" },
{ city:"Lima", tz:"America/Lima", region:"Americas" },
{ city:"Santiago", tz:"America/Santiago", region:"Americas" },
{ city:"Caracas", tz:"America/Caracas", region:"Americas" },
{ city:"Havana", tz:"America/Havana", region:"Americas" },
{ city:"Anchorage", tz:"America/Anchorage", region:"Americas" },
{ city:"Honolulu", tz:"Pacific/Honolulu", region:"Americas" },
{ city:"London", tz:"Europe/London", region:"Europe" },
{ city:"Paris", tz:"Europe/Paris", region:"Europe" },
{ city:"Berlin", tz:"Europe/Berlin", region:"Europe" },
{ city:"Madrid", tz:"Europe/Madrid", region:"Europe" },
{ city:"Rome", tz:"Europe/Rome", region:"Europe" },
{ city:"Amsterdam", tz:"Europe/Amsterdam", region:"Europe" },
{ city:"Brussels", tz:"Europe/Brussels", region:"Europe" },
{ city:"Zurich", tz:"Europe/Zurich", region:"Europe" },
{ city:"Vienna", tz:"Europe/Vienna", region:"Europe" },
{ city:"Stockholm", tz:"Europe/Stockholm", region:"Europe" },
{ city:"Oslo", tz:"Europe/Oslo", region:"Europe" },
{ city:"Copenhagen", tz:"Europe/Copenhagen", region:"Europe" },
{ city:"Helsinki", tz:"Europe/Helsinki", region:"Europe" },
{ city:"Warsaw", tz:"Europe/Warsaw", region:"Europe" },
{ city:"Prague", tz:"Europe/Prague", region:"Europe" },
{ city:"Budapest", tz:"Europe/Budapest", region:"Europe" },
{ city:"Bucharest", tz:"Europe/Bucharest", region:"Europe" },
{ city:"Athens", tz:"Europe/Athens", region:"Europe" },
{ city:"Istanbul", tz:"Europe/Istanbul", region:"Europe" },
{ city:"Kyiv", tz:"Europe/Kyiv", region:"Europe" },
{ city:"Moscow", tz:"Europe/Moscow", region:"Europe" },
{ city:"Lisbon", tz:"Europe/Lisbon", region:"Europe" },
{ city:"Dublin", tz:"Europe/Dublin", region:"Europe" },
{ city:"Dubai", tz:"Asia/Dubai", region:"Asia & Pacific" },
{ city:"Mumbai", tz:"Asia/Kolkata", region:"Asia & Pacific" },
{ city:"Delhi", tz:"Asia/Kolkata", region:"Asia & Pacific" },
{ city:"Karachi", tz:"Asia/Karachi", region:"Asia & Pacific" },
{ city:"Dhaka", tz:"Asia/Dhaka", region:"Asia & Pacific" },
{ city:"Kathmandu", tz:"Asia/Kathmandu", region:"Asia & Pacific" },
{ city:"Bangkok", tz:"Asia/Bangkok", region:"Asia & Pacific" },
{ city:"Ho Chi Minh", tz:"Asia/Ho_Chi_Minh", region:"Asia & Pacific" },
{ city:"Singapore", tz:"Asia/Singapore", region:"Asia & Pacific" },
{ city:"Kuala Lumpur", tz:"Asia/Kuala_Lumpur", region:"Asia & Pacific" },
{ city:"Jakarta", tz:"Asia/Jakarta", region:"Asia & Pacific" },
{ city:"Manila", tz:"Asia/Manila", region:"Asia & Pacific" },
{ city:"Hong Kong", tz:"Asia/Hong_Kong", region:"Asia & Pacific" },
{ city:"Taipei", tz:"Asia/Taipei", region:"Asia & Pacific" },
{ city:"Shanghai", tz:"Asia/Shanghai", region:"Asia & Pacific" },
{ city:"Beijing", tz:"Asia/Shanghai", region:"Asia & Pacific" },
{ city:"Seoul", tz:"Asia/Seoul", region:"Asia & Pacific" },
{ city:"Tokyo", tz:"Asia/Tokyo", region:"Asia & Pacific" },
{ city:"Osaka", tz:"Asia/Tokyo", region:"Asia & Pacific" },
{ city:"Riyadh", tz:"Asia/Riyadh", region:"Asia & Pacific" },
{ city:"Tehran", tz:"Asia/Tehran", region:"Asia & Pacific" },
{ city:"Baghdad", tz:"Asia/Baghdad", region:"Asia & Pacific" },
{ city:"Tel Aviv", tz:"Asia/Jerusalem", region:"Asia & Pacific" },
{ city:"Almaty", tz:"Asia/Almaty", region:"Asia & Pacific" },
{ city:"Ulaanbaatar", tz:"Asia/Ulaanbaatar", region:"Asia & Pacific" },
{ city:"Vladivostok", tz:"Asia/Vladivostok", region:"Asia & Pacific" },
{ city:"Sydney", tz:"Australia/Sydney", region:"Asia & Pacific" },
{ city:"Melbourne", tz:"Australia/Melbourne", region:"Asia & Pacific" },
{ city:"Brisbane", tz:"Australia/Brisbane", region:"Asia & Pacific" },
{ city:"Perth", tz:"Australia/Perth", region:"Asia & Pacific" },
{ city:"Auckland", tz:"Pacific/Auckland", region:"Asia & Pacific" },
{ city:"Fiji", tz:"Pacific/Fiji", region:"Asia & Pacific" },
{ city:"Guam", tz:"Pacific/Guam", region:"Asia & Pacific" },
{ city:"Cairo", tz:"Africa/Cairo", region:"Africa" },
{ city:"Lagos", tz:"Africa/Lagos", region:"Africa" },
{ city:"Nairobi", tz:"Africa/Nairobi", region:"Africa" },
{ city:"Johannesburg", tz:"Africa/Johannesburg", region:"Africa" },
{ city:"Casablanca", tz:"Africa/Casablanca", region:"Africa" },
{ city:"Accra", tz:"Africa/Accra", region:"Africa" },
{ city:"Addis Ababa", tz:"Africa/Addis_Ababa", region:"Africa" },
{ city:"Dar es Salaam", tz:"Africa/Dar_es_Salaam", region:"Africa" },
{ city:"Algiers", tz:"Africa/Algiers", region:"Africa" },
{ city:"Khartoum", tz:"Africa/Khartoum", region:"Africa" },
{ city:"UTC", tz:"UTC", region:"UTC" },
{ city:"Reykjavik", tz:"Atlantic/Reykjavik", region:"UTC" },
];
// ─── CLOCK LOGIC ──────────────────────────────────────────
let clocks = JSON.parse(localStorage.getItem('worldclocks') || '[]');
const save = () => localStorage.setItem('worldclocks', JSON.stringify(clocks));
function nowIn(tz) { return new Date(new Date().toLocaleString('en-US', { timeZone: tz })); }
function tzOffset(tz) {
const now = new Date();
const local = new Date(now.toLocaleString('en-US', { timeZone: tz }));
const diff = (local - now) / 60000;
const sign = diff >= 0 ? '+' : '-';
const abs = Math.abs(Math.round(diff));
const h = Math.floor(abs / 60), m = abs % 60;
return `UTC${sign}${h}${m ? ':' + String(m).padStart(2,'0') : ''}`;
}
function formatTime(d) {
let h = d.getHours();
const m = String(d.getMinutes()).padStart(2,'0');
const ampm = h >= 12 ? 'PM' : 'AM';
return { h: String(h % 12 || 12), m, ampm };
}
function dayRelative(tz) {
const here = new Date(), there = nowIn(tz);
const hd = new Date(here.getFullYear(), here.getMonth(), here.getDate());
const td = new Date(there.getFullYear(), there.getMonth(), there.getDate());
const diff = Math.round((td - hd) / 86400000);
const dn = there.toLocaleDateString('en-US', { weekday:'long', timeZone:tz });
if (diff === 0) return dn + ', today';
if (diff === 1) return dn + ', tomorrow';
if (diff === -1) return dn + ', yesterday';
return dn + (diff > 0 ? ` +${diff}d` : ` ${diff}d`);
}
function renderClocks() {
const list = document.getElementById('clock-list');
const empty = document.getElementById('clock-empty');
if (!clocks.length) { list.innerHTML = ''; empty.style.display = 'flex'; return; }
empty.style.display = 'none';
const sorted = [...clocks].sort((a,b) => nowIn(a.tz) - nowIn(b.tz));
if (list.children.length !== clocks.length) {
list.innerHTML = '';
sorted.forEach((c, i) => {
const li = document.createElement('li');
li.className = 'clock-item';
li.style.animationDelay = `${i * 0.06}s`;
li.innerHTML = clockHTML(c);
li.querySelector('.delete-btn').addEventListener('click', () => {
clocks = clocks.filter(x => !(x.city === c.city && x.tz === c.tz));
save(); renderClocks();
});
list.appendChild(li);
});
} else {
sorted.forEach((c, i) => tickClock(list.children[i], c));
}
}
function clockHTML(c) {
const d = nowIn(c.tz); const { h, m, ampm } = formatTime(d);
const sec = String(d.getSeconds()).padStart(2,'0');
return `
<button class="delete-btn" title="Remove">✕</button>
<div class="clock-left">
<div class="clock-location">${tzOffset(c.tz)}</div>
<div class="clock-city">${c.city}</div>
<div class="clock-day-info">${dayRelative(c.tz)}</div>
</div>
<div class="clock-right">
<div class="clock-digital">${h}<span class="colon">:</span>${m}<span class="clock-ampm">${ampm}</span></div>
<div class="clock-seconds">:${sec}</div>
</div>`;
}
function tickClock(li, c) {
const d = nowIn(c.tz); const { h, m, ampm } = formatTime(d);
const sec = String(d.getSeconds()).padStart(2,'0');
const dig = li.querySelector('.clock-digital');
if (dig) dig.innerHTML = `${h}<span class="colon">:</span>${m}<span class="clock-ampm">${ampm}</span>`;
const secEl = li.querySelector('.clock-seconds');
if (secEl) secEl.textContent = ':' + sec;
const dayEl = li.querySelector('.clock-day-info');
if (dayEl) dayEl.textContent = dayRelative(c.tz);
}
setInterval(renderClocks, 1000);
// ─── MODAL ────────────────────────────────────────────────
function openModal() {
document.getElementById('modal-overlay').classList.add('open');
document.getElementById('modal-search').focus();
renderTZList('');
}
function closeModal() {
document.getElementById('modal-overlay').classList.remove('open');
document.getElementById('modal-search').value = '';
}
function renderTZList(query) {
const ul = document.getElementById('tz-list');
const q = query.toLowerCase();
const filtered = ALL_TZ.filter(t =>
t.city.toLowerCase().includes(q) || t.tz.toLowerCase().includes(q) || t.region.toLowerCase().includes(q)
);
const groups = {};
filtered.forEach(t => { if (!groups[t.region]) groups[t.region] = []; groups[t.region].push(t); });
ul.innerHTML = '';
for (const [region, items] of Object.entries(groups)) {
const lbl = document.createElement('li');
lbl.className = 'tz-group-label';
lbl.textContent = region;
ul.appendChild(lbl);
items.forEach(t => {
const isAdded = clocks.some(c => c.city === t.city && c.tz === t.tz);
const { h, m, ampm } = formatTime(nowIn(t.tz));
const li = document.createElement('li');
li.className = 'tz-item' + (isAdded ? ' added' : '');
li.innerHTML = `
<div class="tz-item-left">
<div class="tz-city-name">${t.city}${isAdded ? '<span class="tz-added-badge">Added</span>' : ''}</div>
<div class="tz-tz-name">${t.tz} · ${tzOffset(t.tz)}</div>
</div>
<div class="tz-current-time">${h}:${m} ${ampm}</div>`;
li.addEventListener('click', () => {
if (!clocks.find(c => c.city === t.city && c.tz === t.tz)) {
clocks.push({ city: t.city, tz: t.tz }); save(); renderClocks();
}
closeModal();
});
ul.appendChild(li);
});
}
if (!filtered.length) ul.innerHTML = '<li style="padding:24px;text-align:center;color:#8e8e93;">No results found</li>';
}
document.getElementById('add-btn').addEventListener('click', openModal);
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('modal-overlay').addEventListener('click', e => {
if (e.target === document.getElementById('modal-overlay')) closeModal();
});
document.getElementById('modal-search').addEventListener('input', e => renderTZList(e.target.value));
// ─── INIT ─────────────────────────────────────────────────
renderClocks();
</script>
</body>
</html>