* feat(collections): sort a collection by when things were added to it
Collections sorted by Newest order on the entity's own recency, which for
images is `i."id" DESC` — upload order. Someone who schedules a post uploads
weeks before it publishes, so adding a freshly published image to a collection
dropped it far down the feed. Reported by a user, who reasonably read Newest as
"newest in this collection".
Adds a `Recently Added` sort ordering on `CollectionItem.id`, and makes it the
default for image, model, post and article collections. Newest keeps its
current meaning.
Newest is NOT switched to published date, which is what the report literally
asked for. Measured on the prod replica: `i."sortAt" DESC` costs 334ms on a
4,500-item collection and 5.7s on the largest (163,624 items), against 19ms
today — `CollectionItem_image_idx ("collectionId", "imageId")` already yields
id order for free, and no index can produce sortAt order without materialising
every item in the collection first. The commented-out sortAt branch in
image.service.ts and its "this causes the app to spike" note were correct.
Models, posts and articles did not have the bug (they sort Newest on
publishedAt / lastVersionAt); there the sort is a new capability, added so the
four collection types behave alike.
Implementation notes:
- The three services using `EXISTS (SELECT 1 FROM "CollectionItem" ...)` need a
real JOIN to expose ci."id" to ORDER BY. The partial unique indexes on
("collectionId", <entity>Id) mean the join cannot multiply rows, so both
shapes select the same entities.
- `Recently Added` is withheld from every general feed menu via the `*SortHidden`
maps and rejected with a 400 when no collectionId is present, mirroring the
existing Random guard.
- It is also held out of the contest random-sort pools. Contest feeds re-roll
their sort each render to spread entry visibility; a deterministic sort in the
pool would pin the newest entries to the top for whichever renders draw it.
Ships an index migration that is NOT yet applied — see the migration file for
the measurements and the operator notes. Without it the sort still works, at
246ms on the largest collection; with an equivalent index scoped to one
collection on dev, the same page went 42.2ms -> 1.8ms and the Sort node
disappeared, reading 102 rows instead of all 44,367.
Verified against the dev database on a collection where add order and upload
order diverge: Recently Added returns 124995538, 86127674, 96212893 where
Newest returns 124995538, 96212893, 86127674, and the CollectionItem ids
confirm the former is the true add order.
The flipt-eval-context ledger rows for image.service.ts move by +7 lines
because this change inserts lines above them; the four getFliptBoolean sites
are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnFnLu369fHmf7dUQBHMXm
* fix(collections): narrow the Recently Added default and make its cursor seekable
Applies the findings from the five-lane review of 579a356751. The two that
change behaviour:
Only image collections default to Recently Added now. Models, posts and
articles go back to Newest — they never had the reported bug (they sort on
publishedAt / lastVersionAt), the default flip there was inferred from
combining two separate answers rather than asked for, and every perf number
behind the change was measured on image collections. It also removes the worst
measured case: a 191k-item model collection did not return in 45s.
The post cursor is single-column for this sort. `encodePostCursor` always
emitted `value|id`, so the predicate was `(ci."id", p.id) <= (…)` — a row
comparison spanning two tables, which Postgres cannot push into an Index Cond
on either. Page 1 used the new index and every page after it re-scanned the
collection from the newest item. `ci."id"` is already unique per row through
the ("collectionId", "postId") join, so the tiebreaker bought nothing.
Also:
- The 400 guards in getAllImages and getModelsRaw sat below early empty
returns, so the misuse they exist to make legible came back as a silent empty
page. Both now run before any return, off `input` rather than the destructure.
The model one also fires ahead of the Meilisearch round-trip.
- getPostsInfinite's collection join uses getAvailableCollectionItemsFilterForUser,
which model and article already call in the same branch. Removes the third
copy of the visibility rule and the raw string interpolation of collectionId
and user id — the sibling services bind both.
- getPostSortClauses throws when RecentlyAdded arrives without the join instead
of falling through to publishedAt. That fallback was the exact shape of the
regression, and a test asserted it as expected output.
- Corrected the migration comment. Its "246ms versus 19ms" and "reads 102 rows
and stops at the LIMIT" were measured on a CollectionItem-only fragment; the
ct CTE has no LIMIT, so that is not the query the feed issues. Real cost is
448ms warm / 1.4s cold on the most-viewed image collection, unbounded where a
collection's newest item is far behind the table's max id.
- Two comments were false rather than imprecise. The contest-pool one claimed
the pool "must hold only sorts that shuffle" — no member shuffles, the
rotation is the randomness, and Newest is in every pool. A test comment
claimed it guarded SortFilter's sortOptions, which is module-private and
imported by nothing; fixed the assertion instead of the wording.
- The three EXISTS->JOIN comments name where the partial uniques actually live
(containers/db/docker-init/02_all_dll.sql, not schema.full.prisma), and record
that the model index name has drifted between that file and prod.
- Extracted resolveImageCollectionSort so the default, the URL override and the
contest override are covered; a revert of the default previously passed every
test in the diff.
- docs/features/collaborative-collections.md said schema.full.prisma declares
only @@index([collectionId], type: Hash) on CollectionItem, which the new
index made wrong.
Declined: exposing a curated sort subset to the LLM tool registry. Recently
Added now parses and then 400s on /api/v1 surfaces that take no collectionId,
but registry.ts deliberately mirrors the full ModelSort enum and its test
asserts equality, arguing a subset "would be a second rule that can drift".
ImageSort.Random has had the identical property for as long as it has existed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnFnLu369fHmf7dUQBHMXm
* fix(collections): format the new test, drop post.service's dead joins array
Fixes the red "ESLint + Prettier (changed files)" check, which gates on ADDED
files: src/components/Collections/__tests__/collection-sort.test.ts was not
Prettier-clean. Formatted that file alone. Collection.tsx is also unformatted
but was already so on main, and CI does not gate modified files — reformatting
it would bury this change in a whole-file diff.
Addresses the two Copilot review comments:
- "`joins` should be `Prisma.Sql[]` since it now contains a collection join with
dynamic values" — the raw-string join it refers to was already replaced in
3a1a051f55 with a parameterized `Prisma.Sql` built from
getAvailableCollectionItemsFilterForUser. That left `joins` with no writers at
all, and checking main shows it had none there either: it was declared and
interpolated into the FROM clause, never pushed to. Deleting it rather than
converting dead code to Prisma.Sql[] removes the raw-SQL footgun for good.
Verified the plain post feed, the collection EXISTS path and the collection
JOIN path all still return correctly.
- "The comment says ORDER BY always includes p.id as a tiebreaker, but Recently
Added orders only by ci.\"id\" DESC" — correct, and the comment predates this
PR. Rewritten to state the exception and why it is sound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnFnLu369fHmf7dUQBHMXm
* fix(collections): keep image collections on Newest
Recently Added is now opt-in for all four collection types instead of
being the image default. Consistency was the ask, and it also means no
existing feed reorders on deploy — the image path is behaviourally
identical to main apart from the new menu entry.
Adds a positive assertion that ?sort=Recently Added still resolves:
dropping it from imageCollectionSortOptions would otherwise make the
URL fall back to Newest with nothing failing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnFnLu369fHmf7dUQBHMXm
* style(collections): let the sort ternary sit on one line
Newest is shorter than RecentlyAdded, so the expression now fits inside
the print width and Prettier wants it collapsed. CI gates added files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnFnLu369fHmf7dUQBHMXm
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
22 KiB
Collaborative Collections
Members can open a collection to submissions from other users and invite collaborators who help run it. Two independent halves: entry acceptance (anyone submits, an owner or manager approves) and collaborators (named people granted permission on the collection).
Trackers: CU 868kn5ehn (the build), CU 868krg9rw (the refinements that followed).
Why it is small
Most of the machinery already existed and was driven by collection configuration, not by moderator status. It was only reachable because moderators alone could set that configuration.
| Capability | Gated on |
|---|---|
Submissions land as CollectionItemStatus.REVIEW |
permission.writeReview, i.e. write = Review — minus the people who work the queue, see "Where a submission lands" |
/collections/[id]/review approve/reject queue |
permissions.manage |
| "Review items" menu entry | permissions.manage |
A non-owner holding manage gets all of the above |
the MANAGE contributor bit |
None of those check isModerator, and none require mode = Contest. This feature does not use
Contest mode — that stays moderator-only, along with its categories, scoring and submission
windows. A community collection is expressed purely as write = Public or write = Review.
write and contributor bits are orthogonal
Collection.write governs the public — what any passer-by may do. Contributor permission bits
govern named people. getUserCollectionPermissionsByIds ORs the two, so a contributor holding
ADD resolves to write = true regardless of the column.
read |
write |
Collaborators | Result |
|---|---|---|---|
| Private | Private | none | An ordinary private collection. The default. |
| Private | Private | invited | Private shared collection. Named people only, no public surface. |
| Private | Review | invited | Collaborators submit, the owner approves. |
| Public | Private | none | Publicly viewable, owner-only additions. |
| Public | Public | any | Open collection, no queue. |
| Public | Review | invited Managers | Community collection — the headline case. |
read bounds who can submit even when write is open: a Private-read collection is visible only
to the owner and its contributors, so "open to submissions" there means open to your collaborators.
CollectionContributor is also the follow table
This is the single most important thing to know before touching any of this code.
follow/unfollow write rows in CollectionContributor, and saveItemInCollections auto-follows
submitters unless metadata.disableFollowOnSubmission is set. On a write: Public collection a
plain follower's row is [VIEW, ADD], because addContributorToCollection defaults to the
collection's followPermissions.
So "has a row" never means "is a collaborator." Any query that treats it that way is a follower-list disclosure — the product exposes only an aggregate follower count, never the members.
The collaborator test is: does the row hold ADD or MANAGE beyond what the collection grants
everyone for free? freeGrantBaseline(collection) derives that free set from the collection's own
read/write columns, and hasElevatedPermission(permissions, baseline) applies it. Derive the
baseline from those columns, never from a runtime-filtered permission array.
Submitting follows the collection
Saving an entry writes a follow row for the submitter, and that row is what keeps the collection in their save picker afterwards. It is deliberate: without it, a collection you want to keep posting to is unreachable from the picker the moment you close the page you found it on — the picker only ever lists collections you hold a row on, and it does not search the ones you don't.
Two properties the implementation depends on:
- The follow is a side effect of the entry landing.
saveItemInCollectionsqueues the ids and applies them after the write, so a save that goes on to write nothing — no write grant anywhere, or the empty-transaction guard — leaves the user following nothing. - Anyone who already holds a row is skipped.
addContributorToCollectionupserts and replaces the target's permissions, so following a collaborator's own seat would strip it back to the free grant. The!isContributor && !isOwnerguard is what stops that.
A follow row is not a collaborator row and does not change where an entry lands. On a write: Review
collection a follower gets exactly [VIEW, ADD_REVIEW] — the free grant — so isCollaboratorRow
stays false on both halves (not elevated, no accepted seat) and they keep queuing. See
Where a submission lands.
metadata.disableFollowOnSubmission opts a collection out. The challenge jobs set it on every
challenge collection they create, which is the one place the flooding is worst: without it every
entrant in a daily challenge picks up a follow they never asked for.
Open collections also carry a Submit an entry button on the collection page, the same entry point contests have always had. Before, a collection asking for submissions had no way to take one from its own page. It covers Image and Post collections; a Model collection is submitted to through the picker.
Where a submission lands
submissionStatus(permission) decides REVIEW vs ACCEPTED, and both save paths call it. The
queue is for the public: everyone the collection has vouched for — the owner, its managers, and
the collaborators it invited — posts straight through. writeReview && !manage && !isOwner && !isCollaborator.
Reading writeReview alone — which is what both call sites did — files the owner and every Manager
into their own queue, because that flag is granted to everyone on a write: Review collection.
Production carries 108 items a collection's own owner submitted and never approved, the oldest from
2025-01-03.
isCollaborator carries the invited half, and it is what makes the Contributor role mean something
on an open collection: without it, an invited Contributor's grant is identical to a follower's. It
is false on contest and system collections (see collectionSupportsCollaborators), so contest
entries still go to review. A follower whose row only mirrors the free grant is not elevated, so
they queue like anyone else — collection-save-existing-membership.service.test.ts pins both.
Roles
| Role | Bits | Can do |
|---|---|---|
| Contributor | VIEW, ADD |
Add items without going through the review queue, and remove the ones they added or authored. |
| Manager | VIEW, ADD, MANAGE |
The above, plus remove anyone's item, work the review queue, edit name/description/cover, and invite Contributors. |
No new permission bits. A Manager may invite and remove Contributors only; granting the Manager
role, changing a Manager's role and removing a Manager are owner-only; the owner is never removable.
Changing privacy (read/write) and mode stays with the owner and moderators.
A seat's role changes in place from the roster, through updateCollaboratorRole. It replaces the
row's permissions with roleGrantPermissions(role, collection) — the role's own bits plus the
collection's free grant — rather than unioning onto what is stored. Unioning is what accepting an
invite used to do, and it makes every demotion a silent no-op: someone who already held MANAGE and
was re-invited as a Contributor kept it and went on reading as a Manager in the roster. Accepting an
invite now builds the row the same way. A role change also rewrites the seat's
CollectionInvite.role, because countCollaborators reads that row too and a stale one keeps
occupying a Manager seat nothing displays.
Removing an item accepts the entity's author, whoever added the row, a manage holder, or a
moderator. For "whoever added the row", every row for that entity has to be theirs — removal
takes them all, so holding one duplicate is not authorization over the others.
Both collection surfaces gate their Remove menu entry on that same rule. They did not before,
and each got it wrong in a different direction: the image collection offered it to manage holders
but not to the image's author, the model collection the reverse. The image feed carries
collectionItemAddedById (one column on the collection CTE in getAllImages, only selected when
the feed is filtered by collection) so the card can offer removal to the submitter; the model feed
does not, so a Contributor who added someone else's model still has to use the save picker.
That last split is why collection.upsert carries no ownership middleware. Authorization lives in
upsertCollection, which requires manage and then strips read/write/mode for anyone who is
not the owner, rather than throwing — so a Manager saving the edit form changes the name and keeps
the privacy it already had. An isOwnerOrModerator middleware in front of it refuses the whole edit
instead, which is what it used to do; the stripping was unreachable and the roles table above was
wrong for as long as that was true. collection.delete keeps the middleware, deliberately.
Invites
CollectionInvite holds pending invites, separate from CollectionContributor so that follow
semantics stay untouched and a decline leaves no residue.
- Accepting writes the role's bits plus the collection's free grant, so it never takes away what following already granted and never leaves a higher role's bits behind (see Roles above).
- Invites expire after 7 days, derived from
createdAt. NoExpiredstatus and no reaper job; stale invites are filtered at read and refused at accept. - Re-inviting after a decline or expiry upserts (there is a unique constraint on
[collectionId, userId]). - Caps: 25 collaborators, at most 5 Managers, both counting non-expired pending invites so an owner cannot issue unlimited invites and blow past the cap when they land. The collection owner is excluded from both counts and from the roster — they are rendered separately.
The roster is readable by anyone who can read the collection. Pending invites are returned only to
manage holders. System-owned (userId <= 0) and mode != null collections are refused outright.
Where invites surface
An invitee has to be able to find an invite before it expires, and the collections sidebar is the only place that knows about them.
- Mail button in the sidebar header, carrying the pending count. This is the one affordance that renders at zero invites, which is why it exists at all — everything else below disappears when there is nothing pending, leaving no way in.
- A band above the filters peeking at the two most recent invites, with View all.
- The invitations modal, the full inbox, opened from either.
- A prompt on the collection page itself, for whoever is holding a live invite to it. This is
what makes
collection-invite-received's link work: it points at/collections/{id}, which an invitee to aread: Privatecollection has no permission to open, so the page renders the prompt in place of the "you do not have sufficient permissions" copy. It reads the invite off the samegetMyInvitesthe sidebar already loaded, so the page pays nothing for it.
The count lives on the mail button and nowhere else. The band deliberately has no badge: it would print the same number twice, and it is the button's copy that has to survive the band being absent.
getMyInvites enriches each row with itemCount (accepted items only) and collaboratorCount
(roster plus the owner) so an invitee can judge what they are being handed. Two things constrain it:
it runs getCollectionRoster per invite, so the query is bounded by take: 50; and it selects
read/write/mode/userId purely to run the collaborator-row rule, then maps them off the
result. Do not let those columns reach the client — the roster rule needs them, an invite card does
not.
Membership gating
Collaboration is a member feature, at any tier. Both opening submissions and inviting collaborators require an active membership, including on a fully private collection.
Authorization is always against the owner's membership, never the actor's — a member Manager must not be able to invite on behalf of a lapsed owner.
Two separate gates:
- Creation —
upsertCollectionblocks the transition intowrite = Public/Review, andinviteCollaboratorblocks new invites. Gating the transition rather than the state is what lets a lapsed owner keep everything they already built. - Lapse —
Collection.collaborationDisabledAt, set by a daily reconciler (reconcile-collection-collaboration.ts) when the owner has no active membership and cleared when they do.writeis never mutated: the permission resolver is a hot path already selecting fromCollection, so reading one more column is free, whereas overwritingwritewould be lossy and need restoring.
The reconciler is bidirectional in one pass, so a missed billing webhook self-heals within a day.
Its scope is deliberately narrow — mode IS NULL, owner is a real user, and either write is not
Private or a non-owner holds elevated permissions. The owner-exclusion matters: owners hold their
own elevated contributor row, so without it the predicate matches essentially every collection.
Both gates have to agree on what a member is. isMemberUser reads the owner's tier off the hub
session, which ranks comped tiers from UserMembershipOverride alongside real subscriptions, so the
reconciler's active_member CTE unions that table in too. Reading CustomerSubscription alone there
let a comped owner invite successfully and then get switched off the same night, with no billing
state they could act on.
The gate is surfaced where each half of the feature lives, not only where it is enforced: an owner without a membership gets the write control disabled with an upgrade CTA rather than no control at all. Omitting it reads as "this collection cannot take submissions" instead of "this is a member feature", which is the one reading that loses the upgrade.
While disabled, the review queue stays open, existing collaborators keep their permissions, and already-issued invites can still be accepted. Only new entries and new collaborators stop.
The closed state uses two separate copy strings — one for visitors, one for the owner. The visitor string describes the collection's state and never the owner's billing situation. Keep them as separate literals; do not template a reason into a shared one.
Surfacing the gate before the attempt
Because the gate is on the owner, a Manager can otherwise pick someone, hit Invite, and get a
failure they have no way to act on. So getCollaborators answers it up front:
{ canInvite: boolean, inviteBlockedReason: 'collaboration-disabled' | 'owner-membership' | null }
Three properties hold it together, and all three are load-bearing:
- It mirrors the refusals
inviteCollaboratorthrows, in the same precedence — lapse first, then membership, with moderators bypassing membership but not lapse. The pre-flight answer and the write path must not be able to disagree; if you add a refusal to one, add it to the other. - The reason is returned only to
manageholders (everyone else getscanInvite: falsewith anullreason), and'owner-membership'narrows further to the owner alone — it names somebody's billing state, and a Manager can act on it no better than on the generic string, so they get'collaboration-disabled'instead. Collapsed on the server, not in the copy: the payload is the disclosure surface, and a client-side string swap leaves the fact sitting in the network tab. - The client keeps the copy, the server keeps the reason. The owner sees an upgrade CTA to
/pricing; everyone else sees that the collection isn't accepting collaborators right now. Templating one string for both is how that split gets lost.
Detail header
The header carries the collaboration state: the owner's avatar ringed inside a stack of collaborator
avatars, <owner> and N collaborators, and a popover listing the owner plus the first few
collaborators with their roles, + N more collaborators, and a link into the collaborators modal
(Manage for manage holders, View all for everyone else). The count in that sentence
excludes the owner while the avatar stack includes them — deliberate, since the owner is rendered
separately everywhere else too.
manage holders also get a Review N button to /collections/[id]/review, from
pendingReviewCount on collection.getById. The count is computed only for manage holders, and
it is an exact count with no cap and no mode exclusion — resist adding either.
The reason that is safe is not in schema.full.prisma. The only CollectionItem index that file
declares that a status-filtered count could use is @@index([collectionId], type: Hash), which
would make the count walk the whole collection. Production actually carries
CollectionItem_collectionId_status_covered — a btree on (collectionId, status, createdAt DESC)
— so the count is an index-only scan: measured at 0.5 ms / 4 buffers on a 190k-item collection
and 0.8 ms / 73 buffers for a real 466-row review backlog. This is the same
schema-diverges-from-production trap as the DB triggers; check pg_indexes before sizing a query
off the Prisma schema.
Notifications
collection-submission-received goes to the owner and every MANAGE holder, resolved at send time,
never to the submitter. collection-invite-received goes to the invitee. Both are toggleable,
unlike contest-collection-item-status-change, which is not, because a submitter must learn the
outcome of their own entry.
Visibility
There is no feature flag. Collaboration is on for everyone, so each of the guards below is the sole thing standing between a collection and a published roster — none of them has a second gate behind it:
- The roster on
collection.getByIdis returned only to a caller withreadpermission. getCollaboratorsandgetCollectionRosterreturn nothing for a curated collection (mode !== null) or a system-owned one (userId <= 0). Curated sets carry staffADD/MANAGErows, which are an internal roster and not collaboration.- A contributor row that only mirrors what the collection already grants everyone belongs to a
follower, not a collaborator, and is excluded — see "
CollectionContributoris also the follow table" above.
The same exclusions have to hold in the UI. A menu entry or summary gated only on read access will
open a panel for a collection that has no roster to show, and a panel that branches only on
isLoading/data renders an empty roster — indistinguishable from a collection that genuinely has
no collaborators.
Outstanding
Known gaps, roughly in the order they'd block a release.
- Submitting still follows, so a user's collection list still fills up with collections they
posted to once. Justin called that out on the 2026-08-14 walkthrough. It was removed on this
branch and put back: without the follow, the picker only lists collections you already hold a row
on and offers no way to search the rest, so a collection you meant to keep posting to becomes
unreachable — a worse failure than a long list. The list needs its own answer (an unfollow
affordance in the picker, separating "submitted to" from "followed", or a
disableFollowOnSubmissiondefault per collection type) rather than dropping the follow. getMyInvitestruncates at 50 silently. The cap bounds the per-invite roster reads, but nothing tells a user with more than 50 pending invites that they are seeing a subset.- Collection items are read straight from the database, not the feed index. Flagged as something to sort out before this scales.
- Deferred from the designs: the standalone "Manage collaborators" header button, and the status chips ("Open to submissions" / "N awaiting review" / "N items"). The actions behind both are reachable today, through the context menu and the roster popover.
- "Manager since <date>" is deliberately absent from the roster.
getCollaboratorsis apublicProceduregated only onread, and the same payload feeds the public header stack, so a join date would be published to every reader. It needs amanage-gated payload first — this was built and reverted once already, andcollection.controller.roster.test.tspins it. - The new UI has no component tests.
CollectionInvitesModal,CollectionInvitesButtonand the roster popover are covered only by the service-level tests underneath them.
Key files
| Area | File |
|---|---|
Permission resolution, isCollaborator |
src/server/services/collection.service.ts (getUserCollectionPermissionsByIds) |
| Invites, roster, caps, role changes | src/server/services/collection-collaborator.service.ts |
| Review-queue rule | src/server/services/collection.service.ts (submissionStatus) |
| Save picker | src/components/Collections/AddToCollectionModal.tsx |
| Shared seat definition | src/server/services/collection-invite.utils.ts |
| Collaborator-row rule, free-grant baseline | src/server/services/collection-permission.utils.ts |
| Lapse reconciler | src/server/jobs/reconcile-collection-collaboration.ts |
| Pending-review count | src/server/services/collection.service.ts (getPendingReviewCount) |
| tRPC surface | src/server/routers/collection.router.ts |
| Roster + invite UI | src/components/Collections/CollectionCollaborators/ |
| Invite prompt on the collection page | src/components/Collections/CollectionCollaborators/CollectionInvitePrompt.tsx |
| Header roster summary | src/components/Collections/CollectionCollaboratorsSummary.tsx |
| Sidebar (mail button, invite band) | src/components/Collections/CollectionsLayout.tsx |