gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit139fafd8139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre139fafd8/components/show.hl

13.2 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. //
  17. // Mission 047 (the creator, 2026-10-01): a caret at the season header's right edge (down = open, right = collapsed); episode
  18. // rows not indented; on a phone the title first, then the poster at full width, then genres/plot/cast; a Follow toggle
  19. // next to the title. SIGNED OUT the checks and Follow are visible too: a click opens the "sign in" modal (modal.hl, copied
  20. // from components.hybriel) and writes nothing.
  21. //
  22. // tracker.worldapi.org#12: link icons under the title — the show on TMDB, IMDb, TheTVDB, TVmaze (a movie: TMDB + IMDb),
  23. // only for the ids it has (shows.hl externalLinksOf), each in a new tab.
  24. parent './main.hl'
  25. import Modal from './modal.hl'
  26. import { appTitle } from '../project.hl'
  27. import { userIdOfSession, loginHref } from '../users.hl'
  28. import { isFollowing, setFollowed } from '../follows.hl'
  29. import { showBySlug, genreById, personById, seasonsOfShow, episodesOfSeason, seasonById, posterName, externalLinksOf } from '../shows.hl'
  30. import { watchesOfUser, isWatched, watchedSet, setWatched, setSeasonWatched, watchClassOf } from '../watches.hl'
  31. // ---- pure helpers ----------------------------------------------------------------------------
  32. // `static`, like calendar.hl's `soonOf` — a PLAIN instance member function that calls an
  33. // hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason, watchesOfUser, …) comes back null
  34. // when called from an `on server` face (confirmed by running the gate: "'buildRows' is not
  35. // callable — it holds null" at the face's call site, even though the SAME call works fine as a
  36. // member's own initializer at the top of this file). `static` is the proven-safe shape (calendar's
  37. // `soonOf`, called from `calLoad`/`calSettingsLoad`, an mpackdb-backed helper exactly like this one).
  38. static initialCollapsed = (seasons) => {
  39. let m = {}
  40. let i = 0
  41. while (i < seasons.length) {
  42. if (i > 0) { m[seasons[i].id] = true }
  43. i = i + 1
  44. }
  45. return m
  46. }
  47. static genreRowsOf = (show) => {
  48. let out = []
  49. for (gid of show.genres) {
  50. let g = genreById(gid)
  51. if (g != null) { out.push({ id = g.id name = g.name href = '/genre/' + g.urlSegment }) }
  52. }
  53. return out
  54. }
  55. static castRowsOf = (show) => {
  56. let out = []
  57. for (c of show.cast) {
  58. let p = personById(c.person)
  59. if (p != null) { out.push({ name = c.actor href = '/person/' + p.urlSegment }) }
  60. }
  61. return out
  62. }
  63. // a View attribute must be a literal, a member or a field path — never an inline expression — so
  64. // every ternary (watched -> icon class, collapsed -> row class) is resolved HERE, once, into a
  65. // plain field the View just reads (row.watchClass, row.rowClass); `watchClassOf` itself lives in
  66. // watches.hl (shared with /unwatched and /schedule, "reuse, don't copy")
  67. static buildRows = (show, uid, collapsed) => {
  68. let watched = watchedSet(uid != null ? watchesOfUser(uid) : [])
  69. let out = []
  70. for (s of seasonsOfShow(show)) {
  71. let isCollapsed = collapsed[s.id] == true
  72. let label = s.title != null && s.title != '' ? s.title : 'Season ' + s.seasonNumber
  73. let seasonWatched = watched['season:' + s.id] == true
  74. 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' })
  75. if (!isCollapsed) {
  76. for (e of episodesOfSeason(s)) {
  77. let epWatched = watched['episode:' + e.id] == true
  78. 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) })
  79. }
  80. }
  81. }
  82. return out
  83. }
  84. // a LOCAL static like buildRows (an imported mpackdb helper called straight from a member's initializer came back false
  85. // after a client-side navigation to the page — the gate's follow check)
  86. static followingOf = (show, uid) => { return uid != null && isFollowing(uid, show.id) }
  87. // ---- reactive members -----------------------------------------------------------------------
  88. slug = null
  89. session = null
  90. userId = userIdOfSession(session)
  91. signedIn = userId != null
  92. // SESSION SYNC (mission 047, Hybriel ff51cf46): a face taking `session` answers with a `sync` of every server site
  93. // derived from session, computed on a BLANK mount (no `slug`), and the browser then re-derives every member reading one
  94. // of them — `rows` via a local static that reaches mpackdb is refused there. So `rows` reads `me`, a plain copy the
  95. // browser re-derives to the same value (unchanged → nothing further re-runs), never `userId`/`session` directly.
  96. me = userId
  97. showRow = showBySlug(slug)
  98. found = showRow != null
  99. notFound = showRow == null
  100. // the Follow toggle: "Follow" = the inverted button (accent border), "Following" = the filled one
  101. following = found ? followingOf(showRow, me) : false
  102. followClass = following ? 'follow' : 'follow inverted'
  103. followText = following ? 'Following' : 'Follow'
  104. signInHref = loginHref
  105. title = found ? showRow.title : ''
  106. summary = found && showRow.summary != null ? showRow.summary : ''
  107. poster = found ? posterName(showRow) : null
  108. hasPoster = poster != null
  109. posterUrl = hasPoster ? '/posters/' + poster : ''
  110. genreRows = found ? genreRowsOf(showRow) : []
  111. hasGenres = genreRows.length > 0
  112. castRows = found ? castRowsOf(showRow) : []
  113. hasCast = castRows.length > 0
  114. linkRows = found ? externalLinksOf(showRow) : []
  115. hasLinks = linkRows.length > 0
  116. collapsed = found ? initialCollapsed(seasonsOfShow(showRow)) : {}
  117. rows = found ? buildRows(showRow, me, collapsed) : []
  118. hasRows = rows.length > 0
  119. __title = found ? title + ' — ' + appTitle : appTitle
  120. View {
  121. showPage {
  122. if (notFound) {
  123. article { class = "main"
  124. h1 { "Not found" }
  125. p { "This show doesn't exist." }
  126. }
  127. }
  128. if (found) {
  129. showHeader {
  130. showPoster { if (hasPoster) { img { src = posterUrl alt = title } } }
  131. showMeta {
  132. showTitle {
  133. h1 { title }
  134. button { id = "follow" type = "button" class = followClass followText on click(e) { emit toggleFollow(e) } }
  135. }
  136. if (hasLinks) {
  137. showLinks {
  138. for (l of linkRows) { a { class = "extlink" href = l.href target = "_blank" rel = "noopener" title = l.title "aria-label" = l.title "data-site" = l.site l.label } }
  139. }
  140. }
  141. if (hasGenres) {
  142. showGenres {
  143. for (g of genreRows) { a { class = "genre-pill" href = g.href g.name } }
  144. }
  145. }
  146. showSummary { summary }
  147. if (hasCast) {
  148. showCast {
  149. for (c of castRows) { a { class = "artist-link" href = c.href c.name } }
  150. }
  151. }
  152. }
  153. }
  154. if (hasRows) {
  155. showSeasons {
  156. for (row of rows) {
  157. if (row.isSeason) {
  158. seasonRow { class = row.rowClass
  159. button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark season watched"
  160. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  161. circle { cx = "12" cy = "12" r = "10" }
  162. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  163. }
  164. on click(e) { emit toggleSeason(row.id) }
  165. }
  166. button { type = "button" class = "season-toggle" value = row.id
  167. seasonLabel { row.label }
  168. seasonCount { row.episodeCountText }
  169. seasonCaret { "aria-hidden" = "true"
  170. svg { viewBox = "0 0 24 24" width = "20" height = "20"
  171. path { d = "M6 9 L12 15 L18 9" }
  172. }
  173. }
  174. on click(e) { emit toggleCollapse(row.id) }
  175. }
  176. }
  177. }
  178. if (row.isEpisode) {
  179. episodeRow {
  180. button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark episode watched"
  181. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  182. circle { cx = "12" cy = "12" r = "10" }
  183. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  184. }
  185. on click(e) { emit toggleEpisode(row.id) }
  186. }
  187. episodeNumber { row.episodeNumber }
  188. episodeTitle { row.title }
  189. }
  190. }
  191. }
  192. }
  193. }
  194. Modal { id = "signin" title = "Sign in"
  195. p { "You need to sign in to follow shows and mark episodes." }
  196. footer {
  197. button { type = "button" value = "close" "Close" }
  198. a { id = "signin-link" class = "button" href = signInHref "Sign in with ident" }
  199. }
  200. }
  201. }
  202. }
  203. }
  204. // Every rebuild of `rows` happens SERVER-SIDE, never by calling `buildRows` from a client handler:
  205. // a member function whose body reaches an hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason,
  206. // watchesOfUser, …) compiles to nothing on the client (no case ever assigns it — a genuine Hybriel
  207. // gap, filed under `issues`) even though the client bundle still calls it, so `this.buildRows(...)`
  208. // throws "not a function" in the browser. The client here only ever ASSIGNS the `rows` the server
  209. // already built (`rows = r.rows`) — never calls a tainted local helper itself. `slug` goes along so
  210. // the face can refetch the show without depending on implicit access to a sibling member.
  211. on toggleCollapse(seasonId) {
  212. let r = emit server showRows(slug, seasonId, collapsed)
  213. if (r == null || r.error != null) { return null }
  214. collapsed = r.collapsed
  215. rows = r.rows
  216. }
  217. // signed out: the modal, nothing is sent
  218. on toggleEpisode(episodeId) {
  219. if (!signedIn) { document.getElementById('signin').showModal() return null }
  220. let r = emit server showToggleEpisode(slug, '' + episodeId, collapsed)
  221. if (r == null || r.error != null) { return null }
  222. rows = r.rows
  223. }
  224. on toggleSeason(seasonId) {
  225. if (!signedIn) { document.getElementById('signin').showModal() return null }
  226. let r = emit server showToggleSeason(slug, '' + seasonId, collapsed)
  227. if (r == null || r.error != null) { return null }
  228. rows = r.rows
  229. }
  230. on toggleFollow(e) {
  231. if (!signedIn) { document.getElementById('signin').showModal() return null }
  232. let r = emit server showToggleFollow(slug)
  233. if (r == null || r.error != null) { return null }
  234. following = r.following
  235. followClass = r.following ? 'follow' : 'follow inverted'
  236. followText = r.following ? 'Following' : 'Follow'
  237. }
  238. // ---- the faces (the trailing `session` is always the server's: hybriel#16) ----
  239. on server showRows(slug, seasonId, collapsed, session) {
  240. let show = showBySlug(slug)
  241. if (show == null) { return { error = 'no such show' } }
  242. let m = collapsed
  243. m[seasonId] = collapsed[seasonId] == true ? false : true
  244. let uid = userIdOfSession(session)
  245. return { ok = true collapsed = m rows = buildRows(show, uid, m) }
  246. }
  247. on server showToggleEpisode(slug, episodeId, collapsed, session) {
  248. let show = showBySlug(slug)
  249. if (show == null) { return { error = 'no such show' } }
  250. let uid = userIdOfSession(session)
  251. if (uid == null) { return { error = 'you are not signed in' } }
  252. let rows = watchesOfUser(uid)
  253. let watched = isWatched(rows, 'episode', episodeId) == false
  254. let ok = setWatched(uid, 'episode', episodeId, rows, watched)
  255. if (!ok) { return { error = 'could not save the watch' } }
  256. return { ok = true rows = buildRows(show, uid, collapsed) }
  257. }
  258. on server showToggleSeason(slug, seasonId, collapsed, session) {
  259. let show = showBySlug(slug)
  260. if (show == null) { return { error = 'no such show' } }
  261. let uid = userIdOfSession(session)
  262. if (uid == null) { return { error = 'you are not signed in' } }
  263. let season = seasonById(seasonId)
  264. if (season == null) { return { error = 'no such season' } }
  265. let rows = watchesOfUser(uid)
  266. let watched = isWatched(rows, 'season', seasonId) == false
  267. let ok = setSeasonWatched(uid, seasonId, season.episodes, rows, watched)
  268. if (!ok) { return { error = 'could not save the watch' } }
  269. return { ok = true rows = buildRows(show, uid, collapsed) }
  270. }
  271. on server showToggleFollow(slug, session) {
  272. let show = showBySlug(slug)
  273. if (show == null) { return { error = 'no such show' } }
  274. let uid = userIdOfSession(session)
  275. if (uid == null) { return { error = 'you are not signed in' } }
  276. let follow = isFollowing(uid, show.id) == false
  277. if (!setFollowed(uid, show.id, follow)) { return { error = 'could not save the follow' } }
  278. return { ok = true following = follow }
  279. }

