feat: add share modal with visibility picker and link management
Create ShareModal component with three-tier visibility picker (private/link/public), share link creation with configurable expiration, clipboard copy, and link revocation. Wire into setup detail page replacing the static visibility badge with an interactive share button. Plan: 32-03 (Setup Sharing System - Share Modal UI) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
295
src/client/components/ShareModal.tsx
Normal file
295
src/client/components/ShareModal.tsx
Normal file
@@ -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<number | null>(14);
|
||||||
|
const [copiedId, setCopiedId] = useState<number | null>(null);
|
||||||
|
const [justCreatedToken, setJustCreatedToken] = useState<string | null>(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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={() => {}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-md mx-4 w-full max-h-[80vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Share Setup</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 rounded"
|
||||||
|
>
|
||||||
|
<LucideIcon name="x" size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility Picker */}
|
||||||
|
<div className="flex flex-col gap-2 mb-4">
|
||||||
|
{VISIBILITY_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVisibilityChange(option.value)}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
currentVisibility === option.value
|
||||||
|
? option.selectedBorder
|
||||||
|
: `${option.border} hover:border-gray-300`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LucideIcon
|
||||||
|
name={option.icon}
|
||||||
|
size={20}
|
||||||
|
className={option.iconColor}
|
||||||
|
/>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{option.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deactivation Warning */}
|
||||||
|
{currentVisibility === "private" && switchingToPrivateWithLinks && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg mb-4">
|
||||||
|
<LucideIcon
|
||||||
|
name="alert-triangle"
|
||||||
|
size={16}
|
||||||
|
className="text-amber-500 mt-0.5 shrink-0"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-amber-700">
|
||||||
|
Switching to private will deactivate all share links. They can be
|
||||||
|
reactivated by switching back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Share Links Section */}
|
||||||
|
{showLinksSection && (
|
||||||
|
<div className="border-t border-gray-100 pt-4 mt-2">
|
||||||
|
<div className="text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Share Links
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Link Row */}
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<select
|
||||||
|
value={expiresInDays === null ? "null" : String(expiresInDays)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setExpiresInDays(
|
||||||
|
e.target.value === "null" ? null : Number(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white"
|
||||||
|
>
|
||||||
|
{EXPIRATION_OPTIONS.map((opt) => (
|
||||||
|
<option
|
||||||
|
key={String(opt.value)}
|
||||||
|
value={opt.value === null ? "null" : String(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateLink}
|
||||||
|
disabled={createShareLink.isPending}
|
||||||
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{createShareLink.isPending ? "Creating..." : "Create Link"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Links List */}
|
||||||
|
{activeLinks.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activeLinks.map((link) => (
|
||||||
|
<div
|
||||||
|
key={link.id}
|
||||||
|
className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-600 truncate flex-1">
|
||||||
|
{window.location.origin}/s/
|
||||||
|
{link.token.slice(0, 8)}...
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 shrink-0">
|
||||||
|
{formatExpiration(link.expiresAt)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(link.token, link.id)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-gray-600 rounded"
|
||||||
|
title="Copy link"
|
||||||
|
>
|
||||||
|
<LucideIcon
|
||||||
|
name={
|
||||||
|
copiedId === link.id ||
|
||||||
|
justCreatedToken === link.token
|
||||||
|
? "check"
|
||||||
|
: "copy"
|
||||||
|
}
|
||||||
|
size={16}
|
||||||
|
className={
|
||||||
|
copiedId === link.id ||
|
||||||
|
justCreatedToken === link.token
|
||||||
|
? "text-green-500"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => revokeShareLink.mutate(link.id)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 rounded"
|
||||||
|
title="Revoke link"
|
||||||
|
>
|
||||||
|
<LucideIcon name="x" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 text-center py-4">
|
||||||
|
No share links yet
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/client/hooks/useShares.ts
Normal file
42
src/client/hooks/useShares.ts
Normal file
@@ -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<ShareLink[]>(`/api/setups/${setupId}/shares`),
|
||||||
|
enabled: !!setupId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateShareLink(setupId: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { expiresInDays: number | null }) =>
|
||||||
|
apiPost<ShareLink>(`/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<ShareLink>(`/api/setups/${setupId}/shares/${shareId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["shares", setupId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
|||||||
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";
|
||||||
|
import { ShareModal } from "../../components/ShareModal";
|
||||||
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
||||||
import { useAuth } from "../../hooks/useAuth";
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
import { useFormatters } from "../../hooks/useFormatters";
|
import { useFormatters } from "../../hooks/useFormatters";
|
||||||
@@ -36,12 +37,13 @@ function SetupDetailPage() {
|
|||||||
: publicSetup;
|
: publicSetup;
|
||||||
|
|
||||||
const deleteSetup = useDeleteSetup();
|
const deleteSetup = useDeleteSetup();
|
||||||
const _updateSetup = useUpdateSetup(numericId);
|
const updateSetup = useUpdateSetup(numericId);
|
||||||
const removeItem = useRemoveSetupItem(numericId);
|
const removeItem = useRemoveSetupItem(numericId);
|
||||||
const updateClassification = useUpdateItemClassification(numericId);
|
const updateClassification = useUpdateItemClassification(numericId);
|
||||||
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -174,14 +176,16 @@ function SetupDetailPage() {
|
|||||||
<LucideIcon name="plus" size={16} />
|
<LucideIcon name="plus" size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Visibility badge — desktop */}
|
{/* Share button — desktop */}
|
||||||
<span
|
<button
|
||||||
className={`hidden md:inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg ${
|
type="button"
|
||||||
|
onClick={() => 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"
|
setup.visibility === "public"
|
||||||
? "text-green-700 bg-green-50"
|
? "text-green-700 bg-green-50 hover:bg-green-100"
|
||||||
: setup.visibility === "link"
|
: setup.visibility === "link"
|
||||||
? "text-blue-600 bg-blue-50"
|
? "text-blue-600 bg-blue-50 hover:bg-blue-100"
|
||||||
: "text-gray-500 bg-gray-50"
|
: "text-gray-500 bg-gray-50 hover:bg-gray-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LucideIcon
|
<LucideIcon
|
||||||
@@ -194,28 +198,21 @@ function SetupDetailPage() {
|
|||||||
}
|
}
|
||||||
size={16}
|
size={16}
|
||||||
/>
|
/>
|
||||||
{setup.visibility === "public"
|
Share
|
||||||
? "Public"
|
</button>
|
||||||
: setup.visibility === "link"
|
{/* Share button — mobile */}
|
||||||
? "Link"
|
<button
|
||||||
: "Private"}
|
type="button"
|
||||||
</span>
|
onClick={() => setShareModalOpen(true)}
|
||||||
{/* Visibility badge — mobile */}
|
className={`md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 rounded-lg transition-colors ${
|
||||||
<span
|
|
||||||
className={`md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 rounded-lg ${
|
|
||||||
setup.visibility === "public"
|
setup.visibility === "public"
|
||||||
? "text-green-700 bg-green-50"
|
? "text-green-700 bg-green-50 hover:bg-green-100"
|
||||||
: setup.visibility === "link"
|
: setup.visibility === "link"
|
||||||
? "text-blue-600 bg-blue-50"
|
? "text-blue-600 bg-blue-50 hover:bg-blue-100"
|
||||||
: "text-gray-500 bg-gray-50"
|
: "text-gray-500 bg-gray-50 hover:bg-gray-100"
|
||||||
}`}
|
}`}
|
||||||
title={
|
aria-label="Share settings"
|
||||||
setup.visibility === "public"
|
title="Share settings"
|
||||||
? "Public"
|
|
||||||
: setup.visibility === "link"
|
|
||||||
? "Link shared"
|
|
||||||
: "Private"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<LucideIcon
|
<LucideIcon
|
||||||
name={
|
name={
|
||||||
@@ -227,7 +224,7 @@ function SetupDetailPage() {
|
|||||||
}
|
}
|
||||||
size={16}
|
size={16}
|
||||||
/>
|
/>
|
||||||
</span>
|
</button>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{/* Delete Setup — desktop */}
|
{/* Delete Setup — desktop */}
|
||||||
@@ -361,6 +358,17 @@ function SetupDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Share Modal — only for authenticated users */}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<ShareModal
|
||||||
|
isOpen={shareModalOpen}
|
||||||
|
onClose={() => setShareModalOpen(false)}
|
||||||
|
setupId={numericId}
|
||||||
|
currentVisibility={setup.visibility}
|
||||||
|
onVisibilityChange={(v) => updateSetup.mutate({ visibility: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog — only for authenticated users */}
|
{/* Delete Confirmation Dialog — only for authenticated users */}
|
||||||
{isAuthenticated && confirmDelete && (
|
{isAuthenticated && 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">
|
||||||
|
|||||||
@@ -23,10 +23,9 @@ import type {
|
|||||||
createThreadSchema,
|
createThreadSchema,
|
||||||
deleteAccountSchema,
|
deleteAccountSchema,
|
||||||
reorderCandidatesSchema,
|
reorderCandidatesSchema,
|
||||||
submitCommunityPriceSchema,
|
|
||||||
upsertMarketPriceSchema,
|
|
||||||
resolveThreadSchema,
|
resolveThreadSchema,
|
||||||
searchGlobalItemsSchema,
|
searchGlobalItemsSchema,
|
||||||
|
submitCommunityPriceSchema,
|
||||||
syncSetupItemsSchema,
|
syncSetupItemsSchema,
|
||||||
updateCandidateSchema,
|
updateCandidateSchema,
|
||||||
updateCategorySchema,
|
updateCategorySchema,
|
||||||
@@ -36,6 +35,7 @@ import type {
|
|||||||
updateSetupSchema,
|
updateSetupSchema,
|
||||||
updateThreadSchema,
|
updateThreadSchema,
|
||||||
upsertGlobalItemSchema,
|
upsertGlobalItemSchema,
|
||||||
|
upsertMarketPriceSchema,
|
||||||
} from "./schemas.ts";
|
} from "./schemas.ts";
|
||||||
|
|
||||||
// Types inferred from Zod schemas
|
// Types inferred from Zod schemas
|
||||||
|
|||||||
Reference in New Issue
Block a user