forked from Qortal/q-blog
216 lines
8.2 KiB
Markdown
216 lines
8.2 KiB
Markdown
# 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}/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?: 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 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).
|
||
- **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[]` (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}/blogs` → `UserBlogs` component.
|
||
2. 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 })`.
|
||
- **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 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 informative `aria-label`s (“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.
|
||
|
||
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 blog’s 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.
|