`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
200 lines
8.0 KiB
TypeScript
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>
|
|
);
|
|
}
|