diff --git a/src/client/components/ShareModal.tsx b/src/client/components/ShareModal.tsx new file mode 100644 index 0000000..82c2852 --- /dev/null +++ b/src/client/components/ShareModal.tsx @@ -0,0 +1,295 @@ +import { useEffect, useState } from "react"; +import { + useCreateShareLink, + useRevokeShareLink, + useShareLinks, +} from "../hooks/useShares"; +import { LucideIcon } from "../lib/iconData"; + +interface ShareModalProps { + isOpen: boolean; + onClose: () => void; + setupId: number; + currentVisibility: "private" | "link" | "public"; + onVisibilityChange: (visibility: "private" | "link" | "public") => void; +} + +const VISIBILITY_OPTIONS = [ + { + value: "private" as const, + icon: "lock", + label: "Private", + description: "Only you can access", + color: "gray", + border: "border-gray-200", + selectedBorder: "border-gray-300 bg-gray-50", + iconColor: "text-gray-500", + }, + { + value: "link" as const, + icon: "link", + label: "Link sharing", + description: "Anyone with the link", + color: "blue", + border: "border-gray-200", + selectedBorder: "border-blue-200 bg-blue-50", + iconColor: "text-blue-600", + }, + { + value: "public" as const, + icon: "globe", + label: "Public", + description: "Visible on your profile", + color: "green", + border: "border-gray-200", + selectedBorder: "border-green-200 bg-green-50", + iconColor: "text-green-700", + }, +] as const; + +const EXPIRATION_OPTIONS = [ + { value: 7, label: "7 days" }, + { value: 14, label: "14 days" }, + { value: 30, label: "30 days" }, + { value: null, label: "No expiration" }, +] as const; + +export function ShareModal({ + isOpen, + onClose, + setupId, + currentVisibility, + onVisibilityChange, +}: ShareModalProps) { + const { data: shareLinks } = useShareLinks(isOpen ? setupId : null); + const createShareLink = useCreateShareLink(setupId); + const revokeShareLink = useRevokeShareLink(setupId); + + const [expiresInDays, setExpiresInDays] = useState(14); + const [copiedId, setCopiedId] = useState(null); + const [justCreatedToken, setJustCreatedToken] = useState(null); + + // Handle Escape key + useEffect(() => { + if (!isOpen) return; + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const activeLinks = shareLinks?.filter((link) => !link.revokedAt) ?? []; + const showLinksSection = + currentVisibility === "link" || currentVisibility === "public"; + const switchingToPrivateWithLinks = + currentVisibility !== "private" && activeLinks.length > 0; + + function handleCreateLink() { + createShareLink.mutate( + { expiresInDays }, + { + onSuccess: (share) => { + const url = `${window.location.origin}/s/${share.token}`; + navigator.clipboard.writeText(url).catch(() => {}); + setJustCreatedToken(share.token); + setTimeout(() => setJustCreatedToken(null), 2000); + }, + }, + ); + } + + function handleCopy(token: string, shareId: number) { + const url = `${window.location.origin}/s/${token}`; + navigator.clipboard.writeText(url).catch(() => {}); + setCopiedId(shareId); + setTimeout(() => setCopiedId(null), 2000); + } + + function handleVisibilityChange( + newVisibility: "private" | "link" | "public", + ) { + if (newVisibility !== currentVisibility) { + onVisibilityChange(newVisibility); + } + } + + function formatExpiration(expiresAt: string | null) { + if (!expiresAt) return "No expiration"; + const date = new Date(expiresAt); + const now = new Date(); + if (date < now) return "Expired"; + const days = Math.ceil( + (date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), + ); + if (days === 0) return "Expires today"; + if (days === 1) return "Expires tomorrow"; + return `Expires in ${days} days`; + } + + return ( +
+ {/* Overlay */} +
{}} + /> + + {/* Modal */} +
+ {/* Header */} +
+

Share Setup

