-
Notifications
You must be signed in to change notification settings - Fork 30
1. Features
Circus is a community-driven discussion platform built on Next.js and Firebase. The sections below provide a high-level map of all user-facing capabilities. Each area is covered in greater depth in the relevant wiki pages.
Authentication and account management capabilities — including email/password sign-up, OAuth providers, password reset, and session persistence — are documented in the Architecture Overview wiki page.
- Create a new community with a unique name, privacy setting (public, restricted, or private), and optional avatar
- Browse all communities via a searchable, paginated discovery page
- Join or leave communities; membership status reflected across the UI immediately
- Community pages with a header banner, description, member count, and creation date
- Moderator controls: edit community settings, manage membership, update branding
- Restricted communities limit posting to members only
- Private communities are visible in the Discover list but restrict access to members
- Create text or image posts within any community the user has access to
- Rich-text body editor with optional media attachment
- Upvote and downvote posts; vote tallies update in real time
- Save and unsave posts to a personal collection
- Delete own posts; moderators may delete any post in their community
- Post feeds ordered by recency, with cursor-based infinite scroll
- Add top-level comments to any post
- Nested replies to existing comments
- Upvote and downvote individual comments
- Delete own comments
- Comment counts shown on post cards throughout the platform
- Global search — instant, in-memory filtering across all public communities and recent posts
- Community recommendations — top-five communities by membership in the sidebar, with a full discovery page
- Dark and light mode — user-selectable, persisted across sessions
- Toast notifications — transient status messages for async operations
- Responsive layout — single-column on mobile, two-column on medium screens and above
- Sticky navbar — always accessible, adapts to screen width
- Infinite scroll — automatic pagination on post feeds and the communities directory
Circus organises all discussion within communities — named spaces that users create, join, and moderate. This section describes the full lifecycle of a community, from creation through to deletion.
Any authenticated user may create a new community via the Create Community modal, accessible from the directory menu in the navigation bar.
The creation form collects two pieces of information:
- Community name — between 3 and 21 characters, alphanumeric only (no spaces, hyphens, or special characters). The name becomes the permanent unique identifier and is used verbatim in the community's URL path.
- Privacy type — one of Public, Restricted, or Private.
Name validation runs in real time as the user types, including a live character counter. On submission, a server-side uniqueness check runs inside a database transaction. If the name is already taken, an inline error is displayed and no data is written. If the name is available, the community is created atomically: the community record is written and the creator is simultaneously recorded as the first member with admin privileges. On success, the user is redirected to the new community's page.
| Rule | Detail |
|---|---|
| Minimum length | 3 characters |
| Maximum length | 21 characters |
| Allowed characters | Alphanumeric only (A–Z, a–z, 0–9) |
| Uniqueness | Enforced server-side via transaction |
| Renameable | No — names are permanent |
flowchart TD
A[User opens Create Community modal] --> B[Enters name & selects privacy type]
B --> C{Client validation passes?}
C -- No --> D[Inline validation error shown]
D --> B
C -- Yes --> E[Submit form]
E --> F{Name unique in database?}
F -- No --> G[Show name-taken error]
G --> B
F -- Yes --> H[Write community record\nWrite creator membership snippet\nMark creator as admin]
H --> I[Redirect to community page]
Every community has a privacy type that controls two independent access dimensions: who may view content, and who may post and comment.
| Privacy Type | Who can view posts | Who can post & comment |
|---|---|---|
| Public | Everyone, including unauthenticated visitors | Any authenticated user |
| Restricted | Everyone, including unauthenticated visitors | Members only |
| Private | Members only | Members only |
Public communities are fully open. Unauthenticated visitors can read all content but must sign in to interact.
Restricted communities are the most common model for communities that want discoverability without open posting. Content is readable by anyone, but only members may contribute.
Private communities hide all content from non-members. The privacy type controls what non-members can read, but does not restrict who may join. Any authenticated user can subscribe to a Private community — there is no invitation or approval gate.
flowchart TD
A[Visitor arrives at community] --> B{Privacy type?}
B -- "Public" --> C[Can read all content]
C --> D{Signed in?}
D -- "Yes" --> E[Can post & comment]
D -- "No" --> F[Read-only · prompted to sign in]
B -- "Restricted" --> G[Can read all content]
G --> H{Is a member?}
H -- "Yes" --> I[Can post & comment]
H -- "No" --> J[Read-only · must join to post]
B -- "Private" --> K{Is a member?}
K -- "Yes" --> L[Can read, post & comment]
K -- "No" --> M[Content hidden · restricted-access banner shown]
A Subscribe button appears in the community header and in community listing rows throughout the platform. When clicked:
- If the user is not signed in, the authentication modal opens automatically. No membership action is taken until the user is authenticated.
- If the user is signed in, a database batch write runs atomically:
- A community snippet is created in the user's personal membership sub-collection, recording their membership and mirroring the community's current logo URL.
- The community's member counter is incremented by one.
- The UI updates immediately without requiring a page reload.
If a user was added as an admin before they had ever explicitly joined, their snippet will already exist with admin status. Subscribing in this case is idempotent.
The same button becomes Unsubscribe for existing members. On click:
- The user's community snippet is deleted.
- The member counter is decremented by one.
- The UI updates immediately.
Note: The community creator can leave their own community via this button. Doing so removes their membership snippet, but creator-level privileges are permanently tied to the community record by user ID and are not revoked by leaving.
Each community has two sources of admin authority:
- The creator — the user who founded the community. Creator status is permanent, stored on the community record itself, and cannot be revoked by any other admin.
- Promoted admins — users explicitly added to an admin list by any existing admin. Their status is stored as an array of user IDs on the community record and is also reflected in their membership snippet.
graph TD
Creator["Creator\n(permanent · irrevocable)"]
Promoted["Promoted Admin\n(can be demoted by any other admin;\ncannot demote themselves)"]
Member["Regular Member"]
NonMember["Non-Member / Visitor"]
Creator -- "Can promote/demote admins" --> Promoted
Promoted -- "All admin powers\nexcept: cannot demote Creator\nor self-demote" --> Member
Member -- "Read/post per privacy rules" --> NonMember
style Creator fill:#c53030,color:#fff
style Promoted fill:#c05621,color:#fff
style Member fill:#2b6cb0,color:#fff
style NonMember fill:#4a5568,color:#fff
From the Community Settings panel (accessible only to admins), the Admins tab provides an email search box. After at least 3 characters are entered and a short debounce, matching users appear as suggestions. Before promoting, the platform performs an exact-match email lookup to prevent partial-match errors. Duplicate promotion attempts are blocked with a warning.
When an admin is confirmed:
- Their user ID is appended to the community's admin list.
- Their membership snippet is updated to reflect admin status.
- If they are not yet a member, they are automatically joined to the community with admin status.
Any admin can remove another promoted admin. Two restrictions are enforced by the interface:
- The creator cannot be demoted — the Remove button is hidden on the creator's row.
- An admin cannot demote themselves.
On removal, the user's admin status is cleared from both the community record and their membership snippet. They remain a community member.
Security note: These restrictions are enforced client-side. Server-side enforcement relies on separately configured database security rules.
| Capability | Description |
|---|---|
| Update logo | Upload, change, or remove the community logo |
| Change privacy type | Switch between Public, Restricted, and Private |
| Manage admins | Add or remove promoted admins |
| Remove members | Eject any member from the community |
| Delete community | Permanently delete the community and all its content |
From Community Settings, an admin can upload a new logo image. The selected file is resized to a maximum of 300 × 300 pixels client-side before upload, then stored in a dedicated location in Firebase Storage. After upload completes:
- The community record is updated with the new logo URL.
- A batch write updates the logo URL on every member's snippet across the platform. This ensures the logo is consistent everywhere members see it listed.
- In-memory application state is patched immediately for a smooth user experience.
Uploading a new logo overwrites the previous image at the same storage path — there is no version history.
An admin can remove the community logo entirely. This:
- Deletes the image file from Firebase Storage.
- Clears the logo URL on the community record.
- Clears the logo URL on all member snippets via a batch write.
Note: The batch write across all member snippets scales with the number of members. For large communities this operation can be slow and database-intensive.
An admin can change the community's privacy type at any time from Community Settings. The change is a single field update applied immediately with no cascading side-effects on existing content.
Note — partial save failure: The privacy type and community logo are updated as separate operations. If one operation fails while the other succeeds (for example, the logo storage write fails but the privacy-type document update succeeds), the community is left in a partially updated state. The platform does not surface a distinct error for this scenario — the user may see a generic failure message or no message at all.
Downgrading visibility (e.g. Public → Private): Content is immediately hidden from non-members at the application layer. However, users who have the community URL can still attempt to navigate there; enforcement of access restrictions for those requests relies on database security rules. No warning is presented to the admin before making the change.
Upgrading visibility (e.g. Private → Public): All content becomes immediately visible to non-members with no confirmation step.
From the About sidebar on any community page, the View Subscribers link opens the full member list. All authenticated users can see the list; only admins see the remove (trash) icon beside each member row.
Clicking the remove icon presents a confirmation dialogue. On confirmation:
- The member's community snippet is deleted.
- The member counter is decremented by one.
O(n) user scan: The members list is populated by fetching the entire platform user table and checking each record for a community membership snippet. This approach degrades in performance and cost as the total number of platform users grows — it is not scoped to the community's members alone.
Admin status not cleared on removal: If the removed member also holds admin status, their entry in the community's admin list is not removed by the member-removal flow. Their user ID persists in the admin list on the community record even though their membership no longer exists.
Creator can be removed as a member: Unlike the Admin Settings panel, the member list does not exclude the community creator. A promoted admin could remove the creator as a regular member. The creator would lose their membership snippet but retain permanent creator-level privileges.
The /communities page lists all communities on the platform, ordered by member count (highest first) and paginated in batches of ten. Infinite scroll triggers the next page as the user scrolls to the bottom.
Communities are grouped into three sections based on the current user's relationship to each:
| Group | Condition |
|---|---|
| Moderating | User has membership and admin status in the community |
| My Communities | User has membership but no admin status |
| Discover Communities | User has no membership |
Groups are computed client-side from the user's loaded membership snippets. Empty groups are not shown.
A Recommendations widget in the right sidebar on the home page and community pages surfaces the top five communities by member count, with inline join/leave controls and a link to the full listing.
Note: Private communities are included in the paginated feed and appear in the Discover section. Non-members who click through will see a restricted-access banner. There is no mechanism to suppress private communities from the discovery listing.
Only admins can delete a community. The option is in Community Settings under the Danger Zone tab and requires explicit confirmation before proceeding.
On deletion, the platform performs a cascading cleanup:
- All posts belonging to the community are queried.
- All post images are deleted from storage in parallel.
- The community's logo image is deleted from storage.
- All document records are collected and deleted in chunked batches of up to 450 at a time:
- The community record itself.
- All post records.
- All comment records on those posts.
- All member snippets across all users.
- On success, the user is redirected to the home page.
flowchart TD
A[Admin confirms deletion] --> B[Query all community posts]
B --> C[Delete all post images from storage\nin parallel]
C --> D[Delete community logo from storage]
D --> E[Collect all document refs:\n• Community record\n• All post records\n• All comment records\n• All member snippets]
E --> F[Chunked batch deletes\nup to 450 docs per batch]
F --> G[Redirect to home page]
| Resource | Cleaned up? |
|---|---|
| Community record | Yes |
| All posts in the community | Yes |
| All comments on those posts | Yes |
| Post images (storage) | Yes |
| Community logo (storage) | Yes |
| Member snippets across all users | Yes |
| Post votes | No — orphaned |
| Saved post references pointing to deleted posts | No — orphaned |
| User profile data | Unaffected |
Orphaned data: Post votes and saved post references that point to deleted posts are not removed during deletion. These remain in the database indefinitely as dead references.
No rollback: The deletion is not wrapped in a single atomic transaction. A failure part-way through leaves the community in a partially deleted state. No compensating rollback is performed.
The Posts system is the core content layer of Circus, covering everything from creating and displaying posts through to voting, sharing, saving, and deletion.
A post can only be created by an authenticated user. Initiating post creation from outside a community page first prompts the user to select a community via the directory picker; from within a community page, the user is taken directly to the submission form.
The form offers two tabs:
- Post — a title field and an optional free-text body.
- Images — allows uploading a single image (PNG, GIF, or JPEG; maximum 3,000 × 3,000 pixels).
The title is validated client-side using Zod: it must be between 1 and 300 characters. The form cannot be submitted whilst validation is failing.
Before the post is written to the database, the platform checks whether the target community is restricted. If it is, the user must be a member. Non-members receive an error notification and the post is not created. Public communities have no membership requirement.
flowchart TD
A[User clicks Create Post] --> B{Authenticated?}
B -- No --> C[Auth modal opens]
B -- Yes --> D{On community page?}
D -- No --> E[Community picker opens]
E --> F[User selects community]
D -- Yes --> G[Navigate to submit page]
F --> G
G --> H[Fill in title and optional body or image]
H --> L{Zod validation passes?}
L -- No --> M[Inline validation error]
L -- Yes --> N{Community restricted?}
N -- Yes, user not a member --> O[Error toast — post not created]
N -- No, or user is member --> P[Write post document to Firestore]
P --> Q{Image attached?}
Q -- No --> S[Success toast — navigate back]
Q -- Yes --> R[Upload image to Firebase Storage]
R --> T[Patch post document with image URL]
T --> S
At the moment a post is created, the community's current profile image URL is stored directly on the post document. This snapshot is not updated if the community later changes its image, so post cards may display an outdated community image.
Circus presents posts in three distinct feed modes depending on the user's context and authentication state.
| Mode | Audience | Ordering |
|---|---|---|
| Community feed | Any visitor on a community page | Newest first |
| Home feed — authenticated | Signed-in users on the home page | Newest first (subscribed communities only) |
| Home feed — guest | Unauthenticated visitors on the home page | Highest vote score first |
flowchart LR
subgraph CF["Community Feed"]
A1["Posts filtered to one community\nOrdered: newest first"]
end
subgraph HFA["Home Feed – Authenticated"]
B1["Posts from subscribed communities only\nOrdered: newest first"]
end
subgraph HFG["Home Feed – Guest"]
C1["Posts from all communities\nOrdered: highest vote score"]
end
A1 --> D["10 posts per page\nCursor pagination"]
B1 --> D
C1 --> D
D --> E["Infinite scroll loads next page\nwhen sentinel enters viewport at 50%"]
Ordering note: While pagination behaviour is consistent across all three feed modes, the ordering criterion differs: the community feed and the authenticated home feed order posts by creation time (newest first), whereas the guest home feed orders posts by vote score descending.
Note: The authenticated home feed uses a Firestore
inquery over the user's subscribed community IDs. Firestoreinqueries have an upper bound (which varies by SDK version); the platform does not split large subscription lists into chunks, so users subscribed to a very large number of communities may see incomplete home feeds.
Posts are loaded in pages of ten. A sentinel element sits at the bottom of the post list; an intersection observer fires when at least 50% of the sentinel enters the viewport. At that point, the next page is fetched using the last document from the previous page as a cursor. Once a page returns fewer than ten results, or no cursor is available, the infinite scroll is disabled and no further requests are made.
When the user navigates away or the community context changes, all loaded posts and pagination state are reset so the next visit starts fresh.
Each post displays an upvote (▲) and a downvote (▼) control. The net vote total is shown between them.
| Current state | User action | Outcome |
|---|---|---|
| No existing vote | Upvote or downvote | Vote recorded; count changes by ±1 |
| Same vote already cast | Click same direction again | Vote removed (toggle off); count reverses by ∓1 |
| Opposite vote already cast | Click opposite direction | Vote switched; count changes by ±2 |
All vote mutations are written as a single atomic batch — the user's vote record and the post's aggregate vote count are always updated together.
Users who are not members of a restricted or private community cannot vote on posts within it. Attempting to do so surfaces an error notification and the vote is discarded.
The UI vote count is updated after the server write completes, not before. This means there is a brief moment where the display lags the user's action. There is no client-side rollback if the server write fails.
When a user loads a community page (or their community membership changes), all of their existing votes for that community are fetched and held in memory. This ensures vote icons correctly reflect prior votes on page load. On logout, all local vote state is cleared.
flowchart TD
A[User clicks upvote or downvote] --> B{Authenticated?}
B -- No --> C[Auth modal opens]
B -- Yes --> D{Restricted or private community?}
D -- Yes, user not a member --> E[Error toast — no vote recorded]
D -- No, or user is member --> F{Existing vote?}
F -- No vote --> G[Record new vote]
F -- Same direction again --> H[Remove vote — toggle off]
F -- Opposite direction --> I[Switch vote direction — ±2 delta]
G --> J[Atomic batch write to database]
H --> J
I --> J
J --> K[Update local vote state in memory]
The Share action on any post copies the post's canonical URL to the user's clipboard. The URL is constructed from the current browser origin combined with the post's community and post identifiers. No server call is made, and the platform does not use the native Web Share API. A toast notification confirms that the link has been copied.
Authenticated users can bookmark posts for later review using the Save action. Saving is a toggle: clicking Save on an already-saved post removes the bookmark.
When a post is saved, a lightweight bookmark record — containing the post ID, community ID, post title, and community image — is written to a subcollection on the user's account. Unsaving deletes that record. The local saved-post list is updated immediately (optimistic update) so the UI responds without waiting for the server.
The saved posts modal, accessible from the navigation bar, lists all bookmarked posts. Each entry shows the community image, post title (as a link to the post), community name (as a link to the community), and a trash icon. Clicking the trash icon removes the bookmark inline. Navigating to a linked post closes the modal automatically.
The full saved-post list is fetched in a single query with no pagination. For users who save a large number of posts, this could result in a slow initial load.
Known gap: The flag that would prevent the saved-post list from being re-fetched on every modal open is never set in the current implementation, so the full list is re-fetched each time the modal opens.
Note: When a post is deleted, any saved-post entries pointing to it are not automatically removed. Stale bookmarks may therefore remain in a user's saved-posts list until they are manually dismissed.
A post can be deleted only by its author or a community admin. The delete button is hidden from all other users; there is no server-side permission enforcement.
A confirmation dialog is shown before any deletion proceeds. On confirmation, the deletion happens in the following order:
- The post's image (if any) is removed from Firebase Storage.
- The post document is deleted from Firestore.
- All comments on the post are collected and batch-deleted from Firestore.
The post is removed from the local feed and from the saved-posts list immediately (optimistic removal). If deletion fails, the post is restored to the feed — however, its entry in the saved-posts list is not restored on failure.
Partial failure risks:
- If the Firestore delete succeeds but the comment batch fails, the post document is gone whilst its comments remain as orphaned records.
- If the Storage delete succeeds but the Firestore delete fails, the image file is permanently lost whilst the post document persists.
Each post is rendered as a card composed of several distinct sections:
| Section | Contents |
|---|---|
| Vote section | Upvote / downvote controls and net vote count |
| Details | Community image (shown in multi-community contexts such as the home feed), community link, author username, relative timestamp |
| Title | Post title (up to 300 characters) |
| Body | First 30 words of the post body; full image thumbnail if present |
| Actions | Share, Save (toggle), Delete (conditional on authorship or admin role) |
The community image displayed in the details section is the snapshot captured at creation time, not the community's current image.
Known gap: Post body is always truncated to 30 words, even when viewing the single-post detail page. Full body rendering is not currently implemented.
The Comments system supports threaded discussion beneath each post, with atomic count synchronisation and cascading deletion.
Authenticated community members can leave a comment on any post. On public communities there is no membership gate; on restricted communities the user must be a member. The comment form accepts free text validated by Zod — an empty submission is not permitted, and the submit button is disabled until the input is valid.
On submission, the new comment document and the post's comment count increment are written as a single atomic batch, ensuring the count never falls out of step with the number of comment documents.
Any comment can be replied to, and any reply can itself receive a reply — but nesting is capped at two levels deep.
graph TD
A["Top-level comment (depth 0)"] --> B["First-level reply (depth 1)"]
B --> C["Second-level reply (depth 2 — maximum)"]
C -. "Reply button hidden" .-> D["No further nesting permitted"]
The depth limit is enforced at two layers:
- UI layer — the Reply button is not rendered on any comment at depth 2 or greater.
- Library layer — the comment creation logic throws an error if the requested depth exceeds 2, providing a backstop independent of the UI.
Replies are submitted using the same atomic batch pattern as top-level comments.
All comments for a post are fetched in a single flat Firestore query, ordered by creation date descending (newest first). There is no paginated or incremental loading — the entire thread is retrieved at once.
Once fetched, the client builds an in-memory tree: each comment is indexed by its ID, and replies are attached to their parent node. Any reply whose parent cannot be found (for example, because the parent was deleted outside the normal cascading path) falls through and is rendered as a top-level comment.
The rendered tree defaults all nodes to expanded. Branch nodes (comments with replies) can be individually collapsed or expanded.
Real-time updates are not supported. Comments posted by other users after the page loads are not shown until the page is refreshed.
Comments are always ordered newest-first (by creation date descending). This ordering applies to both top-level comments and to replies within each thread. There is no user-facing sort control — the order is fixed.
A comment can be deleted by its author or a community admin. A confirmation dialog is shown before any deletion proceeds.
Deleting a comment also deletes all of its descendants. The full set of IDs to delete — the target comment plus all children and grandchildren — is collected from the current in-memory comment tree. All deletions and the corresponding decrement of the post's comment count are committed as a single atomic batch.
flowchart TD
A[User confirms deletion] --> B[Identify target comment]
B --> C[Recursively collect all descendant IDs\nfrom in-memory comment tree]
C --> D[Build full delete list]
D --> E["Atomic batch write:\ndelete all comment documents\ndecrement post comment count by total deleted"]
E --> F[Remove deleted comments from local state]
Staleness risk: The cascading delete relies on the in-memory comment tree being current. If another user has posted replies since the page was loaded, those newer replies will not be included in the delete batch and will remain in Firestore as orphaned documents — invisible to readers but persisting in storage.
Firestore write batches are capped at 500 operations. Given the two-level depth limit, this ceiling is unlikely to be reached in practice, but it remains a theoretical boundary.
The comment count on each post is maintained using Firestore's server-side atomic increment and decrement sentinels. This avoids read-modify-write races when multiple users interact with the same post concurrently.
| Event | Database change | Local state change |
|---|---|---|
| Comment created | Count incremented by 1 | Local selected-post count updated after server write |
| Comment(s) deleted | Count decremented by number deleted | Local selected-post count updated after server write |
The local count update occurs after the server call completes. If the server write fails, local state is not rolled back — the displayed count may drift from the true database value until the next page load.
An Edit button is rendered in the UI for the comment author, but it currently has no action attached. Comment editing is not yet implemented.
Each user on Circus has a profile consisting of a display name and an optional avatar image. This section describes how users update their profile and what those changes do and do not affect across the rest of the platform.
When selecting a profile image, the following constraints apply:
| Constraint | Limit |
|---|---|
| Accepted formats | JPEG, PNG, GIF |
| Maximum file size | 10 MB |
| Maximum dimensions | 300 × 300 px |
Validation is performed client-side at the time of file selection. There is no server-side enforcement of image dimensions.
All selected images that pass the file constraint checks are drawn onto an HTML canvas and resized to fit within 300 × 300 pixels before upload. The resulting image is encoded as JPEG at full quality. This processing occurs entirely in the browser — the original file is never transmitted to the server.
The profile image is stored at a fixed path in Firebase Storage scoped to the user's account. Uploading a new image overwrites the previous one at the same path — there is no version history. After upload, the user's authentication profile is updated with the new image URL and the page is refreshed.
A user can remove their profile image entirely. The file is deleted from storage and the image URL is cleared from their authentication profile.
Profile image changes do not update any existing posts or comments. Post and comment records store only the creator's user ID and display name — not their image URL. As a result, a profile image change has no effect on how a user appears in historical content. Any avatar shown alongside a post or comment is resolved dynamically from the user's current authentication profile at render time, and only in UI locations explicitly designed to display it.
| Rule | Value |
|---|---|
| Required | Yes |
| Minimum length | 1 character |
| Maximum length | 50 characters |
| Uniqueness check | No — duplicate display names are permitted |
| Character restrictions | None |
| Profanity filter | None |
Unlike profile images, display name changes do propagate retroactively to all existing posts and comments. When a user saves a new display name, the following occurs in sequence:
- The authentication profile is updated immediately with the new display name.
- A batch write updates the display name on every post the user has ever created.
- A second batch write updates the display name on every comment the user has ever written.
- The in-memory post list on the current page is patched immediately, so the change is visible without requiring a full data reload for already-rendered content.
- The page is refreshed.
Firestore batch writes are limited to 500 operations per batch. A user who has created more than 500 posts, or more than 500 comments, will silently exceed this limit when changing their display name. The batch write will fail, and their display name will be updated in their authentication profile but will not be updated across all their historical posts or comments. No error is surfaced to the user in this scenario.
There is no chunking or batching in pages — the entire post or comment history is processed in a single batch attempt.
The following diagram shows what occurs when a user saves a display name change:
flowchart TD
A[User saves new display name] --> B[Validate new name against schema]
B --> C[Update Firebase Auth display name]
C --> D["Batch write new name to all posts
& all comments
⚠️ WARNING: Firestore batches are capped at 500 ops.
If the user has >500 posts or >500 comments,
the batch fails silently — no error is shown."]
D --> E[Patch in-memory post list for current page]
E --> F[Done]
For comparison, the effect of a profile image change is far simpler: only the authentication profile is updated. No posts, comments, or in-memory state are touched.
| Change type | Auth profile | All posts | All comments | In-memory state |
|---|---|---|---|---|
| New profile image | Updated | Not updated | Not updated | Not updated |
| Profile image removed | Cleared | Not updated | Not updated | Not updated |
| Display name changed | Updated | Batch-written | Batch-written | Patched |
The profile modal can be opened from two locations:
- Navigation bar — The user menu in the top-right corner of the navbar (visible only when signed in) contains a Profile option. Clicking it opens the profile modal.
- Comment input — When a signed-in user views a post, their avatar is displayed next to the comment text field. Clicking the avatar opens the profile modal directly.
The modal opens in read-only mode, showing the current avatar, display name, and email address. Clicking Edit switches it to edit mode, revealing image controls and an editable display name field. Clicking Cancel discards all pending changes without saving.
There is no global or platform-wide admin role in Circus. The term "admin" always refers to a community-scoped role — a user who has admin privileges in one community has no elevated powers anywhere else on the platform. There is no superadmin account, site-wide moderator role, or any form of cross-community access control.
All user accounts are equal at the platform level. Elevated permissions exist only within the context of a specific community and only for the lifetime of that community.
This section covers the cross-cutting UI features that shape the overall user experience of the Circus platform.
The search control is always visible in the navbar. The experience adapts to screen size: a labelled 'Search' button on mobile and a full-width read-only input field on desktop. Activating either opens a centred modal at the top of the viewport, with the text input focused automatically.
Search is global — it is not scoped to the community the user is currently browsing.
Results appear beneath the input as the user types, grouped into Communities and Posts. Each community result shows its avatar, name, and member count. Each post result shows its title, the community it belongs to, the author, and a relative timestamp. If no matches are found, a "No results found" message is shown. Selecting a result navigates to the relevant page and dismisses the modal.
On first open, the modal triggers a single Firestore read that loads all public communities and the 100 most-recent posts into memory. Every subsequent keystroke filters this in-memory dataset — no further network requests are made for the lifetime of the modal.
Community filtering matches against the community ID (its URL slug). Post filtering matches against the post title or body text. Both matches are case-insensitive.
There is no debouncing — filtering executes synchronously on every keystroke. Because the filtering is purely in-memory, this is imperceptible at low data volumes.
flowchart TD
A([User clicks search trigger]) --> B[Modal opens, input focused]
B --> C{First open?}
C -- Yes --> D[Fetch all public communities\n+ 100 most-recent posts from Firestore]
D --> E[Store dataset in memory]
C -- No --> N[Dataset already in memory]
E --> F[User types a character]
N --> F
F --> G[Filter communities by ID\nFilter posts by title or body]
G --> H{Any matches?}
H -- Yes --> I[Render grouped results\nCommunities · Posts]
H -- No --> J[Render 'No results found']
I --> K([User selects a result])
J --> F
K --> L[Navigate to page\nClose modal\nClear search term]
This approach trades simplicity for scalability. Loading all communities and the 100 most-recent posts on first use is inexpensive for small deployments, but will become slow and costly as data grows. At scale, a server-side search index (such as Algolia or Firestore full-text extensions) would be required to replace the client-side preload strategy.
A Top Communities card appears in the right sidebar on the home page and community pages. It displays the five communities with the highest member counts, each row showing a rank number, the community avatar, the community name, and a Join/Leave button. Every row links directly to that community's page. A View All link at the bottom navigates to the full communities directory. Skeleton rows are shown while data loads.
The sidebar always fetches a fixed batch of five and does not paginate — it is a static snapshot of the top communities.
The Communities Discovery Page shows a paginated list of communities ordered by member count, with cursor-based infinite scroll. Communities marked as restricted or private may still appear in the discovery listing; only search results are filtered to public communities.
A toggle button in the navbar allows authenticated users to switch between light and dark palettes. The change takes effect immediately across the entire application. The chosen preference is remembered across page refreshes and new browser sessions.
The platform uses the next-themes library in class attribute mode. When the user toggles the theme, a light or dark class is applied to the root html element. This class is what drives all colour changes throughout the UI. The preference is stored in browser local storage and reapplied automatically on subsequent visits.
On first visit, if the user has no stored preference, the platform defers to the operating system's colour scheme preference.
CSS transitions are disabled during theme switches to prevent a flash of unstyled content.
All colour variants throughout the UI are defined using Chakra UI's semantic design tokens. Each token provides a base (light) value and a dark variant, so the entire palette switches atomically when the root class changes.
flowchart LR
A([User clicks toggle]) --> B[Colour mode updated]
B --> C[Theme preference written to browser storage]
C --> D[Class attribute on html element\nset to light or dark]
D --> E[Dark-mode design tokens applied]
E --> F([All UI components re-render\nwith new colour tokens])
G([New browser session]) --> H[Saved theme preference read from browser storage]
H --> D
Toast notifications provide non-blocking feedback for async operations. They appear at the bottom-right of the screen, inside a React Portal, so they float above all page content regardless of the current layout stack.
Each toast has a fixed 5-second auto-dismiss duration and a manual close button. Toasts are paused if the page becomes idle. Three severity levels are used in practice: success, error, and warning.
The toaster is initialised once and shared across the entire application via a custom hook. The hook enforces consistent defaults (duration, closable, placement) so that individual call sites only need to supply a title, description, and status level. The <Toaster> component is mounted after the client has hydrated to prevent server-side rendering mismatches.
| Event | Severity |
|---|---|
| Auth sign-in or sign-up failure | Error |
| Attempting to post in a restricted community without membership | Error |
| Failed community or post data fetch | Error |
| Successful community join or leave | Success |
| Admin action outcomes | Success / Error |
The platform uses a two-column flex layout: a main content area on the left and a sidebar on the right. Chakra UI responsive props drive all breakpoint behaviour — there are no separate stylesheet files.
| Viewport | Layout |
|---|---|
| Mobile (< 768 px) | Single column; sidebar hidden; navbar shows logo icon and labelled Search button only |
| Medium (≥ 768 px) | Two-column (≈65% content / 35% sidebar); sidebar visible; full-width search input shown |
| Large (≥ 992 px) | Same as medium; directory dropdown also shows community name text beside the icon |
The navbar is sticky and positioned near the top of the viewport, remaining accessible during scroll without contributing to document flow.
flowchart LR
subgraph Mobile["Mobile (< 768px)"]
A[Main Content]
end
subgraph Desktop["Desktop (≥ 768px)"]
B[Main Content] --- C[Right Sidebar]
end
Mobile -- "Breakpoint: md (768px)" --> Desktop
The sticky navbar is the primary navigation surface. Its contents vary by authentication state:
- Unauthenticated: logo, search control, and Log In / Sign Up buttons
- Authenticated: logo, directory dropdown, search control, colour-mode toggle, saved posts, create-post shortcut, and user menu
The directory dropdown is available to authenticated users and provides quick access to communities the user is involved in. The trigger button reflects the user's current context — it displays the home icon when on the home page, a communities icon on the directory page, and the current community's avatar and name when browsing a community.
The dropdown contains a Create Community shortcut and a View All Communities link at the top, above the community lists. Below these, contents are divided into two sections:
- Moderating — communities where the user has admin privileges
- Subscribed Communities — all other joined communities
Selecting a community from the dropdown navigates to that community's page and closes the menu.
The dropdown's open/close state is managed by a Jotai atom, which is in-memory only and resets on page reload. A side effect watches the current URL path and the active community, updating the trigger's displayed context automatically as the user navigates.
Infinite scroll is used on post feeds and the communities directory page to load content progressively rather than behind a "Load More" button.
A sentinel element is placed at the bottom of each scrollable list. A browser IntersectionObserver monitors this sentinel. When the sentinel reaches 50% visibility within the viewport, the next page of content is fetched and appended to the existing list.
Pagination is cursor-based: the last document from the current page becomes the starting point for the next Firestore query. Each page fetches 10 items. When a batch returns fewer than 10 items, the observer stops triggering further fetches — the list has been fully loaded.
Guard conditions prevent duplicate fetches: if a fetch is already in progress, or if the list is known to be exhausted, the observer callback exits immediately.
| Surface | Content type |
|---|---|
| Home feed | Posts from all subscribed communities |
| Community page feed | Posts scoped to that community |
| Communities directory | All communities ordered by member count |
The Top Communities sidebar widget does not use infinite scroll. It always shows a fixed batch of five communities loaded on mount.
flowchart TD
A([Page renders list]) --> B[Sentinel div placed\nat bottom of list]
B --> C[IntersectionObserver\nattached to sentinel\nthreshold: 50%]
C --> D{Sentinel ≥ 50%\nvisible?}
D -- No --> D
D -- Yes --> E{Loading or\nno more pages?}
E -- Yes --> D
E -- No --> F[Fetch next 10 items\nusing lastVisible cursor]
F --> G[Append items to list]
G --> H{Batch < 10 items?}
H -- Yes --> I([Mark list exhausted\nObserver stops triggering])
H -- No --> J[Cursor updated to last loaded document]
J --> D
- Firebase — Cloud Firestore Data Model
- Firebase — Transactions and Batched Writes
- Firebase — Firestore Quotas (writes and transactions)
- Firebase — Authentication Cloud Function Triggers (1st gen)
- Firebase — Upload Files with Cloud Storage (Web)
- Firebase — Paginate Data with Query Cursors
- Firebase — Collection Group Queries
- Firebase — Firebase Authentication (Web)
- Next.js — App Router Documentation
- Jotai — Atomic State Management for React
- Chakra UI — Introduction
- next-themes — Dark Mode for Next.js
- MDN Web Docs — Intersection Observer API
- Zod — TypeScript-first Schema Validation
- TanStack Query — Optimistic Updates