collections + unify color picker
This commit is contained in:
@@ -5,18 +5,7 @@ import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } fro
|
|||||||
import { EDITOR_LAYER } from "@/lib/constants";
|
import { EDITOR_LAYER } from "@/lib/constants";
|
||||||
import useEditor from "@/store/use-editor";
|
import useEditor from "@/store/use-editor";
|
||||||
import { CursorSphere } from "../shared/cursor-sphere";
|
import { CursorSphere } from "../shared/cursor-sphere";
|
||||||
|
import { PALETTE_COLORS } from "@/components/ui/primitives/color-dot";
|
||||||
// Zone colors for cycling through
|
|
||||||
const ZONE_COLORS = [
|
|
||||||
"#3b82f6", // blue
|
|
||||||
"#ef4444", // red
|
|
||||||
"#22c55e", // green
|
|
||||||
"#f59e0b", // amber
|
|
||||||
"#8b5cf6", // violet
|
|
||||||
"#06b6d4", // cyan
|
|
||||||
"#ec4899", // pink
|
|
||||||
"#84cc16", // lime
|
|
||||||
];
|
|
||||||
|
|
||||||
const Y_OFFSET = 0.02;
|
const Y_OFFSET = 0.02;
|
||||||
|
|
||||||
@@ -73,7 +62,7 @@ const commitZoneDrawing = (
|
|||||||
const name = `Zone ${zoneCount + 1}`;
|
const name = `Zone ${zoneCount + 1}`;
|
||||||
|
|
||||||
// Cycle through colors
|
// Cycle through colors
|
||||||
const color = ZONE_COLORS[zoneCount % ZONE_COLORS.length];
|
const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length];
|
||||||
|
|
||||||
const zone = ZoneNode.parse({
|
const zone = ZoneNode.parse({
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { AnyNodeId, Collection, CollectionId } from '@pascal-app/core'
|
||||||
|
import { useScene } from '@pascal-app/core'
|
||||||
|
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/primitives/dropdown-menu'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover'
|
||||||
|
import { ColorDot } from '@/components/ui/primitives/color-dot'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface CollectionsPopoverProps {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
collectionIds?: CollectionId[]
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CollectionsPopover({ nodeId, collectionIds, children }: CollectionsPopoverProps) {
|
||||||
|
const collections = useScene((s) => s.collections)
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
const createCollection = useScene((s) => s.createCollection)
|
||||||
|
const deleteCollection = useScene((s) => s.deleteCollection)
|
||||||
|
const updateCollection = useScene((s) => s.updateCollection)
|
||||||
|
const addToCollection = useScene((s) => s.addToCollection)
|
||||||
|
const removeFromCollection = useScene((s) => s.removeFromCollection)
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [showCreateInput, setShowCreateInput] = useState(false)
|
||||||
|
const [createName, setCreateName] = useState('')
|
||||||
|
|
||||||
|
const [renamingId, setRenamingId] = useState<CollectionId | null>(null)
|
||||||
|
const [renameValue, setRenameValue] = useState('')
|
||||||
|
const [renameColor, setRenameColor] = useState('')
|
||||||
|
|
||||||
|
const [deletingId, setDeletingId] = useState<CollectionId | null>(null)
|
||||||
|
const [expandedIds, setExpandedIds] = useState<Set<CollectionId>>(new Set())
|
||||||
|
|
||||||
|
const memberIds = collectionIds ?? []
|
||||||
|
const allCollections = Object.values(collections)
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!createName.trim()) return
|
||||||
|
createCollection(createName.trim(), [nodeId])
|
||||||
|
setCreateName('')
|
||||||
|
setShowCreateInput(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameConfirm = (id: CollectionId) => {
|
||||||
|
if (!renameValue.trim()) return
|
||||||
|
updateCollection(id, { name: renameValue.trim(), color: renameColor || undefined })
|
||||||
|
setRenamingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMembership = (collectionId: CollectionId) => {
|
||||||
|
if (memberIds.includes(collectionId)) {
|
||||||
|
removeFromCollection(collectionId, nodeId)
|
||||||
|
} else {
|
||||||
|
addToCollection(collectionId, nodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExpand = (collectionId: CollectionId) => {
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(collectionId)) next.delete(collectionId)
|
||||||
|
else next.add(collectionId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="left"
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
className="w-72 p-0 border-border/50 bg-sidebar/95 backdrop-blur-xl shadow-2xl rounded-xl overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border/50">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-semibold text-foreground tracking-tight">Collections</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowCreateInput((v) => !v); setCreateName('') }}
|
||||||
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create input */}
|
||||||
|
{showCreateInput && (
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={createName}
|
||||||
|
onChange={(e) => setCreateName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleCreate()
|
||||||
|
if (e.key === 'Escape') { setShowCreateInput(false); setCreateName('') }
|
||||||
|
}}
|
||||||
|
placeholder="Collection name…"
|
||||||
|
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!createName.trim()}
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowCreateInput(false); setCreateName('') }}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collections list */}
|
||||||
|
<div className="max-h-72 overflow-y-auto no-scrollbar">
|
||||||
|
{allCollections.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center px-4">
|
||||||
|
<Layers className="h-6 w-6 text-muted-foreground/40" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No collections yet. Create one to group items together.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border/30">
|
||||||
|
{allCollections.map((collection) => {
|
||||||
|
const isIn = memberIds.includes(collection.id)
|
||||||
|
const isExpanded = expandedIds.has(collection.id)
|
||||||
|
const isRenaming = renamingId === collection.id
|
||||||
|
const isDeleting = deletingId === collection.id
|
||||||
|
|
||||||
|
if (isDeleting) {
|
||||||
|
return (
|
||||||
|
<li key={collection.id} className="flex items-center justify-between gap-2 px-3 py-2.5 bg-red-500/10">
|
||||||
|
<span className="text-xs text-foreground/80 truncate">Delete "{collection.name}"?</span>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { deleteCollection(collection.id); setDeletingId(null) }}
|
||||||
|
className="rounded-md px-2 py-0.5 text-[11px] font-medium bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeletingId(null)}
|
||||||
|
className="rounded-md px-2 py-0.5 text-[11px] font-medium hover:bg-white/10 text-muted-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRenaming) {
|
||||||
|
return (
|
||||||
|
<li key={collection.id} className="flex items-center gap-1.5 px-3 py-2">
|
||||||
|
<ColorDot color={renameColor || '#6366f1'} onChange={setRenameColor} />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleRenameConfirm(collection.id)
|
||||||
|
if (e.key === 'Escape') setRenamingId(null)
|
||||||
|
}}
|
||||||
|
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRenameConfirm(collection.id)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRenamingId(null)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={collection.id}>
|
||||||
|
<div className="group flex items-center gap-2 px-3 py-2 hover:bg-white/5 transition-colors">
|
||||||
|
{/* Color dot — click to pick color */}
|
||||||
|
<ColorDot
|
||||||
|
color={collection.color ?? '#6366f1'}
|
||||||
|
onChange={(c) => updateCollection(collection.id, { color: c })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Name + count — clicking toggles membership */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleMembership(collection.id)}
|
||||||
|
className="flex-1 min-w-0 flex items-center gap-1.5 text-left"
|
||||||
|
>
|
||||||
|
<span className={cn('truncate text-xs font-medium', isIn ? 'text-foreground' : 'text-muted-foreground')}>
|
||||||
|
{collection.name}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground/60">
|
||||||
|
{collection.nodeIds.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Membership check */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors pointer-events-none',
|
||||||
|
isIn ? 'border-primary bg-primary/20 text-primary' : 'border-border/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isIn && <Check className="h-2.5 w-2.5" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expand toggle (only if has members) */}
|
||||||
|
{collection.nodeIds.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleExpand(collection.id)}
|
||||||
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded
|
||||||
|
? <ChevronDown className="h-3 w-3" />
|
||||||
|
: <ChevronRight className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* More dropdown */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side="left" align="start" className="min-w-40">
|
||||||
|
<DropdownMenuItem onClick={() => { setRenamingId(collection.id); setRenameValue(collection.name); setRenameColor(collection.color ?? '') }}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem variant="destructive" onClick={() => setDeletingId(collection.id)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded member list */}
|
||||||
|
{isExpanded && (
|
||||||
|
<ul className="pb-1 pl-6 pr-3 flex flex-col gap-0.5">
|
||||||
|
{collection.nodeIds.map((nid) => {
|
||||||
|
const n = nodes[nid]
|
||||||
|
return (
|
||||||
|
<li key={nid} className="flex items-center gap-1.5 py-0.5">
|
||||||
|
<span className="h-1 w-1 rounded-full bg-muted-foreground/40 shrink-0" />
|
||||||
|
<span className={cn('truncate text-[11px]', nid === nodeId ? 'text-foreground font-medium' : 'text-muted-foreground')}>
|
||||||
|
{n?.name ?? nid}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,8 +11,9 @@ import { cn } from '@/lib/utils'
|
|||||||
import { PanelWrapper } from './panel-wrapper'
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { MetricControl } from '../controls/metric-control'
|
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
|
import { CollectionsPopover } from './collections/collections-popover'
|
||||||
|
|
||||||
export function ItemPanel() {
|
export function ItemPanel() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
@@ -231,6 +232,14 @@ export function ItemPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Collections">
|
||||||
|
<ActionGroup>
|
||||||
|
<CollectionsPopover nodeId={selectedId as AnyNode['id']} collectionIds={node.collectionIds}>
|
||||||
|
<ActionButton label="Manage collections…" />
|
||||||
|
</CollectionsPopover>
|
||||||
|
</ActionGroup>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Actions">
|
<PanelSection title="Actions">
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from './popover'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export const PALETTE_COLORS = [
|
||||||
|
'#ef4444', // Red 0°
|
||||||
|
'#f97316', // Orange 30°
|
||||||
|
'#f59e0b', // Amber 45°
|
||||||
|
'#84cc16', // Lime 85°
|
||||||
|
'#22c55e', // Green 142°
|
||||||
|
'#10b981', // Emerald 160°
|
||||||
|
'#06b6d4', // Cyan 190°
|
||||||
|
'#3b82f6', // Blue 217°
|
||||||
|
'#6366f1', // Indigo 239°
|
||||||
|
'#a855f7', // Violet 270°
|
||||||
|
'#64748b', // Dark gray
|
||||||
|
'#cccccc', // Light gray
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ColorDotProps {
|
||||||
|
color: string
|
||||||
|
onChange: (color: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorDot({ color, onChange }: ColorDotProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative shrink-0 h-3 w-3 rounded-sm border border-border/50 cursor-pointer hover:ring-1 hover:ring-ring/50 transition-all"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="left" align="center" sideOffset={6} className="w-auto p-1.5">
|
||||||
|
<div className="grid grid-cols-4 gap-1">
|
||||||
|
{PALETTE_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'h-5 w-5 rounded-sm border transition-transform hover:scale-110',
|
||||||
|
c === color ? 'border-foreground/50 ring-1 ring-ring/50' : 'border-border/30',
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
onClick={() => { onChange(c); setOpen(false) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -41,18 +41,7 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/primitives/popover";
|
} from "@/components/ui/primitives/popover";
|
||||||
import { motion, AnimatePresence, LayoutGroup } from "motion/react";
|
import { motion, AnimatePresence, LayoutGroup } from "motion/react";
|
||||||
|
import { ColorDot } from "@/components/ui/primitives/color-dot";
|
||||||
// Preset colors for zones
|
|
||||||
const PRESET_COLORS = [
|
|
||||||
"#3b82f6", // blue
|
|
||||||
"#22c55e", // green
|
|
||||||
"#eab308", // yellow
|
|
||||||
"#f97316", // orange
|
|
||||||
"#ef4444", // red
|
|
||||||
"#a855f7", // purple
|
|
||||||
"#ec4899", // pink
|
|
||||||
"#06b6d4", // cyan
|
|
||||||
];
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// PROPERTY LINE SECTION
|
// PROPERTY LINE SECTION
|
||||||
@@ -953,37 +942,9 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode, isLast?: boolean }) {
|
|||||||
{/* Horizontal branch line */}
|
{/* Horizontal branch line */}
|
||||||
<div className="absolute top-1/2 h-px bg-border/50 pointer-events-none" style={{ left: 8, width: 4 }} />
|
<div className="absolute top-1/2 h-px bg-border/50 pointer-events-none" style={{ left: 8, width: 4 }} />
|
||||||
|
|
||||||
<Popover>
|
<span className={cn("mr-2", !isSelected && "opacity-40")}>
|
||||||
<PopoverTrigger asChild>
|
<ColorDot color={zone.color} onChange={handleColorChange} />
|
||||||
<button
|
</span>
|
||||||
className={cn(
|
|
||||||
"mr-2 size-3 shrink-0 rounded-sm border border-border/50 transition-all hover:scale-110 cursor-pointer",
|
|
||||||
!isSelected && "opacity-40"
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{ backgroundColor: zone.color }}
|
|
||||||
/>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
align="start"
|
|
||||||
className="w-auto p-2"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-4 gap-1">
|
|
||||||
{PRESET_COLORS.map((color) => (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"size-6 rounded-sm border transition-transform hover:scale-110 cursor-pointer",
|
|
||||||
color === zone.color ? "ring-2 ring-primary ring-offset-1" : ""
|
|
||||||
)}
|
|
||||||
key={color}
|
|
||||||
onClick={() => handleColorChange(color)}
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<div className="flex-1 min-w-0 pr-1">
|
<div className="flex-1 min-w-0 pr-1">
|
||||||
<InlineRenameInput
|
<InlineRenameInput
|
||||||
node={zone}
|
node={zone}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ZoneNode } from "@pascal-app/core";
|
import { ZoneNode, useScene } from "@pascal-app/core";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { ColorDot } from "@/components/ui/primitives/color-dot";
|
||||||
import { InlineRenameInput } from "./inline-rename-input";
|
import { InlineRenameInput } from "./inline-rename-input";
|
||||||
import { TreeNodeWrapper } from "./tree-node";
|
import { TreeNodeWrapper } from "./tree-node";
|
||||||
import { TreeNodeActions } from "./tree-node-actions";
|
import { TreeNodeActions } from "./tree-node-actions";
|
||||||
@@ -13,6 +14,7 @@ interface ZoneTreeNodeProps {
|
|||||||
|
|
||||||
export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
|
export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const updateNode = useScene((state) => state.updateNode);
|
||||||
const isSelected = useViewer((state) => state.selection.zoneId === node.id);
|
const isSelected = useViewer((state) => state.selection.zoneId === node.id);
|
||||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
@@ -41,9 +43,9 @@ export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
|
|||||||
return (
|
return (
|
||||||
<TreeNodeWrapper
|
<TreeNodeWrapper
|
||||||
icon={
|
icon={
|
||||||
<div
|
<ColorDot
|
||||||
className="w-3 h-3 rounded-sm border border-border/50"
|
color={node.color}
|
||||||
style={{ backgroundColor: node.color }}
|
onChange={(color) => updateNode(node.id, { color })}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
|
|||||||
@@ -4,23 +4,8 @@ import { Camera, Hexagon, Trash2 } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import useEditor from "@/store/use-editor";
|
import useEditor from "@/store/use-editor";
|
||||||
import {
|
import { ColorDot } from "@/components/ui/primitives/color-dot";
|
||||||
Popover,
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/primitives/popover";
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/primitives/popover";
|
|
||||||
|
|
||||||
// Preset colors for zones
|
|
||||||
const PRESET_COLORS = [
|
|
||||||
"#3b82f6", // blue
|
|
||||||
"#22c55e", // green
|
|
||||||
"#eab308", // yellow
|
|
||||||
"#f97316", // orange
|
|
||||||
"#ef4444", // red
|
|
||||||
"#a855f7", // purple
|
|
||||||
"#ec4899", // pink
|
|
||||||
"#06b6d4", // cyan
|
|
||||||
];
|
|
||||||
|
|
||||||
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||||
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
|
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
|
||||||
@@ -57,34 +42,9 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<Popover>
|
<span className="mr-2">
|
||||||
<PopoverTrigger asChild>
|
<ColorDot color={zone.color} onChange={handleColorChange} />
|
||||||
<button
|
</span>
|
||||||
className="mr-2 size-3 shrink-0 rounded-sm border border-border/50 transition-transform hover:scale-110 cursor-pointer"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{ backgroundColor: zone.color }}
|
|
||||||
/>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
align="start"
|
|
||||||
className="w-auto p-2"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-4 gap-1">
|
|
||||||
{PRESET_COLORS.map((color) => (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"size-6 rounded-sm border transition-transform hover:scale-110 cursor-pointer",
|
|
||||||
color === zone.color ? "ring-2 ring-primary ring-offset-1" : ""
|
|
||||||
)}
|
|
||||||
key={color}
|
|
||||||
onClick={() => handleColorChange(color)}
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<Hexagon className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
<Hexagon className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
||||||
<span className="truncate flex-1">{zone.name}</span>
|
<span className="truncate flex-1">{zone.name}</span>
|
||||||
{/* Camera snapshot button */}
|
{/* Camera snapshot button */}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { generateId } from './base'
|
||||||
|
import type { AnyNodeId } from './types'
|
||||||
|
|
||||||
|
export type CollectionId = `collection_${string}`
|
||||||
|
|
||||||
|
export type Collection = {
|
||||||
|
id: CollectionId
|
||||||
|
name: string
|
||||||
|
color?: string
|
||||||
|
nodeIds: AnyNodeId[]
|
||||||
|
controlNodeId?: AnyNodeId
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateCollectionId = (): CollectionId => generateId('collection')
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
// Base
|
// Base
|
||||||
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
||||||
|
// Collections
|
||||||
|
export { generateCollectionId, type Collection, type CollectionId } from './collections'
|
||||||
// Camera
|
// Camera
|
||||||
export { CameraSchema } from './camera'
|
export { CameraSchema } from './camera'
|
||||||
export type { AnimationEffect, Asset, AssetInput, Control, Effect, Interactive, LightEffect, SliderControl, TemperatureControl, ToggleControl } from './nodes/item'
|
export type { AnimationEffect, Asset, AssetInput, Control, Effect, Interactive, LightEffect, SliderControl, TemperatureControl, ToggleControl } from './nodes/item'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import dedent from 'dedent'
|
import dedent from 'dedent'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
import type { CollectionId } from '../collections'
|
||||||
|
|
||||||
// --- Control descriptors ---
|
// --- Control descriptors ---
|
||||||
|
|
||||||
@@ -110,6 +111,9 @@ export const ItemNode = BaseNode.extend({
|
|||||||
wallId: z.string().optional(),
|
wallId: z.string().optional(),
|
||||||
wallT: z.number().optional(), // 0-1 parametric position along wall
|
wallT: z.number().optional(), // 0-1 parametric position along wall
|
||||||
|
|
||||||
|
// Denormalized references to collections this node belongs to
|
||||||
|
collectionIds: z.array(z.custom<CollectionId>()).optional(),
|
||||||
|
|
||||||
asset: assetSchema,
|
asset: assetSchema,
|
||||||
}).describe(dedent`Item node - used to represent a item in the building
|
}).describe(dedent`Item node - used to represent a item in the building
|
||||||
- position: position in level coordinate system (or parent coordinate system if attached)
|
- position: position in level coordinate system (or parent coordinate system if attached)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AnyNode, AnyNodeId } from '../../schema'
|
import type { AnyNode, AnyNodeId } from '../../schema'
|
||||||
|
import type { CollectionId } from '../../schema/collections'
|
||||||
import type { SceneState } from '../use-scene'
|
import type { SceneState } from '../use-scene'
|
||||||
|
|
||||||
type AnyContainerNode = AnyNode & { children: string[] }
|
type AnyContainerNode = AnyNode & { children: string[] }
|
||||||
@@ -117,6 +118,7 @@ export const deleteNodesAction = (
|
|||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const nextNodes = { ...state.nodes }
|
const nextNodes = { ...state.nodes }
|
||||||
|
const nextCollections = { ...state.collections }
|
||||||
let nextRootIds = [...state.rootNodeIds]
|
let nextRootIds = [...state.rootNodeIds]
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
@@ -139,7 +141,17 @@ export const deleteNodesAction = (
|
|||||||
// 2. Remove from Root list
|
// 2. Remove from Root list
|
||||||
nextRootIds = nextRootIds.filter((rid) => rid !== id)
|
nextRootIds = nextRootIds.filter((rid) => rid !== id)
|
||||||
|
|
||||||
// 3. Delete the node itself
|
// 3. Remove from any collections it belongs to
|
||||||
|
if ('collectionIds' in node && node.collectionIds) {
|
||||||
|
for (const cid of node.collectionIds as CollectionId[]) {
|
||||||
|
const col = nextCollections[cid]
|
||||||
|
if (col) {
|
||||||
|
nextCollections[cid] = { ...col, nodeIds: col.nodeIds.filter((nid) => nid !== id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Delete the node itself
|
||||||
delete nextNodes[id]
|
delete nextNodes[id]
|
||||||
|
|
||||||
// Inside the deleteNodes loop
|
// Inside the deleteNodes loop
|
||||||
@@ -149,7 +161,7 @@ export const deleteNodesAction = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nodes: nextNodes, rootNodeIds: nextRootIds }
|
return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { temporal } from 'zundo'
|
|||||||
import { create, type StoreApi, type UseBoundStore } from 'zustand'
|
import { create, type StoreApi, type UseBoundStore } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import { BuildingNode } from '../schema'
|
import { BuildingNode } from '../schema'
|
||||||
|
import type { Collection, CollectionId } from '../schema/collections'
|
||||||
|
import { generateCollectionId } from '../schema/collections'
|
||||||
import { LevelNode } from '../schema/nodes/level'
|
import { LevelNode } from '../schema/nodes/level'
|
||||||
import { SiteNode } from '../schema/nodes/site'
|
import { SiteNode } from '../schema/nodes/site'
|
||||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||||
@@ -21,6 +23,9 @@ export type SceneState = {
|
|||||||
// 3. The "Dirty" Set: For the Wall/Physics systems
|
// 3. The "Dirty" Set: For the Wall/Physics systems
|
||||||
dirtyNodes: Set<AnyNodeId>
|
dirtyNodes: Set<AnyNodeId>
|
||||||
|
|
||||||
|
// 4. Relational metadata — not nodes
|
||||||
|
collections: Record<CollectionId, Collection>
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
loadScene: () => void
|
loadScene: () => void
|
||||||
clearScene: () => void
|
clearScene: () => void
|
||||||
@@ -37,12 +42,19 @@ export type SceneState = {
|
|||||||
|
|
||||||
deleteNode: (id: AnyNodeId) => void
|
deleteNode: (id: AnyNodeId) => void
|
||||||
deleteNodes: (ids: AnyNodeId[]) => void
|
deleteNodes: (ids: AnyNodeId[]) => void
|
||||||
|
|
||||||
|
// Collection actions
|
||||||
|
createCollection: (name: string, nodeIds?: AnyNodeId[]) => CollectionId
|
||||||
|
deleteCollection: (id: CollectionId) => void
|
||||||
|
updateCollection: (id: CollectionId, data: Partial<Omit<Collection, 'id'>>) => void
|
||||||
|
addToCollection: (id: CollectionId, nodeId: AnyNodeId) => void
|
||||||
|
removeFromCollection: (id: CollectionId, nodeId: AnyNodeId) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
|
// type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
|
||||||
|
|
||||||
type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
|
type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
|
||||||
temporal: StoreApi<TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds'>>>
|
temporal: StoreApi<TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>>>
|
||||||
}
|
}
|
||||||
|
|
||||||
const useScene: UseSceneStore = create<SceneState>()(
|
const useScene: UseSceneStore = create<SceneState>()(
|
||||||
@@ -58,11 +70,15 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
// 3. Dirty set
|
// 3. Dirty set
|
||||||
dirtyNodes: new Set<AnyNodeId>(),
|
dirtyNodes: new Set<AnyNodeId>(),
|
||||||
|
|
||||||
|
// 4. Collections
|
||||||
|
collections: {} as Record<CollectionId, Collection>,
|
||||||
|
|
||||||
clearScene: () => {
|
clearScene: () => {
|
||||||
set({
|
set({
|
||||||
nodes: {},
|
nodes: {},
|
||||||
rootNodeIds: [],
|
rootNodeIds: [],
|
||||||
dirtyNodes: new Set<AnyNodeId>(),
|
dirtyNodes: new Set<AnyNodeId>(),
|
||||||
|
collections: {},
|
||||||
})
|
})
|
||||||
get().loadScene() // Default scene
|
get().loadScene() // Default scene
|
||||||
},
|
},
|
||||||
@@ -143,11 +159,98 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
|
deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
|
||||||
|
|
||||||
deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
|
deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
|
||||||
|
|
||||||
|
// --- COLLECTIONS ---
|
||||||
|
|
||||||
|
createCollection: (name, nodeIds = []) => {
|
||||||
|
const id = generateCollectionId()
|
||||||
|
const collection: Collection = { id, name, nodeIds }
|
||||||
|
set((state) => {
|
||||||
|
const nextCollections = { ...state.collections, [id]: collection }
|
||||||
|
// Denormalize: stamp collectionId onto each node
|
||||||
|
const nextNodes = { ...state.nodes }
|
||||||
|
for (const nodeId of nodeIds) {
|
||||||
|
const node = nextNodes[nodeId]
|
||||||
|
if (!node) continue
|
||||||
|
const existing = ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||||
|
nextNodes[nodeId] = { ...node, collectionIds: [...existing, id] } as AnyNode
|
||||||
|
}
|
||||||
|
return { collections: nextCollections, nodes: nextNodes }
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteCollection: (id) => {
|
||||||
|
set((state) => {
|
||||||
|
const col = state.collections[id]
|
||||||
|
const nextCollections = { ...state.collections }
|
||||||
|
delete nextCollections[id]
|
||||||
|
// Remove collectionId from all member nodes
|
||||||
|
const nextNodes = { ...state.nodes }
|
||||||
|
for (const nodeId of col?.nodeIds ?? []) {
|
||||||
|
const node = nextNodes[nodeId]
|
||||||
|
if (!node || !('collectionIds' in node)) continue
|
||||||
|
nextNodes[nodeId] = {
|
||||||
|
...node,
|
||||||
|
collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id),
|
||||||
|
} as AnyNode
|
||||||
|
}
|
||||||
|
return { collections: nextCollections, nodes: nextNodes }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCollection: (id, data) => {
|
||||||
|
set((state) => {
|
||||||
|
const col = state.collections[id]
|
||||||
|
if (!col) return state
|
||||||
|
return { collections: { ...state.collections, [id]: { ...col, ...data } } }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
addToCollection: (id, nodeId) => {
|
||||||
|
set((state) => {
|
||||||
|
const col = state.collections[id]
|
||||||
|
if (!col || col.nodeIds.includes(nodeId)) return state
|
||||||
|
const nextCollections = {
|
||||||
|
...state.collections,
|
||||||
|
[id]: { ...col, nodeIds: [...col.nodeIds, nodeId] },
|
||||||
|
}
|
||||||
|
const node = state.nodes[nodeId]
|
||||||
|
if (!node) return { collections: nextCollections }
|
||||||
|
const existing = ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||||
|
const nextNodes = {
|
||||||
|
...state.nodes,
|
||||||
|
[nodeId]: { ...node, collectionIds: [...existing, id] } as AnyNode,
|
||||||
|
}
|
||||||
|
return { collections: nextCollections, nodes: nextNodes }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromCollection: (id, nodeId) => {
|
||||||
|
set((state) => {
|
||||||
|
const col = state.collections[id]
|
||||||
|
if (!col) return state
|
||||||
|
const nextCollections = {
|
||||||
|
...state.collections,
|
||||||
|
[id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
|
||||||
|
}
|
||||||
|
const node = state.nodes[nodeId]
|
||||||
|
if (!node || !('collectionIds' in node)) return { collections: nextCollections }
|
||||||
|
const nextNodes = {
|
||||||
|
...state.nodes,
|
||||||
|
[nodeId]: {
|
||||||
|
...node,
|
||||||
|
collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id),
|
||||||
|
} as AnyNode,
|
||||||
|
}
|
||||||
|
return { collections: nextCollections, nodes: nextNodes }
|
||||||
|
})
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
partialize: (state) => {
|
partialize: (state) => {
|
||||||
const { nodes, rootNodeIds } = state // Only track nodes and rootNodeIds in history
|
const { nodes, rootNodeIds, collections } = state
|
||||||
return { nodes, rootNodeIds }
|
return { nodes, rootNodeIds, collections }
|
||||||
},
|
},
|
||||||
limit: 50, // Limit to last 50 actions
|
limit: 50, // Limit to last 50 actions
|
||||||
},
|
},
|
||||||
@@ -157,7 +260,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
version: 1,
|
version: 1,
|
||||||
// Keep existing local scenes when the persist version changes.
|
// Keep existing local scenes when the persist version changes.
|
||||||
migrate: (persistedState) =>
|
migrate: (persistedState) =>
|
||||||
persistedState as Pick<SceneState, 'nodes' | 'rootNodeIds'>,
|
persistedState as Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>,
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
nodes: Object.fromEntries(
|
nodes: Object.fromEntries(
|
||||||
Object.entries(state.nodes).filter(([_, node]) => {
|
Object.entries(state.nodes).filter(([_, node]) => {
|
||||||
@@ -168,6 +271,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
rootNodeIds: state.rootNodeIds,
|
rootNodeIds: state.rootNodeIds,
|
||||||
|
collections: state.collections,
|
||||||
}),
|
}),
|
||||||
merge: (persistedState, currentState) => {
|
merge: (persistedState, currentState) => {
|
||||||
const persisted = persistedState as Partial<SceneState>
|
const persisted = persistedState as Partial<SceneState>
|
||||||
|
|||||||
Reference in New Issue
Block a user