Compare commits
2 Commits
1fbd9bc609
...
4b26a6c88e
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b26a6c88e | |||
| 731d677da6 |
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
← Back to collection
|
← {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"
|
||||||
>
|
>
|
||||||
← Back to collection
|
← {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
|
||||||
|
|||||||
@@ -381,6 +381,11 @@ function SetupDetailPage() {
|
|||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
linkTo={
|
||||||
|
!showOwnerControls
|
||||||
|
? `/items/${item.id}?setup=${numericId}${shareToken ? `&share=${shareToken}` : ""}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user