18 Commits
Author SHA1 Message Date
Justin 4260f9350c adding vsphere to vmare vsphere 2026-10-18 15:42:41 -05:00
Justin b7367f7e35 change the name of the file 2026-10-18 11:50:58 -05:00
Justin 46987b967e working on a adding vsphere 2026-10-18 11:48:06 -05:00
Justin 64020603e1 changed the file chmod to remove -x 2026-10-18 10:33:20 -05:00
JustinandClaude Opus 5.5 3d9c5c4b2b Remove RAW_DATA fallback and show a log-unavailable message
- Drop the embedded 20-contact RAW_DATA sample; the logbook now comes
  only from data/k5bss-log.json
- Show a banner and table message when the log fails to load, and
  display "—" for the header stats
- Skip building the dashboard when there is no data (it crashed on an
  empty log) and guard the activity chart redraw on resize
- Compute the QSO count from the log instead of hardcoding it

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 10:04:44 -05:00
Justin 38f38c89fe moved the javascrpit to its own file and moved the json to its own file 2026-10-18 09:45:40 -05:00
Justin ac08a5b7e5 movied css inline to its own file 2026-10-18 09:33:00 -05:00
Justin a60a44aa82 working on the homelab 2026-09-06 20:36:36 -05:00
Justin 2d96cd70a0 working on the clean up 2026-09-05 14:52:04 -05:00
Justin 5b35c52d58 Resolve unresolved merge conflict markers left in main
The merge of claude/nifty-bhaskara-fb3ec2 left three-way conflict
markers committed straight into index.html, _headers, and
.claude/launch.json instead of being resolved. Two branches had
independently fixed the same duplicate-CSP bug; keep the more
thorough fix (single CSP source of truth in _headers, no meta tag
in index.html, local photo path) and drop the now-unneeded
'unsafe-inline'/ki5bhv.com allowances.
2026-08-31 19:37:44 -05:00
Justin c31ba229af Merge branch 'claude/nifty-bhaskara-fb3ec2' of https://git.ki5bhv.com/justin/k5bss.com
# Conflicts:
#	.claude/launch.json
#	_headers
#	index.html
2026-08-31 19:26:10 -05:00
Justin 49856bdefc Merge branch 'claude/scan-report-issues-93c6e9' of https://git.ki5bhv.com/justin/k5bss.com
# Conflicts:
#	_headers
#	index.html
2026-08-31 14:40:01 -05:00
JustinandClaude Sonnet 5 f8b0a8b070 Add local static-server launch config for browser preview
Lets the Browser pane preview the site locally (python -m http.server)
without needing a full deploy to check CSP/console behavior.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-31 14:26:21 -05:00
JustinandClaude Sonnet 5 9dd8fae16e Fix CSP violations and font 404 from Cloudflare Radar scan
Two conflicting <meta> CSP tags in index.html (plus a separate _headers
policy) meant the browser enforced their intersection, which blocked
the Cloudflare Insights beacon script and its inline bootstrap. k5bss.html
had the same gap in its own CSP. Consolidated to one consistent policy per
surface that allows static.cloudflareinsights.com / cloudflareinsights.com,
and moved index.html's inline script and onerror handler into js/main.js
so script-src no longer needs 'unsafe-inline' there.

Also added the missing fonts/ibm-plex-mono-400.woff2 that style.css has
referenced since it was wired up, which was 404ing.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-31 14:25:10 -05:00
JustinandClaude Sonnet 5 6cea0312d2 Add local static-server launch config for browser preview
Lets the site be served on localhost for testing (e.g. verifying
font/CSP fixes) without needing a separate dev setup.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-31 14:14:15 -05:00
JustinandClaude Sonnet 5 f45dfaa232 Fix CSP blocking Cloudflare Insights beacon and inline scripts
index.html carried two Content-Security-Policy meta tags that were
never reconciled, so browsers enforced their intersection: the
Cloudflare beacon script/connect endpoints, the profile photo from
ki5bhv.com, and the page's own inline script/onerror handler all got
silently blocked depending on which policy line was checked.

Collapse index.html down to a single correct policy, and add the
same static.cloudflareinsights.com / cloudflareinsights.com
allowances to _headers (the actual Cloudflare Pages response header)
and to k5bss.html's policy, since Cloudflare injects that beacon
site-wide.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-31 14:13:19 -05:00
JustinandClaude Sonnet 5 435f56e0cc Fix 404 on self-hosted IBM Plex Mono font
Download the actual woff2 file so it exists at the path style.css
already references, instead of relying on the unused Google Fonts
preconnect hints.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-31 14:09:38 -05:00
JustinandClaude Sonnet 5 c7a10ae719 Fix CSP violations from Cloudflare Radar scan
Two duplicate <meta> CSP tags in index.html conflicted with the
_headers HTTP header, and browsers enforce the intersection of all
active policies. That blocked the profile photo, the inline nav-
highlight script, and the Cloudflare Insights beacon.

