gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit20e09d8920e09d89Merge t18 (tracker#18 delta sync) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs, both sides kept; pageShowOf summary = summaryOfmre20e09d89/components/show.hl

37.3 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#17 (mission 057): the caret points DOWN while a season is closed and UP while it is open. Opening a
  23. // season shows SKELETON rows at once (min(episodes, 8), a CSS shimmer, styles.hl) and then its episodes from the face
  24. // `showSeasonEpisodes`, which takes NO session: a face with `session` makes hl:web re-mount the whole page on the server and
  25. // ship every session-derived member back as a `sync` (WebFramework.hl `inbound`) — that, not the episodes, was most of the
  26. // old 250–450 ms (real data). The watched state comes from `watchedEpisodes` (this show's watched episodes per season, rendered
  27. // with the page and re-synced after every watch face). Closing is client-only (no request). An ADULT show (only its
  28. // followers see it) keeps the session face `showRows`: the public face refuses adult seasons.
  29. //
  30. // tracker.worldapi.org#12: link icons under the title — the show on TMDB, IMDb, TheTVDB, TVmaze (a movie: TMDB + IMDb),
  31. // only for the ids it has (shows.hl externalLinksOf), each in a new tab.
  32. //
  33. // mission 058: #22 every episode row shows its air date (`release`, 'YYYY-MM-DD' like /my/unwatched; none → empty). #23 a
  34. // season's check is DERIVED: solid exactly when every one of its episodes is watched (a season without episodes: its own
  35. // season watch) — unchecking one episode turns the season off with the face's answer, checking it again turns it on; the
  36. // season click watches all when not all are watched, else unwatches all. #24 a MOVIE's page has its own watched check
  37. // (watch `targetType = 'movie'`, target = the title's id; face `showToggleMovie`), signed out → the sign-in modal.
  38. //
  39. // tracker.worldapi.org#26: an INCOMPLETE title (one a person page added from a filmography — details.hl `needsDetails`) shows
  40. // what it has plus skeletons (poster, cast line, season rows for a series) and completes itself WITHOUT a reload: the
  41. // invisible `showProbe` (endless short CSS animation — hl:web has no mount hook, the person page's trick) calls the face
  42. // `showComplete` until it is done — one step per call (details.hl completeStep: TMDB details + ids + cast; a series' seasons/
  43. // episodes in parts; poster, TVmaze) — and the last answer's parts replace the skeletons. No session (no page re-mount); adult
  44. // refused.
  45. //
  46. // tracker.worldapi.org#28: the WHOLE cast and the key crew (details.hl stores them: a series' cast of every season, main cast
  47. // first by episode count, then "Guest stars"; crew = Created by / Directed by / Written by / Screenplay / Story / Music). The
  48. // cast shows its first `castShown` (20) and a "Show all (N)" toggle, each crew job its first `crewShown` (6) and one "Show all
  49. // crew (N)" toggle — CLIENT-SIDE (the rows are all rendered, CSS hides the rest; `castClass`/`crewClass`, no request). Names
  50. // link to /person/<slug>; the character in brackets after the name (CSS `attr(data-role)` — one element per name: the SSR's
  51. // line breaks inside a wrapper element put a space before the comma).
  52. parent './main.hl'
  53. import Modal from './modal.hl'
  54. import { appTitle } from '../project.hl'
  55. import { userIdOfSession, loginHref } from '../users.hl'
  56. import { isFollowing, setFollowed } from '../follows.hl'
  57. import { showBySlug, showById, genreById, personById, seasonsOfShow, episodesOfSeason, seasonById, posterName, externalLinksOf, summaryOf } from '../shows.hl'
  58. import { watchesOfUser, isWatched, watchedSet, setWatched, setSeasonWatched, watchClassOf } from '../watches.hl'
  59. import { needsDetails, completeStep, completeLine, persistDetails } from '../details.hl'
  60. // ---- pure helpers ----------------------------------------------------------------------------
  61. // `static`, like calendar.hl's `soonOf` — a PLAIN instance member function that calls an
  62. // hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason, watchesOfUser, …) comes back null
  63. // when called from an `on server` face (confirmed by running the gate: "'buildRows' is not
  64. // callable — it holds null" at the face's call site, even though the SAME call works fine as a
  65. // member's own initializer at the top of this file). `static` is the proven-safe shape (calendar's
  66. // `soonOf`, called from `calLoad`/`calSettingsLoad`, an mpackdb-backed helper exactly like this one).
  67. static initialCollapsed = (seasons) => {
  68. let m = {}
  69. let i = 0
  70. while (i < seasons.length) {
  71. if (i > 0) { m[seasons[i].id] = true }
  72. i = i + 1
  73. }
  74. return m
  75. }
  76. static genreRowsOf = (show) => {
  77. let out = []
  78. for (gid of show.genres) {
  79. let g = genreById(gid)
  80. if (g != null) { out.push({ id = g.id name = g.name href = '/genres/' + g.urlSegment }) }
  81. }
  82. return out
  83. }
  84. // tracker#28: how many names show before "Show all" (cast; per crew job)
  85. static castShown = 20
  86. static crewShown = 6
  87. static listOf = (v) => { return v == null ? [] : v }
  88. // the cast as flat rows (no nested for, see THE ROWS above): `isPerson` rows { name, href, roleText (' (Character)' or ''),
  89. // itemClass } and before the first guest one `isLabel` row "Guest stars". itemClass: `more` = hidden until "Show all", `end` =
  90. // no comma after it (the last of a group), `cut` = the last one shown while collapsed (no comma then)
  91. static castRowsOf = (show) => {
  92. let people = []
  93. for (c of listOf(show.cast)) {
  94. let p = c != null && c.person != null ? personById(c.person) : null
  95. // mission 062: a guest without a person record — the link creates it on the first open (project.hl guestRoute)
  96. if (p != null) { people.push({ name = c.actor != null ? c.actor : p.name href = '/person/' + p.urlSegment role = c.character != null ? c.character : '' guest = c.guest == true }) }
  97. else if (c != null && c.person == null && c.tmdbId != null && c.actor != null) { people.push({ name = c.actor href = '/person/tmdb/' + c.tmdbId + '?show=' + show.id role = c.character != null ? c.character : '' guest = true }) }
  98. }
  99. let out = []
  100. let k = 0
  101. let guests = false
  102. while (k < people.length) {
  103. let c = people[k]
  104. if (c.guest && !guests && k > 0) {
  105. guests = true
  106. out.push({ isLabel = true isPerson = false text = 'Guest stars' name = '' href = '' roleText = '' itemClass = k >= castShown ? 'more' : '' })
  107. }
  108. let last = k == people.length - 1 || (people[k + 1].guest && !c.guest)
  109. let cls = (k >= castShown ? ' more' : '') + (last ? ' end' : '') + (k == castShown - 1 ? ' cut' : '')
  110. out.push({ isLabel = false isPerson = true text = '' name = c.name href = c.href roleText = c.role != '' ? ' (' + c.role + ')' : '' itemClass = 'artist-link' + cls })
  111. k = k + 1
  112. }
  113. return out
  114. }
  115. static castCountOf = (rows) => {
  116. let n = 0
  117. for (r of rows) { if (r.isPerson) { n = n + 1 } }
  118. return n
  119. }
  120. static crewLabels = { creator = 'Created by' director = 'Directed by' writer = 'Written by' screenplay = 'Screenplay' story = 'Story' composer = 'Music' }
  121. // the crew as flat rows: per job (in stored order) an `isLabel` row, then its people (itemClass as for the cast, `more` after
  122. // the first crewShown of a job)
  123. static crewRowsOf = (show) => {
  124. let people = []
  125. for (c of listOf(show.crew)) {
  126. let p = c != null ? personById(c.person) : null
  127. if (p != null && crewLabels[c.job] != null) { people.push({ name = c.name != null ? c.name : p.name href = '/person/' + p.urlSegment job = c.job }) }
  128. }
  129. let out = []
  130. let k = 0
  131. let inJob = 0
  132. while (k < people.length) {
  133. let c = people[k]
  134. if (k == 0 || people[k - 1].job != c.job) {
  135. inJob = 0
  136. out.push({ isLabel = true isPerson = false text = crewLabels[c.job] name = '' href = '' itemClass = '' })
  137. }
  138. let last = k == people.length - 1 || people[k + 1].job != c.job
  139. out.push({ isLabel = false isPerson = true text = '' name = c.name href = c.href itemClass = 'crew-link' + (inJob >= crewShown ? ' more' : '') + (last ? ' end' : '') + (inJob == crewShown - 1 ? ' cut' : '') })
  140. inJob = inJob + 1
  141. k = k + 1
  142. }
  143. return out
  144. }
  145. // how many crew names are hidden while collapsed (0 = no toggle) and how many there are
  146. static crewHiddenOf = (rows) => {
  147. let n = 0
  148. for (r of rows) { if (r.isPerson && r.itemClass.includes(' more')) { n = n + 1 } }
  149. return n
  150. }
  151. static crewCountOf = (rows) => {
  152. let n = 0
  153. for (r of rows) { if (r.isPerson) { n = n + 1 } }
  154. return n
  155. }
  156. // a View attribute must be a literal, a member or a field path — never an inline expression — so
  157. // every ternary (watched -> icon class, collapsed -> row class) is resolved HERE, once, into a
  158. // plain field the View just reads (row.watchClass, row.rowClass); `watchClassOf` itself lives in
  159. // watches.hl (shared with /unwatched and /schedule, "reuse, don't copy")
  160. // #22: an episode's air date as the lists show it ('YYYY-MM-DD'), '' when it has none
  161. static airDateOf = (e) => { return e.release != null && hlTypeName(e.release) == 'String' ? e.release : '' }
  162. // #23: a season is watched when ALL its episodes are (by the ids it lists); one without episodes keeps its own season watch
  163. static seasonAllWatched = (s, watched) => {
  164. if (s.episodes.length == 0) { return watched['season:' + s.id] == true }
  165. for (eid of s.episodes) { if (watched['episode:' + eid] != true) { return false } }
  166. return true
  167. }
  168. static buildRows = (show, uid, collapsed) => {
  169. let watched = watchedSet(uid != null ? watchesOfUser(uid) : [])
  170. let out = []
  171. for (s of seasonsOfShow(show)) {
  172. let isCollapsed = collapsed[s.id] == true
  173. let label = s.title != null && s.title != '' ? s.title : 'Season ' + s.seasonNumber
  174. let seasonWatched = seasonAllWatched(s, watched)
  175. out.push({ kind = 'season' isSeason = true isEpisode = false isSkeleton = false id = s.id skeletonCount = s.episodes.length < 8 ? s.episodes.length : 8 label = label episodeCountText = s.episodes.length == 1 ? '1 episode' : s.episodes.length + ' episodes' watched = seasonWatched watchClass = watchClassOf(seasonWatched) collapsed = isCollapsed rowClass = isCollapsed ? 'collapsed' : 'expanded' })
  176. if (!isCollapsed) {
  177. for (e of episodesOfSeason(s)) {
  178. let epWatched = watched['episode:' + e.id] == true
  179. out.push({ kind = 'episode' isSeason = false isEpisode = true isSkeleton = false id = e.id seasonId = s.id episodeNumber = e.episodeNumber title = e.title != null ? e.title : '' airDate = airDateOf(e) watched = epWatched watchClass = watchClassOf(epWatched) })
  180. }
  181. }
  182. }
  183. return out
  184. }
  185. // what the client marks the rows of a season it opens with (tracker#17) — only this show's, never the user's whole watch
  186. // list (every member is shipped to the browser). Per season with a watched episode: `{ all, except }` — `all` = most of
  187. // it is watched, then `except` holds the UNwatched ids, else the watched ones (a fully watched 142-episode season is
  188. // `{ all = true except = {} }`, not 142 ids). No entry = nothing watched.
  189. static watchedEpisodesOf = (show, uid) => {
  190. let m = {}
  191. if (uid == null) { return m }
  192. let watched = watchedSet(watchesOfUser(uid))
  193. for (s of seasonsOfShow(show)) {
  194. let yes = {}
  195. let no = {}
  196. let n = 0
  197. for (eid of s.episodes) {
  198. if (watched['episode:' + eid] == true) { yes[eid] = true n = n + 1 } else { no[eid] = true }
  199. }
  200. if (n > 0) { m[s.id] = n * 2 > s.episodes.length ? { all = true except = no } : { all = false except = yes } }
  201. }
  202. return m
  203. }
  204. // is this episode watched, by that per-season shape (client-side, pure)
  205. static watchedIn = (entry, episodeId) => {
  206. if (entry == null) { return false }
  207. let listed = entry.except[episodeId] == true
  208. return entry.all ? !listed : listed
  209. }
  210. // ---- the rows as the CLIENT changes them (tracker#17) — pure: no import is reached, so they run in the browser ----------
  211. // (`watchClassOf` is an import of the mpackdb-backed watches.hl, so `filledRows` writes its two classes itself)
  212. static seasonRowAs = (row, isCollapsed) => {
  213. return { kind = 'season' isSeason = true isEpisode = false isSkeleton = false id = row.id skeletonCount = row.skeletonCount label = row.label episodeCountText = row.episodeCountText watched = row.watched watchClass = row.watchClass collapsed = isCollapsed rowClass = isCollapsed ? 'collapsed' : 'expanded' }
  214. }
  215. // the season opened: its caret up at once, `skeletonCount` placeholder rows under it
  216. static openingRows = (rows, seasonId) => {
  217. let out = []
  218. for (row of rows) {
  219. if (row.isSeason && row.id == seasonId) {
  220. out.push(seasonRowAs(row, false))
  221. let i = 0
  222. while (i < row.skeletonCount) {
  223. out.push({ kind = 'skeleton' isSeason = false isEpisode = false isSkeleton = true id = seasonId + '-skeleton-' + i seasonId = seasonId })
  224. i = i + 1
  225. }
  226. } else { out.push(row) }
  227. }
  228. return out
  229. }
  230. // the season closed: its episode (and skeleton) rows gone, caret down
  231. static closedRows = (rows, seasonId) => {
  232. let out = []
  233. for (row of rows) {
  234. if (row.isSeason && row.id == seasonId) { out.push(seasonRowAs(row, true)) }
  235. else if (!row.isSeason && row.seasonId == seasonId) { }
  236. else { out.push(row) }
  237. }
  238. return out
  239. }
  240. // the episodes arrived: they replace the season's skeleton rows. No skeleton of it left (closed meanwhile, or a watch face
  241. // already rebuilt every row) → the rows stay as they are (no double rows)
  242. static filledRows = (rows, seasonId, episodes, watched) => {
  243. let out = []
  244. let done = false
  245. for (row of rows) {
  246. if (row.isSkeleton && row.seasonId == seasonId) {
  247. if (!done) {
  248. for (e of episodes) {
  249. let w = watchedIn(watched[seasonId], e.id)
  250. out.push({ kind = 'episode' isSeason = false isEpisode = true isSkeleton = false id = e.id seasonId = seasonId episodeNumber = e.episodeNumber title = e.title airDate = e.airDate watched = w watchClass = w ? 'checkicon solid' : 'checkicon regular' })
  251. }
  252. done = true
  253. }
  254. } else { out.push(row) }
  255. }
  256. return out
  257. }
  258. // a LOCAL static like buildRows (an imported mpackdb helper called straight from a member's initializer came back false
  259. // after a client-side navigation to the page — the gate's follow check)
  260. static followingOf = (show, uid) => { return uid != null && isFollowing(uid, show.id) }
  261. // #24: has this user watched this movie (a watch `movie:<title id>`)?
  262. static movieWatchedOf = (show, uid) => { return uid != null && isWatched(watchesOfUser(uid), 'movie', show.id) }
  263. // mission 056: an ADULT title's page (TMDB's flag `adult == true`, mission 054) is "Not found" for everyone who does not
  264. // follow it (signed out too); a follower sees it as before. Unknown (`null`) is not hidden here — only in the lists.
  265. // With hybriel ≥ 73267707 (#121/#122) `showRow`/`found` are server sites re-derived after every session face with the page's
  266. // slug, so an Unfollow on the page hides it at once (the gate proves it; the old build left the page up until a reload).
  267. // Mission 054's try (`found = … && !adultHiddenFor(showRow, me)`, `collapsed`/`rows` from `found`) broke the checks.
  268. static adultHiddenFor = (show, uid) => { return show.adult == true && !followingOf(show, uid) }
  269. // the page's show — null when there is none OR it is hidden for this user: every member is shipped to the browser, so the
  270. // row of a hidden title must never be one
  271. static visibleShow = (slug, uid) => {
  272. let s = showBySlug(slug)
  273. return s != null && adultHiddenFor(s, uid) ? null : s
  274. }
  275. // mission 062: what the page shows of the title, as a SMALL object — `showRow` reads the session, so hl:web ships it with the page
  276. // AND re-sends it after every watch/follow click (SESSION SYNC below): the whole record was ~1 MB on The Simpsons (300 cast + crew
  277. // entries). null = not found / hidden.
  278. static pageShowOf = (slug, uid) => {
  279. let s = visibleShow(slug, uid)
  280. if (s == null) { return null }
  281. let movie = s.type == 'movie'
  282. return { id = s.id title = s.title summary = summaryOf(s) type = s.type adult = s.adult == true shortId = s.shortId != null ? '' + s.shortId : ''
  283. poster = posterName(s) genreRows = genreRowsOf(s) linkRows = externalLinksOf(s) completing = completingOf(s) following = followingOf(s, uid)
  284. movieWatched = movie ? movieWatchedOf(s, uid) : false }
  285. }
  286. // the seasons/episodes rows and the per-season watch state by the slug (the record itself stays on the server)
  287. static rowsOfSlug = (slug, uid, collapsed) => {
  288. let s = showBySlug(slug)
  289. return s != null ? buildRows(s, uid, collapsed) : []
  290. }
  291. static watchedEpisodesOfSlug = (slug, uid) => {
  292. let s = showBySlug(slug)
  293. return s != null ? watchedEpisodesOf(s, uid) : {}
  294. }
  295. // the initial open/closed seasons from the SLUG alone (not from `showRow`, which reads the session — see `collapsed`)
  296. static initialCollapsedOf = (slug) => {
  297. let s = showBySlug(slug)
  298. return s != null ? initialCollapsed(seasonsOfShow(s)) : {}
  299. }
  300. // mission 062: the cast and crew rows by the SLUG alone (not from `showRow`, which reads the session — see `castRows`). An
  301. // ADULT title (its page only for its followers) shows none: every member is shipped to the browser, and adult titles never get
  302. // credits anyway (details.hl skips them; at most a migrated cast)
  303. static publicShowOf = (slug) => {
  304. let s = showBySlug(slug)
  305. return s != null && s.adult != true ? s : null
  306. }
  307. static castOfSlug = (slug) => {
  308. let s = publicShowOf(slug)
  309. return s != null ? castRowsOf(s) : []
  310. }
  311. static crewOfSlug = (slug) => {
  312. let s = publicShowOf(slug)
  313. return s != null ? crewRowsOf(s) : []
  314. }
  315. // tracker#26: the page loads this title's full details on open
  316. static completingOf = (show) => { return needsDetails(show) }
  317. // the face's work: ONE step of completing the title (details.hl completeStep — a series takes a few; a second viewer drives the
  318. // same job or finds it complete); when done, every part of the page that changes — the rows without anyone's watch state (a
  319. // title that was incomplete had no episodes to watch)
  320. static completedPageOf = (slug) => {
  321. let show = showBySlug(slug)
  322. if (show == null || show.adult == true) { return { error = 'no such show' } }
  323. if (needsDetails(show)) {
  324. let r = completeStep(show.id)
  325. if (r.retry || r.adult == true) { return { error = r.error != '' ? r.error : 'no such show' } }
  326. if (!r.done) { return { done = false steps = r.sum.steps } }
  327. if (!r.skipped) {
  328. persistDetails()
  329. console.log('details on open: ' + completeLine(show.title, r.sum))
  330. }
  331. show = showById(show.id)
  332. }
  333. let cl = initialCollapsed(seasonsOfShow(show))
  334. let pn = posterName(show)
  335. let crew = crewRowsOf(show)
  336. let cast = castRowsOf(show)
  337. return { done = true title = show.title summary = summaryOf(show) poster = pn genreRows = genreRowsOf(show) castRows = cast
  338. castCount = castCountOf(cast) castMore = castCountOf(cast) > castShown crewRows = crew crewCount = crewCountOf(crew) crewMore = crewHiddenOf(crew) > 0
  339. linkRows = externalLinksOf(show) collapsed = cl rows = buildRows(show, null, cl) }
  340. }
  341. // ---- reactive members -----------------------------------------------------------------------
  342. slug = null
  343. session = null
  344. userId = userIdOfSession(session)
  345. signedIn = userId != null
  346. // SESSION SYNC (mission 047; since hybriel #121/#122, mission 056): a face taking `session` answers with a `sync` of every
  347. // member derived from session that reaches an import (here `showRow`, `found`, `following`, `rows`, …), re-derived on the
  348. // server with the page's route params but the MOUNT's other state — so client state (`collapsed`) must never derive from
  349. // them. `me` stays the plain copy mission 047 introduced.
  350. me = userId
  351. showRow = pageShowOf(slug, me)
  352. found = showRow != null
  353. notFound = showRow == null
  354. // the Follow toggle: "Follow" = the inverted button (accent border), "Following" = the filled one
  355. following = found ? showRow.following : false
  356. followClass = following ? 'follow' : 'follow inverted'
  357. followText = following ? 'Following' : 'Follow'
  358. signInHref = loginHref
  359. // #24: a movie's watched check (same icon/classes as the episodes); the classes written out, not `watchClassOf` (an import of
  360. // the mpackdb-backed watches.hl cannot run in the browser when the member is re-derived there)
  361. isMovie = found && showRow.type == 'movie'
  362. movieWatched = isMovie ? showRow.movieWatched : false
  363. movieWatchClass = movieWatched ? 'checkicon solid' : 'checkicon regular'
  364. movieWatchText = movieWatched ? 'Watched' : 'Not watched'
  365. signInText = isMovie ? 'You need to sign in to follow movies and mark them watched.' : 'You need to sign in to follow shows and mark episodes.'
  366. title = found ? showRow.title : ''
  367. // tracker#18: the creator's own text, else TMDB's, else TVmaze's (pageShowOf → shows.hl summaryOf)
  368. summary = found && showRow.summary != null ? showRow.summary : ''
  369. poster = found ? showRow.poster : null
  370. hasPoster = poster != null
  371. posterUrl = hasPoster ? '/posters/' + poster : ''
  372. // mission 060 (tracker.worldapi.org#27): the short id under the poster (none yet while the backfill has not reached it)
  373. shortId = found ? showRow.shortId : ''
  374. hasShortId = shortId != ''
  375. genreRows = found ? showRow.genreRows : []
  376. hasGenres = genreRows.length > 0
  377. // mission 062: cast + crew from the SLUG (`castOfSlug`, `crewOfSlug`), never from `showRow` — a member derived from the session is
  378. // re-derived and re-SENT after every watch/follow click (SESSION SYNC above): The Simpsons' 300 names took 0.7 s per click
  379. castRows = castOfSlug(slug)
  380. hasCast = castRows.length > 0
  381. // tracker#28: "Show all (N)" — the toggles' state is CLIENT state: literals, never derived from `showRow` (session sync)
  382. castCount = castCountOf(castRows)
  383. castMore = castCount > castShown
  384. castAllText = 'Show all (' + castCount + ')'
  385. castClass = 'collapsed'
  386. crewRows = crewOfSlug(slug)
  387. hasCrew = crewRows.length > 0
  388. crewCount = crewCountOf(crewRows)
  389. crewMore = crewHiddenOf(crewRows) > 0
  390. crewAllText = 'Show all crew (' + crewCount + ')'
  391. crewClass = 'collapsed'
  392. linkRows = found ? showRow.linkRows : []
  393. hasLinks = linkRows.length > 0
  394. // NOT from `showRow`/`found`: they read the session (via `me`), so a member derived from them is re-derived on the server
  395. // after every session face with the MOUNT's state — `collapsed` (client state) would snap back to the initial seasons (mission
  396. // 056: the 2nd episode click in a re-opened season did nothing). The season ids of a hidden page are no secret.
  397. collapsed = initialCollapsedOf(slug)
  398. // mission 062: has this page opened/closed a season (client state) — then a watch face sends its own rows (toggleEpisode)
  399. collapsedChanged = false
  400. rows = found ? rowsOfSlug(slug, me, collapsed) : []
  401. hasRows = rows.length > 0
  402. watchedEpisodes = found ? watchedEpisodesOfSlug(slug, me) : {}
  403. // an adult page (followers only) opens its seasons through the session face `showRows`
  404. adultShow = found && showRow.adult == true
  405. // tracker#26: incomplete → skeletons + the probe until `showComplete` answered
  406. completing = found ? showRow.completing : false
  407. completeBusy = false
  408. isSeries = found && showRow.type != 'movie'
  409. skeletonSeasons = ['1', '2', '3']
  410. __title = found ? title + ' — ' + appTitle : appTitle
  411. View {
  412. showPage {
  413. if (notFound) {
  414. article { class = "main"
  415. h1 { "Not found" }
  416. p { "This show doesn't exist." }
  417. }
  418. }
  419. if (found) {
  420. showHeader {
  421. showPoster {
  422. if (hasPoster) { img { src = posterUrl alt = title } }
  423. if (completing) { skeletonPoster { "aria-hidden" = "true" } }
  424. if (hasShortId) { shortIdLabel { id = "shortid" title = "Short id" shortId } }
  425. }
  426. showMeta {
  427. showTitle {
  428. h1 { title }
  429. button { id = "follow" type = "button" class = followClass followText on click(e) { emit toggleFollow(e) } }
  430. }
  431. if (isMovie) {
  432. movieWatch {
  433. button { id = "moviewatch" type = "button" class = movieWatchClass "aria-label" = "mark movie watched"
  434. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  435. circle { cx = "12" cy = "12" r = "10" }
  436. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  437. }
  438. on click(e) { emit toggleMovie(e) }
  439. }
  440. movieWatchLabel { movieWatchText }
  441. }
  442. }
  443. if (hasLinks) {
  444. showLinks {
  445. 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 } }
  446. }
  447. }
  448. if (hasGenres) {
  449. showGenres {
  450. for (g of genreRows) { a { class = "genre-pill" href = g.href g.name } }
  451. }
  452. }
  453. showSummary { summary }
  454. if (hasCrew) {
  455. showCrew { class = crewClass
  456. for (r of crewRows) {
  457. if (r.isLabel) { crewLabel { r.text } }
  458. if (r.isPerson) { a { class = r.itemClass href = r.href r.name } }
  459. }
  460. if (crewMore) {
  461. button { id = "crewtoggle" type = "button" class = "quiet showall"
  462. whenCollapsed { crewAllText }
  463. whenAll { "Show less" }
  464. on click(e) { emit toggleCrewAll(e) }
  465. }
  466. }
  467. }
  468. }
  469. if (hasCast) {
  470. showCast { class = castClass
  471. castHeading { "Cast" }
  472. for (c of castRows) {
  473. if (c.isLabel) { castLabel { class = c.itemClass c.text } }
  474. if (c.isPerson) { a { class = c.itemClass href = c.href "data-role" = c.roleText c.name } }
  475. }
  476. if (castMore) {
  477. button { id = "casttoggle" type = "button" class = "quiet showall"
  478. whenCollapsed { castAllText }
  479. whenAll { "Show less" }
  480. on click(e) { emit toggleCastAll(e) }
  481. }
  482. }
  483. }
  484. }
  485. if (completing) { skeletonCast { "aria-hidden" = "true" } }
  486. }
  487. }
  488. if (completing) {
  489. showLoading { id = "showloading" "aria-busy" = "true"
  490. if (isSeries) {
  491. for (k of skeletonSeasons) {
  492. skeletonRow { "aria-hidden" = "true"
  493. skeletonCheck { }
  494. skeletonTitle { }
  495. }
  496. }
  497. }
  498. showProbe { "aria-hidden" = "true" on animationiteration(e) { emit completeTick(e) } }
  499. }
  500. }
  501. if (hasRows) {
  502. showSeasons {
  503. for (row of rows) {
  504. if (row.isSeason) {
  505. seasonRow { class = row.rowClass
  506. button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark season watched"
  507. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  508. circle { cx = "12" cy = "12" r = "10" }
  509. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  510. }
  511. on click(e) { emit toggleSeason(row.id) }
  512. }
  513. button { type = "button" class = "season-toggle" value = row.id
  514. seasonLabel { row.label }
  515. seasonCount { row.episodeCountText }
  516. seasonCaret { "aria-hidden" = "true"
  517. svg { viewBox = "0 0 24 24" width = "20" height = "20"
  518. path { d = "M6 9 L12 15 L18 9" }
  519. }
  520. }
  521. on click(e) { emit toggleCollapse(row.id) }
  522. }
  523. }
  524. }
  525. if (row.isEpisode) {
  526. episodeRow {
  527. button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark episode watched"
  528. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  529. circle { cx = "12" cy = "12" r = "10" }
  530. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  531. }
  532. on click(e) { emit toggleEpisode(row.id) }
  533. }
  534. episodeNumber { row.episodeNumber }
  535. episodeTitle { row.title }
  536. episodeDate { row.airDate }
  537. }
  538. }
  539. if (row.isSkeleton) {
  540. skeletonRow { "aria-hidden" = "true"
  541. skeletonCheck { }
  542. skeletonNumber { }
  543. skeletonTitle { }
  544. }
  545. }
  546. }
  547. }
  548. }
  549. Modal { id = "signin" title = "Sign in"
  550. p { signInText }
  551. footer {
  552. button { type = "button" value = "close" "Close" }
  553. a { id = "signin-link" class = "button" href = signInHref "Sign in with ident" }
  554. }
  555. }
  556. }
  557. }
  558. }
  559. // Every rebuild of `rows` happens SERVER-SIDE, never by calling `buildRows` from a client handler:
  560. // a member function whose body reaches an hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason,
  561. // watchesOfUser, …) compiles to nothing on the client (no case ever assigns it — a genuine Hybriel
  562. // gap, filed under `issues`) even though the client bundle still calls it, so `this.buildRows(...)`
  563. // throws "not a function" in the browser. The client here only ever ASSIGNS the `rows` the server
  564. // already built (`rows = r.rows`) — never calls a tainted local helper itself. `slug` goes along so
  565. // the face can refetch the show without depending on implicit access to a sibling member.
  566. //
  567. // tracker#17: opening shows the skeleton rows BEFORE the request; the answer replaces them unless the season was closed
  568. // meanwhile (then nothing). An error closes the season again. Closing never asks the server.
  569. on toggleCollapse(seasonId) {
  570. collapsedChanged = true
  571. let m = collapsed
  572. if (collapsed[seasonId] != true) {
  573. m[seasonId] = true
  574. collapsed = m
  575. rows = closedRows(rows, seasonId)
  576. return null
  577. }
  578. m[seasonId] = false
  579. collapsed = m
  580. rows = openingRows(rows, seasonId)
  581. if (adultShow) {
  582. let a = emit server showRows(slug, collapsed)
  583. if (collapsed[seasonId] == true) { return null }
  584. if (a == null || a.error != null) { m[seasonId] = true collapsed = m rows = closedRows(rows, seasonId) return null }
  585. rows = a.rows
  586. return null
  587. }
  588. let r = emit server showSeasonEpisodes('' + seasonId)
  589. if (collapsed[seasonId] == true) { return null }
  590. if (r == null || r.error != null) { m[seasonId] = true collapsed = m rows = closedRows(rows, seasonId) return null }
  591. rows = filledRows(rows, seasonId, r.episodes, watchedEpisodes)
  592. }
  593. // tracker#26: the probe's tick — `showComplete` step by step until `done` (once per page: `completeBusy`; other requests are
  594. // served between two calls); the last answer replaces the skeletons. A lost answer (null) lets the next tick go on where the
  595. // server is; an error (TMDB down, adult) ends the loading and keeps what the page has.
  596. on completeTick(e) {
  597. if (completeBusy || !completing) { return null }
  598. completeBusy = true
  599. let s = slug
  600. let r = emit server showComplete(s)
  601. while (r != null && r.error == null && r.done != true && slug == s) { r = emit server showComplete(s) }
  602. completeBusy = false
  603. if (r == null || slug != s) { return null }
  604. if (r.error != null) { completing = false return null }
  605. title = r.title
  606. summary = r.summary
  607. poster = r.poster
  608. genreRows = r.genreRows
  609. castRows = r.castRows
  610. castCount = r.castCount
  611. castMore = r.castMore
  612. castAllText = 'Show all (' + r.castCount + ')'
  613. crewRows = r.crewRows
  614. crewCount = r.crewCount
  615. crewMore = r.crewMore
  616. crewAllText = 'Show all crew (' + r.crewCount + ')'
  617. linkRows = r.linkRows
  618. collapsed = r.collapsed
  619. collapsedChanged = true
  620. rows = r.rows
  621. completing = false
  622. }
  623. // tracker#28: the cast / crew "Show all" toggles — client-only, no request (CSS shows the hidden names)
  624. on toggleCastAll(e) {
  625. castClass = castClass == 'all' ? 'collapsed' : 'all'
  626. }
  627. on toggleCrewAll(e) {
  628. crewClass = crewClass == 'all' ? 'collapsed' : 'all'
  629. }
  630. // signed out: the modal, nothing is sent
  631. // mission 062: the new `rows` come with the SAME ack as the session sync (SESSION SYNC above: `rows` derives from the session) —
  632. // but that sync is built with the MOUNT's open seasons. So the face sends its own rows only once this page opened/closed a season
  633. // (`collapsedChanged`, or a completed title); else they came twice (a second buildRows + 14 kB on The Simpsons: ~40 ms per click)
  634. on toggleEpisode(episodeId) {
  635. if (!signedIn) { document.getElementById('signin').showModal() return null }
  636. let r = emit server showToggleEpisode(slug, '' + episodeId, collapsed, collapsedChanged)
  637. if (r == null || r.error != null) { return null }
  638. if (r.rows != null) { rows = r.rows }
  639. }
  640. on toggleSeason(seasonId) {
  641. if (!signedIn) { document.getElementById('signin').showModal() return null }
  642. let r = emit server showToggleSeason(slug, '' + seasonId, collapsed, collapsedChanged)
  643. if (r == null || r.error != null) { return null }
  644. if (r.rows != null) { rows = r.rows }
  645. }
  646. // #24: the movie's own check — signed out the modal, nothing sent
  647. on toggleMovie(e) {
  648. if (!signedIn) { document.getElementById('signin').showModal() return null }
  649. let r = emit server showToggleMovie(slug)
  650. if (r == null || r.error != null) { return null }
  651. movieWatched = r.watched
  652. movieWatchClass = r.watched ? 'checkicon solid' : 'checkicon regular'
  653. movieWatchText = r.watched ? 'Watched' : 'Not watched'
  654. }
  655. on toggleFollow(e) {
  656. if (!signedIn) { document.getElementById('signin').showModal() return null }
  657. let r = emit server showToggleFollow(slug)
  658. if (r == null || r.error != null) { return null }
  659. following = r.following
  660. followClass = r.following ? 'follow' : 'follow inverted'
  661. followText = r.following ? 'Following' : 'Follow'
  662. }
  663. // ---- the faces (the trailing `session` is always the server's: hybriel#16) ----
  664. // tracker#26: complete an incomplete title and answer the page's parts — public data, NO session (no page re-mount)
  665. on server showComplete(slug) {
  666. return completedPageOf(slug != null ? '' + slug : '')
  667. }
  668. // every row for these open/closed seasons — only an ADULT show's page still uses it (its followers; tracker#17)
  669. on server showRows(slug, collapsed, session) {
  670. let show = showBySlug(slug)
  671. let uid = userIdOfSession(session)
  672. if (show == null || adultHiddenFor(show, uid)) { return { error = 'no such show' } }
  673. return { ok = true rows = buildRows(show, uid, collapsed) }
  674. }
  675. // tracker#17: one season's episodes, in order — public data, so NO `session` (no page re-mount, no sync: the fast path).
  676. // Refuses a season of an adult title (those pages use showRows above).
  677. on server showSeasonEpisodes(seasonId) {
  678. if (seasonId == null || hlTypeName(seasonId) != 'String') { return { error = 'no such season' } }
  679. let season = seasonById(seasonId)
  680. if (season == null) { return { error = 'no such season' } }
  681. let show = showById(season.show)
  682. if (show == null || show.adult == true) { return { error = 'no such season' } }
  683. let out = []
  684. for (e of episodesOfSeason(season)) { out.push({ id = e.id episodeNumber = e.episodeNumber title = e.title != null ? e.title : '' airDate = airDateOf(e) }) }
  685. return { ok = true episodes = out }
  686. }
  687. on server showToggleEpisode(slug, episodeId, collapsed, withRows, session) {
  688. let show = showBySlug(slug)
  689. if (show == null) { return { error = 'no such show' } }
  690. let uid = userIdOfSession(session)
  691. if (uid == null) { return { error = 'you are not signed in' } }
  692. if (adultHiddenFor(show, uid)) { return { error = 'no such show' } }
  693. let rows = watchesOfUser(uid)
  694. let watched = isWatched(rows, 'episode', episodeId) == false
  695. let ok = setWatched(uid, 'episode', episodeId, rows, watched)
  696. if (!ok) { return { error = 'could not save the watch' } }
  697. return withRows == true ? { ok = true rows = buildRows(show, uid, collapsed) } : { ok = true }
  698. }
  699. on server showToggleSeason(slug, seasonId, collapsed, withRows, session) {
  700. let show = showBySlug(slug)
  701. if (show == null) { return { error = 'no such show' } }
  702. let uid = userIdOfSession(session)
  703. if (uid == null) { return { error = 'you are not signed in' } }
  704. if (adultHiddenFor(show, uid)) { return { error = 'no such show' } }
  705. let season = seasonById(seasonId)
  706. if (season == null) { return { error = 'no such season' } }
  707. let rows = watchesOfUser(uid)
  708. // #23: not all episodes watched → watch them all; all watched → unwatch them all
  709. let watched = !seasonAllWatched(season, watchedSet(rows))
  710. let ok = setSeasonWatched(uid, seasonId, season.episodes, rows, watched)
  711. if (!ok) { return { error = 'could not save the watch' } }
  712. return withRows == true ? { ok = true rows = buildRows(show, uid, collapsed) } : { ok = true }
  713. }
  714. // #24: a movie's watched check (only a movie: a series is watched by its episodes)
  715. on server showToggleMovie(slug, session) {
  716. let show = showBySlug(slug)
  717. if (show == null || show.type != 'movie') { return { error = 'no such movie' } }
  718. let uid = userIdOfSession(session)
  719. if (uid == null) { return { error = 'you are not signed in' } }
  720. if (adultHiddenFor(show, uid)) { return { error = 'no such movie' } }
  721. let rows = watchesOfUser(uid)
  722. let watched = isWatched(rows, 'movie', show.id) == false
  723. if (!setWatched(uid, 'movie', show.id, rows, watched)) { return { error = 'could not save the watch' } }
  724. return { ok = true watched = watched }
  725. }
  726. on server showToggleFollow(slug, session) {
  727. let show = showBySlug(slug)
  728. if (show == null) { return { error = 'no such show' } }
  729. let uid = userIdOfSession(session)
  730. if (uid == null) { return { error = 'you are not signed in' } }
  731. let follow = isFollowing(uid, show.id) == false
  732. if (!setFollowed(uid, show.id, follow)) { return { error = 'could not save the follow' } }
  733. return { ok = true following = follow }
  734. }

