Files
q-blog/docs/features/TECH_IMPL_MULTIBLOG.md
greenflame089 0b100af686 Release v0.2.2
2025-08-22 07:28:42 -04:00

8.2 KiB
Raw Permalink Blame History

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).
  • /{name}/blogsUser 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?: NameRef
    • userBlogs: BlogSummary[] — list of blogs for current user (title + blogHandle).
    • onCreateBlog: () => void
    • onSelectBlog: (blog: BlogRef) => void → navigates to /{name}/{blog}/posts and 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).
  • 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).

Note: If a reusable BlogSwitcher exists, consider refactoring the above as a UserBlogSwitcher variant that consumes the current users 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).
  • Empty states:
    • Self (0 blogs): “You dont 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[] (include blogHandle, title).
  • Consumers:
    • Header dropdown fetches listBlogsByName(currentUser.nameId) when authenticated.
    • UserBlogs page fetches listBlogsByName(params.nameId).

If an endpoint already exists, reuse it. Otherwise add it in src/store/api.ts with blog-scoped cache keys documented in Q-Blog.


Routing Changes

File: src/router.tsx (or where routes are defined)

  1. Add route for /{name}/blogsUserBlogs component.
  2. Update /{name} route element to perform smart redirect:
    • On mount:
      • Fetch blogs = listBlogsByName(params.nameId).
      • If blogs.length === 1navigate('/{name}/' + blogs[0].blogHandle + '/posts', { replace: true }).
      • Else → navigate('/{name}/blogs', { replace: true }).
    • Loading state: show a lightweight spinner/skeleton with polite live region (“Loading blogs…”).

Keep deep links to /{name}/{blog}/… working as today.


Permissions

  • Editing controls on UserBlogs are 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 /blogs with 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 users /{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 informative aria-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 arent present yet, include unit tests now and add MSW/a11y smoke later. Aim to cover behavior, not visuals.

  1. 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.
  2. 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.
  3. UserBlogs Page

    • Renders list matching listBlogsByName response.
    • Self-view shows Edit + Create; other-view hides them.
    • Empty states render correct CTAs/messages.
  4. A11y/axe Smoke

    • Header and UserBlogs produce no critical axe violations.

Step-by-step Changes (files)

  1. 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.
  2. Modify: src/router.tsx

    • Add /{name}/blogs route.
    • Update /{name} route element to perform smart redirect based on fetched blog count.
  3. 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 onSelectBlog to navigate and set active blog; wire onCreateBlog to current flow (dialog/route).
  4. Add (if missing): src/store/api.ts

    • Endpoint: listBlogsByName(nameId) with providesTags: (nameId) => ['blogs', nameId] or equivalent.
    • Types: BlogSummary (title, blogHandle).
  5. Tests:

    • tests/routes/UserBlogs.test.tsx
    • tests/components/HeaderNav.multiblog.test.tsx
    • tests/routes/NameRootRedirect.test.tsx
  6. Docs:

    • Link this guide from docs/USER_JOURNEYS.md and docs/ARCHITECTURE.md after merge.

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}/posts and marks it active.
  • Visiting /{name}:
    • If exactly one blog exists → silently lands on that blogs posts list.
    • Else → shows /{name}/blogs page listing all blogs for that Name.
  • UserBlogs page 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.