Skip to content

Commit 7613cee

Browse files
committed
feat(docs): redesign doc editor properties sidebar
1 parent faa1d6d commit 7613cee

1 file changed

Lines changed: 158 additions & 53 deletions

File tree

frontend/src/pages/DocEditor.tsx

Lines changed: 158 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@ import {
44
ArrowLeft,
55
Check,
66
Cloud,
7+
FolderTree,
8+
Hash,
9+
Languages,
10+
Layers,
711
Loader2,
812
PanelLeftClose,
913
PanelLeftOpen,
1014
PanelRightClose,
1115
PanelRightOpen,
1216
Save,
1317
Settings2,
18+
ShieldCheck,
1419
Sparkles,
20+
Tag,
1521
X,
1622
} from 'lucide-react';
1723
import { Button } from '@/components/ui/button';
@@ -33,6 +39,7 @@ import { useTranslation } from '@/hooks/useTranslation';
3339
import { docsAPI, projectAssignmentsAPI } from '@/lib/api';
3440
import { useResolvedEntityId } from '@/hooks/useResolvedEntityId';
3541
import { parsePositiveIntegerParam } from '@/utils/validation';
42+
import { cn } from '@/lib/utils';
3643
import { formatRelativeTime } from '@/utils/datetime';
3744
import type { Doc, DocDir, DocFolder, DocListItem, DocSpace, DocStatus } from '@/types';
3845

@@ -117,6 +124,12 @@ export function DocEditor() {
117124
return words.length;
118125
}, [content]);
119126

