feat(18-05): add public profile page and setup visibility toggle
- Create public profile page at /users/$userId with avatar, name, bio, setups - Create PublicSetupCard component for profile page setup listing - Add isPublic toggle button on setup detail page - Add Public badge to SetupCard in list view - Update useSetups hook with isPublic field on interfaces
This commit is contained in:
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 {
|
interface SetupCardProps {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
isPublic?: boolean;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
totalWeight: number;
|
totalWeight: number;
|
||||||
totalCost: number;
|
totalCost: number;
|
||||||
@@ -12,6 +13,7 @@ interface SetupCardProps {
|
|||||||
export function SetupCard({
|
export function SetupCard({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
isPublic,
|
||||||
itemCount,
|
itemCount,
|
||||||
totalWeight,
|
totalWeight,
|
||||||
totalCost,
|
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"
|
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">
|
<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">
|
<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"}
|
{itemCount} {itemCount === 1 ? "item" : "items"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export function SetupsView() {
|
|||||||
key={setup.id}
|
key={setup.id}
|
||||||
id={setup.id}
|
id={setup.id}
|
||||||
name={setup.name}
|
name={setup.name}
|
||||||
|
isPublic={setup.isPublic}
|
||||||
itemCount={setup.itemCount}
|
itemCount={setup.itemCount}
|
||||||
totalWeight={setup.totalWeight}
|
totalWeight={setup.totalWeight}
|
||||||
totalCost={setup.totalCost}
|
totalCost={setup.totalCost}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
interface SetupListItem {
|
interface SetupListItem {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
isPublic: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
@@ -38,6 +39,7 @@ interface SetupItemWithCategory {
|
|||||||
interface SetupWithItems {
|
interface SetupWithItems {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
isPublic: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
items: SetupItemWithCategory[];
|
items: SetupItemWithCategory[];
|
||||||
@@ -76,7 +78,7 @@ export function useCreateSetup() {
|
|||||||
export function useUpdateSetup(setupId: number) {
|
export function useUpdateSetup(setupId: number) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { name?: string }) =>
|
mutationFn: (data: { name?: string; isPublic?: boolean }) =>
|
||||||
apiPut<SetupListItem>(`/api/setups/${setupId}`, data),
|
apiPut<SetupListItem>(`/api/setups/${setupId}`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["setups"] });
|
queryClient.invalidateQueries({ queryKey: ["setups"] });
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
useRemoveSetupItem,
|
useRemoveSetupItem,
|
||||||
useSetup,
|
useSetup,
|
||||||
useUpdateItemClassification,
|
useUpdateItemClassification,
|
||||||
|
useUpdateSetup,
|
||||||
} from "../../hooks/useSetups";
|
} from "../../hooks/useSetups";
|
||||||
import { LucideIcon } from "../../lib/iconData";
|
import { LucideIcon } from "../../lib/iconData";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ function SetupDetailPage() {
|
|||||||
const numericId = Number(setupId);
|
const numericId = Number(setupId);
|
||||||
const { data: setup, isLoading } = useSetup(numericId);
|
const { data: setup, isLoading } = useSetup(numericId);
|
||||||
const deleteSetup = useDeleteSetup();
|
const deleteSetup = useDeleteSetup();
|
||||||
|
const updateSetup = useUpdateSetup(numericId);
|
||||||
const removeItem = useRemoveSetupItem(numericId);
|
const removeItem = useRemoveSetupItem(numericId);
|
||||||
const updateClassification = useUpdateItemClassification(numericId);
|
const updateClassification = useUpdateItemClassification(numericId);
|
||||||
|
|
||||||
@@ -160,6 +162,32 @@ function SetupDetailPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Add Items
|
Add Items
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmDelete(true)}
|
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