+ +
+ + {/* Visibility Picker */} +
+ {VISIBILITY_OPTIONS.map((option) => ( + + ))} +
+ + {/* Deactivation Warning */} + {currentVisibility === "private" && switchingToPrivateWithLinks && ( +
+ +

+ Switching to private will deactivate all share links. They can be + reactivated by switching back. +

+
+ )} + + {/* Share Links Section */} + {showLinksSection && ( +
+
+ Share Links +
+ + {/* Create Link Row */} +
+ + +
+ + {/* Active Links List */} + {activeLinks.length > 0 ? ( +
+ {activeLinks.map((link) => ( +
+ + {window.location.origin}/s/ + {link.token.slice(0, 8)}... + + + {formatExpiration(link.expiresAt)} + + + +
+ ))} +
+ ) : ( +

+ No share links yet +

+ )} +
+ )} +
+
+ ); +} diff --git a/src/client/hooks/useShares.ts b/src/client/hooks/useShares.ts new file mode 100644 index 0000000..8590e3b --- /dev/null +++ b/src/client/hooks/useShares.ts @@ -0,0 +1,42 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiDelete, apiGet, apiPost } from "../lib/api"; + +interface ShareLink { + id: number; + setupId: number; + token: string; + permission: string; + expiresAt: string | null; + createdAt: string; + revokedAt: string | null; +} + +export function useShareLinks(setupId: number | null) { + return useQuery({ + queryKey: ["shares", setupId], + queryFn: () => apiGet(`/api/setups/${setupId}/shares`), + enabled: !!setupId, + }); +} + +export function useCreateShareLink(setupId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { expiresInDays: number | null }) => + apiPost(`/api/setups/${setupId}/shares`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["shares", setupId] }); + }, + }); +} + +export function useRevokeShareLink(setupId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (shareId: number) => + apiDelete(`/api/setups/${setupId}/shares/${shareId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["shares", setupId] }); + }, + }); +} diff --git a/src/client/routes/setups/$setupId.tsx b/src/client/routes/setups/$setupId.tsx index 433dae8..aae970c 100644 --- a/src/client/routes/setups/$setupId.tsx +++ b/src/client/routes/setups/$setupId.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { CategoryHeader } from "../../components/CategoryHeader"; import { ItemCard } from "../../components/ItemCard"; import { ItemPicker } from "../../components/ItemPicker"; +import { ShareModal } from "../../components/ShareModal"; import { WeightSummaryCard } from "../../components/WeightSummaryCard"; import { useAuth } from "../../hooks/useAuth"; import { useFormatters } from "../../hooks/useFormatters"; @@ -36,12 +37,13 @@ function SetupDetailPage() { : publicSetup; const deleteSetup = useDeleteSetup(); - const _updateSetup = useUpdateSetup(numericId); + const updateSetup = useUpdateSetup(numericId); const removeItem = useRemoveSetupItem(numericId); const updateClassification = useUpdateItemClassification(numericId); const [pickerOpen, setPickerOpen] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); + const [shareModalOpen, setShareModalOpen] = useState(false); if (isLoading) { return ( @@ -174,14 +176,16 @@ function SetupDetailPage() { - {/* Visibility badge — desktop */} - setShareModalOpen(true)} + className={`hidden md:inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${ setup.visibility === "public" - ? "text-green-700 bg-green-50" + ? "text-green-700 bg-green-50 hover:bg-green-100" : setup.visibility === "link" - ? "text-blue-600 bg-blue-50" - : "text-gray-500 bg-gray-50" + ? "text-blue-600 bg-blue-50 hover:bg-blue-100" + : "text-gray-500 bg-gray-50 hover:bg-gray-100" }`} > - {setup.visibility === "public" - ? "Public" - : setup.visibility === "link" - ? "Link" - : "Private"} - - {/* Visibility badge — mobile */} - + {/* Share button — mobile */} +
{/* Delete Setup — desktop */} @@ -361,6 +358,17 @@ function SetupDetailPage() { /> )} + {/* Share Modal — only for authenticated users */} + {isAuthenticated && ( + setShareModalOpen(false)} + setupId={numericId} + currentVisibility={setup.visibility} + onVisibilityChange={(v) => updateSetup.mutate({ visibility: v })} + /> + )} + {/* Delete Confirmation Dialog — only for authenticated users */} {isAuthenticated && confirmDelete && (
diff --git a/src/shared/types.ts b/src/shared/types.ts index 972cad5..387fd77 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -23,10 +23,9 @@ import type { createThreadSchema, deleteAccountSchema, reorderCandidatesSchema, - submitCommunityPriceSchema, - upsertMarketPriceSchema, resolveThreadSchema, searchGlobalItemsSchema, + submitCommunityPriceSchema, syncSetupItemsSchema, updateCandidateSchema, updateCategorySchema, @@ -36,6 +35,7 @@ import type { updateSetupSchema, updateThreadSchema, upsertGlobalItemSchema, + upsertMarketPriceSchema, } from "./schemas.ts"; // Types inferred from Zod schemas