Consolidate to a single CSP in _headers, allow the actual external
resources the page needs (ki5bhv.com image, cloudflareinsights.com
beacon), and move the inline script/onerror handler into js/main.js
so script-src no longer needs 'unsafe-inline'.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-30 16:28:33 -05:00
20 changed files with 3062 additions and 898 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "static-site",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8123"],
"port": 8123
}
]
}
Executable → Regular
View File
File mode changed.
Executable → Regular
View File
File mode changed.
Executable → Regular
View File
File mode changed.
+1 -1
View File
@@ -1,5 +1,5 @@
/* /*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests Content-Security-Policy: default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' https://cloudflareinsights.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests
X-Content-Type-Options: nosniff X-Content-Type-Options: nosniff
X-Frame-Options: DENY X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin Referrer-Policy: strict-origin-when-cross-origin
+576
View File
@@ -0,0 +1,576 @@
:root {
--bg: #0a0c10;
--surface: #111418;
--surface2: #181c22;
--border: #1e2530;
--green: #00ff88;
--green2: #00cc6a;
--amber: #ffaa00;
--red: #ff4455;
--blue: #4499ff;
--cyan: #00ddff;
--text: #c8d8e8;
--muted: #5a7080;
--font-mono: 'Share Tech Mono', monospace;
--font-display: 'Orbitron', sans-serif;
--font-body: 'Barlow', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-weight: 300;
min-height: 100vh;
overflow-x: hidden;
}
/* Scanline overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 2px,
rgba(0,0,0,0.07) 2px,
rgba(0,0,0,0.07) 4px
);
pointer-events: none;
z-index: 9999;
}
/* ─── HEADER ─── */
header {
position: relative;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 2rem;
overflow: hidden;
}
header::after {
content: '';
position: absolute;
bottom: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--green), var(--cyan), transparent);
animation: scanH 4s linear infinite;
}
@keyframes scanH {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.header-inner {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 0;
gap: 2rem;
flex-wrap: wrap;
}
.callsign-block {
display: flex;
align-items: baseline;
gap: 1rem;
}
.callsign {
font-family: var(--font-display);
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 900;
color: var(--green);
text-shadow: 0 0 30px rgba(0,255,136,0.5), 0 0 60px rgba(0,255,136,0.2);
letter-spacing: 0.05em;
line-height: 1;
}
.op-name {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--muted);
letter-spacing: 0.15em;
}
.header-status {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.status-item {
text-align: center;
}
.status-val {
font-family: var(--font-display);
font-size: 1.6rem;
font-weight: 700;
color: var(--amber);
text-shadow: 0 0 20px rgba(255,170,0,0.4);
display: block;
}
.status-lbl {
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--muted);
letter-spacing: 0.2em;
text-transform: uppercase;
}
.online-badge {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--green);
padding: 0.4rem 1rem;
border: 1px solid var(--green);
border-radius: 2px;
background: rgba(0,255,136,0.05);
letter-spacing: 0.1em;
}
.pulse-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 8px var(--green);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
/* ─── NAV ─── */
nav {
background: var(--surface2);
border-bottom: 1px solid var(--border);
}
.nav-inner {
max-width: 1400px;
margin: 0 auto;
display: flex;
gap: 0;
padding: 0 2rem;
}
.nav-btn {
font-family: var(--font-mono);
font-size: 0.8rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
padding: 0.9rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.nav-btn:hover { color: var(--text); }
.nav-btn.active { color: var(--green); border-bottom-color: var(--green); }
/* ─── MAIN ─── */
main {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.log-error {
background: var(--surface);
border: 1px solid var(--red);
border-radius: 4px;
color: var(--red);
font-family: var(--font-mono);
font-size: 0.85rem;
letter-spacing: 0.1em;
padding: 1rem 1.5rem;
margin-bottom: 1.5rem;
}
.log-error[hidden] { display: none; }
.section { display: none; }
.section.active { display: block; }
/* ─── DASHBOARD ─── */
.dash-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1.5rem;
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
}
.card.green::before { background: linear-gradient(90deg, var(--green), transparent); }
.card.amber::before { background: linear-gradient(90deg, var(--amber), transparent); }
.card.blue::before { background: linear-gradient(90deg, var(--blue), transparent); }
.card.cyan::before { background: linear-gradient(90deg, var(--cyan), transparent); }
.card.red::before { background: linear-gradient(90deg, var(--red), transparent); }
.card-title {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.2em;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 1rem;
}
.bar-chart { display: flex; flex-direction: column; gap: 0.5rem; }
.bar-row {
display: grid;
grid-template-columns: 80px 1fr 40px;
align-items: center;
gap: 0.5rem;
}
.bar-label {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text);
text-align: right;
}
.bar-track {
height: 16px;
background: var(--surface2);
border-radius: 2px;
overflow: hidden;
position: relative;
}
.bar-fill {
height: 100%;
border-radius: 2px;
transition: width 1s cubic-bezier(0.4,0,0.2,1);
}
.bar-count {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--muted);
}
.country-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 220px;
overflow-y: auto;
}
.country-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.3rem 0;
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 0.75rem;
}
.country-name { color: var(--text); }
.country-count {
color: var(--amber);
background: rgba(255,170,0,0.1);
padding: 0.1rem 0.4rem;
border-radius: 2px;
font-size: 0.7rem;
}
/* ─── ACTIVITY CHART ─── */
.activity-card { grid-column: 1 / -1; }
#activityCanvas {
width: 100%;
height: 120px;
display: block;
}
/* ─── LOGBOOK TABLE ─── */
.log-controls {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
align-items: center;
}
.search-input {
font-family: var(--font-mono);
font-size: 0.85rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
color: var(--text);
padding: 0.5rem 1rem;
flex: 1;
min-width: 200px;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus { border-color: var(--green); }
.search-input::placeholder { color: var(--muted); }
.filter-select {
font-family: var(--font-mono);
font-size: 0.8rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
color: var(--text);
padding: 0.5rem 0.75rem;
outline: none;
cursor: pointer;
}
.filter-select option { background: var(--surface2); }
.log-meta {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--muted);
margin-left: auto;
white-space: nowrap;
}
.table-wrap {
overflow-x: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
}
table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 0.8rem;
}
thead th {
background: var(--surface2);
padding: 0.75rem 1rem;
text-align: left;
color: var(--muted);
letter-spacing: 0.15em;
font-size: 0.7rem;
text-transform: uppercase;
border-bottom: 1px solid var(--border);
white-space: nowrap;
cursor: pointer;
user-select: none;
transition: color 0.2s;
}
thead th:hover { color: var(--green); }
thead th.sort-asc::after { content: ' ▲'; font-size: 0.6rem; }
thead th.sort-desc::after { content: ' ▼'; font-size: 0.6rem; }
tbody tr {
border-bottom: 1px solid rgba(30,37,48,0.5);
transition: background 0.15s;
}
tbody tr:hover { background: rgba(0,255,136,0.03); }
tbody td {
padding: 0.6rem 1rem;
color: var(--text);
white-space: nowrap;
}
.call-cell {
color: var(--cyan);
font-weight: 700;
font-size: 0.9rem;
letter-spacing: 0.05em;
}
.band-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 2px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.band-20M, .band-20m { background: rgba(68,153,255,0.15); color: var(--blue); }
.band-40M, .band-40m { background: rgba(0,255,136,0.12); color: var(--green); }
.band-80M, .band-80m { background: rgba(255,68,85,0.12); color: var(--red); }
.band-10M, .band-10m { background: rgba(0,221,255,0.12); color: var(--cyan); }
.band-default { background: rgba(90,112,128,0.2); color: var(--muted); }
.mode-cell { color: var(--amber); font-size: 0.75rem; letter-spacing: 0.1em; }
.lotw-y { color: var(--green); }
.lotw-n { color: var(--muted); }
.rst-cell { color: var(--text); font-size: 0.75rem; }
.dist-cell { color: var(--muted); font-size: 0.75rem; }
/* Pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1.5rem;
font-family: var(--font-mono);
font-size: 0.8rem;
flex-wrap: wrap;
}
.page-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 0.4rem 0.8rem;
border-radius: 2px;
cursor: pointer;
transition: all 0.2s;
}
.page-btn:hover { border-color: var(--green); color: var(--green); }
.page-btn.active { background: rgba(0,255,136,0.1); border-color: var(--green); color: var(--green); }
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.page-info { color: var(--muted); padding: 0.4rem 0.5rem; }
/* ─── ABOUT ─── */
.about-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
@media (max-width: 768px) {
.about-grid { grid-template-columns: 1fr; }
}
.info-block { margin-bottom: 1.5rem; }
.info-label {
font-family: var(--font-mono);
font-size: 0.65rem;
letter-spacing: 0.2em;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 0.3rem;
}
.info-value {
font-family: var(--font-body);
font-size: 1.1rem;
color: var(--text);
font-weight: 600;
}
.info-value a { color: var(--cyan); text-decoration: none; }
.info-value a:hover { text-decoration: underline; }
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.tag {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.1em;
padding: 0.3rem 0.7rem;
border: 1px solid var(--border);
border-radius: 2px;
color: var(--text);
background: var(--surface2);
}
/* ─── SCROLLBAR ─── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--surface2); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* ─── FOOTER ─── */
footer {
text-align: center;
padding: 2rem;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--muted);
border-top: 1px solid var(--border);
margin-top: 3rem;
letter-spacing: 0.15em;
}
footer span { color: var(--green); }
/* ─── LOADING ─── */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--muted);
}
.spinner {
width: 16px; height: 16px;
border: 2px solid var(--border);
border-top-color: var(--green);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 600px) {
.header-inner { padding: 1rem 0; }
.callsign { font-size: 2rem; }
.status-val { font-size: 1.2rem; }
main { padding: 1rem; }
.log-controls { gap: 0.5rem; }
.nav-btn { padding: 0.75rem 1rem; font-size: 0.7rem; }
}
Executable → Regular
View File
File mode changed.
+2114
View File
File diff suppressed because it is too large. Load diff
Executable → Regular
View File
File mode changed.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Executable → Regular
View File
File mode changed.

Before

Width:  |  Height:  |  Size: 473 B

After

Width:  |  Height:  |  Size: 473 B

Executable → Regular
View File
File mode changed.

Before

Width:  |  Height:  |  Size: 826 B

After

Width:  |  Height:  |  Size: 826 B

Executable → Regular
View File
File mode changed.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Executable → Regular
View File
File mode changed.

Before

Width:  |  Height:  |  Size: 370 B

After

Width:  |  Height:  |  Size: 370 B