127+
// Preview chips for the tags field: split on commas, trim, drop empties.
128+
const tagList = useMemo(
129+
() => tags.split(',').map((s) => s.trim()).filter(Boolean),
130+
[tags],
131+
);
132+
120133
useEffect(() => {
121134
let cancelled = false;
122135
(async () => {
@@ -364,14 +377,20 @@ export function DocEditor() {
364377
{showMeta && (
365378
<>
366379
<div
367-
className="fixed inset-0 z-40 bg-black/40 lg:hidden"
380+
className="fixed inset-0 z-40 bg-slate-900/40 backdrop-blur-sm lg:hidden"
368381
onClick={() => setShowMeta(false)}
369382
aria-hidden
370383
/>
371384
<aside
372-
className={`fixed inset-y-0 z-50 w-80 max-w-[85vw] space-y-4 overflow-y-auto bg-background p-4 shadow-xl lg:static lg:z-auto lg:w-auto lg:max-w-none lg:overflow-visible lg:bg-transparent lg:p-0 lg:shadow-none ${isRTL ? 'left-0 border-r border-slate-200 dark:border-slate-800 lg:border-r-0' : 'right-0 border-l border-slate-200 dark:border-slate-800 lg:border-l-0'}`}
385+
className={cn(
386+
'fixed inset-y-0 z-50 flex w-80 max-w-[85vw] flex-col overflow-y-auto bg-background shadow-2xl',
387+
'lg:static lg:z-auto lg:w-auto lg:max-w-none lg:self-start lg:overflow-visible lg:bg-transparent lg:shadow-none',
388+
'lg:sticky lg:top-6',
389+
isRTL ? 'left-0' : 'right-0',
390+
)}
373391
>
374-
<div className="flex items-center justify-between lg:hidden">
392+
{/* Mobile drawer header — desktop uses the panel header below. */}
393+
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-800 lg:hidden">
375394
<span className="flex items-center gap-2 text-sm font-semibold">
376395
<Settings2 className="h-4 w-4 text-muted-foreground" />
377396
{t('docMetadata')}
@@ -380,59 +399,145 @@ export function DocEditor() {
380399
<X className="h-4 w-4" />
381400
</Button>
382401
</div>
383-
<div className="rounded-lg border border-slate-200 p-4 dark:border-slate-800">
384-
<h3 className="mb-3 hidden items-center gap-2 text-sm font-semibold lg:flex">
385-
<Settings2 className="h-4 w-4 text-muted-foreground" />
386-
{t('docMetadata')}
387-
</h3>
388-
<div className="space-y-3">
389-
<div className="space-y-1">
390-
<Label className="text-xs">{t('status')}</Label>
391-
<Select value={status} onValueChange={(v) => setStatus(v as DocStatus)}>
392-
<SelectTrigger><SelectValue /></SelectTrigger>
393-
<SelectContent>
394-
{STATUSES.map((s) => <SelectItem key={s} value={s}>{t(`docStatus_${s}` as any)}</SelectItem>)}
395-
</SelectContent>
396-
</Select>
397-
</div>
398-
{space && (
399-
<div className="space-y-1">
400-
<Label className="text-xs">{t('docFolder')}</Label>
401-
<Select value={folderId ? String(folderId) : 'none'} onValueChange={(v) => setFolderId(v === 'none' ? null : Number(v))}>
402-
<SelectTrigger><SelectValue /></SelectTrigger>
403-
<SelectContent>
404-
<SelectItem value="none">{t('docNoFolder')}</SelectItem>
405-
{folders.map((f) => <SelectItem key={f.id} value={String(f.id)}>{f.name}</SelectItem>)}
406-
</SelectContent>
407-
</Select>
402+
403+
{/* Seamless properties panel: one cohesive card, hairline-divided sections. */}
404+
<div className="flex-1 p-4 lg:p-0">
405+
<div className="overflow-hidden rounded-2xl border border-slate-200/80 bg-card shadow-sm ring-1 ring-black/[0.02] dark:border-slate-800 dark:ring-white/[0.02]">
406+
{/* Panel header (desktop only — mobile has its own above). */}
407+
<div className="hidden items-center gap-2 border-b border-slate-200/80 bg-gradient-to-br from-slate-50 to-transparent px-4 py-3 dark:border-slate-800 dark:from-slate-800/40 lg:flex">
408+
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10 text-primary">
409+
<Settings2 className="h-4 w-4" />
410+
</span>
411+
<span className="text-sm font-semibold tracking-tight">{t('docMetadata')}</span>
412+
</div>
413+
414+
<div className="divide-y divide-slate-200/70 dark:divide-slate-800/70">
415+
{/* Status — segmented control */}
416+
<section className="px-4 py-3.5">
417+
<Label className="mb-2 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
418+
<span className={cn('h-2 w-2 rounded-full', status === 'published' ? 'bg-emerald-500' : status === 'archived' ? 'bg-amber-500' : 'bg-slate-400')} />
419+
{t('status')}
420+
</Label>
421+
<div className="flex gap-1 rounded-xl bg-slate-100 p-1 dark:bg-slate-800/60">
422+
{STATUSES.map((s) => (
423+
<button
424+
key={s}
425+
type="button"
426+
onClick={() => setStatus(s)}
427+
className={cn(
428+
'flex-1 rounded-lg px-2 py-1.5 text-xs font-medium transition-all',
429+
status === s
430+
? cn('shadow-sm', statusTone[s] || statusTone.draft)
431+
: 'text-muted-foreground hover:text-foreground',
432+
)}
433+
>
434+
{t(`docStatus_${s}` as any)}
435+
</button>
436+
))}
437+
</div>
438+
</section>
439+
440+
{/* Folder */}
441+
{space && (
442+
<section className="px-4 py-3.5">
443+
<Label className="mb-2 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
444+
<FolderTree className="h-3.5 w-3.5" />
445+
{t('docFolder')}
446+
</Label>
447+
<Select value={folderId ? String(folderId) : 'none'} onValueChange={(v) => setFolderId(v === 'none' ? null : Number(v))}>
448+
<SelectTrigger className="h-9 rounded-lg"><SelectValue /></SelectTrigger>
449+
<SelectContent>
450+
<SelectItem value="none">{t('docNoFolder')}</SelectItem>
451+
{folders.map((f) => <SelectItem key={f.id} value={String(f.id)}>{f.name}</SelectItem>)}
452+
</SelectContent>
453+
</Select>
454+
</section>
455+
)}
456+
457+
{/* Classification */}
458+
<section className="px-4 py-3.5">
459+
<Label className="mb-2 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
460+
<ShieldCheck className="h-3.5 w-3.5" />
461+
{t('docClassification')}
462+
</Label>
463+
<Input
464+
value={classification}
465+
onChange={(e) => setClassification(e.target.value)}
466+
placeholder={t('docClassificationPlaceholder')}
467+
className="h-9 rounded-lg"
468+
/>
469+
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">{t('docClassificationHelp')}</p>
470+
</section>
471+
472+
{/* Tags */}
473+
<section className="px-4 py-3.5">
474+
<Label className="mb-2 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
475+
<Tag className="h-3.5 w-3.5" />
476+
{t('tags')}
477+
</Label>
478+
<Input
479+
value={tags}
480+
onChange={(e) => setTags(e.target.value)}
481+
placeholder={t('docTagsPlaceholder')}
482+
dir="auto"
483+
className="h-9 rounded-lg"
484+
/>
485+
{tagList.length > 0 && (
486+
<div className="mt-2 flex flex-wrap gap-1.5">
487+
{tagList.map((tag, i) => (
488+
<span
489+
key={`${tag}-${i}`}
490+
dir="auto"
491+
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
492+
>
493+
<Hash className="h-3 w-3 opacity-60" />
494+
{tag}
495+
</span>
496+
))}
497+
</div>
498+
)}
499+
</section>
500+
501+
{/* Direction — segmented control */}
502+
<section className="px-4 py-3.5">
503+
<Label className="mb-2 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
504+
<Languages className="h-3.5 w-3.5" />
505+
{t('docDirection')}
506+
</Label>
507+
<div className="flex gap-1 rounded-xl bg-slate-100 p-1 dark:bg-slate-800/60">
508+
{DIRECTIONS.map((d) => (
509+
<button
510+
key={d}
511+
type="button"
512+
onClick={() => setDir(d)}
513+
className={cn(
514+
'flex-1 rounded-lg px-2 py-1.5 text-xs font-medium transition-all',
515+
dir === d
516+
? 'bg-background text-foreground shadow-sm'
517+
: 'text-muted-foreground hover:text-foreground',
518+
)}
519+
>
520+
{t(`docDir_${d}` as any)}
521+
</button>
522+
))}
523+
</div>
524+
</section>
525+
526+
{/* Space + version footer */}
527+
{space && (
528+
<section className="flex items-center gap-2 bg-slate-50/60 px-4 py-3 dark:bg-slate-800/30">
529+
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-slate-200/70 text-muted-foreground dark:bg-slate-700/50">
530+
<Layers className="h-3.5 w-3.5" />
531+
</span>
532+
<div className="min-w-0">
533+
<p className="truncate text-xs font-medium text-foreground">{space.name}</p>
534+
<p className="text-[11px] text-muted-foreground">{t('docSpace')} · {t('docVersion')} v{doc.current_version}</p>
535+
</div>
536+
</section>
537+
)}
408538
</div>
409-
)}
410-
<div className="space-y-1">
411-
<Label className="text-xs">{t('docClassification')}</Label>
412-
<Input value={classification} onChange={(e) => setClassification(e.target.value)} placeholder={t('docClassificationPlaceholder')} />
413-
<p className="text-[11px] text-muted-foreground">{t('docClassificationHelp')}</p>
414-
</div>
415-
<div className="space-y-1">
416-
<Label className="text-xs">{t('tags')}</Label>
417-
<Input value={tags} onChange={(e) => setTags(e.target.value)} placeholder={t('docTagsPlaceholder')} dir="auto" />
418-
</div>
419-
<div className="space-y-1">
420-
<Label className="text-xs">{t('docDirection')}</Label>
421-
<Select value={dir} onValueChange={(v) => setDir(v as DocDir)}>
422-
<SelectTrigger><SelectValue /></SelectTrigger>
423-
<SelectContent>
424-
{DIRECTIONS.map((d) => <SelectItem key={d} value={d}>{t(`docDir_${d}` as any)}</SelectItem>)}
425-
</SelectContent>
426-
</Select>
427539
</div>
428540
</div>
429-
</div>
430-
{space && (
431-
<div className="rounded-lg border border-slate-200 p-4 text-xs text-muted-foreground dark:border-slate-800">
432-
<p>{t('docSpace')}: <span className="font-medium text-foreground">{space.name}</span></p>
433-
<p className="mt-1">{t('docVersion')}: v{doc.current_version}</p>
434-
</div>
435-
)}
436541
</aside>
437542
</>
438543
)}

0 commit comments

Comments
 (0)