tracker
All repositories: gitoria
9.9 KB
// components/show.hl — `/shows/:slug` (tracker.worldapi.org#4): a show's own page. Creator's words:// "the poster on the left, right of it the title of the show, the genre pills, below the plot summary,// below that the artists just as normal links, separated with comma color red, genres are blue" (the// header) — "then all seasons in reverse order, latest on top, below them collapsable their episodes,// episodes first the round check icon, if the episode was watched by the user looking its solid, if// not its regular, then the episode number, then the title. the last season is not collapsed. click on// the check adds a watch. the season also has a check, if the season is checked or unchecked all its// episodes get checked or unchecked. artists link to /person/<artist-slug> the genre to /genre/<genre-slug>."//// THE ROWS ARE ONE FLAT LIST, not a nested for-loop over seasons > episodes: the vendored build may// still lack the fix for hybriel#86 (a nested for over a reassigned outer list keeps stale inner// cells) — a single-level `for (row of rows)` with a `kind` field, reassigned whole on every click,// is the same proven-safe shape components/main.hl's own header and ident's inbox.hl already use.// `rows` is rebuilt from scratch (seasons/episodes refetched by id, watch rows refetched) on every// toggle — cheap: a show has at most a few dozen episodes per season.parent './main.hl'import { appTitle } from '../project.hl'import { userIdOfSession } from '../users.hl'import { showBySlug, genreById, personById, seasonsOfShow, episodesOfSeason, seasonById, posterName } from '../shows.hl'import { watchesOfUser, isWatched, watchedSet, setWatched, setSeasonWatched, watchClassOf } from '../watches.hl'// ---- pure helpers ----------------------------------------------------------------------------// `static`, like calendar.hl's `soonOf` — a PLAIN instance member function that calls an// hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason, watchesOfUser, …) comes back null// when called from an `on server` face (confirmed by running the gate: "'buildRows' is not// callable — it holds null" at the face's call site, even though the SAME call works fine as a// member's own initializer at the top of this file). `static` is the proven-safe shape (calendar's// `soonOf`, called from `calLoad`/`calSettingsLoad`, an mpackdb-backed helper exactly like this one).static initialCollapsed = (seasons) => {let m = {}let i = 0while (i < seasons.length) {if (i > 0) { m[seasons[i].id] = true }i = i + 1}return m}static genreRowsOf = (show) => {let out = []for (gid of show.genres) {let g = genreById(gid)if (g != null) { out.push({ id = g.id name = g.name href = '/genre/' + g.urlSegment }) }}return out}static castRowsOf = (show) => {let out = []for (c of show.cast) {let p = personById(c.person)if (p != null) { out.push({ name = c.actor href = '/person/' + p.urlSegment }) }}return out}// a View attribute must be a literal, a member or a field path — never an inline expression — so// every ternary (watched -> icon class, collapsed -> row class) is resolved HERE, once, into a// plain field the View just reads (row.watchClass, row.rowClass); `watchClassOf` itself lives in// watches.hl (shared with /unwatched and /schedule, "reuse, don't copy")static buildRows = (show, watchRows, collapsed) => {let watched = watchedSet(watchRows)let out = []for (s of seasonsOfShow(show)) {let isCollapsed = collapsed[s.id] == truelet label = s.title != null && s.title != '' ? s.title : 'Season ' + s.seasonNumberlet seasonWatched = watched['season:' + s.id] == trueout.push({ kind = 'season' isSeason = true isEpisode = false id = s.id label = label episodeCountText = s.episodes.length == 1 ? '1 episode' : s.episodes.length + ' episodes' watched = seasonWatched watchClass = watchClassOf(seasonWatched) collapsed = isCollapsed rowClass = isCollapsed ? 'collapsed' : 'expanded' })if (!isCollapsed) {for (e of episodesOfSeason(s)) {let epWatched = watched['episode:' + e.id] == trueout.push({ kind = 'episode' isSeason = false isEpisode = true id = e.id seasonId = s.id episodeNumber = e.episodeNumber title = e.title != null ? e.title : '' watched = epWatched watchClass = watchClassOf(epWatched) })}}}return out}// ---- reactive members -----------------------------------------------------------------------slug = nullsession = nulluserId = userIdOfSession(session)signedIn = userId != nullshowRow = showBySlug(slug)found = showRow != nullnotFound = showRow == nulltitle = found ? showRow.title : ''summary = found && showRow.summary != null ? showRow.summary : ''poster = found ? posterName(showRow) : nullhasPoster = poster != nullposterUrl = hasPoster ? '/posters/' + poster : ''genreRows = found ? genreRowsOf(showRow) : []hasGenres = genreRows.length > 0castRows = found ? castRowsOf(showRow) : []hasCast = castRows.length > 0collapsed = found ? initialCollapsed(seasonsOfShow(showRow)) : {}watchRows = found && signedIn ? watchesOfUser(userId) : []rows = found ? buildRows(showRow, watchRows, collapsed) : []hasRows = rows.length > 0__title = found ? title + ' — ' + appTitle : appTitleView {showPage {if (notFound) {article { class = "main"h1 { "Not found" }p { "This show doesn't exist." }}}if (found) {showHeader {showPoster { if (hasPoster) { img { src = posterUrl alt = title } } }showMeta {h1 { title }if (hasGenres) {showGenres {for (g of genreRows) { a { class = "genre-pill" href = g.href g.name } }}}showSummary { summary }if (hasCast) {showCast {for (c of castRows) { a { class = "artist-link" href = c.href c.name } }}}}}if (hasRows) {showSeasons {for (row of rows) {if (row.isSeason) {seasonRow { class = row.rowClassif (signedIn) {button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark season watched"svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"circle { cx = "12" cy = "12" r = "10" }path { d = "M7 12.5 L10.5 16 L17 8.5" }}on click(e) { emit toggleSeason(row.id) }}}button { type = "button" class = "season-toggle" value = row.idseasonLabel { row.label }seasonCount { row.episodeCountText }on click(e) { emit toggleCollapse(row.id) }}}}if (row.isEpisode) {episodeRow {if (signedIn) {button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark episode watched"svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"circle { cx = "12" cy = "12" r = "10" }path { d = "M7 12.5 L10.5 16 L17 8.5" }}on click(e) { emit toggleEpisode(row.id) }}}episodeNumber { row.episodeNumber }episodeTitle { row.title }}}}}}}}}// Every rebuild of `rows` happens SERVER-SIDE, never by calling `buildRows` from a client handler:// a member function whose body reaches an hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason,// watchesOfUser, …) compiles to nothing on the client (no case ever assigns it — a genuine Hybriel// gap, filed under `issues`) even though the client bundle still calls it, so `this.buildRows(...)`// throws "not a function" in the browser. The client here only ever ASSIGNS the `rows` the server// already built (`rows = r.rows`) — never calls a tainted local helper itself. `slug` goes along so// the face can refetch the show without depending on implicit access to a sibling member.on toggleCollapse(seasonId) {let r = emit server showRows(slug, seasonId, collapsed)if (r == null || r.error != null) { return null }collapsed = r.collapsedrows = r.rows}on toggleEpisode(episodeId) {if (!signedIn) { return null }let r = emit server showToggleEpisode(slug, '' + episodeId, collapsed)if (r == null || r.error != null) { return null }rows = r.rows}on toggleSeason(seasonId) {if (!signedIn) { return null }let r = emit server showToggleSeason(slug, '' + seasonId, collapsed)if (r == null || r.error != null) { return null }rows = r.rows}// ---- the faces (the trailing `session` is always the server's: hybriel#16) ----on server showRows(slug, seasonId, collapsed, session) {let show = showBySlug(slug)if (show == null) { return { error = 'no such show' } }let m = collapsedm[seasonId] = collapsed[seasonId] == true ? false : truelet uid = userIdOfSession(session)let wr = uid != null ? watchesOfUser(uid) : []return { ok = true collapsed = m rows = buildRows(show, wr, m) }}on server showToggleEpisode(slug, episodeId, collapsed, session) {let show = showBySlug(slug)if (show == null) { return { error = 'no such show' } }let uid = userIdOfSession(session)if (uid == null) { return { error = 'you are not signed in' } }let rows = watchesOfUser(uid)let watched = isWatched(rows, 'episode', episodeId) == falselet ok = setWatched(uid, 'episode', episodeId, rows, watched)if (!ok) { return { error = 'could not save the watch' } }return { ok = true rows = buildRows(show, watchesOfUser(uid), collapsed) }}on server showToggleSeason(slug, seasonId, collapsed, session) {let show = showBySlug(slug)if (show == null) { return { error = 'no such show' } }let uid = userIdOfSession(session)if (uid == null) { return { error = 'you are not signed in' } }let season = seasonById(seasonId)if (season == null) { return { error = 'no such season' } }let rows = watchesOfUser(uid)let watched = isWatched(rows, 'season', seasonId) == falselet ok = setSeasonWatched(uid, seasonId, season.episodes, rows, watched)if (!ok) { return { error = 'could not save the watch' } }return { ok = true rows = buildRows(show, watchesOfUser(uid), collapsed) }}
Branches
- mainmain branch
Latest commits
- 54070a4etracker#8: /my/unwatched + /my/schedule (301 from old), S01E01, title (year), 1 episode, watched-set lookup (unwatched 15s -> 1s)mre
- 3251488atracker#7: /my/shows (followed shows, newest follow first, poster, title, last watched SxxEyy); gate can take screenshots (TRACKER_GATE_SHOTS)mre
- 44b7d9f9tracker#6: /schedule — upcoming episodes of followed shows, soonest firstmre
- 91c9fc8ctracker#5: /unwatched — unwatched released episodes of followed shows, newest firstmre
- a97c0295tracker#4: show page /shows/:slug (header, seasons, episodes, watch checks) + tools/relink-episode-seasons.hlmre
- 05f407c5tracker: no border on any button except inverted ones (Log out, ident status and identities too); header brand weight 100mre
- 17375427tracker#2: tools/migrate.hl + tools/verify.hl — old MongoDB data into mpackdb with new idsmre
- 31aac936tracker#3: no border on the header and on filled buttons; inverted buttons keep theirsmre
- 2ad9d29ctracker#1: login exactly like calendar (identity selector in the header, empty homepage)mre
- 3691e176tracker#1: empty tracker with the ident login (state of 2026-09-27)mre