Binary file not shown.
Executable → Regular
+11 -30
View File
@@ -6,9 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="referrer" content="strict-origin-when-cross-origin" /> <meta name="referrer" content="strict-origin-when-cross-origin" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https://ki5bhv.com data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'self'">
<title>Justin Frasier | Infrastructure Administrator</title> <title>Justin Frasier | Infrastructure Administrator</title>
<link rel="sitemap" type="application/xml" href="/sitemap.xml" /> <link rel="sitemap" type="application/xml" href="/sitemap.xml" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='4' fill='%230f1218'/%3E%3Ctext x='3' y='11' font-family='IBM Plex Mono,Courier New,monospace' font-size='6' fill='%234a5568'%3E~$%3C/text%3E%3Ctext x='2' y='27' font-family='IBM Plex Mono,Courier New,monospace' font-size='19' font-weight='600' fill='%2322c55e'%3Ejf%3C/text%3E%3C/svg%3E" /> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='4' fill='%230f1218'/%3E%3Ctext x='3' y='11' font-family='IBM Plex Mono,Courier New,monospace' font-size='6' fill='%234a5568'%3E~$%3C/text%3E%3Ctext x='2' y='27' font-family='IBM Plex Mono,Courier New,monospace' font-size='19' font-weight='600' fill='%2322c55e'%3Ejf%3C/text%3E%3C/svg%3E" />
@@ -27,8 +24,7 @@
<!-- ── Sidebar ── --> <!-- ── Sidebar ── -->
<aside> <aside>
<img class="profile-photo" src="./photo/justin1.jpg" alt="Justin Frasier" <img class="profile-photo" src="./photo/justin1.jpg" alt="Justin Frasier">
onerror="this.style.display='none'">
<div class="profile-name">Justin Frasier</div> <div class="profile-name">Justin Frasier</div>
<div class="profile-title">Infrastructure Administrator</div> <div class="profile-title">Infrastructure Administrator</div>
<div class="profile-loc">📍 Bossier City, LA</div> <div class="profile-loc">📍 Bossier City, LA</div>
@@ -75,6 +71,7 @@
<span class="tag blue">Azure AD</span> <span class="tag blue">Azure AD</span>
<span class="tag blue">Docker</span> <span class="tag blue">Docker</span>
<span class="tag blue">Proxmox VE</span> <span class="tag blue">Proxmox VE</span>
<span class="tag blue">VMware VSphere</span>
<span class="tag blue">Web Design</span> <span class="tag blue">Web Design</span>
<span class="tag">Self-hosted Infrastructure</span> <span class="tag">Self-hosted Infrastructure</span>
<span class="tag">FCC K5BSS</span> <span class="tag">FCC K5BSS</span>
@@ -259,8 +256,14 @@
<div class="skill-cell"><span class="skill-icon">▸</span>Crisis Management</div> <div class="skill-cell"><span class="skill-icon">▸</span>Crisis Management</div>
</div> </div>
<!-- <div class="skills-subheading">Home Lab</div>
<div class="skills-grid">
<div class="skill-cell"><span class="skill-icon">▸</span>Docker</div>
<div class="skill-cell"><span class="skill-icon">▸</span></div>
<div class="skill-cell"><span class="skill-icon">▸</span>Corporate Security</div>
<div class="skill-cell"><span class="skill-icon">▸</span>Corporate Security</div>
</section> </section>
-->
<!-- Education --> <!-- Education -->
<section id="education"> <section id="education">
<h2><span class="hash">#</span>education</h2> <h2><span class="hash">#</span>education</h2>
@@ -361,33 +364,11 @@
</div> </div>
</div> </div>
</section> </section>
<footer> <footer>
<span style="color:var(--accent)">$</span> site built by Justin Frasier &nbsp;·&nbsp; k5bss.com &nbsp;·&nbsp; <span style="color:var(--accent)">$</span> site built by Justin Frasier &nbsp;·&nbsp; k5bss.com &nbsp;·&nbsp; 2026
2026
</footer> </footer>
</main> </main>
</div> </div>
<script src="js/main.js" defer></script>
<script>
// Highlight active nav link on scroll
const sections = document.querySelectorAll('section[id]');
const links = document.querySelectorAll('nav a');
const observer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
const active = document.querySelector(`nav a[href="#${e.target.id}"]`);
if (active) active.classList.add('active');
}
});
}, { threshold: 0.3 });
sections.forEach(s => observer.observe(s));
</script>
</body> </body>
</html> </html>
+318
View File
@@ -0,0 +1,318 @@
// ─── DATA ───────────────────────────────────────────────────
let ALL_DATA = [];
let filteredData = [];
let sortKey = 'date';
let sortDir = -1; // -1 = desc
let currentPage = 1;
const PAGE_SIZE = 50;
let logUnavailable = false;
// ─── FULL LOG ────────────────────────────────────────────────
const FULL_LOG_URL = 'data/k5bss-log.json';
async function loadFullLog() {
try {
const res = await fetch(FULL_LOG_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.warn('Could not load full log:', err);
return null;
}
}
async function init() {
const log = await loadFullLog();
logUnavailable = !log;
ALL_DATA = log || [];
filteredData = [...ALL_DATA];
if (logUnavailable) {
document.getElementById('log-error').hidden = false;
renderTable();
return;
}
// Update stats
const countries = new Set(ALL_DATA.map(r => r.country).filter(Boolean));
const states = new Set(ALL_DATA.filter(r => r.country === 'United States').map(r => r.state).filter(Boolean));
document.getElementById('stat-qso').textContent = ALL_DATA.length.toLocaleString();
document.getElementById('stat-dxcc').textContent = countries.size;
document.getElementById('stat-states').textContent = states.size;
buildDashboard();
populateFilters();
renderTable();
}
// ─── DASHBOARD ───────────────────────────────────────────────
function buildDashboard() {
// Modes
const modes = countBy(ALL_DATA, 'mode');
const topModes = Object.entries(modes).sort((a,b)=>b[1]-a[1]).slice(0,7);
const maxMode = topModes[0][1];
const modesEl = document.getElementById('modes-chart');
const modeColors = { FT8:'#ffaa00', FT4:'#ffcc44', DIG:'#ff8800', USB:'#4499ff', LSB:'#66aaff', SSB:'#88bbff', RTTY:'#ff4455' };
modesEl.innerHTML = topModes.map(([m, c]) => `
<div class="bar-row">
<div class="bar-label">${m}</div>
<div class="bar-track"><div class="bar-fill" style="width:0%;background:${modeColors[m]||'#888'}" data-target="${(c/maxMode*100).toFixed(1)}"></div></div>
<div class="bar-count">${c}</div>
</div>`).join('');
// Bands
const bands = countBy(ALL_DATA, 'band');
const topBands = Object.entries(bands).sort((a,b)=>b[1]-a[1]).slice(0,8);
const maxBand = topBands[0][1];
const bandEl = document.getElementById('bands-chart');
const bandColors = { '20M':'#4499ff','40M':'#00ff88','80M':'#ff4455','10M':'#00ddff','30M':'#88aaff','17M':'#aaddff','15M':'#ffaaff','12M':'#ffdd88' };
bandEl.innerHTML = topBands.map(([b, c]) => `
<div class="bar-row">
<div class="bar-label">${b}</div>
<div class="bar-track"><div class="bar-fill" style="width:0%;background:${bandColors[b]||'#888'}" data-target="${(c/maxBand*100).toFixed(1)}"></div></div>
<div class="bar-count">${c}</div>
</div>`).join('');
// Countries
const countries = countBy(ALL_DATA, 'country');
const topCountries = Object.entries(countries).sort((a,b)=>b[1]-a[1]).slice(0,20);
const ctEl = document.getElementById('countries-list');
ctEl.innerHTML = topCountries.map(([c,n]) => `
<div class="country-row">
<span class="country-name">${c||'Unknown'}</span>
<span class="country-count">${n}</span>
</div>`).join('');
// Animate bars after a tick
requestAnimationFrame(() => {
setTimeout(() => {
document.querySelectorAll('.bar-fill').forEach(el => {
el.style.width = el.dataset.target + '%';
});
}, 100);
});
// Activity chart
drawActivityChart();
}
function drawActivityChart() {
if (!ALL_DATA.length) return;
const canvas = document.getElementById('activityCanvas');
const dpr = window.devicePixelRatio || 1;
const W = canvas.parentElement.offsetWidth - 48;
const H = 120;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
// Group by year-month
const monthly = {};
ALL_DATA.forEach(r => {
if (!r.date) return;
const key = r.date.slice(0,7);
monthly[key] = (monthly[key]||0) + 1;
});
const keys = Object.keys(monthly).sort();
const vals = keys.map(k => monthly[k]);
const max = Math.max(...vals);
const pad = { l: 10, r: 10, t: 15, b: 30 };
const chartW = W - pad.l - pad.r;
const chartH = H - pad.t - pad.b;
const barW = Math.max(2, (chartW / keys.length) - 1);
ctx.clearRect(0,0,W,H);
// Grid line
ctx.strokeStyle = 'rgba(30,37,48,0.8)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad.l, pad.t + chartH);
ctx.lineTo(W - pad.r, pad.t + chartH);
ctx.stroke();
// Bars
keys.forEach((k, i) => {
const x = pad.l + i * (chartW / keys.length);
const h = (vals[i] / max) * chartH;
const y = pad.t + chartH - h;
const grad = ctx.createLinearGradient(0, y, 0, y + h);
grad.addColorStop(0, 'rgba(0,255,136,0.9)');
grad.addColorStop(1, 'rgba(0,255,136,0.15)');
ctx.fillStyle = grad;
ctx.fillRect(x, y, barW, h);
});
// X labels (every 6 months)
ctx.fillStyle = 'rgba(90,112,128,0.9)';
ctx.font = `${9 * dpr / dpr}px 'Share Tech Mono', monospace`;
ctx.textAlign = 'center';
keys.forEach((k, i) => {
if (i % 6 === 0) {
const x = pad.l + i * (chartW / keys.length) + barW/2;
ctx.fillText(k, x, H - 8);
}
});
}
// ─── TABLE ───────────────────────────────────────────────────
function populateFilters() {
const bands = [...new Set(ALL_DATA.map(r=>r.band).filter(Boolean))].sort();
const modes = [...new Set(ALL_DATA.map(r=>r.mode).filter(Boolean))].sort();
const bandSel = document.getElementById('filter-band');
const modeSel = document.getElementById('filter-mode');
bands.forEach(b => { const o = document.createElement('option'); o.value=b; o.text=b; bandSel.appendChild(o); });
modes.forEach(m => { const o = document.createElement('option'); o.value=m; o.text=m; modeSel.appendChild(o); });
}
function applyFilters() {
const q = document.getElementById('search-input').value.trim().toLowerCase();
const band = document.getElementById('filter-band').value;
const mode = document.getElementById('filter-mode').value;
const lotw = document.getElementById('filter-lotw').value;
filteredData = ALL_DATA.filter(r => {
if (band && r.band !== band) return false;
if (mode && r.mode !== mode) return false;
if (lotw && r.lotw !== lotw) return false;
if (q) {
const hay = [r.call, r.name, r.country, r.qth, r.state, r.grid].join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
currentPage = 1;
renderTable();
}
function sortTable(key) {
if (sortKey === key) sortDir *= -1;
else { sortKey = key; sortDir = key === 'date' || key === 'time' ? -1 : 1; }
filteredData.sort((a,b) => {
let av = a[key]||'', bv = b[key]||'';
if (key === 'dist') { av = parseInt(av)||0; bv = parseInt(bv)||0; }
if (av < bv) return -sortDir;
if (av > bv) return sortDir;
return 0;
});
// Update header indicators
document.querySelectorAll('thead th').forEach(th => {
th.classList.remove('sort-asc','sort-desc');
});
const headers = ['call','name','date','time','band','mode','rst_sent','country','dist','lotw'];
const idx = headers.indexOf(key);
if (idx >= 0) {
const ths = document.querySelectorAll('thead th');
ths[idx].classList.add(sortDir === 1 ? 'sort-asc' : 'sort-desc');
}
renderTable();
}
function renderTable() {
const tbody = document.getElementById('log-tbody');
const total = filteredData.length;
const pages = Math.ceil(total / PAGE_SIZE);
const start = (currentPage - 1) * PAGE_SIZE;
const slice = filteredData.slice(start, start + PAGE_SIZE);
document.getElementById('log-meta').textContent =
`${total.toLocaleString()} QSOs · Page ${currentPage} of ${Math.max(1,pages)}`;
if (slice.length === 0) {
const msg = logUnavailable ? 'LOG UNAVAILABLE — TRY AGAIN LATER' : 'NO CONTACTS FOUND';
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center;padding:2rem;color:var(--muted);font-family:var(--font-mono);font-size:0.8rem;">${msg}</td></tr>`;
} else {
tbody.innerHTML = slice.map(r => {
const timeStr = r.time ? r.time.slice(0,2)+':'+r.time.slice(2,4) : '';
const bandClass = `band-${r.band}`;
const lotwClass = r.lotw === 'Y' ? 'lotw-y' : 'lotw-n';
const lotwSym = r.lotw === 'Y' ? '✓' : '·';
const rst = r.rst_sent && r.rst_rcvd ? `${r.rst_sent} / ${r.rst_rcvd}` : r.rst_sent || '';
return `<tr>
<td class="call-cell">${esc(r.call)}</td>
<td>${esc(r.name ? titleCase(r.name) : '')}</td>
<td>${esc(r.date)}</td>
<td>${timeStr}Z</td>
<td><span class="band-badge ${bandClass}">${esc(r.band)}</span></td>
<td class="mode-cell">${esc(r.mode)}</td>
<td class="rst-cell">${esc(rst)}</td>
<td>${esc(r.country||'')}${r.state ? ' <span style="color:var(--muted)">'+esc(r.state)+'</span>' : ''}</td>
<td class="dist-cell">${r.dist ? Number(r.dist).toLocaleString() : ''}</td>
<td class="${lotwClass}" style="text-align:center">${lotwSym}</td>
</tr>`;
}).join('');
}
renderPagination(pages);
}
function renderPagination(pages) {
const el = document.getElementById('pagination');
if (pages <= 1) { el.innerHTML = ''; return; }
let html = `<button class="page-btn" onclick="goPage(${currentPage-1})" ${currentPage===1?'disabled':''}>‹ Prev</button>`;
const range = [];
if (pages <= 7) {
for (let i=1;i<=pages;i++) range.push(i);
} else {
range.push(1);
if (currentPage > 3) range.push('...');
for (let i=Math.max(2,currentPage-1); i<=Math.min(pages-1,currentPage+1); i++) range.push(i);
if (currentPage < pages-2) range.push('...');
range.push(pages);
}
range.forEach(p => {
if (p === '...') html += `<span class="page-info">…</span>`;
else html += `<button class="page-btn ${p===currentPage?'active':''}" onclick="goPage(${p})">${p}</button>`;
});
html += `<button class="page-btn" onclick="goPage(${currentPage+1})" ${currentPage===pages?'disabled':''}>Next ›</button>`;
el.innerHTML = html;
}
function goPage(p) {
currentPage = p;
renderTable();
document.querySelector('.table-wrap').scrollIntoView({behavior:'smooth',block:'nearest'});
}
// ─── HELPERS ─────────────────────────────────────────────────
function countBy(arr, key) {
return arr.reduce((acc, r) => {
const v = r[key];
if (v) acc[v] = (acc[v]||0)+1;
return acc;
}, {});
}
function esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function titleCase(s) {
return s.toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
}
function showSection(id, btn) {
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById('section-' + id).classList.add('active');
btn.classList.add('active');
}
window.addEventListener('load', init);
window.addEventListener('resize', () => {
if (document.getElementById('section-dashboard').classList.contains('active')) {
drawActivityChart();
}
});
+23
View File
@@ -0,0 +1,23 @@
// Highlight active nav link on scroll
const sections = document.querySelectorAll('section[id]');
const links = document.querySelectorAll('nav a');
const observer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
const active = document.querySelector(`nav a[href="#${e.target.id}"]`);
if (active) active.classList.add('active');
}
});
}, { threshold: 0.3 });
sections.forEach(s => observer.observe(s));
// Hide the profile photo if it fails to load
const profilePhoto = document.querySelector('.profile-photo');
if (profilePhoto) {
profilePhoto.addEventListener('error', () => {
profilePhoto.style.display = 'none';
});
}
Executable → Regular
+8 -867
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="referrer" content="strict-origin-when-cross-origin"> <meta name="referrer" content="strict-origin-when-cross-origin">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' https://cloudflareinsights.com; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests">
<title>K5BSS — Justin Frasier · Ham Radio Station</title> <title>K5BSS — Justin Frasier · Ham Radio Station</title>
<link rel="sitemap" type="application/xml" href="/sitemap.xml"> <link rel="sitemap" type="application/xml" href="/sitemap.xml">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='4' fill='%230f1218'/%3E%3Ctext x='3' y='11' font-family='IBM Plex Mono,Courier New,monospace' font-size='6' fill='%234a5568'%3E~$%3C/text%3E%3Ctext x='2' y='27' font-family='IBM Plex Mono,Courier New,monospace' font-size='19' font-weight='600' fill='%2322c55e'%3Ejf%3C/text%3E%3C/svg%3E"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='4' fill='%230f1218'/%3E%3Ctext x='3' y='11' font-family='IBM Plex Mono,Courier New,monospace' font-size='6' fill='%234a5568'%3E~$%3C/text%3E%3Ctext x='2' y='27' font-family='IBM Plex Mono,Courier New,monospace' font-size='19' font-weight='600' fill='%2322c55e'%3Ejf%3C/text%3E%3C/svg%3E">
@@ -16,570 +16,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&family=Barlow:wght@300;400;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&family=Barlow:wght@300;400;600&display=swap" rel="stylesheet">
<style> <link rel="stylesheet" href="css/k5bss.css">
:root {
--bg: #0a0c10;
--surface: #111418;
--surface2: #181c22;
--border: #1e2530;
--green: #00ff88;
--green2: #00cc6a;
--amber: #ffaa00;
--red: #ff4455;
--blue: #4499ff;
--cyan: #00ddff;
--text: #c8d8e8;
--muted: #5a7080;
--font-mono: 'Share Tech Mono', monospace;
--font-display: 'Orbitron', sans-serif;
--font-body: 'Barlow', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-weight: 300;
min-height: 100vh;
overflow-x: hidden;
}
/* Scanline overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 2px,
rgba(0,0,0,0.07) 2px,
rgba(0,0,0,0.07) 4px
);
pointer-events: none;
z-index: 9999;
}
/* ─── HEADER ─── */
header {
position: relative;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 2rem;
overflow: hidden;
}
header::after {
content: '';
position: absolute;
bottom: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--green), var(--cyan), transparent);
animation: scanH 4s linear infinite;
}
@keyframes scanH {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.header-inner {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 0;
gap: 2rem;
flex-wrap: wrap;
}
.callsign-block {
display: flex;
align-items: baseline;
gap: 1rem;
}
.callsign {
font-family: var(--font-display);
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 900;
color: var(--green);
text-shadow: 0 0 30px rgba(0,255,136,0.5), 0 0 60px rgba(0,255,136,0.2);
letter-spacing: 0.05em;
line-height: 1;
}
.op-name {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--muted);
letter-spacing: 0.15em;
}
.header-status {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.status-item {
text-align: center;
}
.status-val {
font-family: var(--font-display);
font-size: 1.6rem;
font-weight: 700;
color: var(--amber);
text-shadow: 0 0 20px rgba(255,170,0,0.4);
display: block;
}
.status-lbl {
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--muted);
letter-spacing: 0.2em;
text-transform: uppercase;
}
.online-badge {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--green);
padding: 0.4rem 1rem;
border: 1px solid var(--green);
border-radius: 2px;
background: rgba(0,255,136,0.05);
letter-spacing: 0.1em;
}
.pulse-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 8px var(--green);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
/* ─── NAV ─── */
nav {
background: var(--surface2);
border-bottom: 1px solid var(--border);
}
.nav-inner {
max-width: 1400px;
margin: 0 auto;
display: flex;
gap: 0;
padding: 0 2rem;
}
.nav-btn {
font-family: var(--font-mono);
font-size: 0.8rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
padding: 0.9rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.nav-btn:hover { color: var(--text); }
.nav-btn.active { color: var(--green); border-bottom-color: var(--green); }
/* ─── MAIN ─── */
main {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.section { display: none; }
.section.active { display: block; }
/* ─── DASHBOARD ─── */
.dash-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1.5rem;
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
}
.card.green::before { background: linear-gradient(90deg, var(--green), transparent); }
.card.amber::before { background: linear-gradient(90deg, var(--amber), transparent); }
.card.blue::before { background: linear-gradient(90deg, var(--blue), transparent); }
.card.cyan::before { background: linear-gradient(90deg, var(--cyan), transparent); }
.card.red::before { background: linear-gradient(90deg, var(--red), transparent); }
.card-title {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.2em;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 1rem;
}
.bar-chart { display: flex; flex-direction: column; gap: 0.5rem; }
.bar-row {
display: grid;
grid-template-columns: 80px 1fr 40px;
align-items: center;
gap: 0.5rem;
}
.bar-label {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text);
text-align: right;
}
.bar-track {
height: 16px;
background: var(--surface2);
border-radius: 2px;
overflow: hidden;
position: relative;
}
.bar-fill {
height: 100%;
border-radius: 2px;
transition: width 1s cubic-bezier(0.4,0,0.2,1);
}
.bar-count {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--muted);
}
.country-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 220px;
overflow-y: auto;
}
.country-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.3rem 0;
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 0.75rem;
}
.country-name { color: var(--text); }
.country-count {
color: var(--amber);
background: rgba(255,170,0,0.1);
padding: 0.1rem 0.4rem;
border-radius: 2px;
font-size: 0.7rem;
}
/* ─── ACTIVITY CHART ─── */
.activity-card { grid-column: 1 / -1; }
#activityCanvas {
width: 100%;
height: 120px;
display: block;
}
/* ─── LOGBOOK TABLE ─── */
.log-controls {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
align-items: center;
}
.search-input {
font-family: var(--font-mono);
font-size: 0.85rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
color: var(--text);
padding: 0.5rem 1rem;
flex: 1;
min-width: 200px;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus { border-color: var(--green); }
.search-input::placeholder { color: var(--muted); }
.filter-select {
font-family: var(--font-mono);
font-size: 0.8rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
color: var(--text);
padding: 0.5rem 0.75rem;
outline: none;
cursor: pointer;
}
.filter-select option { background: var(--surface2); }
.log-meta {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--muted);
margin-left: auto;
white-space: nowrap;
}
.table-wrap {
overflow-x: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
}
table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 0.8rem;
}
thead th {
background: var(--surface2);
padding: 0.75rem 1rem;
text-align: left;
color: var(--muted);
letter-spacing: 0.15em;
font-size: 0.7rem;
text-transform: uppercase;
border-bottom: 1px solid var(--border);
white-space: nowrap;
cursor: pointer;
user-select: none;
transition: color 0.2s;
}
thead th:hover { color: var(--green); }
thead th.sort-asc::after { content: ' ▲'; font-size: 0.6rem; }
thead th.sort-desc::after { content: ' ▼'; font-size: 0.6rem; }
tbody tr {
border-bottom: 1px solid rgba(30,37,48,0.5);
transition: background 0.15s;
}
tbody tr:hover { background: rgba(0,255,136,0.03); }
tbody td {
padding: 0.6rem 1rem;
color: var(--text);
white-space: nowrap;
}
.call-cell {
color: var(--cyan);
font-weight: 700;
font-size: 0.9rem;
letter-spacing: 0.05em;
}
.band-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 2px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.band-20M, .band-20m { background: rgba(68,153,255,0.15); color: var(--blue); }
.band-40M, .band-40m { background: rgba(0,255,136,0.12); color: var(--green); }
.band-80M, .band-80m { background: rgba(255,68,85,0.12); color: var(--red); }
.band-10M, .band-10m { background: rgba(0,221,255,0.12); color: var(--cyan); }
.band-default { background: rgba(90,112,128,0.2); color: var(--muted); }
.mode-cell { color: var(--amber); font-size: 0.75rem; letter-spacing: 0.1em; }
.lotw-y { color: var(--green); }
.lotw-n { color: var(--muted); }
.rst-cell { color: var(--text); font-size: 0.75rem; }
.dist-cell { color: var(--muted); font-size: 0.75rem; }
/* Pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1.5rem;
font-family: var(--font-mono);
font-size: 0.8rem;
flex-wrap: wrap;
}
.page-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 0.4rem 0.8rem;
border-radius: 2px;
cursor: pointer;
transition: all 0.2s;
}
.page-btn:hover { border-color: var(--green); color: var(--green); }
.page-btn.active { background: rgba(0,255,136,0.1); border-color: var(--green); color: var(--green); }
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.page-info { color: var(--muted); padding: 0.4rem 0.5rem; }
/* ─── ABOUT ─── */
.about-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
@media (max-width: 768px) {
.about-grid { grid-template-columns: 1fr; }
}
.info-block { margin-bottom: 1.5rem; }
.info-label {
font-family: var(--font-mono);
font-size: 0.65rem;
letter-spacing: 0.2em;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 0.3rem;
}
.info-value {
font-family: var(--font-body);
font-size: 1.1rem;
color: var(--text);
font-weight: 600;
}
.info-value a { color: var(--cyan); text-decoration: none; }
.info-value a:hover { text-decoration: underline; }
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.tag {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.1em;
padding: 0.3rem 0.7rem;
border: 1px solid var(--border);
border-radius: 2px;
color: var(--text);
background: var(--surface2);
}
/* ─── SCROLLBAR ─── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--surface2); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* ─── FOOTER ─── */
footer {
text-align: center;
padding: 2rem;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--muted);
border-top: 1px solid var(--border);
margin-top: 3rem;
letter-spacing: 0.15em;
}
footer span { color: var(--green); }
/* ─── LOADING ─── */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--muted);
}
.spinner {
width: 16px; height: 16px;
border: 2px solid var(--border);
border-top-color: var(--green);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 600px) {
.header-inner { padding: 1rem 0; }
.callsign { font-size: 2rem; }
.status-val { font-size: 1.2rem; }
main { padding: 1rem; }
.log-controls { gap: 0.5rem; }
.nav-btn { padding: 0.75rem 1rem; font-size: 0.7rem; }
}
</style>
</head> </head>
<body> <body>
@@ -593,7 +30,7 @@
</div> </div>
<div class="header-status"> <div class="header-status">
<div class="status-item"> <div class="status-item">
<span class="status-val" id="stat-qso">2,112</span> <span class="status-val" id="stat-qso">—</span>
<span class="status-lbl">QSOs Logged</span> <span class="status-lbl">QSOs Logged</span>
</div> </div>
<div class="status-item"> <div class="status-item">
@@ -622,6 +59,10 @@
<main> <main>
<div class="log-error" id="log-error" role="alert" hidden>
⚠ LOG UNAVAILABLE — The logbook could not be loaded. Try again later.
</div>
<!-- DASHBOARD --> <!-- DASHBOARD -->
<div id="section-dashboard" class="section active"> <div id="section-dashboard" class="section active">
<div class="dash-grid"> <div class="dash-grid">
@@ -776,306 +217,6 @@
LOGBOOK GENERATED FROM ADIF EXPORT LOGBOOK GENERATED FROM ADIF EXPORT
</footer> </footer>
<script> <script src="js/k5bss.js"></script>
// ─── DATA ───────────────────────────────────────────────────
const RAW_DATA = [{"call":"WE2M","name":"ROBERT A HOGUE","date":"2021-01-24","time":"0553","band":"40M","mode":"FT8","rst_sent":"-20","rst_rcvd":"-10","country":"United States","state":"WA","qth":"E WENATCHEE","lotw":"Y","dist":"2782","grid":"CN97UJ"},{"call":"WD8AXA","name":"CHARLES D AGOSTI","date":"2021-01-24","time":"0555","band":"40M","mode":"FT8","rst_sent":"-07","rst_rcvd":"-11","country":"United States","state":"MI","qth":"PORTAGE","lotw":"N","dist":"1291","grid":"EN72ef"},{"call":"KG4IXS","name":"RICHARD A VAUGHAN","date":"2021-01-24","time":"0558","band":"40M","mode":"FT8","rst_sent":"+00","rst_rcvd":"-13","country":"United States","state":"VA","qth":"CHATHAM","lotw":"Y","dist":"1390","grid":"FM06HU"},{"call":"KE0DC","name":"","date":"2021-01-24","time":"0600","band":"40M","mode":"FT8","rst_sent":"-08","rst_rcvd":"-09","country":"United States","state":"CO","qth":"AURORA","lotw":"Y","dist":"1274","grid":"DM79OQ"},{"call":"KC0HHN","name":"","date":"2021-01-24","time":"0604","band":"40M","mode":"FT8","rst_sent":"-12","rst_rcvd":"-08","country":"United States","state":"IA","qth":"","lotw":"N","dist":"","grid":"EN31"},{"call":"WB0OEW","name":"JAMES D VOGT","date":"2021-01-24","time":"0606","band":"40M","mode":"FT8","rst_sent":"-10","rst_rcvd":"-15","country":"United States","state":"MO","qth":"CAMDENTON","lotw":"Y","dist":"747","grid":"EM37TH"},{"call":"N4JKO","name":"GARY E HORTON","date":"2021-01-24","time":"0608","band":"40M","mode":"FT8","rst_sent":"-12","rst_rcvd":"-15","country":"United States","state":"TN","qth":"","lotw":"N","dist":"","grid":"EM65RT"},{"call":"WW4LL","name":"LESTER P LEEPER","date":"2021-01-24","time":"0610","band":"40M","mode":"FT8","rst_sent":"-14","rst_rcvd":"-13","country":"United States","state":"GA","qth":"BOWDON","lotw":"Y","dist":"575","grid":"EM73DS"},{"call":"K0MBT","name":"MICHAEL B TEDESCO","date":"2021-01-24","time":"0612","band":"40M","mode":"FT8","rst_sent":"-09","rst_rcvd":"-18","country":"United States","state":"MN","qth":"SHAKOPEE","lotw":"Y","dist":"1247","grid":"EN34HE"},{"call":"WB4LHD","name":"ROY A MCDANIEL","date":"2021-01-24","time":"0618","band":"40M","mode":"FT8","rst_sent":"-14","rst_rcvd":"-12","country":"United States","state":"NC","qth":"SANFORD","lotw":"Y","dist":"1118","grid":"FM03HF"},{"call":"N0KJA","name":"FRANK O KORANDA","date":"2021-01-24","time":"0620","band":"40M","mode":"FT8","rst_sent":"-10","rst_rcvd":"-14","country":"United States","state":"KS","qth":"WICHITA","lotw":"Y","dist":"641","grid":"EM17PT"},{"call":"W9JML","name":"JAMES M LAURENDEAU","date":"2021-01-24","time":"0623","band":"40M","mode":"FT8","rst_sent":"-05","rst_rcvd":"-16","country":"United States","state":"IL","qth":"COLLINSVILLE","lotw":"Y","dist":"668","grid":"EM58GI"},{"call":"K9ADV","name":"DWIGHT A VANDERLINDEN","date":"2021-01-24","time":"0625","band":"40M","mode":"FT8","rst_sent":"-11","rst_rcvd":"-13","country":"United States","state":"IN","qth":"INDIANAPOLIS","lotw":"Y","dist":"929","grid":"EM79MF"},{"call":"WB8YYY","name":"ERNEST E MUENZER","date":"2021-01-24","time":"0628","band":"40M","mode":"FT8","rst_sent":"-10","rst_rcvd":"-05","country":"United States","state":"MI","qth":"","lotw":"Y","dist":"","grid":"EN72"},{"call":"KD2UKS","name":"RAYMOND JOSEPH MCDERMOTT","date":"2021-01-24","time":"0641","band":"40M","mode":"FT8","rst_sent":"-12","rst_rcvd":"-15","country":"United States","state":"NY","qth":"MASTIC","lotw":"N","dist":"1561","grid":"FN30HT"},{"call":"KD8IYI","name":"JEFFREY C WHITLEY","date":"2021-01-24","time":"0644","band":"40M","mode":"FT8","rst_sent":"-13","rst_rcvd":"-13","country":"United States","state":"OH","qth":"","lotw":"Y","dist":"","grid":"EM89LG"},{"call":"K9DW","name":"DAN WELBORN","date":"2021-01-24","time":"0647","band":"40M","mode":"FT8","rst_sent":"-09","rst_rcvd":"-06","country":"United States","state":"AL","qth":"DOTHAN","lotw":"Y","dist":"435","grid":"EM64SA"},{"call":"K8DO","name":"DEAN L OSTERMAN","date":"2021-01-24","time":"0649","band":"40M","mode":"FT8","rst_sent":"-11","rst_rcvd":"-10","country":"United States","state":"MI","qth":"ALLENDALE","lotw":"Y","dist":"1298","grid":"EN62OX"},{"call":"K6VMV","name":"VICTOR M VALADEZ","date":"2021-01-24","time":"0651","band":"40M","mode":"FT8","rst_sent":"-08","rst_rcvd":"-12","country":"United States","state":"CA","qth":"MANTECA","lotw":"N","dist":"1954","grid":"CM97KC"},{"call":"W0ULF","name":"MIKE WOLFE","date":"2021-01-24","time":"0653","band":"40M","mode":"FT8","rst_sent":"-11","rst_rcvd":"-13","country":"United States","state":"NE","qth":"OMAHA","lotw":"Y","dist":"1012","grid":"EN21WC"}];
// We'll load more data dynamically
let ALL_DATA = [];
let filteredData = [];
let sortKey = 'date';
let sortDir = -1; // -1 = desc
let currentPage = 1;
const PAGE_SIZE = 50;
// ─── FULL DATA EMBEDDED ──────────────────────────────────────
// Data will be injected here
const FULL_LOG = [{"call":"WE2M","name":"ROBERT A HOGUE","date":"2021-01-24","time":"0553","band":"40M","mode":"FT8","rst_sent":"-20","rst_rcvd":"-10","country":"United States","state":"WA","qth":"E WENATCHEE","lotw":"Y","dist":"2782","grid":"CN97UJ"},{"call":"WD8AXA","name":"CHARLES D AGOSTI","date":"2021-01-24","time":"0555","band":"40M","mode":"FT8","rst_sent":"-07","rst_rcvd":"-11","country":"United States","state":"MI","qth":"PORTAGE","lotw":"N","dist":"1291","grid":"EN72ef"},{"call":"KG4IXS","name":"RICHARD A VAUGHAN","date":"2021-01-24","time":"0558","band":"40M","mode":"FT8","rst_sent":"+00","rst_rcvd":"-13","country":"United States","state":"VA","qth":"CHATHAM","lotw":"Y","dist":"1390","grid":"FM06HU"},{"call":"KE0DC","name":"DOUGLAS J COMER","date":"2021-01-24","time":"0600","band":"40M","mode":"FT8","rst_sent":"-08","rst_rcvd":"-09","country":"United States","state":"CO","qth":"AURORA","lotw":"Y","dist":"1274","grid":"DM79OQ"},{"call":"KC2DUX","name":"John W Hamann, Jr","date":"2021-01-24","time":"0608","band":"40M","mode":"FT8","rst_sent":"-11","rst_rcvd":"-11","country":"United States","state":"NJ","qth":"Dennisville","lotw":"N","dist":"1850","grid":"FM29oe"},{"call":"N3XLS","name":"Joseph C Raymer","date":"2021-01-24","time":"0613","band":"40M","mode":"FT8","rst_sent":"-07","rst_rcvd":"-15","country":"United States","state":"PA","qth":"North Abington Twp","lotw":"Y","dist":"1880","grid":"FN21DN"},{"call":"HK1O","name":"Juan Guillermo Ochoa P","date":"2021-01-24","time":"0617","band":"40M","mode":"FT8","rst_sent":"-01","rst_rcvd":"-15","country":"Colombia","state":"","qth":"Puerto Colombia","lotw":"Y","dist":"3071","grid":"FK21NA"},{"call":"WJ9E","name":"Michael D Elder","date":"2021-01-24","time":"0645","band":"40M","mode":"FT8","rst_sent":"+05","rst_rcvd":"-14","country":"United States","state":"IN","qth":"Rushville","lotw":"Y","dist":"1081","grid":"EM79GO"},{"call":"K8TE","name":"WILLIAM A MADER, JR","date":"2021-01-24","time":"0647","band":"40M","mode":"FT8","rst_sent":"+06","rst_rcvd":"-05","country":"United States","state":"NM","qth":"RIO RANCHO","lotw":"Y","dist":"1230","grid":"DM65QG"},{"call":"N1UL","name":"ULRICH L ROHDE, PhD","date":"2021-01-24","time":"0703","band":"40M","mode":"FT8","rst_sent":"-08","rst_rcvd":"-11","country":"United States","state":"FL","qth":"MARCO ISLAND","lotw":"Y","dist":"1373","grid":"EL95DW"},{"call":"K9IHM","name":"JERROLD L JOHNSON","date":"2021-01-24","time":"0708","band":"40M","mode":"FT8","rst_sent":"+11","rst_rcvd":"+07","country":"United States","state":"TN","qth":"NEW MARKET","lotw":"Y","dist":"1010","grid":"EM86FC"},{"call":"WB7WUQ","name":"Malcolm A Clark","date":"2021-01-24","time":"0710","band":"40M","mode":"FT8","rst_sent":"-11","rst_rcvd":"-10","country":"United States","state":"WA","qth":"Inchelium","lotw":"Y","dist":"2714","grid":"DN08RG"},{"call":"N6AKO","name":"Luis Dimagiba","date":"2021-01-24","time":"0717","band":"40M","mode":"FT8","rst_sent":"-13","rst_rcvd":"+05","country":"United States","state":"CA","qth":"Arleta","lotw":"Y","dist":"2299","grid":"DM04SG"},{"call":"KC2DUX","name":"John W Hamann, Jr","date":"2021-01-24","time":"0719","band":"40M","mode":"FT8","rst_sent":"-01","rst_rcvd":"-04","country":"United States","state":"NJ","qth":"Dennisville","lotw":"Y","dist":"1850","grid":"FM29OE"},{"call":"KA9FOX","name":"SCOTT E NEADER","date":"2021-01-24","time":"0721","band":"40M","mode":"FT8","rst_sent":"+04","rst_rcvd":"+03","country":"United States","state":"WI","qth":"LA CROSSE","lotw":"Y","dist":"1271","grid":"EN43KS"},{"call":"KI3N","name":"Kenneth A Yates","date":"2021-01-24","time":"0723","band":"40M","mode":"FT8","rst_sent":"+00","rst_rcvd":"-03","country":"United States","state":"AL","qth":"Oxford","lotw":"Y","dist":"742","grid":"EM73CN"},{"call":"KA4NIV","name":"MAURICE E RICE, JR","date":"2021-01-24","time":"0725","band":"40M","mode":"FT8","rst_sent":"-07","rst_rcvd":"+07","country":"United States","state":"SC","qth":"SIMPSONVILLE","lotw":"Y","dist":"1087","grid":"EM84UT"},{"call":"KB1EFS","name":"Steven J Henchy","date":"2021-01-24","time":"0727","band":"40M","mode":"FT8","rst_sent":"-07","rst_rcvd":"-06","country":"United States","state":"MA","qth":"Franklin","lotw":"Y","dist":"2230","grid":"FN42HC"},{"call":"NR5G","name":"LEE E SWANSON","date":"2021-01-24","time":"0728","band":"40M","mode":"FT8","rst_sent":"+01","rst_rcvd":"-06","country":"United States","state":"GA","qth":"WARNER ROBINS","lotw":"N","dist":"943","grid":"EM82ep"},{"call":"KO4ARA","name":"David W Hunt, Jr","date":"2021-01-24","time":"0733","band":"40M","mode":"FT8","rst_sent":"-09","rst_rcvd":"-05","country":"United States","state":"VA","qth":"Annandale","lotw":"Y","dist":"1642","grid":"FM18JT"},{"call":"N7FN","name":"FRANKLIN B NORAGON","date":"2021-01-26","time":"1334","band":"20M","mode":"FT8","rst_sent":"+03","rst_rcvd":"-09","country":"United States","state":"AZ","qth":"Buckeye","lotw":"N","dist":"1765","grid":"DM33qq"},{"call":"N0DOW","name":"Richard WLine truncated
function init() {
ALL_DATA = FULL_LOG.length ? FULL_LOG : RAW_DATA;
filteredData = [...ALL_DATA];
// Update stats
const countries = new Set(ALL_DATA.map(r => r.country).filter(Boolean));
const states = new Set(ALL_DATA.filter(r => r.country === 'United States').map(r => r.state).filter(Boolean));
document.getElementById('stat-dxcc').textContent = countries.size;
document.getElementById('stat-states').textContent = states.size;
buildDashboard();
populateFilters();
renderTable();
}
// ─── DASHBOARD ───────────────────────────────────────────────
function buildDashboard() {
// Modes
const modes = countBy(ALL_DATA, 'mode');
const topModes = Object.entries(modes).sort((a,b)=>b[1]-a[1]).slice(0,7);
const maxMode = topModes[0][1];
const modesEl = document.getElementById('modes-chart');
const modeColors = { FT8:'#ffaa00', FT4:'#ffcc44', DIG:'#ff8800', USB:'#4499ff', LSB:'#66aaff', SSB:'#88bbff', RTTY:'#ff4455' };
modesEl.innerHTML = topModes.map(([m, c]) => `
<div class="bar-row">
<div class="bar-label">${m}</div>
<div class="bar-track"><div class="bar-fill" style="width:0%;background:${modeColors[m]||'#888'}" data-target="${(c/maxMode*100).toFixed(1)}"></div></div>
<div class="bar-count">${c}</div>
</div>`).join('');
// Bands
const bands = countBy(ALL_DATA, 'band');
const topBands = Object.entries(bands).sort((a,b)=>b[1]-a[1]).slice(0,8);
const maxBand = topBands[0][1];
const bandEl = document.getElementById('bands-chart');
const bandColors = { '20M':'#4499ff','40M':'#00ff88','80M':'#ff4455','10M':'#00ddff','30M':'#88aaff','17M':'#aaddff','15M':'#ffaaff','12M':'#ffdd88' };
bandEl.innerHTML = topBands.map(([b, c]) => `
<div class="bar-row">
<div class="bar-label">${b}</div>
<div class="bar-track"><div class="bar-fill" style="width:0%;background:${bandColors[b]||'#888'}" data-target="${(c/maxBand*100).toFixed(1)}"></div></div>
<div class="bar-count">${c}</div>
</div>`).join('');
// Countries
const countries = countBy(ALL_DATA, 'country');
const topCountries = Object.entries(countries).sort((a,b)=>b[1]-a[1]).slice(0,20);
const ctEl = document.getElementById('countries-list');
ctEl.innerHTML = topCountries.map(([c,n]) => `
<div class="country-row">
<span class="country-name">${c||'Unknown'}</span>
<span class="country-count">${n}</span>
</div>`).join('');
// Animate bars after a tick
requestAnimationFrame(() => {
setTimeout(() => {
document.querySelectorAll('.bar-fill').forEach(el => {
el.style.width = el.dataset.target + '%';
});
}, 100);
});
// Activity chart
drawActivityChart();
}
function drawActivityChart() {
const canvas = document.getElementById('activityCanvas');
const dpr = window.devicePixelRatio || 1;
const W = canvas.parentElement.offsetWidth - 48;
const H = 120;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
// Group by year-month
const monthly = {};
ALL_DATA.forEach(r => {
if (!r.date) return;
const key = r.date.slice(0,7);
monthly[key] = (monthly[key]||0) + 1;
});
const keys = Object.keys(monthly).sort();
const vals = keys.map(k => monthly[k]);
const max = Math.max(...vals);
const pad = { l: 10, r: 10, t: 15, b: 30 };
const chartW = W - pad.l - pad.r;
const chartH = H - pad.t - pad.b;
const barW = Math.max(2, (chartW / keys.length) - 1);
ctx.clearRect(0,0,W,H);
// Grid line
ctx.strokeStyle = 'rgba(30,37,48,0.8)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad.l, pad.t + chartH);
ctx.lineTo(W - pad.r, pad.t + chartH);
ctx.stroke();
// Bars
keys.forEach((k, i) => {
const x = pad.l + i * (chartW / keys.length);
const h = (vals[i] / max) * chartH;
const y = pad.t + chartH - h;
const grad = ctx.createLinearGradient(0, y, 0, y + h);
grad.addColorStop(0, 'rgba(0,255,136,0.9)');
grad.addColorStop(1, 'rgba(0,255,136,0.15)');
ctx.fillStyle = grad;
ctx.fillRect(x, y, barW, h);
});
// X labels (every 6 months)
ctx.fillStyle = 'rgba(90,112,128,0.9)';
ctx.font = `${9 * dpr / dpr}px 'Share Tech Mono', monospace`;
ctx.textAlign = 'center';
keys.forEach((k, i) => {
if (i % 6 === 0) {
const x = pad.l + i * (chartW / keys.length) + barW/2;
ctx.fillText(k, x, H - 8);
}
});
}
// ─── TABLE ───────────────────────────────────────────────────
function populateFilters() {
const bands = [...new Set(ALL_DATA.map(r=>r.band).filter(Boolean))].sort();
const modes = [...new Set(ALL_DATA.map(r=>r.mode).filter(Boolean))].sort();
const bandSel = document.getElementById('filter-band');
const modeSel = document.getElementById('filter-mode');
bands.forEach(b => { const o = document.createElement('option'); o.value=b; o.text=b; bandSel.appendChild(o); });
modes.forEach(m => { const o = document.createElement('option'); o.value=m; o.text=m; modeSel.appendChild(o); });
}
function applyFilters() {
const q = document.getElementById('search-input').value.trim().toLowerCase();
const band = document.getElementById('filter-band').value;
const mode = document.getElementById('filter-mode').value;
const lotw = document.getElementById('filter-lotw').value;
filteredData = ALL_DATA.filter(r => {
if (band && r.band !== band) return false;
if (mode && r.mode !== mode) return false;
if (lotw && r.lotw !== lotw) return false;
if (q) {
const hay = [r.call, r.name, r.country, r.qth, r.state, r.grid].join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
currentPage = 1;
renderTable();
}
function sortTable(key) {
if (sortKey === key) sortDir *= -1;
else { sortKey = key; sortDir = key === 'date' || key === 'time' ? -1 : 1; }
filteredData.sort((a,b) => {
let av = a[key]||'', bv = b[key]||'';
if (key === 'dist') { av = parseInt(av)||0; bv = parseInt(bv)||0; }
if (av < bv) return -sortDir;
if (av > bv) return sortDir;
return 0;
});
// Update header indicators
document.querySelectorAll('thead th').forEach(th => {
th.classList.remove('sort-asc','sort-desc');
});
const headers = ['call','name','date','time','band','mode','rst_sent','country','dist','lotw'];
const idx = headers.indexOf(key);
if (idx >= 0) {
const ths = document.querySelectorAll('thead th');
ths[idx].classList.add(sortDir === 1 ? 'sort-asc' : 'sort-desc');
}
renderTable();
}
function renderTable() {
const tbody = document.getElementById('log-tbody');
const total = filteredData.length;
const pages = Math.ceil(total / PAGE_SIZE);
const start = (currentPage - 1) * PAGE_SIZE;
const slice = filteredData.slice(start, start + PAGE_SIZE);
document.getElementById('log-meta').textContent =
`${total.toLocaleString()} QSOs · Page ${currentPage} of ${Math.max(1,pages)}`;
if (slice.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:2rem;color:var(--muted);font-family:var(--font-mono);font-size:0.8rem;">NO CONTACTS FOUND</td></tr>';
} else {
tbody.innerHTML = slice.map(r => {
const timeStr = r.time ? r.time.slice(0,2)+':'+r.time.slice(2,4) : '';
const bandClass = `band-${r.band}`;
const lotwClass = r.lotw === 'Y' ? 'lotw-y' : 'lotw-n';
const lotwSym = r.lotw === 'Y' ? '✓' : '·';
const rst = r.rst_sent && r.rst_rcvd ? `${r.rst_sent} / ${r.rst_rcvd}` : r.rst_sent || '';
return `<tr>
<td class="call-cell">${esc(r.call)}</td>
<td>${esc(r.name ? titleCase(r.name) : '')}</td>
<td>${esc(r.date)}</td>
<td>${timeStr}Z</td>
<td><span class="band-badge ${bandClass}">${esc(r.band)}</span></td>
<td class="mode-cell">${esc(r.mode)}</td>
<td class="rst-cell">${esc(rst)}</td>
<td>${esc(r.country||'')}${r.state ? ' <span style="color:var(--muted)">'+esc(r.state)+'</span>' : ''}</td>
<td class="dist-cell">${r.dist ? Number(r.dist).toLocaleString() : ''}</td>
<td class="${lotwClass}" style="text-align:center">${lotwSym}</td>
</tr>`;
}).join('');
}
renderPagination(pages);
}
function renderPagination(pages) {
const el = document.getElementById('pagination');
if (pages <= 1) { el.innerHTML = ''; return; }
let html = `<button class="page-btn" onclick="goPage(${currentPage-1})" ${currentPage===1?'disabled':''}>‹ Prev</button>`;
const range = [];
if (pages <= 7) {
for (let i=1;i<=pages;i++) range.push(i);
} else {
range.push(1);
if (currentPage > 3) range.push('...');
for (let i=Math.max(2,currentPage-1); i<=Math.min(pages-1,currentPage+1); i++) range.push(i);
if (currentPage < pages-2) range.push('...');
range.push(pages);
}
range.forEach(p => {
if (p === '...') html += `<span class="page-info">…</span>`;
else html += `<button class="page-btn ${p===currentPage?'active':''}" onclick="goPage(${p})">${p}</button>`;
});
html += `<button class="page-btn" onclick="goPage(${currentPage+1})" ${currentPage===pages?'disabled':''}>Next ›</button>`;
el.innerHTML = html;
}
function goPage(p) {
currentPage = p;
renderTable();
document.querySelector('.table-wrap').scrollIntoView({behavior:'smooth',block:'nearest'});
}
// ─── HELPERS ─────────────────────────────────────────────────
function countBy(arr, key) {
return arr.reduce((acc, r) => {
const v = r[key];
if (v) acc[v] = (acc[v]||0)+1;
return acc;
}, {});
}
function esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function titleCase(s) {
return s.toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
}
function showSection(id, btn) {
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById('section-' + id).classList.add('active');
btn.classList.add('active');
}
window.addEventListener('load', init);
window.addEventListener('resize', () => {
if (document.getElementById('section-dashboard').classList.contains('active')) {
drawActivityChart();
}
});
</script>
</body> </body>
</html> </html>
Executable → Regular
View File
File mode changed.
Executable → Regular
View File
File mode changed.