Files
page-builder/src/app/editor/panels/VideoPropertiesPanel.tsx
T
jaybe 48e13d2a75
CI / pr_and_merge (push) Has been skipped
CI / test (push) Failing after 46m46s
CI / test (pull_request) Failing after 43m8s
CI / pr_and_merge (pull_request) Has been skipped
video 블록 및 리펙터링
2025-11-27 10:07:59 +09:00

388 lines
14 KiB
TypeScript

"use client";
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
export type VideoPropertiesPanelProps = {
videoProps: VideoBlockProps;
selectedBlockId: string;
updateBlock: (id: string, partial: Partial<VideoBlockProps>) => void;
};
export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock }: VideoPropertiesPanelProps) {
// 비디오 소스 유형: 업로드(/api/video/:id) 또는 외부 URL 구분
const source: "url" | "upload" =
videoProps.sourceType === "asset" || (videoProps.sourceUrl && videoProps.sourceUrl.startsWith("/api/video/"))
? "upload"
: "url";
return (
<>
{/* 비디오 소스 선택 (URL / 업로드) */}
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>비디오 소스</span>
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label="비디오 소스"
value={source}
onChange={(e) => {
const next = e.target.value as "url" | "upload";
if (next === "url") {
updateBlock(selectedBlockId, {
sourceType: "externalUrl",
assetId: null,
} as any);
} else {
updateBlock(selectedBlockId, {
sourceType: "asset",
} as any);
}
}}
>
<option value="url">URL</option>
<option value="upload">파일 업로드</option>
</select>
</label>
</div>
{/* URL 입력 */}
{source === "url" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>비디오 URL</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="비디오 URL"
value={videoProps.sourceUrl ?? ""}
onChange={(e) => {
const value = e.target.value;
updateBlock(selectedBlockId, {
sourceUrl: value,
sourceType: "externalUrl",
assetId: null,
} as any);
}}
/>
</label>
{/* 접근성: 제목 / aria-label / 캡션 텍스트 (현재는 UI에서 숨김 처리) */}
<div className="hidden">
<label className="flex flex-col gap-1 mt-2">
<span>비디오 제목</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="비디오 제목"
value={(videoProps as any).titleText ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, { titleText: e.target.value } as any);
}}
/>
</label>
<label className="mt-2 flex flex-col gap-1">
<span>비디오 aria-label</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="비디오 aria-label"
value={(videoProps as any).ariaLabel ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, { ariaLabel: e.target.value } as any);
}}
/>
</label>
<label className="mt-2 flex flex-col gap-1">
<span>비디오 캡션 텍스트</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="비디오 캡션 텍스트"
value={(videoProps as any).captionText ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, { captionText: e.target.value } as any);
}}
/>
</label>
</div>
</div>
)}
<div className="mt-3 space-y-1 text-xs text-slate-400">
<label className="flex flex-col gap-1">
<span>포스터 이미지 URL</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="포스터 이미지 URL"
value={videoProps.posterImageSrc ?? ""}
onChange={(e) => {
const value = e.target.value;
updateBlock(selectedBlockId, {
posterImageSrc: value,
posterSourceType: "externalUrl",
posterAssetId: null,
} as any);
}}
/>
</label>
</div>
{/* 파일 업로드 */}
{source === "upload" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>비디오 파일 업로드</span>
<input
type="file"
accept="video/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label="비디오 파일 업로드"
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/video", {
method: "POST",
body: formData,
});
if (!response.ok) {
console.error("비디오 업로드 실패", await response.text());
return;
}
const data = (await response.json()) as { id: string; servedUrl?: string | null };
const servedUrl = data.servedUrl ?? `/api/video/${data.id}`;
updateBlock(selectedBlockId, {
sourceUrl: servedUrl,
sourceType: "asset",
assetId: data.id,
} as any);
} catch (error) {
console.error("비디오 업로드 중 오류", error);
} finally {
event.target.value = "";
}
}}
/>
</label>
</div>
)}
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
<h4 className="text-[11px] font-semibold text-slate-200">비디오 스타일</h4>
{/* 카드 배경색 */}
<div className="space-y-1">
<ColorPickerField
label="카드 배경색"
ariaLabelColorInput="비디오 카드 배경색 피커"
ariaLabelHexInput="비디오 카드 배경색 HEX"
value={videoProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
const next = hex && hex.trim().length > 0 ? hex : undefined;
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
}}
palette={TEXT_COLOR_PALETTE}
/>
</div>
{/* 정렬 */}
<label className="flex flex-col gap-1">
<span>비디오 정렬</span>
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label="비디오 정렬"
value={videoProps.align ?? "center"}
onChange={(e) =>
updateBlock(selectedBlockId, {
align: e.target.value as VideoBlockProps["align"],
} as any)
}
>
<option value="left">왼쪽</option>
<option value="center">가운데</option>
<option value="right">오른쪽</option>
</select>
</label>
{/* 너비 모드 */}
<label className="flex flex-col gap-1">
<span>비디오 너비 모드</span>
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label="비디오 너비 모드"
value={videoProps.widthMode ?? "auto"}
onChange={(e) =>
updateBlock(selectedBlockId, {
widthMode: e.target.value as VideoBlockProps["widthMode"],
} as any)
}
>
<option value="auto">내용에 맞춤</option>
<option value="full">가로 전체</option>
<option value="fixed">고정 너비 (px)</option>
</select>
</label>
{(videoProps.widthMode ?? "auto") === "fixed" && (
<NumericPropertyControl
label="고정 너비 (px)"
unitLabel="(px)"
value={videoProps.widthPx ?? 640}
min={160}
max={1920}
step={10}
presets={[
{ id: "sm", label: "작게", value: 480 },
{ id: "md", label: "보통", value: 640 },
{ id: "lg", label: "넓게", value: 960 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
widthPx: v,
} as any);
}}
/>
)}
{/* 카드 패딩 */}
<NumericPropertyControl
label="카드 패딩"
unitLabel="(px)"
value={typeof videoProps.cardPaddingPx === "number" ? videoProps.cardPaddingPx : 0}
min={0}
max={64}
step={2}
onChangeValue={(v) => {
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
updateBlock(selectedBlockId, { cardPaddingPx: safe } as any);
}}
/>
{/* 카드 모서리 둥글기 */}
<NumericPropertyControl
label="카드 모서리 둥글기"
unitLabel="(px)"
value={typeof videoProps.borderRadiusPx === "number" ? videoProps.borderRadiusPx : 0}
min={0}
max={64}
step={1}
onChangeValue={(v) => {
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
updateBlock(selectedBlockId, { borderRadiusPx: safe } as any);
}}
/>
{/* 시작 시점 (초) */}
<NumericPropertyControl
label="시작 시점 (초)"
unitLabel="(초)"
value={typeof videoProps.startTimeSec === "number" ? videoProps.startTimeSec : 0}
min={0}
max={600}
step={1}
onChangeValue={(v) => {
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
updateBlock(selectedBlockId, { startTimeSec: safe } as any);
}}
/>
{/* 종료 시점 (초) */}
<NumericPropertyControl
label="종료 시점 (초)"
unitLabel="(초)"
value={typeof videoProps.endTimeSec === "number" ? videoProps.endTimeSec : 0}
min={0}
max={600}
step={1}
onChangeValue={(v) => {
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
updateBlock(selectedBlockId, { endTimeSec: safe } as any);
}}
/>
{/* 화면 비율 */}
<label className="flex flex-col gap-1">
<span>화면 비율</span>
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label="화면 비율"
value={videoProps.aspectRatio ?? "16:9"}
onChange={(e) =>
updateBlock(selectedBlockId, {
aspectRatio: e.target.value as VideoBlockProps["aspectRatio"],
} as any)
}
>
<option value="16:9">16:9</option>
<option value="4:3">4:3</option>
<option value="1:1">1:1</option>
</select>
</label>
{/* 재생 옵션 */}
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="flex items-center gap-1">
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
checked={!!videoProps.autoplay}
onChange={(e) =>
updateBlock(selectedBlockId, {
autoplay: e.target.checked,
} as any)
}
/>
<span className="text-[11px]">자동 재생</span>
</label>
<label className="flex items-center gap-1">
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
checked={!!videoProps.loop}
onChange={(e) =>
updateBlock(selectedBlockId, {
loop: e.target.checked,
} as any)
}
/>
<span className="text-[11px]">반복 재생</span>
</label>
<label className="flex items-center gap-1">
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
checked={!!videoProps.muted}
onChange={(e) =>
updateBlock(selectedBlockId, {
muted: e.target.checked,
} as any)
}
/>
<span className="text-[11px]">음소거</span>
</label>
<label className="flex items-center gap-1">
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
checked={videoProps.controls !== false}
onChange={(e) =>
updateBlock(selectedBlockId, {
controls: e.target.checked,
} as any)
}
/>
<span className="text-[11px]">재생 컨트롤 표시</span>
</label>
</div>
</div>
</>
);
}