feat: redesign Courses page with tabs + restore Tjing import (#8)

- Restore src/scrapers/tjing.js with AbortController timeout (8s),
  error-object returns, and verbatim GraphQL queries
- Add getOrCreateLayout() to src/models/course.js
- New /api/tjing/search and /api/tjing/import/:tjingId routes;
  course-table route now includes layoutCount/activeLayoutCount via
  LEFT JOIN aggregation
- Rewrite courses.ejs: action-card with Find/Import tabs, results bar,
  HTMX course-table-region with body:refresh trigger
- Rewrite course-table.ejs: CSS-grid div structure replacing <table>,
  lazy-load expanded layouts via JS htmx.ajax
- Rewrite course-layouts.ejs: layout-card chips with rating tier colouring,
  collapsible inactive layouts section
- Rewrite courses.js: tab switching, live client-side filter, count display,
  Tjing search/import using DOM API (no innerHTML with untrusted data)
- Rewrite courses.css: full new design system using project tokens
This commit is contained in:
Samuel Enocsson
2026-05-25 09:39:44 +02:00
parent f4c5e963d2
commit 4bbf6d9728
8 changed files with 1007 additions and 311 deletions
+27 -37
View File
@@ -1,43 +1,33 @@
<% var body = `
<div class="card-section">
<h3>Find Courses</h3>
<div class="card-section-form">
<input
type="text"
class="input"
id="course-search"
name="q"
placeholder="Search courses by name or city..."
hx-get="/partials/course-table"
hx-trigger="input changed delay:300ms, search"
hx-target="#courses-table"
style="width: 340px;"
/>
<button class="btn" onclick="scrapeCourses()" id="scrape-courses-btn">
<i class="fas fa-sync-alt"></i> Scrape Courses
</button>
</div>
<main class="page-courses">
<section class="action-card">
<div class="action-card-tabs" role="tablist">
<button class="action-tab is-active" role="tab" aria-selected="true" data-tab="find" id="tab-find">Find courses</button>
<button class="action-tab" role="tab" aria-selected="false" data-tab="tjing" id="tab-tjing">Import from Tjing</button>
</div>
<div class="action-card-body">
<div class="action-pane is-active" id="tab-pane-find" role="tabpanel" aria-labelledby="tab-find">
<input type="text" id="course-filter-input" placeholder="Find a course…" autocomplete="off">
<p class="action-hint">Filters the list below as you type.</p>
</div>
<div class="action-pane" id="tab-pane-tjing" role="tabpanel" aria-labelledby="tab-tjing" hidden>
<div class="tjing-search-row">
<input type="text" id="tjing-search-input" placeholder="Search Tjing courses…" autocomplete="off">
<button id="tjing-search-btn" class="btn-primary" onclick="searchTjing()">Search Tjing</button>
</div>
<p class="action-hint">Find and import Swedish courses from tjing.se.</p>
<div id="tjing-results"></div>
</div>
</div>
</section>
<!-- 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 class="results-bar">
<span class="results-count">Showing <strong id="visible-count">0</strong> of <strong id="total-count">0</strong> courses</span>
<a class="results-link" href="#">View all &rarr;</a>
</div>
<!-- Mobile search input (hidden on desktop, shown on mobile via CSS) -->
<input
type="text"
class="m-search-input"
id="course-search-mobile"
name="q"
placeholder="Search courses by name or city..."
hx-get="/partials/course-table"
hx-trigger="input changed delay:300ms, search"
hx-target="#courses-table"
/>
<div id="courses-table" hx-get="/partials/course-table" hx-trigger="load"></div>
<div id="course-table-region" hx-get="/partials/course-table" hx-trigger="load, refresh from:body" hx-swap="innerHTML"></div>
</main>
`; %>
<%- include('../partials/layout', {
@@ -46,4 +36,4 @@
cssFiles: ['courses.css'],
jsFiles: ['courses.js'],
body: body
}) %>
}) %>