8.2 KiB
Implementation Guide — Multiple Blogs per Name (Plan A)
Generated 2025-08-21
AAA mode: Actionable, artifact-oriented, and accessible. This guide defines exact files to create or modify, contracts, a11y rules, redirects, and tests.
Contracts & Routing
Entities (unchanged)
- Name: can own multiple Blog records.
- Blog: immutable link to its Name; addressed by
blogHandle(aka blog id). - Post: 1..1 Blog (unchanged).
Routes (final form)
/{name}- If hasExactlyOneBlog(name) → redirect (replace history) to
/{name}/{blog}/posts(or current posts index). - Else → route to User Blogs page (
/{name}/blogs).
- If hasExactlyOneBlog(name) → redirect (replace history) to
/{name}/blogs→ User Blogs page (new)./{name}/{blog}/*→ existing blog-scoped routes (unchanged).
Assumption: Existing default blog landing is /{name}/{blog}/posts. Adjust if your index differs.
UI Changes
1) Header/Nav — “My Blogs” dropdown
File: src/components/HeaderNav.tsx (or equivalent container for auth controls)
- Replace single “My Blog”/“Create Blog” button with a dropdown (same pattern as the Authenticate/Names dropdown).
- State inputs:
currentUser?: NameRefuserBlogs: BlogSummary[]— list of blogs for current user (title +blogHandle).onCreateBlog: () => voidonSelectBlog: (blog: BlogRef) => void→ navigates to/{name}/{blog}/postsand updates active blog in store.
- Label logic:
userBlogs.length === 0→ show Create Blog (primary button), hide dropdown caret.userBlogs.length >= 1→ show My Blogs with caret; open menu on click.
- Menu content (for ≥1):
- One item per blog:
Title · blogHandle(truncate cautiously). Current active shows a ✓. - Divider
- Create new blog (button-like menu item).
- One item per blog:
- A11y:
- Use proper
role="menu"/menuitem",aria-haspopup="menu",aria-expanded,aria-controls. - Focus returns to the trigger on close. ESC closes.
- Ensure high-contrast tokens; respect
prefers-reduced-motion(no aggressive animation).
- Use proper
Note: If a reusable
BlogSwitcherexists, consider refactoring the above as aUserBlogSwitchervariant that consumes the current user’s blogs.
2) User Blogs page (new)
File: src/pages/UserBlogs.tsx (or src/routes/UserBlogs.tsx)
- H1: “{name}’s Blogs”
- List: Card per blog with title,
blogHandle, optional description/meta. - Actions:
- View → pushes route
/{name}/{blog}/posts. - If
currentUser.nameId === params.nameId→ buttons: Edit, Create Blog (page-level CTA).
- View → pushes route
- Empty states:
- Self (0 blogs): “You don’t have any blogs yet.” + prominent Create Blog.
- Other user (0 blogs): “No public blogs yet.”
A11y: One H1, list semantics, buttons with explicit labels (include blog title in aria-label).
Store & Data
Selectors
selectCurrentUser()→ NameRef / undefined.selectUserBlogs(nameId)→ BlogSummary[] (RTK Query / slice selector).selectActiveBlog()→ BlogRef | null (unchanged).
RTK Query (if missing)
- Endpoint:
listBlogsByName(nameId)- Cache key:
['blogs', nameId]. - Normalized response:
BlogSummary[](includeblogHandle,title).
- Cache key:
- Consumers:
- Header dropdown fetches
listBlogsByName(currentUser.nameId)when authenticated. UserBlogspage fetcheslistBlogsByName(params.nameId).
- Header dropdown fetches
If an endpoint already exists, reuse it. Otherwise add it in
src/store/api.tswith blog-scoped cache keys documented in Q-Blog.
Routing Changes
File: src/router.tsx (or where routes are defined)
- Add route for
/{name}/blogs→UserBlogscomponent. - Update
/{name}route element to perform smart redirect:- On mount:
- Fetch
blogs = listBlogsByName(params.nameId). - If
blogs.length === 1→navigate('/{name}/' + blogs[0].blogHandle + '/posts', { replace: true }). - Else →
navigate('/{name}/blogs', { replace: true }).
- Fetch
- Loading state: show a lightweight spinner/skeleton with polite live region (“Loading blogs…”).
- On mount:
Keep deep links to
/{name}/{blog}/…working as today.
Permissions
- Editing controls on
UserBlogsare visible only when viewing your own Name (currentUser.nameId === params.nameId). - All blog/post routes retain existing guards (no change).
Edge Cases & Behavior
- 0 blogs (self): Header shows Create Blog.
/{name}goes to/blogswith the “create” empty state. - 1 blog: Header shows My Blogs; menu has one item + “Create new”. Name root redirects to that blog.
- N blogs (large): Menu scrolls; consider collapsed ids or optional search later (not part of this change).
- Anonymous viewer: Can see another user’s
/{name}/blogs, no edit/create controls. - Broken blogHandle: If a listed blog 404s, show error boundary as today; menu item should not break the app.
Accessibility Requirements
- Dropdown follows WAI-ARIA menu pattern; keyboard: ArrowUp/Down, Enter, ESC.
- Focus management: opening menu moves focus to first item; closing returns to trigger.
- Single H1 on
UserBlogs. Buttons have informativearia-labels (“View blog Alpha (alpha)”). - Live region only for the brief loading text on Name root redirect; no chatty updates.
Testing Plan (Vitest + RTL + user-event + MSW + jest-axe)
If MSW/jest-axe aren’t present yet, include unit tests now and add MSW/a11y smoke later. Aim to cover behavior, not visuals.
-
Header/Menu
- 0 blogs → shows “Create Blog” button, no dropdown.
- 1+ blogs → shows “My Blogs”; opens menu; selecting a blog navigates to proper route and sets active blog.
- Keyboard navigation: items reachable via arrows; ESC closes; focus returns to trigger.
-
Name Root Redirect
- 1 blog → navigates to blog posts route (replace history).
- 0 or >1 → navigates to
/{name}/blogs. - Loading state renders politely with an
aria-live="polite"region text.
-
UserBlogs Page
- Renders list matching
listBlogsByNameresponse. - Self-view shows Edit + Create; other-view hides them.
- Empty states render correct CTAs/messages.
- Renders list matching
-
A11y/axe Smoke
- Header and
UserBlogsproduce no critical axe violations.
- Header and
Step-by-step Changes (files)
-
Create:
src/pages/UserBlogs.tsx- Functional component rendering list of blogs by
params.nameId. - Uses RTK Query selector/hook to fetch
listBlogsByName. - Buttons: View, (conditional) Edit; page-level Create when self.
- Functional component rendering list of blogs by
-
Modify:
src/router.tsx- Add
/{name}/blogsroute. - Update
/{name}route element to perform smart redirect based on fetched blog count.
- Add
-
Modify:
src/components/HeaderNav.tsx- Replace single button with dropdown:
- When 0 blogs → primary Create Blog button.
- When ≥1 blogs → My Blogs dropdown listing blog items + “Create new blog”.
- Wire
onSelectBlogto navigate and set active blog; wireonCreateBlogto current flow (dialog/route).
- Replace single button with dropdown:
-
Add (if missing):
src/store/api.ts- Endpoint:
listBlogsByName(nameId)withprovidesTags: (nameId) => ['blogs', nameId]or equivalent. - Types:
BlogSummary(title, blogHandle).
- Endpoint:
-
Tests:
tests/routes/UserBlogs.test.tsxtests/components/HeaderNav.multiblog.test.tsxtests/routes/NameRootRedirect.test.tsx
-
Docs:
- Link this guide from
docs/USER_JOURNEYS.mdanddocs/ARCHITECTURE.mdafter merge.
- Link this guide from
Acceptance Criteria
- Header shows Create Blog when user has 0 blogs; shows My Blogs dropdown when ≥1.
- Selecting a blog from the dropdown navigates to
/{name}/{blog}/postsand marks it active. - Visiting
/{name}:- If exactly one blog exists → silently lands on that blog’s posts list.
- Else → shows
/{name}/blogspage listing all blogs for that Name.
UserBlogspage renders with a single H1, accessible list, and correct empty states.- Unit/component tests pass; a11y smoke has no critical issues.
- No regressions to existing blog-scoped routes; deep links remain valid.
Rollout Notes
- No data migrations; purely UI/routing.
- If telemetry exists, consider logging menu usage and redirect outcomes to validate adoption.
- Update release notes after merge.