Skip to content

Build Log Frontend

Current Phase

Frontend MVP (Phases 0–15) complete and confirmed. Docs synced, see changelog.md's Frontend Docs Sync entry. Known Deviations below is now down to the one genuinely unresolved item pending your confirmation, plus a couple of low-priority, frontend-only implementation notes kept for visibility.

Completed Phases (Frontend)

  • Frontend Phase 0: Next.js App Router scaffolded, Tailwind v4 CSS-first config (no tailwind.config.js, @custom-variant for class-based dark mode), theme toggle with localStorage persistence, base layout, health-check smoke test against the backend.
  • Frontend Phase 1: Google Identity Services login flow wired end to end. JWT pair never reaches browser-readable code, Next.js Route Handlers proxy every backend call and store tokens as httpOnly cookies. Catch-all proxy at app/api/backend/[...path]/route.js handles the full token lifecycle: attach access token, on 401 attempt exactly one refresh, on refresh success rotate both cookies and retry the original request, on refresh failure clear both cookies and return 401 so the client immediately reflects logged-out state via a custom "auth:expired" browser event. AuthProvider/useAuth context wraps the app.
  • Frontend Phase 2: School directory (/schools) and school profile (/schools/[id]) pages. useDebouncedValue (500ms), Skeleton, pill SearchBar with inline spinner, load-more-append pagination pattern established here and reused throughout. notFound()/not-found.js and error.js patterns established here and reused throughout.
  • Frontend Phase 3: Department management page (/schools/[id]/departments), scoped to School Representatives and admins using GET /users/me/'s representative_for/is_admin fields per api-contract.md's Frontend Permission Model.
  • Frontend Phase 4: Hub activation request flow (RequestHubModal), handles both documented 400 cases (already has a hub, already pending) inline. Hub home page (/hubs/[id]), /hubs/by-school/[schoolId] redirect route.
  • Frontend Phase 5: Question list wired into the hub page (search, status filter, department filter, ordering), ask-question form, question detail with author-only edit/delete. Generic FilterSidebar/FilterSection shell. clientFetch parses DRF's field-keyed 400 errors. Ask-question flow reworked to be hub-optional at entry (SchoolPicker), TagInput built (search-as-you-type, popular tags on empty query, free-text fallback creates a new tag).
  • Frontend Phase 6: Tag browse page (/tags) and questions-by-tag page (/tags/[name]), paginated, no not-found.js since zero matches is a valid 200 empty state. QuestionListRow gained showSchool for cross-school results.
  • Frontend Phase 7: Question detail renders real answers from the answers array nested in GET /questions/{id}/. AnswerCard supports author-only inline edit/delete. router.refresh() after answer create/delete keeps the question's status icon in sync with backend-driven transitions.
  • Frontend Phase 8: Voting wired on AnswerCard, later simplified to read user_vote directly once the backend added it (see Cross-Cutting Fixes).
  • Frontend Phase 9: Comments (CommentThread, CommentRow), lazy-loaded per answer on first expand. Author-only inline edit/delete. Comment visual hierarchy iterated with feedback: comment body is the visual focus (text-xs), author name and controls sit at text-[11px] below it.
  • Frontend Phase 10: Mark-best-answer on AnswerCard, question-owner-only, hidden on whichever answer is currently best. Optimistic is_best flip across the answers array plus router.refresh() for the SOLVED transition.
  • Frontend Phase 11: Notification bell (top bar) and full /notifications page. unread_count badge, mark-read on click-through, mark-all-read.
  • Frontend Phase 12: Global search page (/search), q optional (null-query results still rank correctly per the documented priority order), tag filter via a single-select picker. School/hub/department ID filtering deliberately not exposed in this pass, no existing picker UI fit that combination well without guessing at a redesign.
  • Frontend Phase 13/14 (combined): ReportButton usable from questions, answers, and comments, hidden for the content's own author. Later split into two pages: /moderation (Moderators/Representatives, scoped to their own hubs) and /admin (Reports, cross-hub team assignment, global unanswered queue), see Cross-Cutting Fixes.
  • Frontend Phase 15: /admin fully built out: SchoolsManager (search, create/edit via a shared modal, deactivate/reactivate), ActivationRequests Queue (status-tabbed, approve/reject with optional reason), UsersManager (search, active/suspended filter, suspend/reactivate, self-suspension disabled client-side matching the backend's own safeguard). HubTeamManager gained a Departments tab (reusing Phase 3's DepartmentForm/DepartmentRow), available to reps on /moderation and to admins via AdminHubPicker on /admin, closing the gap where reps could manage departments per the docs but had no direct path to it from their own management surface.

