Each axis's depth stepper is now one joined control, styled like the operation panel's Scale | Recode | Clone, so the arrows are visible and tell enabled from disabled in dark mode. The Total checkboxes become toggle chips beside their stepper. Operations shows its open state as pressed rather than relabelling to a blue "Hide panel", keeping blue for actions. The zone boxes are gone; the gap between the zones already separates them. The layout name gets more room before it truncates. color-scheme: dark on the dark palette fixes native controls app-wide. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
191 lines
8.0 KiB
JavaScript
191 lines
8.0 KiB
JavaScript
import { useEffect, useRef, useState } from 'react'
|
||
|
||
// The pivot layout, as one control rather than a row of equal chips.
|
||
//
|
||
// Two lists, because a layout is one of two different things: Published, which
|
||
// everyone on this forecast sees and only its owner or an admin may change, and
|
||
// Mine, which nobody else lists. Everything you cannot write is still there to
|
||
// apply -- it is only the write controls that are withheld, so a click never
|
||
// answers 403.
|
||
export default function LayoutMenu({
|
||
layouts = [], activeLayoutId, dirty,
|
||
onApply, onSave, onSaveAs, onSetVisibility, onSetDefault, onRename, onDelete, onReset
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const [saveName, setSaveName] = useState('')
|
||
const [savePublic, setSavePublic] = useState(false)
|
||
const [renaming, setRenaming] = useState(null)
|
||
const [renameTo, setRenameTo] = useState('')
|
||
const boxRef = useRef(null)
|
||
|
||
// Close on an outside click or Escape. Capture phase: the pivot lives in a
|
||
// shadow root and retargets its events, so a bubbled listener on document
|
||
// sees the host rather than the real target and cannot tell inside from out.
|
||
useEffect(() => {
|
||
if (!open) return
|
||
const onDown = (e) => { if (!boxRef.current?.contains(e.target)) setOpen(false) }
|
||
const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
|
||
document.addEventListener('mousedown', onDown, true)
|
||
window.addEventListener('keydown', onKey)
|
||
return () => {
|
||
document.removeEventListener('mousedown', onDown, true)
|
||
window.removeEventListener('keydown', onKey)
|
||
}
|
||
}, [open])
|
||
|
||
const active = layouts.find(l => l.id === activeLayoutId)
|
||
const published = layouts.filter(l => l.visibility === 'published')
|
||
const mine = layouts.filter(l => l.visibility === 'private')
|
||
|
||
const submitSaveAs = () => {
|
||
const name = saveName.trim()
|
||
if (!name) return
|
||
onSaveAs(name, savePublic ? 'published' : 'private')
|
||
setSaveName('')
|
||
setOpen(false)
|
||
}
|
||
|
||
const submitRename = (l) => {
|
||
const name = renameTo.trim()
|
||
if (name && name !== l.name) onRename(l, name)
|
||
setRenaming(null)
|
||
}
|
||
|
||
const iconBtn = 'px-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100 leading-none'
|
||
|
||
const row = (l) => (
|
||
<div key={l.id}
|
||
className={`group flex items-center gap-1 px-2 py-1 rounded cursor-pointer
|
||
${l.id === activeLayoutId ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50 text-gray-700'}`}
|
||
onClick={() => { if (renaming !== l.id) { onApply(l); setOpen(false) } }}>
|
||
|
||
{renaming === l.id ? (
|
||
<input autoFocus value={renameTo} onClick={e => e.stopPropagation()}
|
||
onChange={e => setRenameTo(e.target.value)}
|
||
onBlur={() => submitRename(l)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') submitRename(l)
|
||
if (e.key === 'Escape') setRenaming(null)
|
||
}}
|
||
className="flex-1 min-w-0 border border-blue-300 rounded px-1 py-0 outline-none bg-white" />
|
||
) : (
|
||
<>
|
||
<span className="flex-1 min-w-0 truncate">{l.name}</span>
|
||
{l.is_default && (
|
||
<span title="Applied when this forecast is opened for the first time"
|
||
className="text-amber-500 shrink-0">★</span>
|
||
)}
|
||
{l.scope === 'source' && (
|
||
<span title="Applies to every forecast of this source"
|
||
className="text-gray-300 shrink-0" style={{fontSize:'9px'}}>ALL</span>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Withheld rather than offered-and-refused: the server allows a write only
|
||
to the owner or an admin, and can_edit says which this is. */}
|
||
{l.can_edit && renaming !== l.id && (
|
||
<span className="hidden group-hover:flex items-center gap-0.5 shrink-0"
|
||
onClick={e => e.stopPropagation()}>
|
||
<button className={iconBtn} title="Rename"
|
||
onClick={() => { setRenaming(l.id); setRenameTo(l.name) }}>✎</button>
|
||
{l.visibility === 'private' ? (
|
||
<button className={iconBtn} title="Publish — everyone on this forecast sees it"
|
||
onClick={() => onSetVisibility(l, 'published')}>↑</button>
|
||
) : (
|
||
<>
|
||
{!l.is_default && (
|
||
<button className={iconBtn} title="Apply this when the forecast is first opened"
|
||
onClick={() => onSetDefault(l)}>★</button>
|
||
)}
|
||
<button className={iconBtn} title="Unpublish — keep it, but only for you"
|
||
onClick={() => onSetVisibility(l, 'private')}>↓</button>
|
||
</>
|
||
)}
|
||
<button className={`${iconBtn} hover:text-red-500`} title="Delete"
|
||
onClick={() => onDelete(l)}>×</button>
|
||
</span>
|
||
)}
|
||
|
||
{!l.can_edit && (
|
||
<span className="hidden group-hover:inline text-gray-300 shrink-0 truncate"
|
||
style={{fontSize:'9px'}} title={`Published by ${l.owner}`}>{l.owner}</span>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<div ref={boxRef} className="relative flex items-center gap-1.5">
|
||
<span className="pf-section-label">Layout</span>
|
||
|
||
<button onClick={() => setOpen(o => !o)}
|
||
className={`flex items-center gap-1 border rounded px-2 py-0.5 max-w-[22rem] transition-colors
|
||
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
|
||
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
||
<span className="truncate">{active ? active.name : 'Unsaved'}</span>
|
||
{dirty && <span title="Changed since it was saved" className="text-amber-500 leading-none">•</span>}
|
||
<span className="text-gray-400 leading-none">▾</span>
|
||
</button>
|
||
|
||
{/* Save is the common action and stays outside the menu, but only when
|
||
there is something to save it to and it is yours to overwrite. */}
|
||
{active?.can_edit && dirty && (
|
||
<button onClick={() => onSave(active)}
|
||
className="border border-blue-200 text-blue-600 hover:text-blue-800 rounded px-2 py-0.5">
|
||
Save
|
||
</button>
|
||
)}
|
||
|
||
{open && (
|
||
<div className="absolute top-full left-0 mt-1 z-50 w-72 bg-white border border-gray-200
|
||
rounded shadow-lg py-1 text-xs">
|
||
|
||
{published.length > 0 && (
|
||
<>
|
||
<div className="px-2 pt-1 pb-0.5 text-gray-400 uppercase tracking-wide"
|
||
style={{fontSize:'9px'}}>Published</div>
|
||
<div className="px-1">{published.map(row)}</div>
|
||
</>
|
||
)}
|
||
|
||
{mine.length > 0 && (
|
||
<>
|
||
<div className="px-2 pt-2 pb-0.5 text-gray-400 uppercase tracking-wide"
|
||
style={{fontSize:'9px'}}>Mine</div>
|
||
<div className="px-1">{mine.map(row)}</div>
|
||
</>
|
||
)}
|
||
|
||
{!layouts.length && (
|
||
<div className="px-3 py-2 text-gray-400">No saved layouts yet</div>
|
||
)}
|
||
|
||
<div className="border-t border-gray-100 mt-1 pt-1 px-2">
|
||
<div className="flex items-center gap-1 py-1">
|
||
<input value={saveName} onChange={e => setSaveName(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') submitSaveAs() }}
|
||
placeholder="Save current view as…"
|
||
className="flex-1 min-w-0 border border-gray-300 rounded px-1.5 py-0.5
|
||
outline-none focus:border-blue-400" />
|
||
<button onClick={submitSaveAs} disabled={!saveName.trim()}
|
||
className="text-blue-600 hover:text-blue-800 px-1 disabled:opacity-30">Save</button>
|
||
</div>
|
||
<label className="flex items-center gap-1.5 py-0.5 text-gray-500 cursor-pointer">
|
||
<input type="checkbox" checked={savePublic}
|
||
onChange={e => setSavePublic(e.target.checked)} />
|
||
Publish to everyone on this forecast
|
||
</label>
|
||
</div>
|
||
|
||
<div className="border-t border-gray-100 mt-1 pt-1 px-2 pb-0.5">
|
||
<button onClick={() => { onReset(); setOpen(false) }}
|
||
className="text-gray-400 hover:text-gray-700 py-0.5">
|
||
Reset to a blank pivot
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|