o2o-site-AEO/solution/frontend/src/features/builder/SectionListPanel.tsx
Mina Choi c85c577349 이름: solution/front → solution/frontend
`backend` 옆에 `front` 가 있을 이유가 없었다. negosium 의 negodata/front 를 그대로
베꼈고 그게 왜 front 인지는 따져보지 않았다 — 근거 없이 들여온 이름이라 바로잡는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:27:16 +09:00

200 lines
8.0 KiB
TypeScript

import {
closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {CSS} from '@dnd-kit/utilities';
import type {SectionItem} from '@o2o/shared';
import {GripVertical, Layers, Lock, Shapes} from 'lucide-react';
import {Switch} from '@/components/ui/switch';
import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder';
import {resolveVariant} from './canvas/registry';
interface SectionListPanelProps {
onAfterSelect?: () => void;
}
interface SortableSectionProps extends SectionListPanelProps {
section: SectionItem;
index: number;
}
function SortableSection({section, index, onAfterSelect}: SortableSectionProps) {
const industry = useBuilderStore((s) => s.industry);
const setRightTab = useBuilderStore((s) => s.setRightTab);
const isSelected = useBuilderStore((s) => s.selectedSectionId === section.id);
const selectSection = useBuilderStore((s) => s.selectSection);
const toggleSection = useBuilderStore((s) => s.toggleSection);
const variant = resolveVariant(section, industry);
const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({
id: section.id,
});
const select = () => {
selectSection(section.id);
setRightTab('content');
onAfterSelect?.();
if (!section.isEnabled) return;
// 좁은 화면에서는 onAfterSelect가 캔버스 탭을 먼저 연다. 다음 페인트가 끝난 뒤
// 실제 섹션으로 이동해야 display:none 상태의 위치를 잘못 읽지 않는다.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document
.getElementById(`canvas-section-${section.id}`)
?.scrollIntoView({behavior: 'smooth', block: 'start'});
});
});
};
return (
<li
ref={setNodeRef}
style={{transform: CSS.Transform.toString(transform), transition}}
className={cn('relative', isDragging && 'z-10')}
>
<div
role="button"
tabIndex={0}
onClick={select}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
select();
}
}}
className={cn(
'group flex cursor-pointer items-center justify-between rounded-md border p-2 text-xs transition-[background-color,border-color,opacity,box-shadow]',
isDragging && 'cursor-grabbing opacity-70 shadow-lg',
isSelected
? 'border-primary bg-primary/8 font-semibold text-foreground'
: section.isEnabled
? 'border-border bg-card hover:bg-muted/60'
: 'border-border bg-muted/40 text-muted-foreground',
)}
>
<div className="flex min-w-0 items-center gap-2 pr-1">
<button
type="button"
title={`${section.name} 드래그하여 순서 변경`}
aria-label={`${section.name} 드래그하여 순서 변경`}
onClick={(event) => event.stopPropagation()}
className="-mr-1 touch-none cursor-grab rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
>
<GripVertical className="size-3.5" />
</button>
<span className="w-3 text-right font-mono text-[10px] text-muted-foreground">
{index + 1}
</span>
<div className="min-w-0 truncate">
<span className={cn('block truncate', !section.isEnabled && 'line-through')}>
{section.name}
</span>
{variant && (
<span
role="button"
tabIndex={0}
title={`${variant.name} — 눌러서 레이아웃 바꾸기`}
onClick={(event) => {
event.stopPropagation();
selectSection(section.id);
setRightTab('design');
onAfterSelect?.();
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
selectSection(section.id);
setRightTab('design');
onAfterSelect?.();
}}
className="flex cursor-pointer items-center gap-0.5 truncate text-[9px] font-normal text-muted-foreground hover:text-foreground"
>
<Shapes className="size-2.5 shrink-0" />
<span className="truncate">{variant.name}</span>
</span>
)}
{section.isLocked && (
<span className="flex items-center gap-0.5 text-[9px] font-normal text-muted-foreground">
<Lock className="size-2.5" /> 필수 고정
</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1" onClick={(event) => event.stopPropagation()} role="presentation">
{section.isLocked ? (
<span title="SEO·필수 마크업이 달린 섹션이라 끌 수 없습니다." className="p-1 text-muted-foreground">
<Lock className="size-4" />
</span>
) : (
<Switch checked={section.isEnabled} onCheckedChange={() => toggleSection(section.id)} aria-label={`${section.name} 섹션 표시 여부`} />
)}
</div>
</div>
</li>
);
}
export function SectionListPanel({onAfterSelect}: SectionListPanelProps) {
const sections = useBuilderStore((s) => s.sections);
const reorderSection = useBuilderStore((s) => s.reorderSection);
const sensors = useSensors(
useSensor(PointerSensor, {activationConstraint: {distance: 6}}),
useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates}),
);
const activeCount = sections.filter((section) => section.isEnabled).length;
const handleDragEnd = ({active, over}: DragEndEvent) => {
if (!over || active.id === over.id) return;
const fromIndex = sections.findIndex((section) => section.id === active.id);
const toIndex = sections.findIndex((section) => section.id === over.id);
reorderSection(fromIndex, toIndex);
};
return (
<div className="flex h-full w-full shrink-0 select-none flex-col overflow-hidden border-r border-border bg-card lg:w-[250px]">
<div className="flex items-center justify-between border-b border-border bg-muted/50 p-3.5">
<div className="flex items-center gap-1.5">
<Layers className="size-4" />
<span className="text-xs font-bold">섹션 관리</span>
</div>
<span className="rounded border border-border bg-card px-2 py-0.5 font-mono text-[11px] text-muted-foreground">
{activeCount} / {sections.length} 활성
</span>
</div>
<p className="border-b border-border bg-muted/30 px-3.5 py-2 text-[11px] text-muted-foreground">
핸들을 드래그해 순서를 바꾸고, 레이아웃 이름을 누르면 모양을 바꿉니다.
</p>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sections.map((section) => section.id)} strategy={verticalListSortingStrategy}>
<ul className="flex-1 space-y-1.5 overflow-y-auto p-2">
{sections.map((section, index) => (
<SortableSection
key={section.id}
section={section}
index={index}
onAfterSelect={onAfterSelect}
/>
))}
</ul>
</SortableContext>
</DndContext>
<p className="border-t border-border bg-muted/30 p-2.5 text-center text-[10px] text-muted-foreground">
* 자물쇠 항목은 검색·AI 노출에 필요한 마크업이 달려 있어 고정입니다.
</p>
</div>
);
}