School Cosmetic Slug (Post-MVP, Frontend)

Schools gained the same UUID-primary, cosmetic-slug URL pattern questions already use: /schools/{id}/{slug}, via schoolUrl() in lib/urls.js. School already had an unused slug field for this purpose (generated once from short_name, never regenerated, matching School's soft-delete/never-changing identity pattern). app/schools/[id]/page.js moved to app/schools/[id]/[[...slug]]/page.js, a Next.js optional catch-all; the sibling departments/page.js static route is unaffected, static segments resolve ahead of catch-alls at the same level. Every internal link to a school page (SchoolListRow, homepage sidebar, HubTeamManager's "Visit school", the departments page's back link) now goes through schoolUrl() rather than hardcoding /schools/{id}.

Known gap, not fixed here: the question detail page's "back to school" link still routes through /hubs/{id} (a permanent redirect) rather than linking to schoolUrl() directly, since Question detail's nested hub.school object has no id field per api-contract.md, only name/short_name/slug. A prior BUILD_LOG note claimed this id was already available; it isn't, per the actual documented response shape. Corrected here rather than propagated. Needs a small backend addition (hub.school.id on Question detail) before this can close.

School Directory Fields Display (Post-MVP, Frontend)

Displays the four new School fields (institution_type, ownership, state, country) added by the backend's School Data Curation pass. Built and verified against the null case specifically, since the real NUC/NBTE/NCCE import hadn't landed yet at the time of this work, every field renders independently and conditionally, a school with only one of the three populated shows exactly one badge, not three slots with two hidden.

  • lib/schoolLabels.js: single source of truth for institution_type/ownership enum-to-label mappings, consumed by both the school profile page and the admin SchoolFormModal, to avoid two hand-written copies drifting.
  • SchoolMetaBadge: shared icon+label component for the profile hero.
  • Profile hero restructured from one continuous wrapped meta line into three sectioned rows (primary facts, institutional classification, hub activity), each with its own top border, done alongside this since the single-line layout wouldn't have scaled to the added fields on mobile.
  • country intentionally has no permanent badge, renders only when the value is something other than "Nigeria", per the issue's explicit reasoning: a single-country dataset showing "Nigeria" on every school page is noise, not information.
  • SchoolFormModal gained inputs for all four fields (admin follow-up, approved alongside the main issue), institution_type/ownership default to "Not set" rather than a first-option default, since both are genuinely nullable and shouldn't be forced.
  • Known follow-up, not built in this pass: school directory page filter controls for these same three fields, tracked in feature-list.md's School Data Curation section.
  • Directory filter controls landed: app/schools/page.js gained institution_type/ownership selects (reusing lib/schoolLabels.js's option lists) and a debounced free-text state filter, alongside the existing search and has_hub checkbox. hasActiveFilters and the empty-state message now account for all four filters together, not just has_hub. Bundled fix: the two remaining border-gray-200/700 literals in this file (border-y and divide) swapped for the --color-border token, same drift item tracked since Project_audit_notes.md's Pass 5/7/9 findings.

UI Polish Pass (Post-MVP, Frontend)

Not a numbered phase. A full visual pass following user feedback, deliberately scoped as "touch up the existing interface" rather than a redesign, after a full alternative mockup (different layout, card-heavy, pill-shaped everything) was reviewed and explicitly rejected in favor of the current structure with more personality.

  • Typography: switched to Plus Jakarta Sans app-wide via next/font/google (self-hosted, no runtime request), one family, hierarchy via weight only.
  • Color: introduced a warm "milk" light background and matching warm dark background, ink-blue (#3454D1) as the single accent. One iteration overshot this by also re-tinting Tailwind's entire default gray scale, which bled a muddy brown tone into every neutral surface app-wide (sidebar text, popovers, skeletons, borders). Reverted, Tailwind's default gray scale is intentionally left untouched, only --color-bg, --color-text, --color-border, --color-accent, and the red scale (for destructive states) are overridden.
  • Status colors: SOLVED given its own distinct success color instead of reusing the accent; ANSWERED corrected from a leftover unrelated stock blue to the actual accent, so it's visually consistent with every other accent-colored affordance in the app.
  • Skeleton loaders: flat pulse replaced with a real shimmer sweep (.skeleton-shimmer), Skeleton.js is the only component touched, every loading state across the app inherited it automatically.
  • Homepage hero: rebuilt as a full-bleed background panel (image URL is a one-line swap once real artwork exists, currently an accent-tinted placeholder plus a legibility scrim), replacing the earlier two-slot illustration placeholder concept. Required a non-obvious full-bleed CSS fix: the straightforward calc(50% - 50vw) margin trick only produces a correct result when the parent has no max-width of its own; AppShell's centered, capped
    broke that assumption and introduced a real horizontal scrollbar on wide viewports. Replaced with a margin-left:50%
  • transform:translateX(-50%) approach, which is correct regardless of parent constraints.
  • Motion: CSS-only throughout, no new dependency. Global button press feedback and consistent focus-visible rings added via plain element selectors in globals.css, no per-component classes needed. Opt-in .stagger-list class added to every question/answer/comment list container for a staggered fade-up on load; new rows appended via "View more" animate in on their own since React only mounts the new elements.
  • Shape language: swept every rounded-full usage except the two genuine pill-shaped search bars and two legitimate circular elements (profile avatar, notification unread-count badge) down to a standard rounded radius, matching the rest of the app's buttons.
  • Question following and locking wired into the frontend for the first time (FollowButton, LockToggle), previously backend-complete but unbuilt. LockToggle shipped with a real bug: the success path never reset local loading state back to idle, only the catch block did, so after a successful lock/unlock the button appeared permanently frozen in a loading state until a full page reload remounted the component. router.refresh() only updates props from the server, it never resets a Client Component's own local state, that reset has to happen explicitly alongside the refresh call. Fixed.
  • School and Hub pages merged into one, /schools/[id] now shows the full hero (location, website, verification, question/moderator counts), Ask a question action, and the complete filterable question list. /hubs/[id] and /hubs/by-school/[schoolId] both reduced to thin redirects into the merged page, kept as real routes since existing bookmarks and email links point at them.
  • Actions menu / kebab pattern: question, answer, and comment action rows (Edit, Delete, Report, Escalate) consolidated into a shared Dropdown + ActionsMenu pair, replacing an inline row that had grown too long after Report and Escalate landed. Visible-vs-collapsed split: Follow stays inline on questions, vote control and Mark-best-answer stay inline on answers, everything else (including Lock/Unlock, not yet migrated, pending LockToggle.js review) collapses into the menu. Required ReportButton/EscalateButton to become controlled components (open/onClose props instead of owning their own trigger), so their modal state survives the menu closing on item click. AnswerCard's byline moved above the body, small and muted, matching CommentRow's existing pattern, so the answer content reads as the visual priority. ProfileMenu and NotificationBell later migrated onto the same Dropdown primitive for consistency, picking up Escape-to-close as a side benefit neither had before.

Cross-Cutting Fixes (Post-MVP, Frontend)

  • Cookie-Forwarding Fix: apiFetch (every Server Component page) was calling the internal /api/backend proxy without forwarding the incoming request's cookies, since a server-side fetch() never inherits the browser's cookie jar automatically, making every SSR data request silently anonymous. Fixed by reading cookies() and forwarding a Cookie header explicitly. Residual limitation: a token refresh mid-SSR-render can't propagate the new cookie back to the browser, since Server Components can't call cookies().set(). Self-heals on the next client-side interaction via clientFetch.
  • Vote State Visibility Fix: backend added user_vote ("UP"/"DOWN"/null) to the Answer serializer everywhere an answer object appears. AnswerCard simplified to read it directly instead of session-only tracking.
  • SOLVED Status Semantics Fix: backend decoupled "has a designated best answer" from "closed to further input." AnswerForm's SOLVED-block removed, answers can be submitted to a question at any status.
  • Question URL slugs: question detail route restructured to an optional catch-all, app/questions/[id]/[[...slug]]/page.js, so URLs render as /questions/{id}/{slug} (matching outbound email links) while the slug segment itself is never read for lookup. lib/urls.js's questionUrl() builds this consistently, and falls back to a client-generated slug (lib/slugify.js) when an API response doesn't include one (e.g. search results), since the slug is cosmetic either way.
  • Nav rebuild: original single-row Header caused horizontal viewport overflow on narrow screens (a flex child's default min-width: auto prevented shrinking). Replaced with AppShell (TopBar + collapsible Sidebar overlay + Footer). Sidebar is a toggle-only overlay at every screen width, not a persistent desktop column, per explicit direction. Sidebar/TopBar read the same --color-bg/--color-border CSS variables as the page body (not Tailwind's gray-900) so chrome and page background are always identical by construction. Popover surfaces (ProfileMenu, notification dropdown) deliberately keep bg-white/dark:bg-gray-800, meant to read as elevated above the page, not blended into it. Theme toggle lives at the bottom of the sidebar.
  • NaN relative-time fix: timeAgo() now guards against a missing/invalid date and returns null rather than NaN; QuestionListRow falls back to created_at when updated_at is absent (e.g. search results) and only renders the timestamp when one is actually available. Same missing-field pattern also affected answer_count on search results, QuestionListRow now only renders that segment when the field is present as a number.
  • Search Users picker: GET /users/search/ (admin or active School Representative) now drives a real search-and-pick UI (UserSearchPicker) for assigning moderators/representatives, replacing an earlier raw-UUID input workaround.
  • Profile page rebuilt: editable name (PATCH /users/me/), stat cards from GET /users/me/'s stats, a merged "recent activity" feed (client-side merge of the first page of GET /questions/?author=, GET /users/me/answers/, and GET /users/me/comments/, sorted by date), full paginated tabs per content type. Deliberately excludes reports entirely, per api-contract.md's explicit design that reporter activity is never surfaced on any profile. ProfileMenu gained a "Your profile" link above "Log out."
  • Backend fix (applied, not a frontend change): POST /reports/{id}/resolve/ with DELETE_CONTENT was 500ing, GenericForeignKey's cached content_object went stale (pk set to None) after .delete(), and Django's save-time check refused to save the parent Report with a stale cached relation. Fixed with report.refresh_from_db() after deleting the content, before setting the resolution fields.
  • Backend fix (applied, not a frontend change): GET /users/me/comments/ was 500ing (KeyError on a nonexistent "user_id" kwarg that doesn't exist on that URL) and had AllowAny permissions contradicting its own documented "requires authentication, scoped to the requesting user" behavior. Fixed to use IsAuthenticated and filter by request.user.id directly.
  • Backend fix (applied, not a frontend change): deactivated schools and departments disappeared from every view, including the admin/rep view meant to manage and reactivate them, since SchoolListCreateView, SchoolDetailView, and DepartmentListCreateView all unconditionally filtered to is_active=True regardless of requester, despite api-contract.md already documenting admin/rep visibility into inactive records correctly. Fixed by branching the queryset on admin/rep status; school admin detail/patch now always includes inactive records so reactivation is possible through the API at all (previously required a Django admin round-trip). List-view public caching bypassed entirely for admin requests to avoid cache cross-contamination between privileged and public responses.
  • Moderation/Admin split: /moderation (Moderators and Representatives, scoped to their own hubs) and /admin (Reports, cross-hub team assignment, schools, activation requests, users) fully separated by audience. HubTeamManager's hub cards gained a Departments tab (reusing Phase 3's components) and an always-visible Unanswered Questions tab: reps who aren't also moderators for a hub see a message explaining they can self-assign as moderator via the Moderators tab to unlock it, rather than the tab disappearing and hiding that workflow entirely.
  • FilterSidebar's activeCount prop (added last round but left unwired) is now passed a real number from every consumer (HubQuestionList, TagQuestionsClient, SearchResultsClient), so the mobile toggle's badge shows an actual count instead of a generic dot.
  • Best answer highlight: the question detail page now pins a compact, read-only preview of the current best answer directly under the question body, with a "View in list" control that smooth-scrolls to and briefly highlights the corresponding full AnswerCard further down. Deliberately not a second interactive instance (no independent vote buttons or comment thread in the preview), to avoid two UI copies of the same answer drifting out of sync with each other; voting and commenting stay owned by the one real AnswerCard in the list. Updates automatically when a different answer is marked best, since it's derived from the same answers state AnswersSection already tracks.
  • UnansweredQueue switched from client-side loaded-page filtering to a real debounced server-side search, now that GET /questions/unanswered/ supports a search param. Matches HubQuestionList's existing pattern. Closes the last remaining "search only covers what's loaded" caveat from the Admin/Moderation Filter Pass.

Known Deviations From Docs (Frontend)

  • api-contract.md's Search Users section documents "Admin or School Representative" access; a message during development described the intended permission as including Moderators instead. Still unconfirmed.
  • Notifications and Reports targeting "answer" or "comment" content have no standalone detail page to deep-link to; both render the content as non-clickable plain text instead of a broken link.
  • Concurrent-request refresh race not yet mitigated: if multiple requests expire at the same moment, more than one may attempt to use the same refresh token before rotation completes, one could be rejected. Unlikely at MVP traffic levels.
  • The HTML-entity arrow sweep (← etc. to lucide icons) is incomplete, only the question detail page's back link was converted incidentally during the follow/lock work. At minimum the departments page and tags page back-links still use literal arrow characters.

Planned, Not Yet Scheduled (UI/UX Polish Phase)

  • Homepage rebuild: unfiltered paginated question list with hub/tag metadata, load-more pagination, and tags/schools side cards.
  • Friendly, user-facing error copy to replace raw backend/network error text throughout the app.
  • School profile and hub pages merged into one page (single hero + question list), since the relationship is strictly one-to-one and currently splits minimal info across two pages for no strong reason.
  • School Representatives currently have no visibility into reports for their own school's content, Reports is admin-only per api-contract.md. Worth considering: rep-scoped report access limited to their own hub's content, would need both a backend permission/query change and a frontend surface.
  • Question following: backend complete and documented (Follow/Unfollow Question, is_following field), not yet wired into the frontend. Needs a follow/unfollow control on the question detail page.
  • Question lock: backend complete and documented (Lock/Unlock Question, is_locked field), not yet wired into the frontend. Needs a lock/unlock control visible to admins, reps, and moderators, and an is_locked-aware state on the answer form (a locked question should read similarly to how SOLVED once did, before that was decoupled, this time as a genuine, deliberate block).
  • Replace remaining HTML entity arrows (← etc.) across the app with proper icon components (lucide-react), for visual consistency with every other directional/status affordance already built as an icon.
  • School Representative visibility into reports for their own school's content, Reports is admin-only per api-contract.md today.
  • Standalone answer/comment permalink page for notification/report deep-links.
  • Full favicon/OG-image/web-manifest asset pass, deferred until the project's final name and logomark are decided, base technical SEO (metadata, sitemap.js, robots.js) shipped ahead of that.
  • Dynamic OG images (Phase C): lib/og.js holds the shared OgCard layout, Google Fonts glyph-subset loader, and hex color tokens transcribed from globals.css (Satori/ImageResponse can't read CSS variables or Tailwind classes, inline style objects only). opengraph-image.js route files added for the homepage (also the site-wide fallback via Next's file-convention inheritance), schools, questions, and tags. The public/og-default.png placeholder requested during the earlier metadata pass is no longer needed and was removed from app/layout.js's static openGraph/twitter image references, this system replaces it entirely.
  • OG content refinements: schools now carry a self-explanatory one-line description ("Academic Q&A hub for {short_name} students") in place of the raw location string. Questions gained an answer-count stat line (explicitly handles the zero-answer case rather than showing "0 answers"), folded SOLVED status into that same line instead of a separate badge, tags gained a "{N} questions answered" stat, deliberately framed as evidence of usefulness rather than a bare count, consistent with project-plan.md's Knowledge Over Social Activity principle.
  • Phase D: BreadcrumbList structured data added to school and question pages. School page's back-link changed from an arrow-back pattern to a plain "Schools" breadcrumb nav, since it now has multiple valid entry points (directory, homepage, search) rather than one obvious parent to arrow back to; question page kept its arrow-back pattern, which still fits since a question has exactly one clear parent (its school). Both back/breadcrumb links wrapped in

Platform Account Marker (Post-MVP, Frontend)

Not a role system: lib/platformAccount.js compares a single hardcoded account id (NEXT_PUBLIC_PLATFORM_ACCOUNT_ID, unset by default) against an author's id, nothing else. PlatformAuthorBadge (a small BadgeCheck icon) renders next to that one account's name wherever an author is shown (question "Asked by", answer and comment bylines), and renders nothing for every other account, and nothing at all anywhere if the env var is unset. No is_admin exposure on any serializer, no per-user role logic. Exists to give Academia's own seed/editorial account (used to post initial content ahead of real user growth) a visible identity mark, since avatars aren't rendered anywhere in the current UI and full_name alone was judged not distinctive enough on its own.