ProfileSettings reaches zero: api keys, profile, voice, dock, languages and the model defaults. Language names now come from Intl.DisplayNames rather than the catalog. The runtime already knows "German" in English and "alemao" in Portuguese, so 28 names would have been 56 hand-written entries that Intl gets right for free -- including for locales not shipped yet. Same reasoning D7 gives for dates and numbers. The tag LIST stays hardcoded: which languages you may claim to speak is a product choice. ApiKeys had toLocaleDateString(undefined, ...), which followed the BROWSER locale -- the exact contradiction D7 describes. It uses useFormat().date now. Its revoked-key toggle hand-rolled a plural with a ternary on n === 1; that is a real plural entry now. Scanner: an Authorization header value like "Bearer <token>" is not a sentence. Excluded by auth-scheme prefix; verified the delta is exactly the four such credentials across both remaining areas.
247 lines
8.1 KiB
TypeScript
247 lines
8.1 KiB
TypeScript
import { useMemo, useState, useCallback, type DragEvent } from 'react';
|
|
import { X, Plus, RotateCcw } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useDock } from 'officerdev';
|
|
import { usePermissions } from 'hooks/usePermissions';
|
|
import { useDockItems, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
type DockPillProps = {
|
|
label: string;
|
|
path: string;
|
|
color: string;
|
|
visible: boolean;
|
|
onAction: (path: string) => void;
|
|
onDragStart: (ev: DragEvent, path: string) => void;
|
|
onDropOnPill?: (draggedPath: string) => void;
|
|
dropIndicator?: 'left' | 'right' | null;
|
|
onDragOverPill?: (ev: DragEvent) => void;
|
|
onDragLeavePill?: () => void;
|
|
};
|
|
|
|
const DockPill = ({
|
|
label,
|
|
path,
|
|
color,
|
|
visible,
|
|
onAction,
|
|
onDragStart,
|
|
onDropOnPill,
|
|
dropIndicator,
|
|
onDragOverPill,
|
|
onDragLeavePill,
|
|
}: DockPillProps) => (
|
|
<span
|
|
draggable
|
|
onDragStart={(ev) => onDragStart(ev, path)}
|
|
onDragOver={onDragOverPill}
|
|
onDragLeave={onDragLeavePill}
|
|
onDrop={
|
|
onDropOnPill
|
|
? (ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
const p = ev.dataTransfer.getData('text/plain');
|
|
if (p) onDropOnPill(p);
|
|
}
|
|
: undefined
|
|
}
|
|
className={`relative inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors ${dropIndicator === 'left' ? 'ring-l-2 ring-duck-teal' : ''} ${dropIndicator === 'right' ? 'ring-r-2 ring-duck-teal' : ''}`}
|
|
>
|
|
{dropIndicator === 'left' && <span className="absolute -left-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />}
|
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: color }} />
|
|
{label}
|
|
<button
|
|
onClick={() => onAction(path)}
|
|
className="ml-0.5 p-0.5 rounded-full hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
|
>
|
|
{visible ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
|
</button>
|
|
{dropIndicator === 'right' && <span className="absolute -right-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />}
|
|
</span>
|
|
);
|
|
|
|
type DropZoneProps = {
|
|
label: string;
|
|
children: React.ReactNode;
|
|
onDrop: (path: string) => void;
|
|
};
|
|
|
|
const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
|
const [over, setOver] = useState(false);
|
|
|
|
const handleDragOver = useCallback((ev: DragEvent) => {
|
|
ev.preventDefault();
|
|
setOver(true);
|
|
}, []);
|
|
|
|
const handleDragLeave = useCallback(() => setOver(false), []);
|
|
|
|
const handleDrop = useCallback(
|
|
(ev: DragEvent) => {
|
|
ev.preventDefault();
|
|
setOver(false);
|
|
const path = ev.dataTransfer.getData('text/plain');
|
|
if (path) onDrop(path);
|
|
},
|
|
[onDrop],
|
|
);
|
|
|
|
return (
|
|
<div className="grid gap-1.5">
|
|
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">
|
|
{label}
|
|
</span>
|
|
<div
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
className={`min-h-[48px] p-2 rounded-lg border border-dashed transition-colors flex flex-wrap gap-1.5 ${over ? 'border-duck-teal bg-duck-teal/5' : 'border-duck-dark/15 dark:border-foreground/15'}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const DockSettings = () => {
|
|
const { t } = useTranslation();
|
|
// Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone
|
|
// pin a tile that cannot appear, which reads as the setting being broken.
|
|
const { plugins } = usePermissions();
|
|
const allDockItems = useDockItems(plugins);
|
|
const { items, allItems, setItems, reset } = useDock(
|
|
allDockItems,
|
|
useMemo(() => defaultDockPaths(plugins), [plugins]),
|
|
);
|
|
const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null);
|
|
|
|
const visiblePaths = new Set(items.map((i) => i.to));
|
|
const hiddenItems = allItems.filter((i) => !visiblePaths.has(i.to));
|
|
|
|
const onDragStart = useCallback((ev: DragEvent, path: string) => {
|
|
ev.dataTransfer.setData('text/plain', path);
|
|
ev.dataTransfer.effectAllowed = 'move';
|
|
}, []);
|
|
|
|
const addItem = useCallback(
|
|
(path: string) => {
|
|
if (visiblePaths.has(path)) return;
|
|
setItems([...items.map((i) => i.to), path]);
|
|
},
|
|
[items, visiblePaths, setItems],
|
|
);
|
|
|
|
const removeItem = useCallback(
|
|
(path: string) => {
|
|
setItems(items.filter((i) => i.to !== path).map((i) => i.to));
|
|
},
|
|
[items, setItems],
|
|
);
|
|
|
|
const insertAt = useCallback(
|
|
(draggedPath: string, targetPath: string, side: 'left' | 'right') => {
|
|
const paths = items.filter((i) => i.to !== draggedPath).map((i) => i.to);
|
|
const targetIdx = paths.indexOf(targetPath);
|
|
if (targetIdx === -1) return;
|
|
const insertIdx = side === 'left' ? targetIdx : targetIdx + 1;
|
|
paths.splice(insertIdx, 0, draggedPath);
|
|
setItems(paths);
|
|
setDropTarget(null);
|
|
},
|
|
[items, setItems],
|
|
);
|
|
|
|
const handlePillDragOver = useCallback((ev: DragEvent, pillPath: string) => {
|
|
ev.preventDefault();
|
|
ev.dataTransfer.dropEffect = 'move';
|
|
const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
|
|
const midX = rect.left + rect.width / 2;
|
|
const side = ev.clientX < midX ? 'left' : 'right';
|
|
setDropTarget((prev) => (prev?.path === pillPath && prev?.side === side ? prev : { path: pillPath, side }));
|
|
}, []);
|
|
|
|
const handlePillDragLeave = useCallback(() => setDropTarget(null), []);
|
|
|
|
const handlePillDrop = useCallback(
|
|
(draggedPath: string, targetPath: string) => {
|
|
if (draggedPath === targetPath) {
|
|
setDropTarget(null);
|
|
return;
|
|
}
|
|
const side = dropTarget?.path === targetPath ? dropTarget.side : 'right';
|
|
insertAt(draggedPath, targetPath, side);
|
|
},
|
|
[dropTarget, insertAt],
|
|
);
|
|
|
|
const onDropVisible = useCallback(
|
|
(path: string) => {
|
|
setDropTarget(null);
|
|
if (visiblePaths.has(path)) return;
|
|
addItem(path);
|
|
},
|
|
[visiblePaths, addItem],
|
|
);
|
|
|
|
const onDropHidden = useCallback(
|
|
(path: string) => {
|
|
setDropTarget(null);
|
|
if (!visiblePaths.has(path)) return;
|
|
removeItem(path);
|
|
},
|
|
[visiblePaths, removeItem],
|
|
);
|
|
|
|
return (
|
|
<div className="grid gap-4">
|
|
<DropZone label={t('settings.profile.dock.visible')} onDrop={onDropVisible}>
|
|
{items.length === 0 && (
|
|
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
|
|
{t('settings.profile.dock.dragToShow')}
|
|
</span>
|
|
)}
|
|
{items.map((item) => (
|
|
<DockPill
|
|
key={item.to}
|
|
label={item.label}
|
|
path={item.to}
|
|
color={item.color}
|
|
visible
|
|
onAction={removeItem}
|
|
onDragStart={onDragStart}
|
|
dropIndicator={dropTarget?.path === item.to ? dropTarget.side : null}
|
|
onDragOverPill={(ev) => handlePillDragOver(ev, item.to)}
|
|
onDragLeavePill={handlePillDragLeave}
|
|
onDropOnPill={(draggedPath) => handlePillDrop(draggedPath, item.to)}
|
|
/>
|
|
))}
|
|
</DropZone>
|
|
|
|
<DropZone label={t('settings.profile.dock.hidden')} onDrop={onDropHidden}>
|
|
{hiddenItems.length === 0 && (
|
|
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
|
|
{t('settings.profile.dock.allVisible')}
|
|
</span>
|
|
)}
|
|
{hiddenItems.map((item) => (
|
|
<DockPill
|
|
key={item.to}
|
|
label={item.label}
|
|
path={item.to}
|
|
color={item.color}
|
|
visible={false}
|
|
onAction={addItem}
|
|
onDragStart={onDragStart}
|
|
/>
|
|
))}
|
|
</DropZone>
|
|
|
|
<Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer">
|
|
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
|
|
{t('settings.profile.dock.reset')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|