Files
tolaria/src/components/WorkspaceSettingsRows.tsx
2026-05-14 17:30:13 +02:00

293 lines
10 KiB
TypeScript

import { Info, Trash as Trash2 } from '@phosphor-icons/react'
import { type KeyboardEvent, useState } from 'react'
import { AccentColorPicker } from './AccentColorPicker'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { SettingsGroupItem } from './SettingsControls'
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'
import { WorkspaceMoveButtons } from './WorkspaceMoveButtons'
import { createTranslator, type AppLocale } from '../lib/i18n'
import { trackEvent } from '../lib/telemetry'
import type { VaultOption } from './status-bar/types'
import { canMoveVaultPath, moveVaultPath, type VaultMoveDirection } from '../utils/vaultOrdering'
import { workspaceIdentityFromVault } from '../utils/workspaces'
interface WorkspaceSettingsRowsProps {
defaultWorkspacePath?: string | null
locale: AppLocale
onRemoveVault?: (path: string) => void
onReorderVaults?: (orderedPaths: string[]) => void
onSetDefaultWorkspace?: (path: string) => void
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
setVaultPendingRemoval: (vault: VaultOption) => void
vaults: VaultOption[]
}
function workspaceInputId(alias: string, field: 'name' | 'label' | 'slug'): string {
return `settings-workspace-${alias}-${field}`
}
function sanitizeWorkspaceShortLabel(value: string): string {
return value.trim().toUpperCase().slice(0, 3)
}
function WorkspaceFieldLabel({
htmlFor,
label,
tooltip,
}: {
htmlFor: string
label: string
tooltip: string
}) {
return (
<div className="flex min-w-0 items-center gap-1">
<label className="truncate text-[11px] font-medium text-muted-foreground" htmlFor={htmlFor}>
{label}
</label>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-4 w-4 shrink-0 rounded-full p-0 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={tooltip}
>
<Info size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="start">{tooltip}</TooltipContent>
</Tooltip>
</div>
)
}
function WorkspaceIdentityInputs({
canEdit,
locale,
onUpdateWorkspaceIdentity,
vault,
}: Pick<WorkspaceSettingsRowsProps, 'locale' | 'onUpdateWorkspaceIdentity'> & {
canEdit: boolean
vault: VaultOption
}) {
const t = createTranslator(locale)
const workspace = workspaceIdentityFromVault(vault)
const nameId = workspaceInputId(workspace.alias, 'name')
const labelId = workspaceInputId(workspace.alias, 'label')
const slugId = workspaceInputId(workspace.alias, 'slug')
const savedNameDraft = vault.label ?? ''
const savedShortLabelDraft = sanitizeWorkspaceShortLabel(vault.shortLabel || workspace.shortLabel)
const [nameDraft, setNameDraft] = useState(savedNameDraft)
const [shortLabelDraft, setShortLabelDraft] = useState(savedShortLabelDraft)
const commitNameDraft = () => {
if (!canEdit || nameDraft === savedNameDraft) return
onUpdateWorkspaceIdentity?.(vault.path, { label: nameDraft })
}
const commitShortLabelDraft = () => {
const normalizedShortLabel = sanitizeWorkspaceShortLabel(shortLabelDraft)
if (!canEdit || normalizedShortLabel === savedShortLabelDraft) return
onUpdateWorkspaceIdentity?.(vault.path, { shortLabel: normalizedShortLabel })
}
const blurOnEnter = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') event.currentTarget.blur()
}
return (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(80px,0.28fr)_minmax(140px,0.55fr)]">
<div className="grid gap-1.5">
<WorkspaceFieldLabel htmlFor={nameId} label={t('settings.workspaces.name')} tooltip={t('settings.workspaces.nameTooltip')} />
<Input
id={nameId}
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
onBlur={commitNameDraft}
onKeyDown={blurOnEnter}
aria-label={t('settings.workspaces.nameAria', { label: workspace.label })}
disabled={!canEdit}
className="h-8 bg-transparent"
/>
</div>
<div className="grid gap-1.5">
<WorkspaceFieldLabel htmlFor={labelId} label={t('settings.workspaces.label')} tooltip={t('settings.workspaces.labelTooltip')} />
<Input
id={labelId}
value={shortLabelDraft}
maxLength={3}
onChange={(event) => setShortLabelDraft(sanitizeWorkspaceShortLabel(event.target.value))}
onBlur={commitShortLabelDraft}
onKeyDown={blurOnEnter}
aria-label={t('settings.workspaces.labelAria', { label: workspace.label })}
disabled={!canEdit}
className="h-8 bg-transparent uppercase"
/>
</div>
<div className="grid gap-1.5">
<WorkspaceFieldLabel htmlFor={slugId} label={t('settings.workspaces.slug')} tooltip={t('settings.workspaces.slugTooltip')} />
<Input
id={slugId}
value={vault.alias ?? workspace.alias}
aria-label={t('settings.workspaces.slugAria', { label: workspace.label })}
readOnly
aria-readonly="true"
className="h-8 cursor-default bg-muted/30 text-muted-foreground"
/>
</div>
</div>
)
}
function WorkspaceRowActions({
canEdit,
canMoveDown,
canMoveUp,
locale,
onMoveVault,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
onRemoveVault,
workspace,
vault,
}: Pick<WorkspaceSettingsRowsProps, 'locale' | 'onRemoveVault' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity'> & {
canEdit: boolean
canMoveDown: boolean
canMoveUp: boolean
onMoveVault?: (path: string, direction: VaultMoveDirection) => void
workspace: ReturnType<typeof workspaceIdentityFromVault>
vault: VaultOption
}) {
const t = createTranslator(locale)
const removeLabel = t('settings.workspaces.removeAria', { label: workspace.label })
return (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
<WorkspaceMoveButtons canMoveDown={canMoveDown} canMoveUp={canMoveUp} locale={locale} onMoveVault={onMoveVault} vault={vault} workspace={workspace} />
<AccentColorPicker
className="gap-1.5"
disabled={!canEdit}
selectedColor={vault.color ?? null}
onSelectColor={(color) => onUpdateWorkspaceIdentity?.(vault.path, { color })}
size={18}
/>
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => {
onSetDefaultWorkspace?.(vault.path)
trackEvent('workspace_default_changed', { workspace_alias: workspace.alias })
}}
disabled={!onSetDefaultWorkspace || workspace.defaultForNewNotes}
data-testid={`settings-workspace-default-${workspace.alias}`}
>
{workspace.defaultForNewNotes ? t('workspace.manager.default') : t('workspace.manager.makeDefault')}
</Button>
{onRemoveVault && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onRemoveVault(vault.path)}
disabled={workspace.defaultForNewNotes}
aria-label={removeLabel}
title={removeLabel}
data-testid={`settings-workspace-remove-${workspace.alias}`}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 size={15} />
</Button>
)}
</div>
)
}
function WorkspaceSettingsRow({
defaultWorkspacePath,
locale,
onMoveVault,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
onRemoveVault,
vaults,
vault,
}: Pick<WorkspaceSettingsRowsProps, 'defaultWorkspacePath' | 'locale' | 'onRemoveVault' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity' | 'vaults'> & {
onMoveVault?: (path: string, direction: VaultMoveDirection) => void
vault: VaultOption
}) {
const workspace = workspaceIdentityFromVault(vault, { defaultWorkspacePath })
const canEdit = !!onUpdateWorkspaceIdentity && vault.path !== '' && !vault.managedDefault
return (
<div className="grid gap-3 px-4 py-4" data-testid={`settings-workspace-row-${workspace.alias}`}>
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
<div className="min-w-[220px] flex-1">
<div className="truncate text-sm font-medium text-foreground">{workspace.label}</div>
<div className="truncate text-[11px] text-muted-foreground">{workspace.path}</div>
</div>
<WorkspaceRowActions
canEdit={canEdit}
canMoveDown={canMoveVaultPath(vaults, vault.path, 'down')}
canMoveUp={canMoveVaultPath(vaults, vault.path, 'up')}
locale={locale}
onMoveVault={onMoveVault}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
onRemoveVault={onRemoveVault}
workspace={workspace}
vault={vault}
/>
</div>
<WorkspaceIdentityInputs canEdit={canEdit} locale={locale} onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity} vault={vault} />
</div>
)
}
function moveVaultInList(
vaults: VaultOption[],
onReorderVaults: ((orderedPaths: string[]) => void) | undefined,
path: string,
direction: VaultMoveDirection,
) {
const orderedPaths = moveVaultPath(vaults, path, direction)
if (orderedPaths) onReorderVaults?.(orderedPaths)
}
export function WorkspaceSettingsRows({
defaultWorkspacePath,
locale,
onRemoveVault,
onReorderVaults,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
setVaultPendingRemoval,
vaults,
}: WorkspaceSettingsRowsProps) {
const moveVault = (path: string, direction: VaultMoveDirection) => {
moveVaultInList(vaults, onReorderVaults, path, direction)
}
return (
<SettingsGroupItem testId="settings-workspace-list">
<div className="-mx-4 divide-y divide-border">
{vaults.map((vault) => (
<WorkspaceSettingsRow
key={vault.path}
defaultWorkspacePath={defaultWorkspacePath}
locale={locale}
onMoveVault={onReorderVaults ? moveVault : undefined}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
onRemoveVault={onRemoveVault ? () => setVaultPendingRemoval(vault) : undefined}
vaults={vaults}
vault={vault}
/>
))}
</div>
</SettingsGroupItem>
)
}