docs / grandstack-web / Concepts

How the features work

Each section says what the user sees, where the code is, and the one non-obvious thing. Facts here were checked against the running app.

Faceted search (/movies)

Code: src/pages/Results.tsx, src/search/FilterSidebar.tsx, src/lib/filters.ts.

  • Filters: title text, genres (several), decades (several), minimum rating, maximum runtime, and a person (cast or director). Sort: rating, newest, oldest, title, shortest.

One Results operation carries three where inputs: where for the movies and total, genreWhere (filters minus genres) for genre counts, and decadeWhere (filters minus decades) for decade counts.

Figure: how one request returns the movies, the total and every filter option’s count. Click the image for full size; the numbered notes below carry the same information as text.

  1. where. All active filters: used for the results grid and the total in the heading.
  2. genreWhere. The filters without the genre filter, used to count each genre option.
  3. decadeWhere. The filters without the decade filter, used to count each decade (nine aliased fields in the same operation).
  4. Why “as if the filter were off”. Ticking Drama still shows how many films each other genre would give; options are not hidden by their own selection, and an option with 0 stays disabled.
  5. $decadeWhere is non-null. It is used inside a list literal, so it is declared MovieWhere! and the app always sends {} rather than null.
  • Facet counts respect the other filters. Results is one GraphQL operation with several fields:
    • movies(where: $where, ...) and moviesConnection(where: $where) { totalCount } for the results and the total;
    • genres { name moviesConnection(where: { node: $genreWhere }) { totalCount } }, where $genreWhere is the current filters without the genre filter, so selecting Drama still shows how many films each other genre would give;
    • nine aliased moviesConnection fields, one per decade (d1940 … d2020), using $decadeWhere (the filters without the decade filter) combined with that decade’s year range.
  • Options with a count of zero are disabled unless already selected.
  • Title search is case-sensitive in the API (contains), so buildWhere matches the text as typed, in Title Case and in lower case.
  • 24 results per page; “Show more” raises the limit.
  • Gotcha for new facets: $decadeWhere is declared MovieWhere! (non-null) because it is used inside a list literal; always send {} rather than null.

People browser (/people)

Code: src/pages/People.tsx, src/lib/peopleFilters.ts, src/search/PersonCard.tsx.

  • Role chips (Everyone, Actors, Directors) with counts, a name search, a minimum-films select and a sort (most films, name).
  • Sorting and role filtering use the stored actedCount and directedCount properties on Person, because computed fields cannot be sorted in plain GraphQL. The server recalculates them after every mutation (see docs/mutations.md in the grandstack-server repo).
  • Role counts use the same trick as facets: an operation field per role with the filters minus the role.

Person page (/people/:name)

Code: src/pages/PersonDetail.tsx, src/graph/PersonGraph.tsx.

  • Stats (films, active years, average rating, top genres) are computed in the browser from the person’s filmography, since a person has at most a few dozen films.
  • Filmography is split into “Acted in” and “Directed” with a sort control.
  • “Frequent collaborators” comes from the server’s Person.collaborators field (a Cypher traversal ranked by shared films).
  • The career graph shows the person, up to 12 acted-in films, up to 12 directed films and up to 6 collaborators.

Movie page and recommendations (/movies/:title)

Code: src/pages/MovieDetail.tsx, src/graph/MovieGraph.tsx.

  • “You might also like” is the server’s Movie.similar field: films scored by shared director (3), shared cast (2) and shared genre (1).
  • The graph shows the movie with its directors, up to 10 cast members and its genres. Clicking a person opens their page; clicking a genre opens filtered results.

Graph views

Code: src/graph/GraphView.tsx (shared), MovieGraph.tsx and PersonGraph.tsx (build nodes and links).

GraphView wraps react-force-graph-2d (lazy-loaded) and draws nodes on a canvas. To add a graph for another page, build a list of { id, label, kind } nodes and { source, target, label } links and render <GraphView ... /> with a legend and a click handler. Edge labels are the Neo4j relationship types.

Compare (/compare)

Code: src/lib/compare.tsx, src/pages/Compare.tsx, the tray in src/App.tsx.

  • Tick “Compare” on up to four movies; the selection survives reloads (localStorage).
  • The page highlights the best rating and shortest runtime and shows cast shared by more than one selected movie in bold.
  • The tray is hidden on the compare screen and on create/edit forms, because a fixed bar at the bottom would cover the form buttons.

Create, edit and delete

Code: src/forms/*, src/pages/MovieNew.tsx, MovieEdit.tsx, PersonNew.tsx, PersonEdit.tsx.

  • MovieForm edits fields, genres (existing or new), directors and cast with roles. PersonPicker is a typeahead over existing people with a “Create new person” option; people are connected if they exist and created if not.
  • src/forms/model.ts holds the logic and has unit tests:
    • validateMovie, validatePerson (title and name required, year 1888 to 2100, rating 0 to 10, runtime 1 or more, no duplicate cast member);
    • buildCreateInput makes the createMovies input (connect existing, create new, roles on edges);
    • buildUpdateInput(before, after, existingGenres) makes the updateMovies input as a diff (disconnect what was removed, connect or create what was added, one edge update per changed role). It never sends “disconnect all, then connect”, because the server runs disconnects last and would remove the new links;
    • friendlyError turns the unique-constraint error into a readable message.
  • Roles are typed as one text box; several roles for one person are separated by semicolons.
  • Editing a field clears its error until the next submit; submit focuses the first invalid field.
  • Deleting asks for confirmation in a native <dialog> (focus trapped, Esc cancels). Every form shows a banner that there is no authentication.

Posters and avatars

Code: src/poster/Poster.tsx, src/ui/Avatar.tsx.

Posters are generated, not downloaded. hash(title) (FNV-1a) picks gradient angle and hues; the first genre sets the base hue (GENRE_HUE). The same title always gives the same poster. Avatars use the same hash on the person’s name and show initials. Change the look by editing posterStyle and GENRE_HUE.

Theme

Code: src/theme/tokens.css, useTheme in src/App.tsx.

Dark is the default; the header toggle switches to light and remembers the choice. Colours are CSS custom properties on :root and :root[data-theme="light"].

Source: grandstack-web/docs/features.md (this page is a copy; edit it in the repository)