Merge branch 'worktree-agent-af80e237' into Develop
# Conflicts: # .planning/REQUIREMENTS.md # .planning/STATE.md
This commit is contained in:
224
src/client/components/ProfileSection.tsx
Normal file
224
src/client/components/ProfileSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/client/components/PublicSetupCard.tsx
Normal file
33
src/client/components/PublicSetupCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
43
src/client/hooks/useProfile.ts
Normal file
43
src/client/hooks/useProfile.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
102
src/client/routes/users/$userId.tsx
Normal file
102
src/client/routes/users/$userId.tsx
Normal 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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user