Branches

Latest commits

  • 20e09d89Merge t18 (tracker#18 delta sync) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs, both sides kept; pageShowOf summary = summaryOfmre
  • f83571c3tracker#18 (mission 067): daily sync by change lists — TMDB /tv|movie/changes (since the stored day, paged) + TVmaze /updates/shows → only our changed titles (followed: full step, unfollowed: light step — changed seasons, no TVmaze), full walk on first run / gap > 14 days / failed list; show record refreshed (title, tmdbSummary, tagline, status, genres …; renamed titles re-indexed); summary = the creator's own text (page: summary > tmdbSummary > tvmazeSummary), one-time clear of copied summaries (9,647 on the live copy); gate 261, tests/realdata-018*.mjs, README + STATUSmre
  • f2b00674Merge t26 (tracker#26 + #28) into main (mission 062): short ids for every new person (castPersonId, guest route), guest stars stored on the title and created as people only when opened (/person/tmdb/<id>?show=<id> → 302), lean watch/follow clicks (showRow a small object, cast/crew from the slug, watches cached per user, face rows only after a season toggle); gate 311, tests/realdata-062.mjs, README + STATUSmre
  • fa1f9dfatracker#29 (mission 063): unwatched check muted grey outline + check (accent only on hover), watched stays solid — no code regression, the accent outline read as ticked; gate checks real checks visibly (computed style + screenshot pixel) on /my/unwatched, show, movie, /my/movies; gate 266, tests/realdata-063.mjs, README + STATUSmre
  • 10bb3f93tracker#28 (mission 061): full cast (all seasons, main cast by episodes, guest stars) + crew (created by, directed by, written by, screenplay, story, music) — stored by the details completion, the daily sync, the search import (one details request) and a background credits job (resumes, RSS limit); show page collapsed after 20 with client-side Show all; showBySlug via a slug map; gate 249, tests/realdata-028.mjs, README + STATUSmre
  • d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmre
  • 25a50bc4tracker#26 (mission 059): titles from a filmography are completed — on open (skeleton, step-wise face showComplete, no reload) and by the in-app details repair (resumes, TMDB-paced, series in parts); cast from TMDB credits; gate 231, tests/realdata-026.mjs, README + STATUSmre
  • f14db671tracker#22-#25 (mission 058): episode air dates, season check = all episodes watched, movie watched check (+ /my/movies count), /genres/<genre> pages (movies + series, newest first, paginated); gate 238, tests/realdata-058.mjs, README + STATUSmre
  • 1704ec45tracker#17 (mission 057): season caret down/up, skeleton rows while a season loads, sessionless showSeasonEpisodes face (no page re-mount), client-only close; gate 214, tests/realdata-057.mjs, README + STATUSmre
  • f2fe3e36mission 056: README + STATUS (merge, fixes, Hybriel 8590df63, real-data check), tests/realdata-056.mjs, tools/check-public-slugs.hlmre
  • f40c250emission 056: re-vendor hybriel master 8590df63 (#121, #122); an adult title's page is Not found for non-followers; gate: leave the page before stopping the servermre
  • 2b7fdd6cmission 056: signed-out header one row on phones ("Log in", nowrap), backfill skips adult titles' posters, gate checksmre
  • 2c53d5efMerge branch 't16-person' (tracker#16 person pages) into main; filmography shows only public titles (054 adult flag), gate race fix (backfill start line)mre
  • c171227emission 054: hide adult/unknown titles from the public lists and the search; in-app adult-flag backfill (TMDB details + poster per title, resumes), gate + real-data proofmre
  • 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