You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This commit was created on GitHub.com and signed with GitHub’s verified signature.
Added
Collections: two tables, eight routes, and the takedown (backend/app/models/collection.py, backend/app/services/collections.py, backend/app/routers/collections.py, backend/app/services/event_filters.py, docs/api.md, docs/data-model.md) (#343). An analyst's named, curated set of their own geolocated and detected events: collections and collection_events, both foreign keys cascading so a GDPR erasure passes through. A collection carries two required free-text fields, a title capped like an event's and a short description saying what it holds, capped at 500 characters in schemas/collection on the profile bio's figure. One validator covers the pair on the create and the update alike, so both are stripped of surrounding whitespace and a value of spaces is a 422 rather than a stored blank. Create, read, write both details in one PATCH, drop, page the items, put one on or take one off, plus the two list reads on GET /users/{username}/collections and GET /events/{id}/collections. Every write is owner-only, and an event joins its owner's collection alone, the one invariant the service carries rather than a SQL constraint. What a collection may show is one predicate, collectable_events, read by the item page, the count, the date range, the card mosaic and the add verb, so a row that closes or is taken down leaves all five at once with no write to the membership table. A collection stores no picture: the cover on the read is up to four tiles taken off the items themselves in chronological order, graphic rows skipped and an image preferred over a clip, ranked for a whole page of collections in one windowed query. The items page on a cursor keyed on event_date, event_time, created_at, id, undated last. PATCH /admin/collections/{id}/moderation moves a reported collection along the reversible hidden_at an event already carries, withholding it with {"hidden": true} and restoring it with {"hidden": false}, audited as collection_hidden and collection_restored; DELETE /admin/collections/{id} stays as the takedown alias, and a withheld collection 404s for everyone but an admin.
Collections on the front end: the collection page, the profile section and the add-to-collection panel (frontend/src/lib/collections.ts, frontend/src/app/collections/[id]/page.tsx, frontend/src/components/collections/, frontend/src/components/map/CoverageMap.tsx, frontend/src/app/profile/[username]/page.tsx, frontend/src/proxy.ts, docs/design.md) (#343). Three surfaces read or write one. /collections/{id} opens on the title, the owner's byline, the meta line stating the count and the span, and the description under it at reading size, then the items on the shared Coverage map and the chronological list under it, both off one set so the pins and the rows cannot describe different collections. The profile card wears a mosaic of the collection's own first items, the way a playlist icon is made of the covers of what is on it: one tile fills the 16:9 slot, two split it, three stand the earliest tall on the left, four fill a 2x2, and a collection with nothing to show falls back to the site's one no-media box. Nothing is uploaded and nothing is stored, so a card cannot fall out of step with its shelf, and the page header carries no picture at all. The profile gains a Collections grid between Insights and Recent submissions, four cards with a Show more that opens the analyst's whole shelf in /search on type=collection&author=, the submissions list's link through the same builder; the grid is absent for a visitor with nothing to show and a first-run surface for its owner. GET /search browses the collections group on author alone, so an empty query lists that analyst's visible, non-empty collections newest first. Every card carries the description clamped to two lines. The three writes that take a collection's details are one form, the title over the description with the shared remaining / cap counter on each and a submit that refuses a blank or over-long value on either. The owner's two verbs ride the header cluster with their panels under it, the split the event page uses, and only dropping one is red, behind the two-click confirm, since the events it held stay as they were. An event's owner shelves it from a panel in the header's management tier whose rows toggle optimistically and roll back on a refusal. /collections joins the public prefixes in proxy.ts, so a shared link reads signed out like the events it holds, and the narrow-viewport suite gains the page as its owner at both phone widths.
Collections are a search result group (backend/app/services/search.py, backend/app/schemas/search.py, backend/app/routers/search.py, backend/app/services/event_filters.py, backend/alembic/versions/q5s7u9w1y3a5_collections.py, frontend/src/app/search/page.tsx, frontend/src/components/collections/CollectionCard.tsx, docs/api.md, docs/design.md) (#343). GET /search answers with a fourth group. A query matches a collection's title and its description as one document, under a GIN index on the same simple configuration the events and users groups take, ranked by ts_rank with created_at descending as the tie-break. type=collection asks for the group alone and type=all carries it. A hit is the collection itself, the one CollectionRead every read surface renders, so a result opens on the same mosaic, count and date range the profile prints; a withheld collection, one whose owner is soft-deleted and one holding nothing showable are all absent, the terms a visitor already reads a profile on. The event filters empty the group, being predicates on an event, with one exception: author names an analyst, which a collection carries, so it narrows the group to that owner's shelves. The search page gains a Collections chip between Events and Analysts and renders each hit as the profile's own card, with the byline that card hides on a profile.
Step through a collection on its own page, and a Description card for its description (frontend/src/app/collections/[id]/page.tsx, frontend/src/components/collections/CollectionReader.tsx, frontend/src/components/collections/ReaderHeader.tsx, frontend/src/components/collections/CollectionItems.tsx, frontend/src/components/map/DetailSidePanel.tsx, frontend/src/components/map/Map.tsx, frontend/src/components/ui/EntityCard.tsx, docs/design.md) (#343). The collection page reads as three sections under its header: Description, which prints the description whole at reading size where it used to crowd the header; Coverage, now a player; and Events. The player is the map page's own two surfaces rearranged around a position, the shared map over every item's pin with the current one lit and the walked ones dimmed, and that item's event in the map's own detail panel beside it, under a header saying N of M with the two moves. The panel gains an inline placement for it, a column of the page with no viewport insets, no bottom sheet and no height cap of its own, so the two sit side by side in a 32rem block from sm up and stack below it with the step controls at the panel's top edge on the app's phone tap step. Nothing joins the pins: the order is said by the counter and by which pin is lit. The step rides the page's own URL as ?step=, 1-based and clamped, moved with router.replace so the back button leaves the page rather than walking back through every step, and ArrowLeft / ArrowRight step too unless a field has the caret. A click anywhere on a row of the Events list moves the player to it and lights that row, the new onSelect / selected pair on the catalogue card, which keeps the way to the event's own page on the row's title. One cursor walk serves the pins, the panel and the rows, so N of M counts what the list shows. The narrow-viewport suite gains the player as a visitor at both phone widths.
Opening and changing a collection are pages of their own, and the profile card drops its type square (frontend/src/app/collections/new/page.tsx, frontend/src/app/collections/[id]/edit/page.tsx, frontend/src/app/collections/[id]/page.tsx, frontend/src/components/collections/, frontend/src/lib/collections.ts, docs/design.md) (#343). /collections/new takes the title and the description behind the wall, the way /submit takes an event, and /collections/{id}/edit takes the same two for a collection its owner already holds. Both are the one details form under PageShell and a Card, so a write has an address a reload keeps and a link reaches, and the surfaces that send a reader there stay reading surfaces. The create page carries an optional ?event={id}: the collection it opens receives that event in the same act and the page returns to the event, which is what the add-to-collection panel's New collection row now links to. The profile section's two entry points link to the same page and grow no form. The edit page is owner only, with the refusal the backend gives stated before the form, and it holds the drop at the bottom as its destructive zone, two-click confirm kept, landing on the owner's profile once the collection is gone. The collection page keeps one Edit link in its header cluster, the inline panels and useCollectionActions are gone, and a profile card drops the accent square in front of its title: the mosaic is what tells one collection from the next, and the mark that names the type where a surface still has to is one export, CollectionIcon. The narrow-viewport suite gains the edit form at both phone widths.
Pick a collection's events with a search, on both write pages (frontend/src/components/collections/EventPicker.tsx, frontend/src/components/collections/CollectionDetailsForm.tsx, frontend/src/app/collections/new/page.tsx, frontend/src/app/collections/[id]/edit/page.tsx, backend/app/schemas/collection.py, backend/app/services/collections.py, docs/api.md, docs/design.md) (#343). /collections/new and /collections/{id}/edit carry one form: the title and description over a picker of the analyst's own events, so the set is chosen where the collection is named rather than one event at a time afterwards. The picker is two blocks and both pages read the same. Events in this collection is what the collection holds if the analyst saves now, counted by the phrasing every collection surface prints and ordered the way the collection's own page reads its items, each row carrying the red cross that takes it off, the control a collection's item list already uses. Add events is the way in and shows five rows at most: the analyst's own most recent with nothing typed, through GET /events (view=located, author=, scoped to the two statuses a collection may hold, limit=), and the first matches once something is typed, through GET /search (type=event, author=), with a line saying what is left out and that the search is the way to it. A row on either block is the catalogue's own compact card carrying one control: Add on the second block, a disabled Added for a row the first block already holds, and adding moves the row up at once. POST /collections takes an optional event_ids, de-duplicated and capped at 500, added in the same transaction under the checks the membership route runs, so a foreign (403), ineligible (409) or unknown (404) id fails the create whole and no half-filled collection lands; ?event= on the create page opens the collection already holding that event rather than writing a second request after the fact. The edit page opens the picker on what the collection holds and saves the difference through the idempotent membership routes, in order, reporting the first refusal.
Report a collection, and drop one from its own page (backend/app/models/content_report.py, backend/app/services/reports.py, backend/app/services/admin.py, backend/app/routers/collections.py, backend/alembic/versions/q5s7u9w1y3a5_collections.py, frontend/src/components/report/useReportContent.tsx, frontend/src/app/collections/[id]/page.tsx, frontend/src/components/admin/ReportsPanel.tsx, docs/api.md, docs/data-model.md, docs/design.md) (#343). A collection is reportable the way an event is. POST /collections/{id}/report takes the same body, the same five buckets under the same 2000-character cap and the same 10/hour per-IP limit, open to anonymous viewers for the same reason: the reader who notices a shelf misrepresenting what it holds rarely holds an account here. The row lands in content_reports, which gains a collection_id foreign key beside event_id on the same ON DELETE SET NULL terms and a ck_content_reports_one_target CHECK holding a row to one target, "never both" rather than "exactly one" because a report outlives the thing it names and ends up naming neither. One queue answers for both: GET /admin/reports lists a collection report beside an event one, carrying the collection's current title and its owner, since a collection is a name rather than a page an admin recognises from an id, and one resolve route applies the verdicts each kind takes. hidden stamps collections.hidden_at through the same mutation DELETE /admin/collections/{id} performs, so the two doors leave one audit trail; marked_graphic is an event verdict alone and a collection report answering it is a 409 rather than a verdict that changed nothing. The reporting form is one hook parameterised by what is being reported, so the two surfaces cannot drift on a bucket or a cap, and the collection page's header cluster now carries the red flag for every reader plus, for its owner, the Edit pencil and a red trash that drops the collection under the two-click confirm and lands on their profile. The edit page keeps its own Drop card. The narrow-viewport suite measures the report form on the collection page at both phone widths.