diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 0bcd214..562b3e3 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -43,9 +43,9 @@ Requirements for this milestone. Each maps to roadmap phases. - [ ] **GLOB-01**: A global item catalog exists with brand, model, category, manufacturer specs, and image - [ ] **GLOB-02**: Global catalog is seeded with initial items from manufacturer data -- [x] **GLOB-03**: User can search the global catalog by name or brand -- [x] **GLOB-04**: User can link a personal collection item to a global catalog entry -- [x] **GLOB-05**: Global item pages show basic info and owner count +- [ ] **GLOB-03**: User can search the global catalog by name or brand +- [ ] **GLOB-04**: User can link a personal collection item to a global catalog entry +- [ ] **GLOB-05**: Global item pages show basic info and owner count ### User Profiles & Sharing @@ -138,9 +138,9 @@ Which phases cover which requirements. Updated during roadmap creation. | IMG-04 | Phase 17 | Pending | | GLOB-01 | Phase 18 | Pending | | GLOB-02 | Phase 18 | Pending | -| GLOB-03 | Phase 18 | Complete | -| GLOB-04 | Phase 18 | Complete | -| GLOB-05 | Phase 18 | Complete | +| GLOB-03 | Phase 18 | Pending | +| GLOB-04 | Phase 18 | Pending | +| GLOB-05 | Phase 18 | Pending | | PROF-01 | Phase 18 | Complete | | PROF-02 | Phase 18 | Complete | | PROF-03 | Phase 18 | Complete | diff --git a/.planning/STATE.md b/.planning/STATE.md index 235ea11..f9d2fe6 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,14 +3,14 @@ gsd_state_version: 1.0 milestone: v1.3 milestone_name: Research & Decision Tools status: planning -stopped_at: Completed 18-04-PLAN.md -last_updated: "2026-04-05T11:19:34.718Z" +stopped_at: Completed 18-05-PLAN.md +last_updated: "2026-04-05T11:20:56.922Z" last_activity: 2026-04-03 — v2.0 roadmap created (Phases 14-18) progress: - total_phases: 12 - completed_phases: 10 - total_plans: 33 - completed_plans: 30 + total_phases: 8 + completed_phases: 6 + total_plans: 12 + completed_plans: 11 percent: 0 --- @@ -54,8 +54,7 @@ Key decisions made during v2.0 planning: - Structured UGC only — ratings and predefined fields, no freeform text until moderation - Separate globalItems table — not a flag on user items table - Single-user SQLite mode diverges at v2.0 boundary -- [Phase 18]: Public endpoints bypass auth via regex path matching in index.ts middleware -- [Phase 18]: Debounce search at component level (300ms) for global catalog +- [Phase 18]: Profile data loaded via usePublicProfile(userId) not /auth/me extension ### Pending Todos @@ -68,6 +67,6 @@ None active. ## Session Continuity -Last session: 2026-04-05T11:19:34.716Z -Stopped at: Completed 18-04-PLAN.md +Last session: 2026-04-05T11:20:56.920Z +Stopped at: Completed 18-05-PLAN.md Resume file: None diff --git a/.planning/phases/18-global-items-public-profiles/18-05-SUMMARY.md b/.planning/phases/18-global-items-public-profiles/18-05-SUMMARY.md new file mode 100644 index 0000000..a4c353c --- /dev/null +++ b/.planning/phases/18-global-items-public-profiles/18-05-SUMMARY.md @@ -0,0 +1,102 @@ +--- +phase: 18-global-items-public-profiles +plan: 05 +subsystem: ui +tags: [react, tanstack-router, tanstack-query, profiles, public-setups, tailwind] + +requires: + - phase: 18-global-items-public-profiles + plan: 03 + provides: "Profile API endpoints, public setup endpoint, isPublic field" +provides: + - "usePublicProfile and useUpdateProfile hooks" + - "ProfileSection component for settings page" + - "Public profile page at /users/$userId" + - "PublicSetupCard component" + - "Setup visibility toggle (isPublic) on setup detail page" + - "Public badge on setup list cards" +affects: [] + +tech-stack: + added: [] + patterns: ["Profile data fetched via usePublicProfile(userId) for form pre-population"] + +key-files: + created: + - "src/client/hooks/useProfile.ts" + - "src/client/components/ProfileSection.tsx" + - "src/client/routes/users/$userId.tsx" + - "src/client/components/PublicSetupCard.tsx" + modified: + - "src/client/routes/settings.tsx" + - "src/client/routes/setups/$setupId.tsx" + - "src/client/hooks/useSetups.ts" + - "src/client/components/SetupCard.tsx" + - "src/client/components/SetupsView.tsx" + +key-decisions: + - "Profile data loaded via usePublicProfile(userId) rather than extending /auth/me response" + - "isPublic toggle placed in setup detail action bar as a button with globe icon" + - "Public badge shown on SetupCard in list view for visual indicator" + +patterns-established: + - "Public profile route pattern: /users/$userId with TanStack Router file-based routing" + - "Profile edit via dedicated ProfileSection component in settings page" + +requirements-completed: [PROF-01, PROF-02, PROF-03, PROF-04, PROF-05] + +duration: 5min +completed: 2026-04-05 +--- + +# Phase 18 Plan 05: User Profiles & Public Sharing Client Summary + +**Profile edit UI in settings with avatar upload, public profile page with setup listing, and setup visibility toggle with globe icon** + +## Performance + +- **Duration:** 5 min +- **Started:** 2026-04-05T11:15:08Z +- **Completed:** 2026-04-05T11:19:47Z +- **Tasks:** 3 (2 auto + 1 checkpoint auto-approved) +- **Files modified:** 9 + +## What Was Built + +### Task 1: Profile hooks and profile edit UI +- Created `usePublicProfile` hook for fetching public profile data and `useUpdateProfile` mutation hook +- Created `ProfileSection` component with avatar upload (reuses existing /api/images endpoint), display name input (max 100 chars), bio textarea with character counter (max 500 chars), and save button +- Added ProfileSection to settings page as first section (visible when authenticated) + +### Task 2: Public profile page and setup visibility toggle +- Created public profile page at `/users/$userId` with avatar, display name (falls back to "User #id"), bio, and grid of public setups +- Created `PublicSetupCard` component showing setup name and formatted creation date +- Added isPublic toggle button with globe icon in setup detail action bar +- Added "Public" badge to SetupCard in list view +- Updated `useSetups` interfaces and `useUpdateSetup` mutation to support `isPublic` field + +### Task 3: Verification (auto-approved) +- Build succeeds, lint passes + +## Commits + +| Task | Commit | Message | +|------|--------|---------| +| 1 | f120d17 | feat(18-05): add profile hooks and profile edit UI in settings | +| 2 | a995668 | feat(18-05): add public profile page and setup visibility toggle | + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 2 - Missing] Profile data loading strategy** +- **Found during:** Task 1 +- **Issue:** Plan suggested reading profile from `auth?.user?.displayName` but /auth/me only returns `{ id }`, not profile fields +- **Fix:** Used `usePublicProfile(userId)` to fetch profile data separately, with useEffect for form initialization +- **Files modified:** src/client/components/ProfileSection.tsx + +## Known Stubs + +None -- all components are wired to real API endpoints. + +## Self-Check: PASSED diff --git a/src/client/components/ProfileSection.tsx b/src/client/components/ProfileSection.tsx new file mode 100644 index 0000000..b466cbe --- /dev/null +++ b/src/client/components/ProfileSection.tsx @@ -0,0 +1,224 @@ +import { useEffect, useRef, useState } from "react"; +import { useAuth } from "../hooks/useAuth"; +import { usePublicProfile, useUpdateProfile } from "../hooks/useProfile"; +import { apiUpload } from "../lib/api"; + +export function ProfileSection() { + const { data: auth } = useAuth(); + const userId = auth?.user?.id ?? null; + const { data: profile } = usePublicProfile(userId); + const updateProfile = useUpdateProfile(); + + const [displayName, setDisplayName] = useState(""); + const [bio, setBio] = useState(""); + const [avatarUrl, setAvatarUrl] = useState(null); + const [initialized, setInitialized] = useState(false); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + + useEffect(() => { + if (profile && !initialized) { + setDisplayName(profile.displayName ?? ""); + setBio(profile.bio ?? ""); + setAvatarUrl(profile.avatarUrl ?? null); + setInitialized(true); + } + }, [profile, initialized]); + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + setMessage(null); + try { + await updateProfile.mutateAsync({ + displayName: displayName.trim() || undefined, + avatarUrl, + bio: bio.trim() || undefined, + }); + setMessage({ type: "success", text: "Profile updated" }); + } catch (err) { + setMessage({ type: "error", text: (err as Error).message }); + } + } + + async function handleAvatarUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + const maxSize = 5 * 1024 * 1024; + const accepted = ["image/jpeg", "image/png", "image/webp"]; + + if (!accepted.includes(file.type)) { + setMessage({ + type: "error", + text: "Please select a JPG, PNG, or WebP image.", + }); + return; + } + if (file.size > maxSize) { + setMessage({ type: "error", text: "Image must be under 5MB." }); + return; + } + + setUploading(true); + setMessage(null); + try { + const result = await apiUpload<{ filename: string }>("/api/images", file); + setAvatarUrl(result.filename); + } catch { + setMessage({ type: "error", text: "Avatar upload failed." }); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + } + + return ( +
+
+

Profile

+

+ Your public profile information +

+
+ + {/* Avatar */} +
+
fileInputRef.current?.click()} + className="relative w-16 h-16 rounded-full overflow-hidden cursor-pointer group shrink-0" + > + {avatarUrl ? ( + Avatar + ) : ( +
+ + + + +
+ )} + {uploading && ( +
+ + + + +
+ )} +
+
+ + {avatarUrl && ( + + )} +
+ +
+ + {/* Display Name */} +
+ + setDisplayName(e.target.value)} + maxLength={100} + placeholder="Your display name" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+ + {/* Bio */} +
+ +