feat: public item detail view for shared and public setups
All checks were successful
CI / ci (push) Successful in 1m23s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 15s

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>
This commit is contained in:
2026-04-13 20:17:54 +02:00
parent 731d677da6
commit 4b26a6c88e
5 changed files with 212 additions and 15 deletions

View File

@@ -83,6 +83,51 @@ export function useSharedSetup(token: string | null) {
});
}
interface SetupItem {
id: number;
name: string;
brand?: string | null;
weightGrams: number | null;
priceCents: number | null;
quantity: number;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
imageUrl?: string | null;
globalItemId: number | null;
createdAt: string;
categoryName: string;
categoryIcon: string;
classification: string;
}
export function usePublicSetupItem(
setupId: number | null,
itemId: number | null,
) {
return useQuery({
queryKey: ["setups", setupId, "items", itemId, "public"],
queryFn: () =>
apiGet<SetupItem>(`/api/setups/${setupId}/items/${itemId}/public`),
enabled: setupId != null && itemId != null,
retry: (count, error) =>
error instanceof ApiError && error.status === 404 ? false : count < 3,
});
}
export function useSharedSetupItem(
token: string | null,
itemId: number | null,
) {
return useQuery({
queryKey: ["shared-setup", token, "items", itemId],
queryFn: () => apiGet<SetupItem>(`/api/shared/${token}/items/${itemId}`),
enabled: !!token && itemId != null,
retry: false,
});
}
export function useCreateSetup() {
const queryClient = useQueryClient();
return useMutation({

View File

@@ -1,16 +1,25 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { z } from "zod";
import { CategoryPicker } from "../../components/CategoryPicker";
import { GearImage, imageContainerBg } from "../../components/GearImage";
import { ImageCropEditor } from "../../components/ImageCropEditor";
import { ImageUpload } from "../../components/ImageUpload";
import { useAuth } from "../../hooks/useAuth";
import { useFormatters } from "../../hooks/useFormatters";
import { useDuplicateItem, useItem, useUpdateItem } from "../../hooks/useItems";
import { usePublicSetupItem, useSharedSetupItem } from "../../hooks/useSetups";
import { LucideIcon } from "../../lib/iconData";
import { useUIStore } from "../../stores/uiStore";
const itemSearchSchema = z.object({
setup: z.number().optional().catch(undefined),
share: z.string().optional().catch(undefined),
});
export const Route = createFileRoute("/items/$itemId")({
component: ItemDetail,
validateSearch: itemSearchSchema,
});
interface EditFormState {
@@ -27,18 +36,47 @@ interface EditFormState {
function ItemDetail() {
const { itemId } = Route.useParams();
const { setup: setupId, share: shareToken } = Route.useSearch();
const navigate = useNavigate();
const {
data: item,
isLoading,
error,
} = useItem(Number(itemId)) as ReturnType<typeof useItem> & {
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
// Determine access mode: shared (token), public (setup context, no auth), or owner
const isSharedAccess = !!shareToken;
const isPublicAccess = !isSharedAccess && !!setupId && !isAuthenticated;
const isOwnerAccess = !isSharedAccess && !isPublicAccess;
// Fetch item based on access mode
const ownerQuery = useItem(
isOwnerAccess ? Number(itemId) : null,
) as ReturnType<typeof useItem> & {
data:
| (NonNullable<ReturnType<typeof useItem>["data"]> & {
imageUrl?: string | null;
})
| undefined;
};
const sharedQuery = useSharedSetupItem(
isSharedAccess ? shareToken : null,
isSharedAccess ? Number(itemId) : null,
);
const publicQuery = usePublicSetupItem(
isPublicAccess ? (setupId ?? null) : null,
isPublicAccess ? Number(itemId) : null,
);
const activeQuery = isSharedAccess
? sharedQuery
: isPublicAccess
? publicQuery
: ownerQuery;
const item = activeQuery.data as typeof ownerQuery.data;
const isLoading = activeQuery.isLoading;
const error = activeQuery.error;
const isReadOnly = isSharedAccess || isPublicAccess;
const { weight, price } = useFormatters();
const updateItem = useUpdateItem();
const duplicateItem = useDuplicateItem();
@@ -157,14 +195,26 @@ function ItemDetail() {
);
}
// Back link: return to setup if accessed from setup context, else collection
const backLink = setupId
? {
to: "/setups/$setupId" as const,
params: { setupId: String(setupId) },
search: shareToken ? { share: shareToken } : {},
}
: { to: "/collection" as const, params: {}, search: {} };
const backLabel = setupId ? "Back to setup" : "Back to collection";
if (error || !item) {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Link
to="/collection"
to={backLink.to}
params={backLink.params}
search={backLink.search}
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
&larr; Back to collection
&larr; {backLabel}
</Link>
<div className="text-center py-16">
<p className="text-sm text-gray-500">Item not found</p>
@@ -181,12 +231,14 @@ function ItemDetail() {
{/* Top bar */}
<div className="flex items-center justify-between mb-6">
<Link
to="/collection"
to={backLink.to}
params={backLink.params}
search={backLink.search}
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
&larr; Back to collection
&larr; {backLabel}
</Link>
{!isEditing && (
{!isEditing && !isReadOnly && (
<div className="flex items-center gap-2">
{/* Duplicate — desktop */}
<button

View File

@@ -383,9 +383,7 @@ function SetupDetailPage() {
}
linkTo={
!showOwnerControls
? item.globalItemId
? `/global-items/${item.globalItemId}`
: null
? `/items/${item.id}?setup=${numericId}${shareToken ? `&share=${shareToken}` : ""}`
: undefined
}
/>