feat: add project visibility settings for scans and floorplans (#106)

- Add show_scans_public and show_guides_public columns to projects table
- Add visibility toggles to editor sidebar settings panel (auto-save)
- Add visibility toggles to project settings dialog (auto-save)
- Propagate settings to community viewer (hide scans/guides for non-owners)
- Add updateProjectVisibility server action
- Fix username onboarding dialog stuck after DB reset (validate update affected rows)
- Add error handling to UsernameGate for stale sessions

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-02-19 16:59:23 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 659c01052e
commit f4f9f8a220
12 changed files with 1253 additions and 68 deletions
+22 -4
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { initSpatialGridSync, useScene } from '@pascal-app/core' import { initSpatialGridSync, useScene } from '@pascal-app/core'
import { Viewer } from '@pascal-app/viewer' import { Viewer, useViewer } from '@pascal-app/viewer'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ViewerCameraControls } from './viewer-camera-controls' import { ViewerCameraControls } from './viewer-camera-controls'
@@ -20,6 +20,8 @@ export default function ViewerPage() {
const [projectId, setProjectId] = useState<string | null>(null) const [projectId, setProjectId] = useState<string | null>(null)
const [projectName, setProjectName] = useState<string | null>(null) const [projectName, setProjectName] = useState<string | null>(null)
const [owner, setOwner] = useState<ProjectOwner | null>(null) const [owner, setOwner] = useState<ProjectOwner | null>(null)
const [canShowScans, setCanShowScans] = useState(true)
const [canShowGuides, setCanShowGuides] = useState(true)
const setScene = useScene((state) => state.setScene) const setScene = useScene((state) => state.setScene)
useEffect(() => { useEffect(() => {
@@ -42,10 +44,26 @@ export default function ViewerPage() {
const result = await getProjectModelPublic(id) const result = await getProjectModelPublic(id)
if (result.success && result.data) { if (result.success && result.data) {
const { project, model } = result.data const { project, model, isOwner } = result.data
const projectData = project as any
setProjectId(project.id) setProjectId(project.id)
setProjectName(project.name) setProjectName(project.name)
setOwner((project as any).owner ?? null) setOwner(projectData.owner ?? null)
// Apply public visibility settings for scans/guides (only for non-owners)
if (!isOwner) {
const scansAllowed = projectData.show_scans_public !== false
const guidesAllowed = projectData.show_guides_public !== false
setCanShowScans(scansAllowed)
setCanShowGuides(guidesAllowed)
if (!scansAllowed) {
useViewer.getState().setShowScans(false)
}
if (!guidesAllowed) {
useViewer.getState().setShowGuides(false)
}
}
if (model?.scene_graph) { if (model?.scene_graph) {
const { nodes, rootNodeIds } = model.scene_graph const { nodes, rootNodeIds } = model.scene_graph
@@ -88,7 +106,7 @@ export default function ViewerPage() {
return ( return (
<div className="relative h-screen w-full"> <div className="relative h-screen w-full">
<ViewerOverlay projectName={projectName} owner={owner} /> <ViewerOverlay projectName={projectName} owner={owner} canShowScans={canShowScans} canShowGuides={canShowGuides} />
<ViewerGuestCTA /> <ViewerGuestCTA />
<Viewer> <Viewer>
<ViewerCameraControls /> <ViewerCameraControls />
@@ -19,9 +19,11 @@ const getNodeName = (node: AnyNode): string => {
interface ViewerOverlayProps { interface ViewerOverlayProps {
projectName?: string | null projectName?: string | null
owner?: ProjectOwner | null owner?: ProjectOwner | null
canShowScans?: boolean
canShowGuides?: boolean
} }
export const ViewerOverlay = ({ projectName, owner }: ViewerOverlayProps) => { export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShowGuides = true }: ViewerOverlayProps) => {
const selection = useViewer((s) => s.selection) const selection = useViewer((s) => s.selection)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
const showScans = useViewer((s) => s.showScans) const showScans = useViewer((s) => s.showScans)
@@ -171,8 +173,10 @@ export const ViewerOverlay = ({ projectName, owner }: ViewerOverlayProps) => {
{/* Controls Panel - Top Right */} {/* Controls Panel - Top Right */}
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2"> <div className="absolute top-4 right-4 z-10 flex flex-col gap-2">
{/* Visibility Controls */} {/* Visibility Controls */}
{(canShowScans || canShowGuides) && (
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]"> <div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
<span className="text-xs text-neutral-500 px-2 pb-1">Visibility</span> <span className="text-xs text-neutral-500 px-2 pb-1">Visibility</span>
{canShowScans && (
<button <button
onClick={() => useViewer.getState().setShowScans(!showScans)} onClick={() => useViewer.getState().setShowScans(!showScans)}
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${ className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
@@ -182,6 +186,8 @@ export const ViewerOverlay = ({ projectName, owner }: ViewerOverlayProps) => {
<Box className="w-4 h-4" /> <Box className="w-4 h-4" />
3D Scans 3D Scans
</button> </button>
)}
{canShowGuides && (
<button <button
onClick={() => useViewer.getState().setShowGuides(!showGuides)} onClick={() => useViewer.getState().setShowGuides(!showGuides)}
className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${ className={`flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors ${
@@ -191,7 +197,9 @@ export const ViewerOverlay = ({ projectName, owner }: ViewerOverlayProps) => {
<Image className="w-4 h-4" /> <Image className="w-4 h-4" />
Guides Guides
</button> </button>
)}
</div> </div>
)}
{/* Camera Mode */} {/* Camera Mode */}
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]"> <div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
@@ -3,9 +3,11 @@ import { useViewer } from "@pascal-app/viewer";
import { Camera, Download, Save, Trash2, Upload } from "lucide-react"; import { Camera, Download, Save, Trash2, Upload } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Button } from "@/components/ui/primitives/button"; import { Button } from "@/components/ui/primitives/button";
import { Switch } from "@/components/ui/primitives/switch";
import useEditor from "@/store/use-editor"; import useEditor from "@/store/use-editor";
import { AudioSettingsDialog } from "./audio-settings-dialog"; import { AudioSettingsDialog } from "./audio-settings-dialog";
import { useProjectStore } from "@/features/community/lib/projects/store"; import { useProjectStore } from "@/features/community/lib/projects/store";
import { updateProjectVisibility } from "@/features/community/lib/projects/actions";
export function SettingsPanel() { export function SettingsPanel() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -77,8 +79,74 @@ export function SettingsPanel() {
setTimeout(() => setIsGeneratingThumbnail(false), 3000); setTimeout(() => setIsGeneratingThumbnail(false), 3000);
}; };
const handleVisibilityChange = async (
field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
value: boolean,
) => {
if (!projectId) return;
// Optimistic update
useProjectStore.setState((state) => ({
activeProject: state.activeProject
? {
...state.activeProject,
...(field === 'isPrivate' && { is_private: value }),
...(field === 'showScansPublic' && { show_scans_public: value }),
...(field === 'showGuidesPublic' && { show_guides_public: value }),
}
: null,
}));
await updateProjectVisibility(projectId, { [field]: value });
};
return ( return (
<div className="flex flex-col gap-6 p-3"> <div className="flex flex-col gap-6 p-3">
{/* Visibility Section (only for cloud projects) */}
{projectId && !isLocalProject && (
<div className="space-y-3">
<label className="font-medium text-muted-foreground text-xs uppercase">
Visibility
</label>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Public</div>
<div className="text-xs text-muted-foreground">
{activeProject?.is_private ? 'Only you' : 'Anyone'} can view
</div>
</div>
<Switch
checked={!activeProject?.is_private}
onCheckedChange={(checked) => handleVisibilityChange('isPrivate', !checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Show 3D Scans</div>
<div className="text-xs text-muted-foreground">
Visible to public viewers
</div>
</div>
<Switch
checked={activeProject?.show_scans_public ?? true}
onCheckedChange={(checked) => handleVisibilityChange('showScansPublic', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Show Floorplans</div>
<div className="text-xs text-muted-foreground">
Visible to public viewers
</div>
</div>
<Switch
checked={activeProject?.show_guides_public ?? true}
onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
/>
</div>
</div>
)}
{/* Export Section */} {/* Export Section */}
<div className="space-y-2"> <div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase"> <label className="font-medium text-muted-foreground text-xs uppercase">
@@ -1,16 +1,15 @@
'use client' 'use client'
import { useState } from 'react' import { useRef, useState } from 'react'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/primitives/dialog' } from '@/components/ui/primitives/dialog'
import { Switch } from '@/components/ui/primitives/switch' import { Switch } from '@/components/ui/primitives/switch'
import { updateProjectName, updateProjectPrivacy, deleteProject } from '../lib/projects/actions' import { updateProjectName, updateProjectVisibility, deleteProject } from '../lib/projects/actions'
import type { Project } from '../lib/projects/types' import type { Project } from '../lib/projects/types'
interface ProjectSettingsDialogProps { interface ProjectSettingsDialogProps {
@@ -28,42 +27,35 @@ export function ProjectSettingsDialog({
onUpdate, onUpdate,
onDelete, onDelete,
}: ProjectSettingsDialogProps) { }: ProjectSettingsDialogProps) {
const [loading, setLoading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [name, setName] = useState(project.name || '') const [name, setName] = useState(project.name || '')
const [isPrivate, setIsPrivate] = useState(project.is_private) const [isPrivate, setIsPrivate] = useState(project.is_private)
const [showScansPublic, setShowScansPublic] = useState(project.show_scans_public ?? true)
const [showGuidesPublic, setShowGuidesPublic] = useState(project.show_guides_public ?? true)
const nameTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const handleSave = async () => { const handleNameChange = (value: string) => {
setLoading(true) setName(value)
try { if (nameTimerRef.current) clearTimeout(nameTimerRef.current)
// Update name if changed nameTimerRef.current = setTimeout(async () => {
const trimmedName = name.trim() const trimmed = value.trim()
if (trimmedName && trimmedName !== (project.name || '')) { if (trimmed && trimmed !== (project.name || '')) {
const nameResult = await updateProjectName(project.id, trimmedName) await updateProjectName(project.id, trimmed)
if (!nameResult.success) { onUpdate?.()
alert(`Failed to update name: ${nameResult.error}`)
setLoading(false)
return
}
} }
}, 500)
}
// Update privacy if changed const handleVisibilityChange = async (
if (isPrivate !== project.is_private) { field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
const privacyResult = await updateProjectPrivacy(project.id, isPrivate) value: boolean,
if (!privacyResult.success) { ) => {
alert(`Failed to update privacy: ${privacyResult.error}`) if (field === 'isPrivate') setIsPrivate(value)
setLoading(false) if (field === 'showScansPublic') setShowScansPublic(value)
return if (field === 'showGuidesPublic') setShowGuidesPublic(value)
}
}
onUpdate?.() await updateProjectVisibility(project.id, { [field]: value })
onOpenChange(false) onUpdate?.()
} catch (error) {
alert('Failed to save settings')
} finally {
setLoading(false)
}
} }
const handleDelete = async () => { const handleDelete = async () => {
@@ -92,7 +84,7 @@ export function ProjectSettingsDialog({
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Project Settings</DialogTitle> <DialogTitle>Project Settings</DialogTitle>
<DialogDescription>Update project name and privacy settings</DialogDescription> <DialogDescription>Changes are saved automatically</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
@@ -105,10 +97,9 @@ export function ProjectSettingsDialog({
id="project-name" id="project-name"
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Project" placeholder="My Project"
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary" className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={loading}
/> />
</div> </div>
@@ -122,10 +113,31 @@ export function ProjectSettingsDialog({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Public</span> <span className="text-sm text-muted-foreground">Public</span>
<Switch checked={!isPrivate} onCheckedChange={(checked) => setIsPrivate(!checked)} /> <Switch checked={!isPrivate} onCheckedChange={(checked) => handleVisibilityChange('isPrivate', !checked)} />
</div> </div>
</div> </div>
{/* Public Visibility Toggles */}
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Show 3D Scans</div>
<div className="text-sm text-muted-foreground">
Visible to public viewers
</div>
</div>
<Switch checked={showScansPublic} onCheckedChange={(checked) => handleVisibilityChange('showScansPublic', checked)} />
</div>
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Show Floorplans</div>
<div className="text-sm text-muted-foreground">
Visible to public viewers
</div>
</div>
<Switch checked={showGuidesPublic} onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)} />
</div>
{/* Danger Zone */} {/* Danger Zone */}
<div className="border-t border-border pt-6"> <div className="border-t border-border pt-6">
<h3 className="font-medium text-destructive mb-2">Danger Zone</h3> <h3 className="font-medium text-destructive mb-2">Danger Zone</h3>
@@ -136,31 +148,12 @@ export function ProjectSettingsDialog({
type="button" type="button"
onClick={handleDelete} onClick={handleDelete}
className="rounded-md border border-destructive bg-destructive/10 px-4 py-2 text-sm text-destructive hover:bg-destructive/20" className="rounded-md border border-destructive bg-destructive/10 px-4 py-2 text-sm text-destructive hover:bg-destructive/20"
disabled={isDeleting || loading} disabled={isDeleting}
> >
{isDeleting ? 'Deleting...' : 'Delete Project'} {isDeleting ? 'Deleting...' : 'Delete Project'}
</button> </button>
</div> </div>
</div> </div>
<DialogFooter>
<button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-accent"
disabled={loading || isDeleting}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
disabled={loading || isDeleting}
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
@@ -17,10 +17,15 @@ export function UsernameGate({ children }: { children: React.ReactNode }) {
setNeedsUsername(false) setNeedsUsername(false)
return return
} }
getUsername().then((username) => { getUsername()
setNeedsUsername(!username) .then((username) => {
setChecking(false) setNeedsUsername(!username)
}) setChecking(false)
})
.catch(() => {
setNeedsUsername(false)
setChecking(false)
})
}, [isAuthenticated, isLoading]) }, [isAuthenticated, isLoading])
return ( return (
@@ -64,10 +64,15 @@ export async function updateUsername(
return { success: false, error: 'Username is already taken' } return { success: false, error: 'Username is already taken' }
} }
await db const updated = await db
.update(schema.users) .update(schema.users)
.set({ username: trimmed }) .set({ username: trimmed })
.where(eq(schema.users.id, session.user.id)) .where(eq(schema.users.id, session.user.id))
.returning({ id: schema.users.id })
if (updated.length === 0) {
return { success: false, error: 'User not found. Please sign out and sign in again.' }
}
revalidatePath('/') revalidatePath('/')
revalidatePath('/settings') revalidatePath('/settings')
@@ -461,7 +461,7 @@ export async function getPublicProjectsByUserId(userId: string): Promise<ActionR
* Allows viewing if: project is public OR user owns the project * Allows viewing if: project is public OR user owns the project
*/ */
export async function getProjectModelPublic(projectId: string): Promise< export async function getProjectModelPublic(projectId: string): Promise<
ActionResult<{ project: Project; model: any | null }> ActionResult<{ project: Project; model: any | null; isOwner: boolean }>
> { > {
try { try {
const session = await getSession() const session = await getSession()
@@ -515,6 +515,7 @@ export async function getProjectModelPublic(projectId: string): Promise<
data: { data: {
project: projectData as Project, project: projectData as Project,
model: model || null, model: model || null,
isOwner: !!isOwner,
}, },
} }
} catch (error) { } catch (error) {
@@ -607,6 +608,64 @@ export async function updateProjectPrivacy(
} }
} }
/**
* Update project visibility settings (privacy + public scan/guide visibility)
*/
export async function updateProjectVisibility(
projectId: string,
settings: {
isPrivate?: boolean
showScansPublic?: boolean
showGuidesPublic?: boolean
},
): Promise<ActionResult> {
try {
const session = await getSession()
if (!session?.user) {
return { success: false, error: 'Not authenticated' }
}
const supabase = await createServerSupabaseClient()
// Verify ownership
const { data: project } = await supabase
.from('projects')
.select('owner_id')
.eq('id', projectId)
.single()
if ((project as any)?.owner_id !== session.user.id) {
return { success: false, error: 'Unauthorized' }
}
const updateData: Record<string, boolean> = {}
if (settings.isPrivate !== undefined) updateData.is_private = settings.isPrivate
if (settings.showScansPublic !== undefined) updateData.show_scans_public = settings.showScansPublic
if (settings.showGuidesPublic !== undefined) updateData.show_guides_public = settings.showGuidesPublic
if (Object.keys(updateData).length === 0) {
return { success: true, message: 'No changes' }
}
const { error } = await (supabase
.from('projects') as any)
.update(updateData)
.eq('id', projectId)
if (error) {
return { success: false, error: error.message }
}
return { success: true, message: 'Visibility settings updated' }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update visibility settings',
}
}
}
/** /**
* Update project name * Update project name
*/ */
@@ -13,6 +13,8 @@ export type DbProject = {
created_at: string created_at: string
updated_at: string updated_at: string
is_private: boolean is_private: boolean
show_scans_public: boolean
show_guides_public: boolean
views: number views: number
likes: number likes: number
thumbnail_url: string | null thumbnail_url: string | null
@@ -55,7 +57,7 @@ export type Database = {
Tables: { Tables: {
projects: { projects: {
Row: DbProject Row: DbProject
Insert: Omit<DbProject, 'created_at' | 'updated_at' | 'views' | 'likes'> Insert: Omit<DbProject, 'created_at' | 'updated_at' | 'views' | 'likes' | 'show_scans_public' | 'show_guides_public'> & { show_scans_public?: boolean; show_guides_public?: boolean }
Update: Partial<Omit<DbProject, 'id' | 'created_at' | 'updated_at'>> Update: Partial<Omit<DbProject, 'id' | 'created_at' | 'updated_at'>>
} }
projects_addresses: { projects_addresses: {
@@ -104,6 +106,8 @@ export type Project = {
updated_at: string updated_at: string
// Community features // Community features
is_private: boolean is_private: boolean
show_scans_public: boolean
show_guides_public: boolean
views: number views: number
likes: number likes: number
thumbnail_url: string | null thumbnail_url: string | null
@@ -20,6 +20,8 @@ export const projects = pgTable(
metadata: t.jsonb('metadata'), metadata: t.jsonb('metadata'),
// Community features // Community features
isPrivate: t.boolean('is_private').notNull().default(true), isPrivate: t.boolean('is_private').notNull().default(true),
showScansPublic: t.boolean('show_scans_public').notNull().default(true),
showGuidesPublic: t.boolean('show_guides_public').notNull().default(true),
views: t.integer('views').notNull().default(0), views: t.integer('views').notNull().default(0),
likes: t.integer('likes').notNull().default(0), likes: t.integer('likes').notNull().default(0),
thumbnailUrl: t.text('thumbnail_url'), thumbnailUrl: t.text('thumbnail_url'),
@@ -0,0 +1,2 @@
ALTER TABLE "projects" ADD COLUMN "show_scans_public" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN "show_guides_public" boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -22,6 +22,13 @@
"when": 1771478645202, "when": 1771478645202,
"tag": "20260219052405_add-social-profile-fields", "tag": "20260219052405_add-social-profile-fields",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1771536409437,
"tag": "20260219212649_young_dragon_man",
"breakpoints": true
} }
] ]
} }