feat: migrate setup visibility from boolean to three-tier system

Replace isPublic boolean with visibility enum (private/link/public) across
the full stack. Add shares table to schema for future share link support.
Update all services, routes, schemas, hooks, components, and tests.

Plan: 32-01 (Setup Sharing System - Schema Migration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 17:55:46 +02:00
parent 727abf1528
commit edc9793c2d
20 changed files with 1556 additions and 81 deletions

View File

@@ -4,7 +4,7 @@ import { useFormatters } from "../hooks/useFormatters";
interface SetupCardProps {
id: number;
name: string;
isPublic?: boolean;
visibility?: "private" | "link" | "public";
itemCount: number;
totalWeight: number;
totalCost: number;
@@ -13,7 +13,7 @@ interface SetupCardProps {
export function SetupCard({
id,
name,
isPublic,
visibility,
itemCount,
totalWeight,
totalCost,
@@ -30,9 +30,15 @@ export function SetupCard({
<h3 className="text-sm font-semibold text-gray-900 truncate">
{name}
</h3>
{isPublic && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-green-50 text-green-600 shrink-0">
Public
{visibility && visibility !== "private" && (
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium shrink-0 ${
visibility === "public"
? "bg-green-50 text-green-600"
: "bg-blue-50 text-blue-600"
}`}
>
{visibility === "public" ? "Public" : "Link"}
</span>
)}
</div>

View File

@@ -100,7 +100,7 @@ export function SetupsView() {
key={setup.id}
id={setup.id}
name={setup.name}
isPublic={setup.isPublic}
visibility={setup.visibility}
itemCount={setup.itemCount}
totalWeight={setup.totalWeight}
totalCost={setup.totalCost}

View File

@@ -11,7 +11,7 @@ import {
interface SetupListItem {
id: number;
name: string;
isPublic: boolean;
visibility: "private" | "link" | "public";
createdAt: string;
updatedAt: string;
itemCount: number;
@@ -39,7 +39,7 @@ interface SetupItemWithCategory {
interface SetupWithItems {
id: number;
name: string;
isPublic: boolean;
visibility: "private" | "link" | "public";
createdAt: string;
updatedAt: string;
items: SetupItemWithCategory[];
@@ -88,8 +88,10 @@ export function useCreateSetup() {
export function useUpdateSetup(setupId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name?: string; isPublic?: boolean }) =>
apiPut<SetupListItem>(`/api/setups/${setupId}`, data),
mutationFn: (data: {
name?: string;
visibility?: "private" | "link" | "public";
}) => apiPut<SetupListItem>(`/api/setups/${setupId}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] });
},

View File

@@ -36,7 +36,7 @@ function SetupDetailPage() {
: publicSetup;
const deleteSetup = useDeleteSetup();
const updateSetup = useUpdateSetup(numericId);
const _updateSetup = useUpdateSetup(numericId);
const removeItem = useRemoveSetupItem(numericId);
const updateClassification = useUpdateItemClassification(numericId);
@@ -174,33 +174,60 @@ function SetupDetailPage() {
<LucideIcon name="plus" size={16} />
</button>
{/* Public toggle — desktop */}
<button
type="button"
onClick={() => updateSetup.mutate({ isPublic: !setup.isPublic })}
className={`hidden md:inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
setup.isPublic
? "text-green-700 bg-green-50 hover:bg-green-100"
: "text-gray-500 bg-gray-50 hover:bg-gray-100"
{/* Visibility badge — desktop */}
<span
className={`hidden md:inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg ${
setup.visibility === "public"
? "text-green-700 bg-green-50"
: setup.visibility === "link"
? "text-blue-600 bg-blue-50"
: "text-gray-500 bg-gray-50"
}`}
>
<LucideIcon name="globe" size={16} />
{setup.isPublic ? "Public" : "Private"}
</button>
{/* Public toggle — mobile */}
<button
type="button"
onClick={() => updateSetup.mutate({ isPublic: !setup.isPublic })}
className={`md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 rounded-lg transition-colors ${
setup.isPublic
? "text-green-700 bg-green-50 hover:bg-green-100"
: "text-gray-500 bg-gray-50 hover:bg-gray-100"
<LucideIcon
name={
setup.visibility === "public"
? "globe"
: setup.visibility === "link"
? "link"
: "lock"
}
size={16}
/>
{setup.visibility === "public"
? "Public"
: setup.visibility === "link"
? "Link"
: "Private"}
</span>
{/* Visibility badge — mobile */}
<span
className={`md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 rounded-lg ${
setup.visibility === "public"
? "text-green-700 bg-green-50"
: setup.visibility === "link"
? "text-blue-600 bg-blue-50"
: "text-gray-500 bg-gray-50"
}`}
aria-label={setup.isPublic ? "Public" : "Private"}
title={setup.isPublic ? "Public" : "Private"}
title={
setup.visibility === "public"
? "Public"
: setup.visibility === "link"
? "Link shared"
: "Private"
}
>
<LucideIcon name="globe" size={16} />
</button>
<LucideIcon
name={
setup.visibility === "public"
? "globe"
: setup.visibility === "link"
? "link"
: "lock"
}
size={16}
/>
</span>
<div className="flex-1" />
{/* Delete Setup — desktop */}