2 Commits

Author SHA1 Message Date
4b26a6c88e 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>
2026-04-13 20:17:54 +02:00
731d677da6 fix: shared setup items link to catalog instead of requiring auth
Items with a globalItemId now link to /global-items/:id (public) in
shared and public setup views. Items without a catalog link are not
clickable. Owner view behavior unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:10:02 +02:00
6 changed files with 231 additions and 16 deletions

View File

@@ -25,6 +25,7 @@ interface ItemCardProps {
onRemove?: () => void; onRemove?: () => void;
classification?: string; classification?: string;
onClassificationCycle?: () => void; onClassificationCycle?: () => void;
linkTo?: string | null;
} }
export function ItemCard({ export function ItemCard({
@@ -46,19 +47,29 @@ export function ItemCard({
onRemove, onRemove,
classification, classification,
onClassificationCycle, onClassificationCycle,
linkTo,
}: ItemCardProps) { }: ItemCardProps) {
const { weight, price } = useFormatters(); const { weight, price } = useFormatters();
const navigate = useNavigate(); const navigate = useNavigate();
const openExternalLink = useUIStore((s) => s.openExternalLink); const openExternalLink = useUIStore((s) => s.openExternalLink);
const duplicateItem = useDuplicateItem(); const duplicateItem = useDuplicateItem();
const handleClick =
linkTo === null
? undefined
: () => {
if (linkTo) {
navigate({ to: linkTo });
} else {
navigate({ to: "/items/$itemId", params: { itemId: String(id) } });
}
};
return ( return (
<button <button
type="button" type="button"
onClick={() => onClick={handleClick}
navigate({ to: "/items/$itemId", params: { itemId: String(id) } }) className={`relative w-full text-left bg-white rounded-xl border border-gray-100 transition-all overflow-hidden group ${linkTo === null ? "cursor-default" : "hover:border-gray-200 hover:shadow-sm"}`}
}
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
> >
{!onRemove && ( {!onRemove && (
<span <span

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() { export function useCreateSetup() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({

View File

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

View File

@@ -381,6 +381,11 @@ function SetupDetailPage() {
}) })
: undefined : undefined
} }
linkTo={
!showOwnerControls
? `/items/${item.id}?setup=${numericId}${shareToken ? `&share=${shareToken}` : ""}`
: undefined
}
/> />
))} ))}
</div> </div>

View File

@@ -29,9 +29,12 @@ import { setupRoutes } from "./routes/setups.ts";
import { tagRoutes } from "./routes/tags.ts"; import { tagRoutes } from "./routes/tags.ts";
import { threadRoutes } from "./routes/threads.ts"; import { threadRoutes } from "./routes/threads.ts";
import { totalRoutes } from "./routes/totals.ts"; import { totalRoutes } from "./routes/totals.ts";
import { getSetupWithItemsById } from "./services/setup.service.ts"; import {
getSetupItemById,
getSetupWithItemsById,
} from "./services/setup.service.ts";
import { validateShareToken } from "./services/share.service.ts"; import { validateShareToken } from "./services/share.service.ts";
import { withImageUrls } from "./services/storage.service.ts"; import { withImageUrl, withImageUrls } from "./services/storage.service.ts";
// Seed default data on startup // Seed default data on startup
await seedDefaults(); await seedDefaults();
@@ -185,6 +188,43 @@ app.get("/api/shared/:token", async (c) => {
return c.json({ ...setup, items: enrichedItems }); return c.json({ ...setup, items: enrichedItems });
}); });
// Shared setup item detail via token (no auth required)
app.get("/api/shared/:token/items/:itemId", async (c) => {
const db = c.get("db");
const token = c.req.param("token");
const itemId = Number(c.req.param("itemId"));
if (!itemId || Number.isNaN(itemId))
return c.json({ error: "Invalid item ID" }, 400);
const result = await validateShareToken(db, token);
if (!result) return c.json({ error: "Not found" }, 404);
const item = await getSetupItemById(db, result.setupId, itemId);
if (!item) return c.json({ error: "Not found" }, 404);
const enriched = await withImageUrl(item);
return c.json(enriched);
});
// Public setup item detail (no auth required — setup must be public)
app.get("/api/setups/:setupId/items/:itemId/public", async (c) => {
const db = c.get("db");
const setupId = Number(c.req.param("setupId"));
const itemId = Number(c.req.param("itemId"));
if (!setupId || !itemId || Number.isNaN(setupId) || Number.isNaN(itemId))
return c.json({ error: "Invalid ID" }, 400);
// Verify setup is public
const { setups } = await import("../db/schema.ts");
const { eq } = await import("drizzle-orm");
const [setup] = await db
.select({ visibility: setups.visibility })
.from(setups)
.where(eq(setups.id, setupId));
if (!setup || setup.visibility !== "public")
return c.json({ error: "Not found" }, 404);
const item = await getSetupItemById(db, setupId, itemId);
if (!item) return c.json({ error: "Not found" }, 404);
const enriched = await withImageUrl(item);
return c.json(enriched);
});
// Short share URL redirect (no auth required — before SPA catch-all) // Short share URL redirect (no auth required — before SPA catch-all)
app.get("/s/:token", async (c) => { app.get("/s/:token", async (c) => {
const db = c.get("db"); const db = c.get("db");
@@ -206,6 +246,12 @@ app.use("/api/*", async (c, next) => {
// Skip public setup view (GET /api/setups/:id/public) // Skip public setup view (GET /api/setups/:id/public)
if (/^\/api\/setups\/\d+\/public$/.test(c.req.path) && c.req.method === "GET") if (/^\/api\/setups\/\d+\/public$/.test(c.req.path) && c.req.method === "GET")
return next(); return next();
// Skip public setup item view (GET /api/setups/:id/items/:id/public)
if (
/^\/api\/setups\/\d+\/items\/\d+\/public$/.test(c.req.path) &&
c.req.method === "GET"
)
return next();
// Skip public tags endpoint (GET /api/tags) // Skip public tags endpoint (GET /api/tags)
if (c.req.path.startsWith("/api/tags") && c.req.method === "GET") if (c.req.path.startsWith("/api/tags") && c.req.method === "GET")
return next(); return next();

View File

@@ -170,6 +170,62 @@ export async function getSetupWithItemsById(db: Db, setupId: number) {
return { ...setup, items: itemList }; return { ...setup, items: itemList };
} }
/**
* Get a single item from a setup by item ID (no auth check).
* Used for public/shared setup item viewing.
* Returns null if the item is not in the setup.
*/
export async function getSetupItemById(
db: Db,
setupId: number,
itemId: number,
) {
const [row] = await db
.select({
id: items.id,
name: sql<string>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
ELSE ${items.name}
END,
${items.name}
)`.as("name"),
brand: sql<
string | null
>`CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.brand} ELSE ${items.brand} END`.as(
"brand",
),
weightGrams: sql<number | null>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
${items.weightGrams}
)`.as("weight_grams"),
priceCents: sql<number | null>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
${items.priceCents}
)`.as("price_cents"),
quantity: items.quantity,
categoryId: items.categoryId,
notes: items.notes,
productUrl: items.productUrl,
imageFilename: sql<string | null>`COALESCE(
${items.imageFilename},
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
)`.as("image_filename"),
globalItemId: items.globalItemId,
createdAt: items.createdAt,
categoryName: categories.name,
categoryIcon: categories.icon,
classification: setupItems.classification,
})
.from(setupItems)
.innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id))
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.where(and(eq(setupItems.setupId, setupId), eq(items.id, itemId)));
return row ?? null;
}
export async function updateSetup( export async function updateSetup(
db: Db, db: Db,
userId: number, userId: number,