Initial commit
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* OrgVersionSwitcher — editor-toolbar "version picker" for the org.
|
||||
*
|
||||
* Conceptually equivalent to Figma's page switcher or VS Code's git-branch
|
||||
* indicator: a compact pill showing the active saved-org name (+ modified
|
||||
* indicator when the editor has changed since the last loaded snapshot),
|
||||
* plus a glass popover command-menu for search / load / save-as-copy
|
||||
* / delete.
|
||||
*
|
||||
* All visual tokens match the house dialect (refined-technical, dark, no
|
||||
* emoji, no purple gradients). Styles live in structure.css under `.sos-*`.
|
||||
*
|
||||
* Lives in the StructureEditor toolbar (Team tab, org mode only).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface SavedOrg {
|
||||
name: string
|
||||
organization_name?: string
|
||||
saved_at: number
|
||||
roles_count: number
|
||||
employees_count: number
|
||||
}
|
||||
|
||||
export interface OrgVersionSwitcherProps {
|
||||
savedOrgs: SavedOrg[] | null
|
||||
activeName: string | null
|
||||
isDirty: boolean
|
||||
onRefresh: () => void
|
||||
onSaveAs: (name: string, overwrite: boolean) => void
|
||||
onLoad: (name: string) => void
|
||||
onDelete: (name: string) => void
|
||||
}
|
||||
|
||||
const MAX_DISPLAY_NAME = 80
|
||||
|
||||
function formatRelativeTime(epochSeconds: number): string {
|
||||
const now = Date.now() / 1000
|
||||
const diff = Math.max(0, now - epochSeconds)
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
||||
try {
|
||||
return new Date(epochSeconds * 1000).toLocaleDateString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function displayOrgName(org: SavedOrg): string {
|
||||
return (org.organization_name || org.name).trim() || org.name
|
||||
}
|
||||
|
||||
function isValidDisplayName(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0
|
||||
&& trimmed.length <= MAX_DISPLAY_NAME
|
||||
&& !/[\\/]/.test(trimmed)
|
||||
&& !/[\u0000-\u001f]/.test(trimmed)
|
||||
}
|
||||
|
||||
function slugifyOrgDisplayName(value: string): string {
|
||||
const ascii = value.normalize('NFKD').replace(/[^\x00-\x7F]/g, '')
|
||||
const slug = ascii
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-z0-9_-]+/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^[_-]+|[_-]+$/g, '')
|
||||
return slug.slice(0, 64) || 'org'
|
||||
}
|
||||
|
||||
/* Inline SVG glyphs — match the house convention (no emoji, no font icons). */
|
||||
function LayersGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path d="M8 1.5L1.5 4.5L8 7.5L14.5 4.5L8 1.5Z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
|
||||
<path d="M2 8L8 10.8L14 8" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" opacity="0.6" />
|
||||
<path d="M2 11.5L8 14.2L14 11.5" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" opacity="0.35" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Caret({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||
<circle cx="5" cy="5" r="3.2" stroke="currentColor" strokeWidth="1.3" />
|
||||
<path d="M7.5 7.5L10 10" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrgVersionSwitcher({
|
||||
savedOrgs, activeName, isDirty, onRefresh, onSaveAs, onLoad, onDelete,
|
||||
}: OrgVersionSwitcherProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [highlighted, setHighlighted] = useState(0)
|
||||
const [saveAsMode, setSaveAsMode] = useState(false)
|
||||
const [saveAsName, setSaveAsName] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
const [loadingName, setLoadingName] = useState<string | null>(null)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const saveInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Clear loading state once the list or activeName updates (proxy for ack).
|
||||
useEffect(() => { setLoadingName(null) }, [activeName, savedOrgs])
|
||||
|
||||
// Filter list by search.
|
||||
const filtered = (savedOrgs ?? []).filter(o =>
|
||||
!search.trim() || o.name.toLowerCase().includes(search.trim().toLowerCase()),
|
||||
)
|
||||
|
||||
// Refresh once on first open.
|
||||
const firstOpenRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (open && !firstOpenRef.current) {
|
||||
firstOpenRef.current = true
|
||||
onRefresh()
|
||||
}
|
||||
}, [open, onRefresh])
|
||||
|
||||
// Auto-focus save input when entering save-as mode.
|
||||
useEffect(() => {
|
||||
if (saveAsMode) saveInputRef.current?.focus()
|
||||
}, [saveAsMode])
|
||||
|
||||
// Clamp highlight when filtered list shrinks.
|
||||
useEffect(() => {
|
||||
setHighlighted(h => Math.min(h, Math.max(0, filtered.length - 1)))
|
||||
}, [filtered.length])
|
||||
|
||||
// Close on outside click.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
closePopover()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick)
|
||||
return () => document.removeEventListener('mousedown', onDocClick)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
setHighlighted(0)
|
||||
setSaveAsMode(false)
|
||||
setSaveAsName('')
|
||||
setConfirmDelete(null)
|
||||
}, [])
|
||||
|
||||
const handleLoad = useCallback((name: string) => {
|
||||
if (name === activeName) {
|
||||
// Already active — no need to round-trip; just close the popover.
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
setLoadingName(name)
|
||||
onLoad(name)
|
||||
closePopover()
|
||||
}, [onLoad, closePopover, activeName])
|
||||
|
||||
const handleDelete = useCallback((name: string) => {
|
||||
onDelete(name)
|
||||
setConfirmDelete(null)
|
||||
}, [onDelete])
|
||||
|
||||
const saveAsTrimmed = saveAsName.trim()
|
||||
const saveAsValid = isValidDisplayName(saveAsTrimmed)
|
||||
const saveAsSlug = slugifyOrgDisplayName(saveAsTrimmed)
|
||||
const saveAsExists = (savedOrgs ?? []).some(o =>
|
||||
o.name === saveAsSlug || displayOrgName(o).toLowerCase() === saveAsTrimmed.toLowerCase(),
|
||||
)
|
||||
|
||||
const handleSaveAs = useCallback(() => {
|
||||
if (!saveAsValid) return
|
||||
onSaveAs(saveAsTrimmed, saveAsExists)
|
||||
setSaveAsMode(false)
|
||||
setSaveAsName('')
|
||||
closePopover()
|
||||
}, [saveAsValid, saveAsTrimmed, saveAsExists, onSaveAs, closePopover])
|
||||
|
||||
// Keyboard navigation inside the popover.
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
if (saveAsMode) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSaveAs()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setHighlighted(h => Math.min(h + 1, Math.max(0, filtered.length - 1)))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setHighlighted(h => Math.max(0, h - 1))
|
||||
} else if (e.key === 'Enter') {
|
||||
const target = filtered[highlighted]
|
||||
if (target) handleLoad(target.name)
|
||||
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
|
||||
const target = filtered[highlighted]
|
||||
if (target) {
|
||||
e.preventDefault()
|
||||
setConfirmDelete(target.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sos-wrap" ref={wrapperRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="sos-pill"
|
||||
data-open={open ? 'true' : 'false'}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
title={activeName ? `Active saved architecture: ${activeName}` : 'No saved architecture loaded'}
|
||||
>
|
||||
<LayersGlyph className="sos-pill-glyph" />
|
||||
{activeName ? (
|
||||
<span className="sos-pill-name">{activeName}</span>
|
||||
) : (
|
||||
<span className="sos-pill-name sos-pill-name--placeholder">draft</span>
|
||||
)}
|
||||
{isDirty && <span className="sos-pill-dirty" aria-label="Modified since opened" />}
|
||||
<Caret className="sos-pill-caret" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="sos-popover" role="listbox" onKeyDown={onKeyDown} tabIndex={-1}>
|
||||
<div className="sos-search-row">
|
||||
<SearchGlyph className="sos-search-glyph" />
|
||||
<input
|
||||
type="text"
|
||||
className="sos-search-input"
|
||||
placeholder="Search architectures…"
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setHighlighted(0) }}
|
||||
autoFocus
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<kbd className="sos-search-hint">↑↓ ↵</kbd>
|
||||
</div>
|
||||
|
||||
<div className="sos-list">
|
||||
{savedOrgs === null ? (
|
||||
<div className="sos-empty">Loading…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="sos-empty">
|
||||
{(savedOrgs ?? []).length === 0 ? 'No saved architectures.' : 'No matches.'}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((org, idx) => {
|
||||
const isActive = activeName === org.name
|
||||
const isHighlighted = highlighted === idx
|
||||
const title = displayOrgName(org)
|
||||
return (
|
||||
<div
|
||||
key={org.name}
|
||||
className={`sos-row${isActive ? ' sos-row--active' : ''}`}
|
||||
data-highlighted={isHighlighted ? 'true' : 'false'}
|
||||
onMouseEnter={() => setHighlighted(idx)}
|
||||
onClick={() => handleLoad(org.name)}
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
>
|
||||
<div className="sos-row-meta">
|
||||
<span className="sos-row-name">{title}</span>
|
||||
<span className="sos-row-stats">
|
||||
{title !== org.name && `${org.name} · `}
|
||||
{org.roles_count} {org.roles_count === 1 ? 'role' : 'roles'}
|
||||
{' · '}
|
||||
{org.employees_count} {org.employees_count === 1 ? 'employee' : 'employees'}
|
||||
{' · '}
|
||||
{formatRelativeTime(org.saved_at)}
|
||||
</span>
|
||||
</div>
|
||||
{loadingName === org.name ? (
|
||||
<span className="sos-row-loading">loading…</span>
|
||||
) : isActive ? (
|
||||
<span className="sos-row-active-chip">active</span>
|
||||
) : confirmDelete === org.name ? (
|
||||
<button
|
||||
type="button"
|
||||
className="sos-row-delete sos-row-delete--confirm"
|
||||
onClick={e => { e.stopPropagation(); handleDelete(org.name) }}
|
||||
>
|
||||
confirm
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="sos-row-delete"
|
||||
onClick={e => { e.stopPropagation(); setConfirmDelete(org.name) }}
|
||||
title="Delete"
|
||||
>
|
||||
delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sos-save-as">
|
||||
{saveAsMode ? (
|
||||
<div className="sos-save-as-form">
|
||||
<input
|
||||
ref={saveInputRef}
|
||||
type="text"
|
||||
className="sos-save-as-input"
|
||||
placeholder="Organization name"
|
||||
value={saveAsName}
|
||||
onChange={e => setSaveAsName(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSaveAs}
|
||||
disabled={!saveAsValid}
|
||||
>
|
||||
{saveAsExists ? 'Overwrite copy' : 'Save as copy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => { setSaveAsMode(false); setSaveAsName('') }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="sos-save-as-trigger"
|
||||
onClick={() => setSaveAsMode(true)}
|
||||
>
|
||||
+ Save as copy...
|
||||
</button>
|
||||
)}
|
||||
{saveAsMode && saveAsName && !saveAsValid && (
|
||||
<div className="sos-save-as-hint sos-save-as-hint--warn">
|
||||
Use up to 80 characters. Slashes are not allowed.
|
||||
</div>
|
||||
)}
|
||||
{saveAsMode && saveAsValid && saveAsExists && (
|
||||
<div className="sos-save-as-hint sos-save-as-hint--warn">
|
||||
Name exists - this will overwrite that copy.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user