feat: mobile UI card layout for players and courses (#16)
This commit is contained in:
@@ -19,6 +19,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile tab pill (visible only on mobile via CSS) -->
|
||||
<div class="m-tab-pill">
|
||||
<button class="m-tab-pill__btn m-tab-pill__btn--active" type="button">Find courses</button>
|
||||
<button class="m-tab-pill__btn m-tab-pill__btn--disabled" type="button" disabled>Import from Tjing</button>
|
||||
</div>
|
||||
|
||||
<div id="courses-table" hx-get="/partials/course-table" hx-trigger="load"></div>
|
||||
`; %>
|
||||
|
||||
|
||||
@@ -78,6 +78,20 @@
|
||||
|
||||
<!-- Footnote -->
|
||||
<p class="footnote">Unofficial PDGA rating tracker. Ratings scraped from pdga.com on each refresh.</p>
|
||||
|
||||
<!-- Mobile sticky add-bar (visible only on mobile via CSS) -->
|
||||
<form class="mobile-add-bar" onsubmit="searchAndAddPlayerMobile(event)">
|
||||
<input
|
||||
id="pdga-number-input-mobile"
|
||||
name="pdga"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="PDGA #"
|
||||
oninput="this.value = this.value.replace(/\\D/g,'')"
|
||||
aria-label="PDGA number"
|
||||
/>
|
||||
<button type="submit" class="btn-primary">Add</button>
|
||||
</form>
|
||||
`; %>
|
||||
|
||||
<% var modals = `
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<%
|
||||
var _query = (typeof query !== 'undefined') ? query : null;
|
||||
var _total = (typeof total !== 'undefined') ? total : courses.length;
|
||||
%>
|
||||
|
||||
<div class="mobile-section-head">
|
||||
<span class="kicker">Showing <%= courses.length %> of <%= _total %></span>
|
||||
<a href="/courses">View all →</a>
|
||||
</div>
|
||||
|
||||
<% if (courses.length === 0) { %>
|
||||
<p style="text-align: center; color: var(--ink-3); padding: 40px 0;">No courses found.</p>
|
||||
<% } else { %>
|
||||
<div class="mobile-list">
|
||||
<% courses.forEach(function(course) {
|
||||
var lastUpdated = new Date(course.last_updated).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
var layoutCount = course.layoutCount || 0;
|
||||
%>
|
||||
<div class="m-course-card" id="m-course-<%= course.id %>" onclick="toggleMobileCourseLayouts(<%= course.id %>)">
|
||||
<div class="m-course-card__head">
|
||||
<div class="m-course-card__name-stack">
|
||||
<div class="m-course-name-row">
|
||||
<span class="m-course-name"><%= course.name %></span>
|
||||
<% if (layoutCount > 0) { %>
|
||||
<span class="m-layouts-pill"><%= layoutCount %> layout<%= layoutCount !== 1 ? 's' : '' %></span>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="m-course-card__meta"><%= course.city %> · <%= lastUpdated %></div>
|
||||
</div>
|
||||
<span class="m-chevron">▼</span>
|
||||
</div>
|
||||
|
||||
<div class="m-course-card__expand">
|
||||
<div id="m-layouts-container-<%= course.id %>" class="layouts-container">
|
||||
<div class="no-layouts">Tap to load layouts...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } %>
|
||||
@@ -43,4 +43,5 @@
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
<%- include('course-cards', { courses: courses, query: locals.query, total: locals.total }) %>
|
||||
<% } %>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title><%= title %></title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@@ -10,6 +10,7 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300..800;1,300..800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
<link rel="stylesheet" href="/css/mobile.css">
|
||||
<% if (typeof cssFiles !== 'undefined') { %>
|
||||
<% cssFiles.forEach(function(file) { %>
|
||||
<link rel="stylesheet" href="/css/<%= file %>">
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<form class="mobile-add-bar" onsubmit="searchAndAddPlayerMobile(event)">
|
||||
<input
|
||||
id="pdga-number-input-mobile"
|
||||
name="pdga"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="PDGA #"
|
||||
oninput="this.value = this.value.replace(/\D/g,'')"
|
||||
aria-label="PDGA number"
|
||||
/>
|
||||
<button type="submit" class="btn-primary">Add</button>
|
||||
</form>
|
||||
@@ -0,0 +1,120 @@
|
||||
<%
|
||||
// Mobile sparkline helper — parametrised, used only in this partial
|
||||
function renderSparkline(values, opts) {
|
||||
opts = opts || {};
|
||||
var w = opts.w || 70;
|
||||
var h = opts.h || 26;
|
||||
if (!values || values.length < 2) return '';
|
||||
var min = Math.min.apply(null, values);
|
||||
var max = Math.max.apply(null, values);
|
||||
var range = max - min || 1;
|
||||
var xStep = w / (values.length - 1);
|
||||
|
||||
var pts = values.map(function(v, i) {
|
||||
return {
|
||||
x: (i * xStep).toFixed(1),
|
||||
y: (((max - v) / range) * (h - 4) + 2).toFixed(1)
|
||||
};
|
||||
});
|
||||
|
||||
var linePath = pts.map(function(p, i) {
|
||||
return (i === 0 ? 'M' : 'L') + ' ' + p.x + ' ' + p.y;
|
||||
}).join(' ');
|
||||
|
||||
var last = pts[pts.length - 1];
|
||||
var areaPath = linePath + ' L ' + last.x + ' ' + h + ' L 0 ' + h + ' Z';
|
||||
|
||||
return '<svg width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '" class="m-chart-spark" aria-hidden="true">' +
|
||||
'<path d="' + areaPath + '" style="fill:var(--accent);fill-opacity:0.10"/>' +
|
||||
'<path d="' + linePath + '" style="stroke:var(--accent);stroke-width:1.5;fill:none;stroke-linejoin:round;stroke-linecap:round"/>' +
|
||||
'<circle cx="' + last.x + '" cy="' + last.y + '" r="2.5" style="fill:var(--accent)"/>' +
|
||||
'</svg>';
|
||||
}
|
||||
%>
|
||||
|
||||
<div class="mobile-section-head">
|
||||
<span class="kicker">TRACKED PLAYERS · <%= ratings.length %></span>
|
||||
<button id="trendchart-toggle-mobile" class="pill-button" type="button" aria-pressed="false">Trend chart</button>
|
||||
</div>
|
||||
|
||||
<% if (ratings.length === 0) { %>
|
||||
<p style="text-align: center; color: var(--ink-3); padding: 40px 0;">No players tracked yet.</p>
|
||||
<% } else { %>
|
||||
<div class="mobile-list">
|
||||
<% ratings.forEach(function(player, index) {
|
||||
var sparkSvg = renderSparkline(player.monthlyHistory || [], { w: 70, h: 26 });
|
||||
var isFirst = index === 0;
|
||||
var rank = index + 1;
|
||||
|
||||
var ratingIsNull = (player.rating == null);
|
||||
var ratingCls = ratingIsNull ? 'flat' : (player.ratingChange > 0 ? 'up' : player.ratingChange < 0 ? 'down' : 'flat');
|
||||
var ratingGlyph = (ratingIsNull || player.ratingChange === 0) ? '–' : (player.ratingChange > 0 ? '▲' : '▼');
|
||||
var ratingNum = ratingIsNull ? '—' : (player.ratingChange > 0 ? '+' + player.ratingChange : String(player.ratingChange));
|
||||
|
||||
var predIsNull = (player.predictedRating == null);
|
||||
var predCls = predIsNull ? 'flat' : (player.deltaPredicted > 0 ? 'up' : player.deltaPredicted < 0 ? 'down' : 'flat');
|
||||
var predGlyph = (predIsNull || player.deltaPredicted === 0) ? '–' : (player.deltaPredicted > 0 ? '▲' : '▼');
|
||||
var predNum = predIsNull ? '—' : (player.deltaPredicted > 0 ? '+' + player.deltaPredicted : String(player.deltaPredicted));
|
||||
%>
|
||||
<div class="m-card" id="m-card-<%= player.pdgaNumber %>" onclick="toggleMobilePlayerCard(<%= player.pdgaNumber %>)">
|
||||
<div class="m-card__head">
|
||||
<div class="m-rank-chip<%= isFirst ? ' m-rank-chip--first' : '' %>"><%= rank %></div>
|
||||
<div class="m-card__name-stack">
|
||||
<span class="m-player-name"><%= player.name %></span>
|
||||
<span class="m-pdga-num">#<%= player.pdgaNumber %></span>
|
||||
</div>
|
||||
<span class="m-chevron">▼</span>
|
||||
</div>
|
||||
|
||||
<div class="m-card__body">
|
||||
<div class="m-card__stats">
|
||||
<div class="m-stat-row">
|
||||
<span class="m-stat-label">RATING</span>
|
||||
<span class="m-num"><%= player.rating || '—' %></span>
|
||||
<span class="delta-pill <%= ratingCls %>"><span class="delta-glyph"><%= ratingGlyph %></span><span class="delta-num"><%= ratingNum %></span></span>
|
||||
</div>
|
||||
<div class="m-stat-row">
|
||||
<span class="m-stat-label">PREDICTED</span>
|
||||
<span class="m-num m-num--predicted"><%= player.predictedRating || '—' %></span>
|
||||
<span class="delta-pill <%= predCls %> delta-predicted-pill"><span class="delta-glyph"><%= predGlyph %></span><span class="delta-num"><%= predNum %></span></span>
|
||||
</div>
|
||||
</div>
|
||||
<% if (sparkSvg) { %>
|
||||
<div class="m-card__sparkline"><span class="sparkline"><%- sparkSvg %></span></div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<div class="m-card__expand">
|
||||
<% if (player.ratingHistory && player.ratingHistory.length > 0) { %>
|
||||
<div class="player-chart m-chart"
|
||||
data-variant="mobile"
|
||||
data-history='<%- JSON.stringify(player.ratingHistory) %>'>
|
||||
</div>
|
||||
<% } %>
|
||||
<dl class="m-detail-grid">
|
||||
<div>
|
||||
<dt>Current rating</dt>
|
||||
<dd><%= player.rating || '—' %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last month</dt>
|
||||
<dd><%= player.lastMonthRating || '—' %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Change vs last month</dt>
|
||||
<dd><span class="delta-pill <%= ratingCls %>"><span class="delta-glyph"><%= ratingGlyph %></span><span class="delta-num"><%= ratingNum %></span></span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Predicted next update</dt>
|
||||
<dd><%= player.predictedRating || '—' %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Gap to predicted</dt>
|
||||
<dd><span class="delta-pill <%= predCls %> delta-predicted-pill"><span class="delta-glyph"><%= predGlyph %></span><span class="delta-num"><%= predNum %></span></span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } %>
|
||||
@@ -1,7 +1,9 @@
|
||||
<%
|
||||
function renderSparkline(values) {
|
||||
function renderSparkline(values, opts) {
|
||||
opts = opts || {};
|
||||
var w = opts.w || 96;
|
||||
var h = opts.h || 28;
|
||||
if (!values || values.length < 2) return '';
|
||||
var w = 96, h = 28;
|
||||
var min = Math.min.apply(null, values);
|
||||
var max = Math.max.apply(null, values);
|
||||
var range = max - min || 1;
|
||||
@@ -104,4 +106,5 @@ function renderSparkline(values) {
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
<%- include('ratings-cards', { ratings: ratings }) %>
|
||||
<% } %>
|
||||
|
||||
@@ -43,4 +43,41 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar__mobile">
|
||||
<div class="topbar__mobile-row1">
|
||||
<a href="/" class="topbar__mobile-brand">
|
||||
<span class="topbar__mobile-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 17 L9 11 L13 15 L21 6" />
|
||||
<path d="M14 6 L21 6 L21 13" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="topbar__mobile-brand-text">
|
||||
<span class="topbar__mobile-title">Rating Tracker</span>
|
||||
<span class="topbar__mobile-sub">Disc golf · unofficial</span>
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
class="topbar__mobile-refresh"
|
||||
type="button"
|
||||
hx-post="/api/refresh-all"
|
||||
hx-vals='{"page": "<%= activePage %>"}'
|
||||
hx-target="#topbar"
|
||||
hx-swap="outerHTML"
|
||||
hx-disabled-elt="this"
|
||||
title="Refresh all"
|
||||
aria-label="Refresh all"
|
||||
>
|
||||
<span class="topbar__refresh-icon" aria-hidden="true">↻</span>
|
||||
<span class="topbar__refresh-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="topbar__mobile-row2">
|
||||
<nav class="topbar__mobile-nav" aria-label="Primary">
|
||||
<a href="/" class="<%= activePage === 'players' ? 'active' : '' %>">Players</a>
|
||||
<a href="/courses" class="<%= activePage === 'courses' ? 'active' : '' %>">Courses</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user