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

@@ -29,9 +29,12 @@ import { setupRoutes } from "./routes/setups.ts";
import { tagRoutes } from "./routes/tags.ts";
import { threadRoutes } from "./routes/threads.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 { withImageUrls } from "./services/storage.service.ts";
import { withImageUrl, withImageUrls } from "./services/storage.service.ts";
// Seed default data on startup
await seedDefaults();
@@ -185,6 +188,43 @@ app.get("/api/shared/:token", async (c) => {
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)
app.get("/s/:token", async (c) => {
const db = c.get("db");
@@ -206,6 +246,12 @@ app.use("/api/*", async (c, next) => {
// Skip public setup view (GET /api/setups/:id/public)
if (/^\/api\/setups\/\d+\/public$/.test(c.req.path) && c.req.method === "GET")
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)
if (c.req.path.startsWith("/api/tags") && c.req.method === "GET")
return next();