feat(share-modal): D1.4 — 3-level visibility picker (public/unlisted/private)
Phase 1 D1.4 — replaces the binary public/private toggle in ShareModal
with three radio-button-styled options. Optimistic UI: every option
renders for every user; the backend's 403 (with structured
visibility_not_allowed detail) redirects to /pricing?from=visibility_X
so the pricing page can lead the right pitch.
Why optimistic-then-redirect instead of hiding/locking options:
1. Discovery — Free / Maker users SEE Pro unlocks Private. That's the
exact conversion signal the pricing page is trying to surface.
2. Discovery without surprise — the locked click goes to /pricing
with a hint, not a dead modal.
3. Less plan-coupling — this upstream component doesn't need to know
about the pro overlay's plan store. Backend is the only source of
truth for what's allowed.
Touched:
- ShareModal.tsx: full rewrite as a 3-option picker with badges
(Maker / Pro) on the gated options.
- projectService.ts: ProjectResponse / ProjectSaveData now declare
`visibility?: 'public' | 'unlisted' | 'private'`. is_public stays
declared for backward compat with old callers.
- useProjectStore.ts: CurrentProject gains `visibility?`; setVisibility
accepts EITHER the legacy boolean OR the new enum and keeps both
fields coherent.
- common.json (4 locales): new editor.share.visibility.{publicLabel,
publicHint, unlistedLabel, unlistedHint, privateLabel, privateHint}
+ editor.share.updateFailed.
Backend gating + DB migration are in the velxio-prod pro overlay
(commit referencing this submodule pointer).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c957767e2e
commit
9a40de78c0
|
|
@ -1,28 +1,67 @@
|
|||
/**
|
||||
* ShareModal — shows a shareable project link and visibility toggle.
|
||||
* ShareModal — shows a shareable project link and 3-level visibility picker.
|
||||
*
|
||||
* Phase 1 D1.4 — replaced the binary public/private toggle with a
|
||||
* three-option enum (public / unlisted / private). The UI is intentionally
|
||||
* "optimistic": every option renders for every user, regardless of plan.
|
||||
* The backend gates by `user.plan_id` and responds with HTTP 403 +
|
||||
* `{ error: "visibility_not_allowed", upgrade_to: "Maker"|"Pro" }` when
|
||||
* the user picks something their plan doesn't cover. We surface that as
|
||||
* a redirect to /pricing with a `from=visibility_<level>` hint so the
|
||||
* pricing page can lead the right pitch.
|
||||
*
|
||||
* Why "optimistic-then-redirect" instead of locked buttons:
|
||||
* 1. Discovery — Free / Maker users SEE that Pro unlocks 'private',
|
||||
* which is exactly the conversion signal we want surfaced.
|
||||
* 2. Discovery without surprise — clicking the locked option goes
|
||||
* somewhere actionable (/pricing) rather than a no-op or generic
|
||||
* modal.
|
||||
* 3. Less plan-coupling — this upstream component doesn't need to
|
||||
* import from the pro overlay's auth store. The backend is the
|
||||
* single source of truth.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useProjectStore } from '../../store/useProjectStore';
|
||||
import { updateProject } from '../../services/projectService';
|
||||
import type { ProjectVisibility } from '../../services/projectService';
|
||||
|
||||
interface ShareModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type VisibilityErrorDetail = {
|
||||
error?: string;
|
||||
upgrade_to?: 'Maker' | 'Pro';
|
||||
upgrade_url?: string;
|
||||
requested_visibility?: ProjectVisibility;
|
||||
};
|
||||
|
||||
export const ShareModal: React.FC<ShareModalProps> = ({ onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const currentProject = useProjectStore((s) => s.currentProject);
|
||||
const setVisibility = useProjectStore((s) => s.setVisibility);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [savingTo, setSavingTo] = useState<ProjectVisibility | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pick the project's current effective visibility. The backend fills
|
||||
// this in post-migration; old payloads (or cached pages from before the
|
||||
// deploy) might be missing it — fall back to is_public so the modal
|
||||
// never renders empty.
|
||||
const initialVisibility: ProjectVisibility =
|
||||
currentProject?.visibility ?? (currentProject?.isPublic ? 'public' : 'private');
|
||||
const [active, setActive] = useState<ProjectVisibility>(initialVisibility);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(initialVisibility);
|
||||
}, [initialVisibility]);
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
const shareUrl = `${window.location.origin}/project/${currentProject.id}`;
|
||||
const isPublic = currentProject.isPublic;
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
|
|
@ -31,83 +70,107 @@ export const ShareModal: React.FC<ShareModalProps> = ({ onClose }) => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleToggleVisibility = async () => {
|
||||
setToggling(true);
|
||||
const handlePick = async (next: ProjectVisibility) => {
|
||||
if (next === active || savingTo) return;
|
||||
setError(null);
|
||||
setSavingTo(next);
|
||||
try {
|
||||
await updateProject(currentProject.id, { is_public: !isPublic });
|
||||
setVisibility(!isPublic);
|
||||
} catch {
|
||||
// Silently fail — user can retry
|
||||
await updateProject(currentProject.id, {
|
||||
visibility: next,
|
||||
// Keep the legacy boolean in sync so any code path still reading
|
||||
// it (older callers) sees a consistent value.
|
||||
is_public: next === 'public',
|
||||
});
|
||||
setActive(next);
|
||||
setVisibility(next === 'public');
|
||||
} catch (err) {
|
||||
const e = err as { response?: { status?: number; data?: { detail?: VisibilityErrorDetail } } };
|
||||
const status = e?.response?.status;
|
||||
const detail = e?.response?.data?.detail;
|
||||
if (status === 403 && detail?.error === 'visibility_not_allowed') {
|
||||
// Backend rejected — route the user to /pricing with a hint
|
||||
// matching the level they tried to set.
|
||||
const url = detail.upgrade_url || '/pricing';
|
||||
const from = `visibility_${next}`;
|
||||
window.location.href = `${url}?from=${from}`;
|
||||
return;
|
||||
}
|
||||
setError(t('editor.share.updateFailed'));
|
||||
} finally {
|
||||
setToggling(false);
|
||||
setSavingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const options: Array<{
|
||||
value: ProjectVisibility;
|
||||
label: string;
|
||||
hint: string;
|
||||
badge?: string;
|
||||
}> = [
|
||||
{
|
||||
value: 'public',
|
||||
label: t('editor.share.visibility.publicLabel'),
|
||||
hint: t('editor.share.visibility.publicHint'),
|
||||
},
|
||||
{
|
||||
value: 'unlisted',
|
||||
label: t('editor.share.visibility.unlistedLabel'),
|
||||
hint: t('editor.share.visibility.unlistedHint'),
|
||||
badge: 'Maker',
|
||||
},
|
||||
{
|
||||
value: 'private',
|
||||
label: t('editor.share.visibility.privateLabel'),
|
||||
hint: t('editor.share.visibility.privateHint'),
|
||||
badge: 'Pro',
|
||||
},
|
||||
];
|
||||
|
||||
return createPortal(
|
||||
<div style={styles.overlay} onClick={onClose}>
|
||||
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||
<h2 style={styles.title}>{t('editor.share.title')}</h2>
|
||||
|
||||
{/* Visibility toggle */}
|
||||
<div style={styles.visibilityRow}>
|
||||
<div style={styles.visibilityInfo}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{isPublic ? (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#4ade80"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
)}
|
||||
<span
|
||||
style={{ color: isPublic ? '#4ade80' : '#f59e0b', fontWeight: 600, fontSize: 13 }}
|
||||
{/* Three-option visibility picker */}
|
||||
<div style={styles.visibilityList}>
|
||||
{options.map((opt) => {
|
||||
const isActive = active === opt.value;
|
||||
const isSaving = savingTo === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handlePick(opt.value)}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
...styles.visibilityOption,
|
||||
borderColor: isActive ? '#0e639c' : '#333',
|
||||
background: isActive ? 'rgba(14,99,156,0.12)' : '#1e1e1e',
|
||||
opacity: isSaving ? 0.6 : 1,
|
||||
cursor: isSaving ? 'wait' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{isPublic ? t('editor.share.public') : t('editor.share.private')}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ color: '#888', fontSize: 12 }}>
|
||||
{isPublic ? t('editor.share.publicHint') : t('editor.share.privateHint')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleVisibility}
|
||||
disabled={toggling}
|
||||
style={{
|
||||
...styles.toggleBtn,
|
||||
opacity: toggling ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{toggling
|
||||
? '...'
|
||||
: isPublic
|
||||
? t('editor.share.makePrivate')
|
||||
: t('editor.share.makePublic')}
|
||||
</button>
|
||||
<div style={styles.optionHead}>
|
||||
<span style={styles.optionLabel}>{opt.label}</span>
|
||||
{opt.badge && (
|
||||
<span style={styles.badge}>{opt.badge}</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#4ade80" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.optionHint}>{opt.hint}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && <div style={styles.warning}>{error}</div>}
|
||||
|
||||
{/* Share link */}
|
||||
<div style={styles.linkRow}>
|
||||
<input
|
||||
|
|
@ -120,14 +183,8 @@ export const ShareModal: React.FC<ShareModalProps> = ({ onClose }) => {
|
|||
<button onClick={handleCopy} style={styles.copyBtn}>
|
||||
{copied ? (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#4ade80"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#4ade80" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
|
|
@ -137,7 +194,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ onClose }) => {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{!isPublic && (
|
||||
{active === 'private' && (
|
||||
<div style={styles.warning}>{t('editor.share.privateWarning')}</div>
|
||||
)}
|
||||
|
||||
|
|
@ -154,91 +211,57 @@ export const ShareModal: React.FC<ShareModalProps> = ({ onClose }) => {
|
|||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
|
||||
},
|
||||
modal: {
|
||||
background: '#252526',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: 8,
|
||||
padding: '1.75rem',
|
||||
width: 440,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
background: '#252526', border: '1px solid #3c3c3c', borderRadius: 8,
|
||||
padding: '1.75rem', width: 460,
|
||||
display: 'flex', flexDirection: 'column', gap: 16,
|
||||
},
|
||||
title: { color: '#ccc', margin: 0, fontSize: 18, fontWeight: 600 },
|
||||
visibilityRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
visibilityList: { display: 'flex', flexDirection: 'column', gap: 6 },
|
||||
visibilityOption: {
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
background: '#1e1e1e',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 6,
|
||||
},
|
||||
visibilityInfo: {
|
||||
border: '1px solid #333',
|
||||
background: '#1e1e1e',
|
||||
color: '#ccc',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
gap: 2,
|
||||
transition: 'all .12s ease',
|
||||
},
|
||||
toggleBtn: {
|
||||
background: 'transparent',
|
||||
border: '1px solid #555',
|
||||
borderRadius: 4,
|
||||
color: '#ccc',
|
||||
padding: '6px 12px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
optionHead: { display: 'flex', alignItems: 'center', gap: 8 },
|
||||
optionLabel: { fontWeight: 600, fontSize: 13, color: '#eee' },
|
||||
optionHint: { fontSize: 11, color: '#888', lineHeight: 1.4 },
|
||||
badge: {
|
||||
fontSize: 10,
|
||||
background: '#0e639c',
|
||||
color: '#fff',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
fontWeight: 600,
|
||||
},
|
||||
linkRow: { display: 'flex', gap: 6 },
|
||||
linkInput: {
|
||||
flex: 1,
|
||||
background: '#1e1e1e',
|
||||
border: '1px solid #444',
|
||||
borderRadius: 4,
|
||||
padding: '8px 10px',
|
||||
color: '#4fc3f7',
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
outline: 'none',
|
||||
flex: 1, background: '#1e1e1e', border: '1px solid #444', borderRadius: 4,
|
||||
padding: '8px 10px', color: '#4fc3f7', fontSize: 13,
|
||||
fontFamily: 'monospace', outline: 'none',
|
||||
},
|
||||
copyBtn: {
|
||||
background: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
color: '#fff',
|
||||
padding: '8px 16px',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: '#0e639c', border: 'none', borderRadius: 4, color: '#fff',
|
||||
padding: '8px 16px', fontSize: 13, cursor: 'pointer', fontWeight: 500,
|
||||
display: 'flex', alignItems: 'center',
|
||||
},
|
||||
warning: {
|
||||
background: '#3d2e00',
|
||||
border: '1px solid #f59e0b44',
|
||||
borderRadius: 4,
|
||||
color: '#f59e0b',
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
background: '#3d2e00', border: '1px solid #f59e0b44', borderRadius: 4,
|
||||
color: '#f59e0b', padding: '8px 12px', fontSize: 12,
|
||||
},
|
||||
actions: { display: 'flex', justifyContent: 'flex-end' },
|
||||
closeBtn: {
|
||||
background: 'transparent',
|
||||
border: '1px solid #555',
|
||||
borderRadius: 4,
|
||||
color: '#ccc',
|
||||
padding: '8px 16px',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
background: 'transparent', border: '1px solid #555', borderRadius: 4,
|
||||
color: '#ccc', padding: '8px 16px', fontSize: 13, cursor: 'pointer',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -414,5 +414,18 @@
|
|||
"ctaUpgrade": "Upgrade to Pro — $15/mo",
|
||||
"ctaSeePlans": "See plans",
|
||||
"ctaWait": "Wait until reset"
|
||||
},
|
||||
"editor": {
|
||||
"share": {
|
||||
"updateFailed": "Could not update visibility — please try again.",
|
||||
"visibility": {
|
||||
"publicLabel": "Public",
|
||||
"publicHint": "Anyone can find this project on velxio.dev.",
|
||||
"unlistedLabel": "Unlisted",
|
||||
"unlistedHint": "Hidden from search and the gallery — only accessible via the share link.",
|
||||
"privateLabel": "Private",
|
||||
"privateHint": "Only you can see this project."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -414,5 +414,18 @@
|
|||
"ctaUpgrade": "Pasar a Pro — $15/mes",
|
||||
"ctaSeePlans": "Ver planes",
|
||||
"ctaWait": "Esperar al reinicio"
|
||||
},
|
||||
"editor": {
|
||||
"share": {
|
||||
"updateFailed": "No se pudo cambiar la visibilidad — intentá de nuevo.",
|
||||
"visibility": {
|
||||
"publicLabel": "Público",
|
||||
"publicHint": "Cualquiera puede encontrar este proyecto en velxio.dev.",
|
||||
"unlistedLabel": "No listado",
|
||||
"unlistedHint": "Oculto de búsqueda y galería — solo accesible con el link.",
|
||||
"privateLabel": "Privado",
|
||||
"privateHint": "Solo vos podés ver este proyecto."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -414,5 +414,18 @@
|
|||
"ctaUpgrade": "Fazer upgrade para Pro — $15/mês",
|
||||
"ctaSeePlans": "Ver planos",
|
||||
"ctaWait": "Aguardar reinício"
|
||||
},
|
||||
"editor": {
|
||||
"share": {
|
||||
"updateFailed": "Não foi possível alterar a visibilidade — tente novamente.",
|
||||
"visibility": {
|
||||
"publicLabel": "Público",
|
||||
"publicHint": "Qualquer pessoa pode encontrar este projeto em velxio.dev.",
|
||||
"unlistedLabel": "Não listado",
|
||||
"unlistedHint": "Oculto da busca e da galeria — acessível apenas pelo link.",
|
||||
"privateLabel": "Privado",
|
||||
"privateHint": "Apenas você pode ver este projeto."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -414,5 +414,18 @@
|
|||
"ctaUpgrade": "升级到 Pro — $15/月",
|
||||
"ctaSeePlans": "查看套餐",
|
||||
"ctaWait": "等待重置"
|
||||
},
|
||||
"editor": {
|
||||
"share": {
|
||||
"updateFailed": "无法更改可见性,请重试。",
|
||||
"visibility": {
|
||||
"publicLabel": "公开",
|
||||
"publicHint": "任何人都可以在 velxio.dev 上找到此项目。",
|
||||
"unlistedLabel": "不列出",
|
||||
"unlistedHint": "从搜索和画廊中隐藏 —— 只能通过分享链接访问。",
|
||||
"privateLabel": "私有",
|
||||
"privateHint": "只有你能看到此项目。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,22 @@ export interface FileGroup {
|
|||
files: SketchFile[];
|
||||
}
|
||||
|
||||
// Phase 1 D1.3 — three-level visibility enum mirroring the backend
|
||||
// projects.visibility column. Keep aligned with pro/backend/app/schemas/
|
||||
// project.py::Visibility.
|
||||
export type ProjectVisibility = 'public' | 'unlisted' | 'private';
|
||||
|
||||
export interface ProjectResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
is_public: boolean;
|
||||
// Phase 1 D1.3 — present on rows after the migration; older serializations
|
||||
// (cached pages or rolled-back deploys) might miss it. Treat undefined as
|
||||
// 'public' if is_public is true, otherwise 'private', mirroring the
|
||||
// backend's _to_response fallback.
|
||||
visibility?: ProjectVisibility;
|
||||
board_type: string;
|
||||
files: SketchFile[]; // active board's files (legacy)
|
||||
file_groups: FileGroup[]; // all boards' file groups
|
||||
|
|
@ -41,6 +51,10 @@ export interface ProjectSaveData {
|
|||
name: string;
|
||||
description?: string;
|
||||
is_public: boolean;
|
||||
// Phase 1 D1.3 — optional explicit visibility. When omitted the backend
|
||||
// resolves from `is_public` for backward compat with old clients. New
|
||||
// clients (ShareModal post-D1.4) always send this.
|
||||
visibility?: ProjectVisibility;
|
||||
board_type: string;
|
||||
files: SketchFile[]; // legacy: active board's files
|
||||
file_groups?: FileGroup[]; // multi-board: all groups
|
||||
|
|
|
|||
|
|
@ -1,23 +1,45 @@
|
|||
import { create } from 'zustand';
|
||||
import type { ProjectVisibility } from '../services/projectService';
|
||||
|
||||
interface CurrentProject {
|
||||
id: string;
|
||||
slug: string;
|
||||
ownerUsername: string;
|
||||
isPublic: boolean;
|
||||
// Phase 1 D1.3 — three-level visibility. Kept in sync with isPublic
|
||||
// (which legacy callers still read) by setVisibility().
|
||||
visibility?: ProjectVisibility;
|
||||
}
|
||||
|
||||
interface ProjectState {
|
||||
currentProject: CurrentProject | null;
|
||||
setCurrentProject: (project: CurrentProject) => void;
|
||||
clearCurrentProject: () => void;
|
||||
setVisibility: (isPublic: boolean) => void;
|
||||
// Updated to accept either the legacy boolean OR the new enum so the
|
||||
// ShareModal callsite and any older callers keep working uniformly.
|
||||
setVisibility: (next: boolean | ProjectVisibility) => void;
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectState>((set) => ({
|
||||
currentProject: null,
|
||||
setCurrentProject: (project) => set({ currentProject: project }),
|
||||
clearCurrentProject: () => set({ currentProject: null }),
|
||||
setVisibility: (isPublic) =>
|
||||
set((s) => (s.currentProject ? { currentProject: { ...s.currentProject, isPublic } } : s)),
|
||||
setVisibility: (next) =>
|
||||
set((s) => {
|
||||
if (!s.currentProject) return s;
|
||||
// Translate boolean → enum and vice versa so both fields are
|
||||
// always coherent.
|
||||
let isPublic: boolean;
|
||||
let visibility: ProjectVisibility;
|
||||
if (typeof next === 'boolean') {
|
||||
isPublic = next;
|
||||
visibility = next ? 'public' : 'private';
|
||||
} else {
|
||||
visibility = next;
|
||||
isPublic = next === 'public';
|
||||
}
|
||||
return {
|
||||
currentProject: { ...s.currentProject, isPublic, visibility },
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in New Issue