Files
tolaria/src/components/WorkspaceSettingsSection.tsx
2026-05-14 12:14:16 +02:00

314 lines
11 KiB
TypeScript

import { Info, Trash as Trash2 } from '@phosphor-icons/react'
import { type KeyboardEvent, useState } from 'react'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'
import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
import { SettingsGroup, SettingsGroupItem, SettingsSwitchRow } from './SettingsControls'
import { createTranslator, type AppLocale } from '../lib/i18n'
import { trackEvent } from '../lib/telemetry'
import type { VaultOption } from './status-bar/types'
import { workspaceIdentityFromVault } from '../utils/workspaces'
import { AccentColorPicker } from './AccentColorPicker'
interface WorkspaceSettingsSectionProps {
defaultWorkspacePath?: string | null
enabled: boolean
locale: AppLocale
onEnabledChange: (enabled: boolean) => void
onRemoveVault?: (path: string) => void
onSetDefaultWorkspace?: (path: string) => void
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<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<WorkspaceSettingsSectionProps, '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,
locale,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
onRemoveVault,
workspace,
vault,
}: Pick<WorkspaceSettingsSectionProps, 'locale' | 'onRemoveVault' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity'> & {
canEdit: boolean
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">
<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,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
onRemoveVault,
vault,
}: Pick<WorkspaceSettingsSectionProps, 'defaultWorkspacePath' | 'locale' | 'onRemoveVault' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity'> & {
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}
locale={locale}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
onRemoveVault={onRemoveVault}
workspace={workspace}
vault={vault}
/>
</div>
<WorkspaceIdentityInputs
canEdit={canEdit}
locale={locale}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
vault={vault}
/>
</div>
)
}
export function WorkspaceSettingsSection({
defaultWorkspacePath,
enabled,
locale,
onEnabledChange,
onRemoveVault,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
vaults,
}: WorkspaceSettingsSectionProps) {
const t = createTranslator(locale)
const [vaultPendingRemoval, setVaultPendingRemoval] = useState<VaultOption | null>(null)
const pendingRemovalIdentity = vaultPendingRemoval ? workspaceIdentityFromVault(vaultPendingRemoval, { defaultWorkspacePath }) : null
return (
<TooltipProvider>
<SettingsGroup>
<SettingsSwitchRow
label={t('settings.workspaces.enable')}
description={t('settings.workspaces.enableDescription')}
checked={enabled}
onChange={onEnabledChange}
testId="settings-multi-workspace-enabled"
/>
{enabled && (
<SettingsGroupItem testId="settings-workspace-list">
<div className="-mx-4 divide-y divide-border">
{vaults.map((vault) => (
<WorkspaceSettingsRow
key={vault.path}
defaultWorkspacePath={defaultWorkspacePath}
locale={locale}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
onRemoveVault={onRemoveVault ? () => setVaultPendingRemoval(vault) : undefined}
vault={vault}
/>
))}
</div>
</SettingsGroupItem>
)}
</SettingsGroup>
<ConfirmDeleteDialog
open={!!vaultPendingRemoval}
title={t('status.vault.removeConfirmTitle')}
message={t('status.vault.removeConfirmMessage', { label: pendingRemovalIdentity?.label ?? '' })}
confirmLabel={t('status.vault.removeConfirmAction')}
onCancel={() => setVaultPendingRemoval(null)}
onConfirm={() => {
if (vaultPendingRemoval) {
onRemoveVault?.(vaultPendingRemoval.path)
}
setVaultPendingRemoval(null)
}}
/>
</TooltipProvider>
)
}