Merge branch 'worktree-agent-af80e237' into Develop

# Conflicts:
#	.planning/REQUIREMENTS.md
#	.planning/STATE.md
This commit is contained in:
2026-04-05 13:21:56 +02:00
12 changed files with 571 additions and 19 deletions

View File

@@ -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 |

View File

@@ -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

View File

@@ -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

View File

@@ -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<string | null>(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<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<form onSubmit={handleSave} className="space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-900">Profile</h3>
<p className="text-xs text-gray-500 mt-0.5">
Your public profile information
</p>
</div>
{/* Avatar */}
<div className="flex items-center gap-4">
<div
onClick={() => fileInputRef.current?.click()}
className="relative w-16 h-16 rounded-full overflow-hidden cursor-pointer group shrink-0"
>
{avatarUrl ? (
<img
src={`/uploads/${avatarUrl}`}
alt="Avatar"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-300 group-hover:text-gray-400 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
)}
{uploading && (
<div className="absolute inset-0 bg-white/60 flex items-center justify-center">
<svg
className="w-5 h-5 text-gray-500 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
</div>
<div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-sm text-gray-600 hover:text-gray-800 transition-colors"
>
{uploading ? "Uploading..." : "Change avatar"}
</button>
{avatarUrl && (
<button
type="button"
onClick={() => setAvatarUrl(null)}
className="block text-xs text-red-500 hover:text-red-700 mt-0.5"
>
Remove
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleAvatarUpload}
className="hidden"
/>
</div>
{/* Display Name */}
<div>
<label
htmlFor="displayName"
className="block text-sm font-medium text-gray-700 mb-1"
>
Display Name
</label>
<input
id="displayName"
type="text"
value={displayName}
onChange={(e) => 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"
/>
</div>
{/* Bio */}
<div>
<label
htmlFor="bio"
className="block text-sm font-medium text-gray-700 mb-1"
>
Bio
</label>
<textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
maxLength={500}
rows={3}
placeholder="Tell others about yourself and your gear interests"
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 resize-none"
/>
<p className="text-xs text-gray-400 mt-1 text-right">
{bio.length}/500
</p>
</div>
{message && (
<p
className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}
>
{message.text}
</p>
)}
<button
type="submit"
disabled={updateProfile.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
{updateProfile.isPending ? "Saving..." : "Save Profile"}
</button>
</form>
);
}

View File

@@ -0,0 +1,33 @@
import { Link } from "@tanstack/react-router";
interface PublicSetupCardProps {
setup: {
id: number;
name: string;
createdAt: string;
};
}
export function PublicSetupCard({ setup }: PublicSetupCardProps) {
const formattedDate = new Date(setup.createdAt).toLocaleDateString(
undefined,
{
year: "numeric",
month: "short",
day: "numeric",
},
);
return (
<Link
to="/setups/$setupId"
params={{ setupId: String(setup.id) }}
className="block bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4"
>
<h3 className="text-sm font-semibold text-gray-900 truncate">
{setup.name}
</h3>
<p className="text-xs text-gray-400 mt-1">{formattedDate}</p>
</Link>
);
}

View File

@@ -4,6 +4,7 @@ import { useFormatters } from "../hooks/useFormatters";
interface SetupCardProps {
id: number;
name: string;
isPublic?: boolean;
itemCount: number;
totalWeight: number;
totalCost: number;
@@ -12,6 +13,7 @@ interface SetupCardProps {
export function SetupCard({
id,
name,
isPublic,
itemCount,
totalWeight,
totalCost,
@@ -24,7 +26,16 @@ export function SetupCard({
className="block w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 truncate">{name}</h3>
<div className="flex items-center gap-1.5 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">
{name}
</h3>
{isPublic && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-green-50 text-green-600 shrink-0">
Public
</span>
)}
</div>
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400 shrink-0">
{itemCount} {itemCount === 1 ? "item" : "items"}
</span>

View File

@@ -100,6 +100,7 @@ export function SetupsView() {
key={setup.id}
id={setup.id}
name={setup.name}
isPublic={setup.isPublic}
itemCount={setup.itemCount}
totalWeight={setup.totalWeight}
totalCost={setup.totalCost}

View File

@@ -0,0 +1,43 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPut } from "../lib/api";
interface PublicProfile {
id: number;
displayName: string | null;
avatarUrl: string | null;
bio: string | null;
setups: { id: number; name: string; createdAt: string }[];
}
interface UpdateProfileData {
displayName?: string;
avatarUrl?: string | null;
bio?: string;
}
interface ProfileResponse {
id: number;
displayName: string | null;
avatarUrl: string | null;
bio: string | null;
}
export function usePublicProfile(userId: number | null) {
return useQuery({
queryKey: ["profiles", userId],
queryFn: () => apiGet<PublicProfile>(`/api/users/${userId}/profile`),
enabled: userId != null,
});
}
export function useUpdateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateProfileData) =>
apiPut<ProfileResponse>("/api/auth/profile", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["profiles"] });
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
});
}

View File

@@ -11,6 +11,7 @@ import {
interface SetupListItem {
id: number;
name: string;
isPublic: boolean;
createdAt: string;
updatedAt: string;
itemCount: number;
@@ -38,6 +39,7 @@ interface SetupItemWithCategory {
interface SetupWithItems {
id: number;
name: string;
isPublic: boolean;
createdAt: string;
updatedAt: string;
items: SetupItemWithCategory[];
@@ -76,7 +78,7 @@ export function useCreateSetup() {
export function useUpdateSetup(setupId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name?: string }) =>
mutationFn: (data: { name?: string; isPublic?: boolean }) =>
apiPut<SetupListItem>(`/api/setups/${setupId}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });

View File

@@ -1,5 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useRef, useState } from "react";
import { ProfileSection } from "../components/ProfileSection";
import {
useApiKeys,
useAuth,
@@ -219,7 +220,13 @@ function SettingsPage() {
<h1 className="text-xl font-semibold text-gray-900">Settings</h1>
</div>
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
{auth?.user && (
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
<ProfileSection />
</div>
)}
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">Weight Unit</h3>

View File

@@ -10,6 +10,7 @@ import {
useRemoveSetupItem,
useSetup,
useUpdateItemClassification,
useUpdateSetup,
} from "../../hooks/useSetups";
import { LucideIcon } from "../../lib/iconData";
@@ -24,6 +25,7 @@ function SetupDetailPage() {
const numericId = Number(setupId);
const { data: setup, isLoading } = useSetup(numericId);
const deleteSetup = useDeleteSetup();
const updateSetup = useUpdateSetup(numericId);
const removeItem = useRemoveSetupItem(numericId);
const updateClassification = useUpdateItemClassification(numericId);
@@ -160,6 +162,32 @@ function SetupDetailPage() {
</svg>
Add Items
</button>
{/* Public toggle */}
<button
type="button"
onClick={() => updateSetup.mutate({ isPublic: !setup.isPublic })}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
setup.isPublic
? "text-green-700 bg-green-50 hover:bg-green-100"
: "text-gray-500 bg-gray-50 hover:bg-gray-100"
}`}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
{setup.isPublic ? "Public" : "Private"}
</button>
<div className="flex-1" />
<button
type="button"
onClick={() => setConfirmDelete(true)}

View File

@@ -0,0 +1,102 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { PublicSetupCard } from "../../components/PublicSetupCard";
import { usePublicProfile } from "../../hooks/useProfile";
export const Route = createFileRoute("/users/$userId")({
component: PublicProfilePage,
});
function PublicProfilePage() {
const { userId } = Route.useParams();
const numericId = Number(userId);
const { data: profile, isLoading, isError } = usePublicProfile(numericId);
if (isLoading) {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
<div className="animate-pulse space-y-6">
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-full bg-gray-200" />
<div className="space-y-2">
<div className="h-6 bg-gray-200 rounded w-40" />
<div className="h-4 bg-gray-200 rounded w-64" />
</div>
</div>
<div className="h-4 bg-gray-200 rounded w-24" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="h-24 bg-gray-200 rounded-xl" />
<div className="h-24 bg-gray-200 rounded-xl" />
</div>
</div>
</div>
);
}
if (isError || !profile) {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<p className="text-gray-500">User not found.</p>
<Link
to="/"
className="text-sm text-gray-500 hover:text-gray-700 mt-4 inline-block"
>
&larr; Back to home
</Link>
</div>
);
}
const displayName = profile.displayName || `User #${profile.id}`;
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
{/* Profile header */}
<div className="flex items-center gap-5 mb-8">
{profile.avatarUrl ? (
<img
src={`/uploads/${profile.avatarUrl}`}
alt={displayName}
className="w-20 h-20 rounded-full object-cover"
/>
) : (
<div className="w-20 h-20 rounded-full bg-gray-100 flex items-center justify-center">
<svg
className="w-10 h-10 text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
)}
<div>
<h1 className="text-xl font-semibold text-gray-900">{displayName}</h1>
{profile.bio && (
<p className="text-sm text-gray-500 mt-1 max-w-md">{profile.bio}</p>
)}
</div>
</div>
{/* Public setups */}
<div>
<h2 className="text-base font-medium text-gray-900 mb-4">
Public Setups
</h2>
{profile.setups.length === 0 ? (
<p className="text-sm text-gray-400 py-8 text-center">
No public setups yet
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{profile.setups.map((setup) => (
<PublicSetupCard key={setup.id} setup={setup} />
))}
</div>
)}
</div>
</div>
);
}