Branches

Latest commits

  • 139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre
  • 93be9476tracker#16: person pages /person/<slug> with the filmography fetched from TMDB on the first visit (step by step), gatemre
  • 47a3cae6STATUS: mission 053 merge commit idsmre
  • dcc5eecaMerge branch 't14-search'mre
  • 03edc783Merge branch 't15-tvmaze'mre
  • 71b46345tracker#15: numbering check by date or title, placeholder titles in other languages, docs + real-data proofmre
  • 6bb2daf1tracker#13: homepage (tiles, intro, latest movies/shows), /shows, /movies/page/N, /my/movies; lists cached in memorymre
  • b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
  • 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
  • 34f2c15btracker#15: TVmaze merge in the sync (gaps only: new episodes/seasons, empty titles/air dates; numbering check), fake TVmaze episodes + gatemre
  • cbdc4ea7tracker#12: link icons TMDB/IMDb/TVDB/TVmaze; sync fills missing ids (TVmaze lookup); movies fetched via /movie/mre
  • b105bcd8tracker#11: Hybriel master ff51cf46 (checks no longer vanish), mobile-first styles, carets, follow button, sign-in modal, inverted check, orange castmre
  • 31b758aatracker#10: installable app (manifest, service worker, offline shell), own icon + faviconmre
  • 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