feat(ai-character): 관리자 인증 셸 구현

This commit is contained in:
Yu Sung
2026-07-27 15:04:03 +09:00
parent ba43dd829e
commit 5356bd1e6a
82 changed files with 6017 additions and 18 deletions

View File

@@ -0,0 +1,153 @@
import { useRef, useState } from "react";
import { calculateCropOutputSize, createCroppedImageFile } from "@/shared/lib/crop-image";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { useModalFocus } from "@/shared/ui/use-modal-focus";
export type CropSourceImage = {
readonly file: File;
readonly height: number;
readonly previewUrl: string;
readonly width: number;
};
export type ImageCropPolicy = {
readonly aspect: number | "free";
readonly maxWidth: number;
readonly noUpscale: boolean;
};
export type ImageCropDialogProps = {
readonly image: CropSourceImage;
readonly onApply: (file: File) => void;
readonly onCancel: () => void;
readonly open: boolean;
readonly policy: ImageCropPolicy;
readonly renderCrop?: (request: CropRenderRequest) => Promise<File>;
};
const MOVE_STEP = 10;
const ZOOM_STEP = 0.1;
export function ImageCropDialog({ image, onApply, onCancel, open, policy, renderCrop = createCroppedImageFile }: ImageCropDialogProps) {
const [offsetX, setOffsetX] = useState(0);
const [offsetY, setOffsetY] = useState(0);
const [zoom, setZoom] = useState(1);
const dragPointRef = useRef<{ readonly x: number; readonly y: number } | null>(null);
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
const outputSize = calculateCropOutputSize({ aspect: policy.aspect, maxWidth: policy.maxWidth, noUpscale: policy.noUpscale, sourceHeight: image.height, sourceWidth: image.width });
if (!open) {
return null;
}
function resetCrop() {
setOffsetX(0);
setOffsetY(0);
setZoom(1);
}
function move(deltaX: number, deltaY: number) {
setOffsetX((current) => current + deltaX);
setOffsetY((current) => current + deltaY);
}
function changeZoom(nextZoom: number) {
setZoom(Math.min(3, Math.max(1, Number(nextZoom.toFixed(1)))));
}
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
move(0, MOVE_STEP);
return;
case "ArrowLeft":
event.preventDefault();
move(-MOVE_STEP, 0);
return;
case "ArrowRight":
event.preventDefault();
move(MOVE_STEP, 0);
return;
case "ArrowUp":
event.preventDefault();
move(0, -MOVE_STEP);
return;
case "+":
event.preventDefault();
changeZoom(zoom + ZOOM_STEP);
return;
case "-":
event.preventDefault();
changeZoom(zoom - ZOOM_STEP);
return;
default:
}
}
function startDrag(event: React.PointerEvent<HTMLElement>) {
dragPointRef.current = { x: event.clientX, y: event.clientY };
event.currentTarget.setPointerCapture?.(event.pointerId);
}
function drag(event: React.PointerEvent<HTMLElement>) {
const dragPoint = dragPointRef.current;
if (dragPoint === null) {
return;
}
move(event.clientX - dragPoint.x, event.clientY - dragPoint.y);
dragPointRef.current = { x: event.clientX, y: event.clientY };
}
function stopDrag() {
dragPointRef.current = null;
}
async function applyCrop() {
const file = await renderCrop({
aspect: policy.aspect,
file: image.file,
offsetX,
offsetY,
outputHeight: outputSize.height,
outputWidth: outputSize.width,
previewUrl: image.previewUrl,
sourceHeight: image.height,
sourceWidth: image.width,
zoom,
});
onApply(file);
}
return (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-label="이미지 crop" aria-modal="true" className="flex w-full max-w-lg flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="dialog">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold"> crop</h2>
<p className="text-sm text-muted-foreground">, , .</p>
</div>
<div aria-label="이미지 crop 미리보기" className="overflow-hidden rounded-lg border border-border bg-muted p-4" onKeyDown={handleKeyDown} onPointerDown={startDrag} onPointerLeave={stopDrag} onPointerMove={drag} onPointerUp={stopDrag} role="application" tabIndex={0}>
<img alt="선택한 이미지 미리보기" className="mx-auto max-h-64 max-w-full" src={image.previewUrl} style={{ transform: `translate(${offsetX}px, ${offsetY}px) scale(${zoom})` }} />
</div>
<p className="text-sm font-semibold text-info"> {outputSize.width} × {outputSize.height}px</p>
<div className="grid gap-2 sm:grid-cols-2">
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(0, -MOVE_STEP)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(0, MOVE_STEP)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(-MOVE_STEP, 0)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(MOVE_STEP, 0)} type="button"> </button>
</div>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-label="확대 비율" max="3" min="1" onChange={(event) => changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} />
</label>
<div className="flex justify-end gap-2">
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={resetCrop} type="button"></button>
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button"></button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void applyCrop()} type="button"></button>
</div>
</section>
</div>
);
}