Files
GearBox/src/client/routes/setups/$setupId.tsx
Jean-Luc Makiola 4b26a6c88e
All checks were successful
CI / ci (push) Successful in 1m23s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 15s
feat: public item detail view for shared and public setups
Items in shared/public setups are now viewable without auth. Clicking
an item in a shared setup navigates to /items/:id?setup=:setupId&share=token
which fetches the item via a public endpoint authorized by the setup's
visibility or share token. Read-only mode hides all owner controls.

- Added getSetupItemById service function
- Added GET /api/shared/:token/items/:itemId endpoint
- Added GET /api/setups/:setupId/items/:itemId/public endpoint
- Added usePublicSetupItem and useSharedSetupItem hooks
- Item detail page detects setup context and switches to public fetch
- Back link returns to setup instead of collection in setup context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:17:54 +02:00

459 lines
14 KiB
TypeScript

import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { z } from "zod";
import { CategoryHeader } from "../../components/CategoryHeader";
import { ItemCard } from "../../components/ItemCard";
import { ItemPicker } from "../../components/ItemPicker";
import { ShareModal } from "../../components/ShareModal";
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
import { useAuth } from "../../hooks/useAuth";
import { useFormatters } from "../../hooks/useFormatters";
import {
useDeleteSetup,
usePublicSetup,
useRemoveSetupItem,
useSetup,
useSharedSetup,
useUpdateItemClassification,
useUpdateSetup,
} from "../../hooks/useSetups";
import { LucideIcon } from "../../lib/iconData";
export const Route = createFileRoute("/setups/$setupId")({
component: SetupDetailPage,
validateSearch: z.object({
share: z.string().optional(),
}),
});
function SetupDetailPage() {
const { setupId } = Route.useParams();
const { share: shareToken } = Route.useSearch();
const { weight, price } = useFormatters();
const navigate = useNavigate();
const numericId = Number(setupId);
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
const isSharedView = !!shareToken;
// Priority: share token > authenticated owner > public viewer
const sharedSetup = useSharedSetup(shareToken ?? null);
const privateSetup = useSetup(
!isSharedView && isAuthenticated ? numericId : null,
);
const publicSetup = usePublicSetup(
!isSharedView && !isAuthenticated ? numericId : null,
);
const {
data: setup,
isLoading,
isError: isSharedError,
} = isSharedView ? sharedSetup : isAuthenticated ? privateSetup : publicSetup;
const deleteSetup = useDeleteSetup();
const updateSetup = useUpdateSetup(numericId);
const removeItem = useRemoveSetupItem(numericId);
const updateClassification = useUpdateItemClassification(numericId);
const [pickerOpen, setPickerOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
if (isLoading) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="animate-pulse space-y-6">
<div className="h-8 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
))}
</div>
</div>
</div>
);
}
if (isSharedView && isSharedError) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 text-center">
<LucideIcon
name="link"
size={48}
className="text-gray-300 mx-auto mb-4"
/>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Link not available
</h2>
<p className="text-sm text-gray-500">
This share link has expired or is no longer valid.
</p>
</div>
);
}
if (!setup) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<p className="text-gray-500">Setup not found.</p>
</div>
);
}
// Compute totals from items (multiply by quantity)
const totalWeight = setup.items.reduce(
(sum, item) => sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
0,
);
const totalCost = setup.items.reduce(
(sum, item) => sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
0,
);
const itemCount = setup.items.length;
const currentItemIds = setup.items.map((item) => item.id);
// Group items by category
const groupedItems = new Map<
number,
{
items: typeof setup.items;
categoryName: string;
categoryIcon: string;
}
>();
for (const item of setup.items) {
const group = groupedItems.get(item.categoryId);
if (group) {
group.items.push(item);
} else {
groupedItems.set(item.categoryId, {
items: [item],
categoryName: item.categoryName,
categoryIcon: item.categoryIcon,
});
}
}
function nextClassification(current: string): string {
const order = ["base", "worn", "consumable"];
const idx = order.indexOf(current);
return order[(idx + 1) % order.length];
}
function handleDelete() {
deleteSetup.mutate(numericId, {
onSuccess: () => navigate({ to: "/setups" }),
});
}
const showOwnerControls = !isSharedView && isAuthenticated;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Shared setup banner */}
{isSharedView && setup && (
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border-b border-blue-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
<LucideIcon name="link" size={16} className="text-blue-500" />
<span className="text-sm text-blue-700">Shared setup</span>
</div>
)}
{/* Setup-specific sticky bar */}
<div className="sticky top-14 z-[9] bg-gray-50 border-b border-gray-100 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-12">
<div className="flex items-center gap-3 min-w-0">
<Link
to="/collection"
search={{ tab: "setups" }}
className="text-sm text-gray-500 hover:text-gray-700 shrink-0"
>
&larr;
</Link>
<h2 className="text-base font-semibold text-gray-900 truncate">
{setup.name}
</h2>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>
<span className="font-medium text-gray-700">{itemCount}</span>{" "}
{itemCount === 1 ? "item" : "items"}
</span>
<span>
<span className="font-medium text-gray-700">
{weight(totalWeight)}
</span>{" "}
total
</span>
<span>
<span className="font-medium text-gray-700">
{price(totalCost)}
</span>{" "}
cost
</span>
</div>
</div>
</div>
{/* Actions — only visible to authenticated owner (hidden in shared view) */}
{showOwnerControls && (
<div className="flex items-center gap-3 py-4">
{/* Add Items — desktop */}
<button
type="button"
onClick={() => setPickerOpen(true)}
className="hidden md:inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
<LucideIcon name="plus" size={16} />
Add Items
</button>
{/* Add Items — mobile */}
<button
type="button"
onClick={() => setPickerOpen(true)}
className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 bg-gray-700 hover:bg-gray-800 text-white rounded-lg transition-colors"
aria-label="Add Items"
title="Add Items"
>
<LucideIcon name="plus" size={16} />
</button>
{/* Share button — desktop */}
<button
type="button"
onClick={() => setShareModalOpen(true)}
className={`hidden md:inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
setup.visibility === "public"
? "text-green-700 bg-green-50 hover:bg-green-100"
: setup.visibility === "link"
? "text-blue-600 bg-blue-50 hover:bg-blue-100"
: "text-gray-500 bg-gray-50 hover:bg-gray-100"
}`}
>
<LucideIcon
name={
setup.visibility === "public"
? "globe"
: setup.visibility === "link"
? "link"
: "lock"
}
size={16}
/>
Share
</button>
{/* Share button — mobile */}
<button
type="button"
onClick={() => setShareModalOpen(true)}
className={`md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 rounded-lg transition-colors ${
setup.visibility === "public"
? "text-green-700 bg-green-50 hover:bg-green-100"
: setup.visibility === "link"
? "text-blue-600 bg-blue-50 hover:bg-blue-100"
: "text-gray-500 bg-gray-50 hover:bg-gray-100"
}`}
aria-label="Share settings"
title="Share settings"
>
<LucideIcon
name={
setup.visibility === "public"
? "globe"
: setup.visibility === "link"
? "link"
: "lock"
}
size={16}
/>
</button>
<div className="flex-1" />
{/* Delete Setup — desktop */}
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="hidden md:inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
>
Delete Setup
</button>
{/* Delete Setup — mobile */}
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
aria-label="Delete Setup"
title="Delete Setup"
>
<LucideIcon name="trash-2" size={16} />
</button>
</div>
)}
{/* Empty state */}
{itemCount === 0 && (
<div className="py-16 text-center">
<div className="max-w-md mx-auto">
<div className="mb-4">
<LucideIcon
name="package"
size={48}
className="text-gray-400 mx-auto"
/>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
No items in this setup
</h2>
<p className="text-sm text-gray-500 mb-6">
Add items from your collection to build this loadout.
</p>
{showOwnerControls && (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
Add Items
</button>
)}
</div>
</div>
)}
{/* Weight summary card + items grouped by category */}
{itemCount > 0 && (
<div className="pb-6">
<WeightSummaryCard items={setup.items} />
{Array.from(groupedItems.entries()).map(
([
categoryId,
{ items: categoryItems, categoryName, categoryIcon },
]) => {
const catWeight = categoryItems.reduce(
(sum, item) =>
sum + (item.weightGrams ?? 0) * (item.quantity ?? 1),
0,
);
const catCost = categoryItems.reduce(
(sum, item) =>
sum + (item.priceCents ?? 0) * (item.quantity ?? 1),
0,
);
return (
<div key={categoryId} className="mb-8">
<CategoryHeader
categoryId={categoryId}
name={categoryName}
icon={categoryIcon}
totalWeight={catWeight}
totalCost={catCost}
itemCount={categoryItems.length}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryItems.map((item) => (
<ItemCard
key={item.id}
id={item.id}
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
quantity={item.quantity}
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}
imageUrl={item.imageUrl}
productUrl={item.productUrl}
onRemove={
showOwnerControls
? () => removeItem.mutate(item.id)
: undefined
}
classification={item.classification}
onClassificationCycle={
showOwnerControls
? () =>
updateClassification.mutate({
itemId: item.id,
classification: nextClassification(
item.classification,
),
})
: undefined
}
linkTo={
!showOwnerControls
? `/items/${item.id}?setup=${numericId}${shareToken ? `&share=${shareToken}` : ""}`
: undefined
}
/>
))}
</div>
</div>
);
},
)}
</div>
)}
{/* Item Picker — only for authenticated owner */}
{showOwnerControls && (
<ItemPicker
setupId={numericId}
currentItemIds={currentItemIds}
isOpen={pickerOpen}
onClose={() => setPickerOpen(false)}
/>
)}
{/* Share Modal — only for authenticated owner */}
{showOwnerControls && (
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
setupId={numericId}
currentVisibility={setup.visibility}
onVisibilityChange={(v) => updateSetup.mutate({ visibility: v })}
/>
)}
{/* Delete Confirmation Dialog — only for authenticated owner */}
{showOwnerControls && confirmDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={() => setConfirmDelete(false)}
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Delete Setup
</h3>
<p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "}
<span className="font-medium">{setup.name}</span>? This will not
remove items from your collection.
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setConfirmDelete(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleteSetup.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
>
{deleteSetup.isPending ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
)}
</div>
);
}