gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit2fa9d9972fa9d997tracker#9: TMDB sync (followed shows: seasons, episodes, posters), tools/sync-tmdb.hl + daily run 04:00 UTC, fake TMDB in gatemre2fa9d997/components/show.hl

9.9 KB

  1. // components/show.hl — `/shows/:slug` (tracker.worldapi.org#4): a show's own page. Creator's words:
  2. // "the poster on the left, right of it the title of the show, the genre pills, below the plot summary,
  3. // below that the artists just as normal links, separated with comma color red, genres are blue" (the
  4. // header) — "then all seasons in reverse order, latest on top, below them collapsable their episodes,
  5. // episodes first the round check icon, if the episode was watched by the user looking its solid, if
  6. // not its regular, then the episode number, then the title. the last season is not collapsed. click on
  7. // the check adds a watch. the season also has a check, if the season is checked or unchecked all its
  8. // episodes get checked or unchecked. artists link to /person/<artist-slug> the genre to /genre/<genre-slug>."
  9. //
  10. // THE ROWS ARE ONE FLAT LIST, not a nested for-loop over seasons > episodes: the vendored build may
  11. // still lack the fix for hybriel#86 (a nested for over a reassigned outer list keeps stale inner
  12. // cells) — a single-level `for (row of rows)` with a `kind` field, reassigned whole on every click,
  13. // is the same proven-safe shape components/main.hl's own header and ident's inbox.hl already use.
  14. // `rows` is rebuilt from scratch (seasons/episodes refetched by id, watch rows refetched) on every
  15. // toggle — cheap: a show has at most a few dozen episodes per season.
  16. parent './main.hl'
  17. import { appTitle } from '../project.hl'
  18. import { userIdOfSession } from '../users.hl'
  19. import { showBySlug, genreById, personById, seasonsOfShow, episodesOfSeason, seasonById, posterName } from '../shows.hl'
  20. import { watchesOfUser, isWatched, watchedSet, setWatched, setSeasonWatched, watchClassOf } from '../watches.hl'
  21. // ---- pure helpers ----------------------------------------------------------------------------
  22. // `static`, like calendar.hl's `soonOf` — a PLAIN instance member function that calls an
  23. // hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason, watchesOfUser, …) comes back null
  24. // when called from an `on server` face (confirmed by running the gate: "'buildRows' is not
  25. // callable — it holds null" at the face's call site, even though the SAME call works fine as a
  26. // member's own initializer at the top of this file). `static` is the proven-safe shape (calendar's
  27. // `soonOf`, called from `calLoad`/`calSettingsLoad`, an mpackdb-backed helper exactly like this one).
  28. static initialCollapsed = (seasons) => {
  29. let m = {}
  30. let i = 0
  31. while (i < seasons.length) {
  32. if (i > 0) { m[seasons[i].id] = true }
  33. i = i + 1
  34. }
  35. return m
  36. }
  37. static genreRowsOf = (show) => {
  38. let out = []
  39. for (gid of show.genres) {
  40. let g = genreById(gid)
  41. if (g != null) { out.push({ id = g.id name = g.name href = '/genre/' + g.urlSegment }) }
  42. }
  43. return out
  44. }
  45. static castRowsOf = (show) => {
  46. let out = []
  47. for (c of show.cast) {
  48. let p = personById(c.person)
  49. if (p != null) { out.push({ name = c.actor href = '/person/' + p.urlSegment }) }
  50. }
  51. return out
  52. }
  53. // a View attribute must be a literal, a member or a field path — never an inline expression — so
  54. // every ternary (watched -> icon class, collapsed -> row class) is resolved HERE, once, into a
  55. // plain field the View just reads (row.watchClass, row.rowClass); `watchClassOf` itself lives in
  56. // watches.hl (shared with /unwatched and /schedule, "reuse, don't copy")
  57. static buildRows = (show, watchRows, collapsed) => {
  58. let watched = watchedSet(watchRows)
  59. let out = []
  60. for (s of seasonsOfShow(show)) {
  61. let isCollapsed = collapsed[s.id] == true
  62. let label = s.title != null && s.title != '' ? s.title : 'Season ' + s.seasonNumber
  63. let seasonWatched = watched['season:' + s.id] == true
  64. out.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' })
  65. if (!isCollapsed) {
  66. for (e of episodesOfSeason(s)) {
  67. let epWatched = watched['episode:' + e.id] == true
  68. out.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) })
  69. }
  70. }
  71. }
  72. return out
  73. }
  74. // ---- reactive members -----------------------------------------------------------------------
  75. slug = null
  76. session = null
  77. userId = userIdOfSession(session)
  78. signedIn = userId != null
  79. showRow = showBySlug(slug)
  80. found = showRow != null
  81. notFound = showRow == null
  82. title = found ? showRow.title : ''
  83. summary = found && showRow.summary != null ? showRow.summary : ''
  84. poster = found ? posterName(showRow) : null
  85. hasPoster = poster != null
  86. posterUrl = hasPoster ? '/posters/' + poster : ''
  87. genreRows = found ? genreRowsOf(showRow) : []
  88. hasGenres = genreRows.length > 0
  89. castRows = found ? castRowsOf(showRow) : []
  90. hasCast = castRows.length > 0
  91. collapsed = found ? initialCollapsed(seasonsOfShow(showRow)) : {}
  92. watchRows = found && signedIn ? watchesOfUser(userId) : []
  93. rows = found ? buildRows(showRow, watchRows, collapsed) : []
  94. hasRows = rows.length > 0
  95. __title = found ? title + ' — ' + appTitle : appTitle
  96. View {
  97. showPage {
  98. if (notFound) {
  99. article { class = "main"
  100. h1 { "Not found" }
  101. p { "This show doesn't exist." }
  102. }
  103. }
  104. if (found) {
  105. showHeader {
  106. showPoster { if (hasPoster) { img { src = posterUrl alt = title } } }
  107. showMeta {
  108. h1 { title }
  109. if (hasGenres) {
  110. showGenres {
  111. for (g of genreRows) { a { class = "genre-pill" href = g.href g.name } }
  112. }
  113. }
  114. showSummary { summary }
  115. if (hasCast) {
  116. showCast {
  117. for (c of castRows) { a { class = "artist-link" href = c.href c.name } }
  118. }
  119. }
  120. }
  121. }
  122. if (hasRows) {
  123. showSeasons {
  124. for (row of rows) {
  125. if (row.isSeason) {
  126. seasonRow { class = row.rowClass
  127. if (signedIn) {
  128. button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark season watched"
  129. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  130. circle { cx = "12" cy = "12" r = "10" }
  131. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  132. }
  133. on click(e) { emit toggleSeason(row.id) }
  134. }
  135. }
  136. button { type = "button" class = "season-toggle" value = row.id
  137. seasonLabel { row.label }
  138. seasonCount { row.episodeCountText }
  139. on click(e) { emit toggleCollapse(row.id) }
  140. }
  141. }
  142. }
  143. if (row.isEpisode) {
  144. episodeRow {
  145. if (signedIn) {
  146. button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark episode watched"
  147. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  148. circle { cx = "12" cy = "12" r = "10" }
  149. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  150. }
  151. on click(e) { emit toggleEpisode(row.id) }
  152. }
  153. }
  154. episodeNumber { row.episodeNumber }
  155. episodeTitle { row.title }
  156. }
  157. }
  158. }
  159. }
  160. }
  161. }
  162. }
  163. }
  164. // Every rebuild of `rows` happens SERVER-SIDE, never by calling `buildRows` from a client handler:
  165. // a member function whose body reaches an hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason,
  166. // watchesOfUser, …) compiles to nothing on the client (no case ever assigns it — a genuine Hybriel
  167. // gap, filed under `issues`) even though the client bundle still calls it, so `this.buildRows(...)`
  168. // throws "not a function" in the browser. The client here only ever ASSIGNS the `rows` the server
  169. // already built (`rows = r.rows`) — never calls a tainted local helper itself. `slug` goes along so
  170. // the face can refetch the show without depending on implicit access to a sibling member.
  171. on toggleCollapse(seasonId) {
  172. let r = emit server showRows(slug, seasonId, collapsed)
  173. if (r == null || r.error != null) { return null }
  174. collapsed = r.collapsed
  175. rows = r.rows
  176. }
  177. on toggleEpisode(episodeId) {
  178. if (!signedIn) { return null }
  179. let r = emit server showToggleEpisode(slug, '' + episodeId, collapsed)
  180. if (r == null || r.error != null) { return null }
  181. rows = r.rows
  182. }
  183. on toggleSeason(seasonId) {
  184. if (!signedIn) { return null }
  185. let r = emit server showToggleSeason(slug, '' + seasonId, collapsed)
  186. if (r == null || r.error != null) { return null }
  187. rows = r.rows
  188. }
  189. // ---- the faces (the trailing `session` is always the server's: hybriel#16) ----
  190. on server showRows(slug, seasonId, collapsed, session) {
  191. let show = showBySlug(slug)
  192. if (show == null) { return { error = 'no such show' } }
  193. let m = collapsed
  194. m[seasonId] = collapsed[seasonId] == true ? false : true
  195. let uid = userIdOfSession(session)
  196. let wr = uid != null ? watchesOfUser(uid) : []
  197. return { ok = true collapsed = m rows = buildRows(show, wr, m) }
  198. }
  199. on server showToggleEpisode(slug, episodeId, collapsed, session) {
  200. let show = showBySlug(slug)
  201. if (show == null) { return { error = 'no such show' } }
  202. let uid = userIdOfSession(session)
  203. if (uid == null) { return { error = 'you are not signed in' } }
  204. let rows = watchesOfUser(uid)
  205. let watched = isWatched(rows, 'episode', episodeId) == false
  206. let ok = setWatched(uid, 'episode', episodeId, rows, watched)
  207. if (!ok) { return { error = 'could not save the watch' } }
  208. return { ok = true rows = buildRows(show, watchesOfUser(uid), collapsed) }
  209. }
  210. on server showToggleSeason(slug, seasonId, collapsed, session) {
  211. let show = showBySlug(slug)
  212. if (show == null) { return { error = 'no such show' } }
  213. let uid = userIdOfSession(session)
  214. if (uid == null) { return { error = 'you are not signed in' } }
  215. let season = seasonById(seasonId)
  216. if (season == null) { return { error = 'no such season' } }
  217. let rows = watchesOfUser(uid)
  218. let watched = isWatched(rows, 'season', seasonId) == false
  219. let ok = setSeasonWatched(uid, seasonId, season.episodes, rows, watched)
  220. if (!ok) { return { error = 'could not save the watch' } }
  221. return { ok = true rows = buildRows(show, watchesOfUser(uid), collapsed) }
  222. }

Branches

Latest commits

  • 2fa9d997tracker#9: TMDB sync (followed shows: seasons, episodes, posters), tools/sync-tmdb.hl + daily run 04:00 UTC, fake TMDB in gatemre
  • 49e1f61edeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 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