feat: add shared setup viewer with token detection and read-only mode
Detect ?share=token query param on setup detail page, fetch via /api/shared/:token, and display read-only view with "Shared setup" banner. Hide all owner controls (add items, share, delete, classification) in shared view. Show "Link not available" error for invalid tokens. Plan: 32-04 (Setup Sharing System - Shared Setup Viewer) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,15 @@ export function usePublicSetup(setupId: number | null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSharedSetup(token: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["shared-setup", token],
|
||||||
|
queryFn: () => apiGet<SetupWithItems>(`/api/shared/${token}`),
|
||||||
|
enabled: !!token,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateSetup() {
|
export function useCreateSetup() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 { CategoryHeader } from "../../components/CategoryHeader";
|
import { CategoryHeader } from "../../components/CategoryHeader";
|
||||||
import { ItemCard } from "../../components/ItemCard";
|
import { ItemCard } from "../../components/ItemCard";
|
||||||
import { ItemPicker } from "../../components/ItemPicker";
|
import { ItemPicker } from "../../components/ItemPicker";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
usePublicSetup,
|
usePublicSetup,
|
||||||
useRemoveSetupItem,
|
useRemoveSetupItem,
|
||||||
useSetup,
|
useSetup,
|
||||||
|
useSharedSetup,
|
||||||
useUpdateItemClassification,
|
useUpdateItemClassification,
|
||||||
useUpdateSetup,
|
useUpdateSetup,
|
||||||
} from "../../hooks/useSetups";
|
} from "../../hooks/useSetups";
|
||||||
@@ -19,22 +21,35 @@ import { LucideIcon } from "../../lib/iconData";
|
|||||||
|
|
||||||
export const Route = createFileRoute("/setups/$setupId")({
|
export const Route = createFileRoute("/setups/$setupId")({
|
||||||
component: SetupDetailPage,
|
component: SetupDetailPage,
|
||||||
|
validateSearch: z.object({
|
||||||
|
share: z.string().optional(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
function SetupDetailPage() {
|
function SetupDetailPage() {
|
||||||
const { setupId } = Route.useParams();
|
const { setupId } = Route.useParams();
|
||||||
|
const { share: shareToken } = Route.useSearch();
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const numericId = Number(setupId);
|
const numericId = Number(setupId);
|
||||||
|
|
||||||
const { data: auth } = useAuth();
|
const { data: auth } = useAuth();
|
||||||
const isAuthenticated = !!auth?.user;
|
const isAuthenticated = !!auth?.user;
|
||||||
|
const isSharedView = !!shareToken;
|
||||||
|
|
||||||
const privateSetup = useSetup(isAuthenticated ? numericId : null);
|
// Priority: share token > authenticated owner > public viewer
|
||||||
const publicSetup = usePublicSetup(!isAuthenticated ? numericId : null);
|
const sharedSetup = useSharedSetup(shareToken ?? null);
|
||||||
const { data: setup, isLoading } = isAuthenticated
|
const privateSetup = useSetup(
|
||||||
? privateSetup
|
!isSharedView && isAuthenticated ? numericId : null,
|
||||||
: publicSetup;
|
);
|
||||||
|
const publicSetup = usePublicSetup(
|
||||||
|
!isSharedView && !isAuthenticated ? numericId : null,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
data: setup,
|
||||||
|
isLoading,
|
||||||
|
isError: isSharedError,
|
||||||
|
} = isSharedView ? sharedSetup : isAuthenticated ? privateSetup : publicSetup;
|
||||||
|
|
||||||
const deleteSetup = useDeleteSetup();
|
const deleteSetup = useDeleteSetup();
|
||||||
const updateSetup = useUpdateSetup(numericId);
|
const updateSetup = useUpdateSetup(numericId);
|
||||||
@@ -60,6 +75,24 @@ function SetupDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (!setup) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
|
||||||
@@ -115,8 +148,18 @@ function SetupDetailPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showOwnerControls = !isSharedView && isAuthenticated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<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 */}
|
{/* 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="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 justify-between h-12">
|
||||||
@@ -153,8 +196,8 @@ function SetupDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions — only visible to authenticated users */}
|
{/* Actions — only visible to authenticated owner (hidden in shared view) */}
|
||||||
{isAuthenticated && (
|
{showOwnerControls && (
|
||||||
<div className="flex items-center gap-3 py-4">
|
<div className="flex items-center gap-3 py-4">
|
||||||
{/* Add Items — desktop */}
|
{/* Add Items — desktop */}
|
||||||
<button
|
<button
|
||||||
@@ -265,7 +308,7 @@ function SetupDetailPage() {
|
|||||||
<p className="text-sm text-gray-500 mb-6">
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
Add items from your collection to build this loadout.
|
Add items from your collection to build this loadout.
|
||||||
</p>
|
</p>
|
||||||
{isAuthenticated && (
|
{showOwnerControls && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPickerOpen(true)}
|
onClick={() => setPickerOpen(true)}
|
||||||
@@ -322,13 +365,13 @@ function SetupDetailPage() {
|
|||||||
imageUrl={item.imageUrl}
|
imageUrl={item.imageUrl}
|
||||||
productUrl={item.productUrl}
|
productUrl={item.productUrl}
|
||||||
onRemove={
|
onRemove={
|
||||||
isAuthenticated
|
showOwnerControls
|
||||||
? () => removeItem.mutate(item.id)
|
? () => removeItem.mutate(item.id)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
classification={item.classification}
|
classification={item.classification}
|
||||||
onClassificationCycle={
|
onClassificationCycle={
|
||||||
isAuthenticated
|
showOwnerControls
|
||||||
? () =>
|
? () =>
|
||||||
updateClassification.mutate({
|
updateClassification.mutate({
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
@@ -348,8 +391,8 @@ function SetupDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Item Picker — only for authenticated users */}
|
{/* Item Picker — only for authenticated owner */}
|
||||||
{isAuthenticated && (
|
{showOwnerControls && (
|
||||||
<ItemPicker
|
<ItemPicker
|
||||||
setupId={numericId}
|
setupId={numericId}
|
||||||
currentItemIds={currentItemIds}
|
currentItemIds={currentItemIds}
|
||||||
@@ -358,8 +401,8 @@ function SetupDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Share Modal — only for authenticated users */}
|
{/* Share Modal — only for authenticated owner */}
|
||||||
{isAuthenticated && (
|
{showOwnerControls && (
|
||||||
<ShareModal
|
<ShareModal
|
||||||
isOpen={shareModalOpen}
|
isOpen={shareModalOpen}
|
||||||
onClose={() => setShareModalOpen(false)}
|
onClose={() => setShareModalOpen(false)}
|
||||||
@@ -369,8 +412,8 @@ function SetupDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog — only for authenticated users */}
|
{/* Delete Confirmation Dialog — only for authenticated owner */}
|
||||||
{isAuthenticated && confirmDelete && (
|
{showOwnerControls && confirmDelete && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/30"
|
className="absolute inset-0 bg-black/30"
|
||||||
|
|||||||
Reference in New Issue
Block a user