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,215 @@
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AccessDeniedError } from "../api-error";
import { createApiClient } from "../client";
import { server } from "../../test/server";
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("authenticated API requests", () => {
test("sends Korean language without bearer authentication to login", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
let acceptLanguage: string | null = null;
let authorization: string | null = null;
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, ({ request }) => {
acceptLanguage = request.headers.get("Accept-Language");
authorization = request.headers.get("Authorization");
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
}),
);
// When
await client.request({
path: "/admin/member/login",
method: "POST",
headers: {
Authorization: "Bearer caller-supplied-token",
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "admin@test.com", password: "secret" }),
responseSchema: valueSchema,
authentication: "none",
});
// Then
expect(acceptLanguage).toBe("ko");
expect(authorization).toBeNull();
});
test("sends bearer authentication to a protected request", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
let authorization: string | null = null;
server.use(
http.get(`${apiBaseUrl}/api/v2/protected`, ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
}),
);
// When
await client.request({
path: "/api/v2/protected",
responseSchema: valueSchema,
authentication: "required",
});
// Then
expect(authorization).toBe("Bearer header.payload.signature");
});
test("sends bearer authentication to logout", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
let authorization: string | null = null;
server.use(
http.post(`${apiBaseUrl}/member/logout`, ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
}),
);
// When
await client.request({
path: "/member/logout",
method: "POST",
responseSchema: valueSchema,
authentication: "required",
});
// Then
expect(authorization).toBe("Bearer header.payload.signature");
});
test("clears the session and redirects once for each concurrent 401 burst", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const clearSession = vi.fn();
const onAuthExpired = vi.fn();
const client = createApiClient({
getToken: () => "header.payload.signature",
clearSession,
onAuthExpired,
});
server.use(
http.get(`${apiBaseUrl}/protected`, () =>
HttpResponse.json(
{
success: false,
message: "인증 정보가 없습니다.",
data: null,
errorProperty: null,
},
{ status: 401 },
),
),
);
// When
const firstBurst = await Promise.allSettled([
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
]);
const secondBurst = await Promise.allSettled([
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
]);
// Then
expect(firstBurst).toHaveLength(2);
expect(secondBurst).toHaveLength(2);
expect(clearSession).toHaveBeenCalledTimes(2);
expect(onAuthExpired).toHaveBeenCalledTimes(2);
});
test("surfaces access denied without clearing the session", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client, clearSession, onAuthExpired } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/protected`, () =>
HttpResponse.json(
{
success: false,
message: "접근 권한이 없습니다.",
data: null,
errorProperty: null,
},
{ status: 403 },
),
),
);
// When
const request = client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
});
// Then
await expect(request).rejects.toBeInstanceOf(AccessDeniedError);
expect(clearSession).not.toHaveBeenCalled();
expect(onAuthExpired).not.toHaveBeenCalled();
});
test("does not write sensitive request data to the console", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient("jwt.secret.value");
const multipartBody = new FormData();
multipartBody.append("request", JSON.stringify({ password: "secret-password" }));
multipartBody.append("file", new File(["private-content"], "private.txt"));
const consoleDebug = vi.spyOn(console, "debug");
const consoleError = vi.spyOn(console, "error");
const consoleInfo = vi.spyOn(console, "info");
const consoleLog = vi.spyOn(console, "log");
const consoleWarn = vi.spyOn(console, "warn");
server.use(
http.post(`${apiBaseUrl}/api/v2/protected`, () =>
HttpResponse.json({ success: true, message: null, data: { value: "ok" } }),
),
);
// When
await client.request({
path: "/api/v2/protected?signedUrl=https%3A%2F%2Fcdn.example.com%2Fprivate%3Fsignature%3Dabc",
method: "POST",
body: multipartBody,
responseSchema: valueSchema,
authentication: "required",
});
// Then
expect(consoleDebug).not.toHaveBeenCalled();
expect(consoleError).not.toHaveBeenCalled();
expect(consoleInfo).not.toHaveBeenCalled();
expect(consoleLog).not.toHaveBeenCalled();
expect(consoleWarn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
import { vi } from "vitest";
import { z } from "zod";
import { createApiClient } from "../client";
export const apiBaseUrl = "https://api.example.com";
export const valueSchema = z.object({ value: z.string() });
export type TestClient = {
readonly client: ReturnType<typeof createApiClient>;
readonly clearSession: ReturnType<typeof vi.fn>;
readonly onAuthExpired: ReturnType<typeof vi.fn>;
};
export function createTestClient(token = "header.payload.signature"): TestClient {
let currentToken: string | null = token;
const clearSession = vi.fn(() => {
currentToken = null;
});
const onAuthExpired = vi.fn();
return {
client: createApiClient({
getToken: () => currentToken,
clearSession,
onAuthExpired,
}),
clearSession,
onAuthExpired,
};
}

View File

@@ -0,0 +1,117 @@
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ApiError } from "../api-error";
import { server } from "../../test/server";
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("API client", () => {
test("accepts a successful envelope without errorProperty", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/public`, () =>
HttpResponse.json({ success: true, message: null, data: { value: "ok" } }),
),
);
// When
const response = await client.request({
path: "/public",
responseSchema: valueSchema,
authentication: "none",
});
// Then
expect(response).toEqual({ value: "ok" });
});
test("accepts a successful envelope with null errorProperty", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/public`, () =>
HttpResponse.json({
success: true,
message: null,
data: { value: "ok" },
errorProperty: null,
}),
),
);
// When
const response = await client.request({
path: "/public",
responseSchema: valueSchema,
authentication: "none",
});
// Then
expect(response).toEqual({ value: "ok" });
});
test.each([400, 404, 405, 415, 500])(
"preserves a Korean server error envelope for status %i",
async (status) => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
const message = `서버 오류 ${status}`;
const errorProperty = "request";
server.use(
http.get(`${apiBaseUrl}/error`, () =>
HttpResponse.json(
{ success: false, message, data: null, errorProperty },
{ status },
),
),
);
// When
const request = client.request({
path: "/error",
responseSchema: valueSchema,
authentication: "none",
});
// Then
await expect(request).rejects.toMatchObject({ status, message, errorProperty });
},
);
test("exposes server errors as ApiError", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/error`, () =>
HttpResponse.json(
{
success: false,
message: "잘못된 요청입니다.",
data: null,
errorProperty: null,
},
{ status: 400 },
),
),
);
// When
const request = client.request({
path: "/error",
responseSchema: valueSchema,
authentication: "none",
});
// Then
await expect(request).rejects.toBeInstanceOf(ApiError);
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, test } from "vitest";
import { createPageParams, type PageData } from "../pagination";
describe("createPageParams", () => {
test("uses documented default page and size", () => {
// Given
const request = {};
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams).toEqual({ page: 0, size: 20 });
});
test("clamps size to the documented lower bound", () => {
// Given
const request = { size: 1 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.size).toBe(20);
});
test("clamps size to the documented upper bound", () => {
// Given
const request = { size: 51 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.size).toBe(50);
});
test("keeps a provided page unchanged", () => {
// Given
const request = { page: 3, size: 20 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.page).toBe(3);
});
test("defines the documented page response shape", () => {
// Given
const page: PageData<string> = {
totalCount: 1,
page: 0,
size: 20,
hasNext: false,
items: ["루나"],
};
// When
const firstItem = page.items[0];
// Then
expect(firstItem).toBe("루나");
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import { ApiError } from "../api-error";
import { createQueryClient, shouldRetryQuery } from "../query-client";
describe("TanStack Query defaults", () => {
test("does not retry unauthenticated query errors", () => {
// Given
const error = new ApiError({
status: 401,
message: "인증 정보가 없습니다.",
errorProperty: null,
});
// When
const shouldRetry = shouldRetryQuery(0, error);
// Then
expect(shouldRetry).toBe(false);
});
test("does not retry access-denied query errors", () => {
// Given
const error = new ApiError({
status: 403,
message: "접근 권한이 없습니다.",
errorProperty: null,
});
// When
const shouldRetry = shouldRetryQuery(0, error);
// Then
expect(shouldRetry).toBe(false);
});
test("limits retries for other query errors", () => {
// Given
const error = new Error("network failure");
// When
const shouldRetry = shouldRetryQuery(2, error);
// Then
expect(shouldRetry).toBe(false);
});
test("disables mutation retries", () => {
// Given
const queryClient = createQueryClient();
// When
const retry = queryClient.getDefaultOptions().mutations?.retry;
// Then
expect(retry).toBe(false);
});
});

View File

@@ -0,0 +1,21 @@
export type ApiErrorOptions = {
readonly status: number;
readonly message: string;
readonly errorProperty: string | null;
};
export class ApiError extends Error {
override readonly name: string = "ApiError";
readonly status: number;
readonly errorProperty: string | null;
constructor(options: ApiErrorOptions) {
super(options.message);
this.status = options.status;
this.errorProperty = options.errorProperty;
}
}
export class AccessDeniedError extends ApiError {
override readonly name: string = "AccessDeniedError";
}

131
src/shared/api/client.ts Normal file
View File

@@ -0,0 +1,131 @@
import type { z } from "zod";
import { AccessDeniedError, ApiError } from "./api-error";
import { getRuntimeEnv } from "../config/env";
import { createApiResponseSchema } from "./types";
type AuthenticationMode = "none" | "required";
export type ApiClientDependencies = {
readonly getToken: () => string | null;
readonly clearSession: () => void;
readonly onAuthExpired: () => void;
};
export type ApiRequestOptions<Data> = {
readonly path: string;
readonly responseSchema: z.ZodType<Data>;
readonly authentication: AuthenticationMode;
readonly method?: string;
readonly headers?: HeadersInit;
readonly body?: BodyInit | null;
};
export type ApiClient = {
readonly request: <Data>(options: ApiRequestOptions<Data>) => Promise<Data>;
};
function toApiError(
status: number,
response: {
readonly message: string;
readonly errorProperty: string | null;
},
): ApiError {
if (status === 403) {
return new AccessDeniedError({
status,
message: response.message,
errorProperty: response.errorProperty,
});
}
return new ApiError({
status,
message: response.message,
errorProperty: response.errorProperty,
});
}
export function createApiClient(dependencies: ApiClientDependencies): ApiClient {
let hasHandledAuthenticationExpiry = false;
let activeProtectedRequestCount = 0;
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
const isProtectedRequest = options.authentication === "required";
if (isProtectedRequest) {
activeProtectedRequestCount += 1;
}
try {
const headers = new Headers(options.headers);
headers.set("Accept-Language", "ko");
headers.delete("Authorization");
if (isProtectedRequest) {
const token = dependencies.getToken();
if (token !== null) {
headers.set("Authorization", `Bearer ${token}`);
}
}
const init: RequestInit = { headers };
if (options.method !== undefined) {
init.method = options.method;
}
if (options.body !== undefined) {
init.body = options.body;
}
const response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init);
const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse(
await response.json(),
);
if (!parsedResponse.success) {
throw new ApiError({
status: response.status,
message: "API 응답 형식이 올바르지 않습니다.",
errorProperty: null,
});
}
const apiResponse = parsedResponse.data;
if (response.ok && apiResponse.success) {
return apiResponse.data;
}
if (!apiResponse.success) {
if (response.status === 401 && isProtectedRequest) {
if (!hasHandledAuthenticationExpiry) {
hasHandledAuthenticationExpiry = true;
dependencies.clearSession();
dependencies.onAuthExpired();
}
}
throw toApiError(response.status, apiResponse);
}
throw new ApiError({
status: response.status,
message: "API 오류 응답 형식이 올바르지 않습니다.",
errorProperty: null,
});
} finally {
if (isProtectedRequest) {
activeProtectedRequestCount -= 1;
if (activeProtectedRequestCount === 0) {
hasHandledAuthenticationExpiry = false;
}
}
}
},
};
}

View File

@@ -0,0 +1,24 @@
export type PageData<Item> = {
readonly totalCount: number;
readonly page: number;
readonly size: number;
readonly hasNext: boolean;
readonly items: readonly Item[];
};
export type PageParams = {
readonly page?: number;
readonly size?: number;
};
export function createPageParams(params: PageParams = {}): {
readonly page: number;
readonly size: number;
} {
const size = params.size ?? 20;
return {
page: params.page ?? 0,
size: Math.min(Math.max(size, 20), 50),
};
}

View File

@@ -0,0 +1,22 @@
import { QueryClient } from "@tanstack/react-query";
import { ApiError } from "./api-error";
export function shouldRetryQuery(failureCount: number, error: unknown): boolean {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
return false;
}
return failureCount < 2;
}
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: shouldRetryQuery },
mutations: { retry: false },
},
});
}
export const queryClient = createQueryClient();

34
src/shared/api/types.ts Normal file
View File

@@ -0,0 +1,34 @@
import { z } from "zod";
export type ApiSuccessResponse<Data> = {
readonly success: true;
readonly message: null;
readonly data: Data;
readonly errorProperty?: null;
};
export type ApiErrorResponse = {
readonly success: false;
readonly message: string;
readonly data: null;
readonly errorProperty: string | null;
};
export type ApiResponse<Data> = ApiSuccessResponse<Data> | ApiErrorResponse;
export function createApiResponseSchema<Data>(dataSchema: z.ZodType<Data>) {
return z.discriminatedUnion("success", [
z.object({
success: z.literal(true),
message: z.null(),
data: dataSchema,
errorProperty: z.null().optional(),
}),
z.object({
success: z.literal(false),
message: z.string(),
data: z.null(),
errorProperty: z.string().nullable(),
}),
]);
}

View File

@@ -0,0 +1,12 @@
import { expect, test } from "vitest";
import { formatCanAmount, formatSeoulDateTime } from "@/shared/lib/formatters";
test("formatSeoulDateTime displays UTC input in Asia/Seoul", () => {
expect(formatSeoulDateTime("2026-07-25T15:30:00.000Z")).toBe("2026. 07. 26. 00:30");
});
test("formatCanAmount displays non-negative integer can units without domain status labels", () => {
expect(formatCanAmount(0)).toBe("0캔");
expect(formatCanAmount(12345)).toBe("12,345캔");
});

View File

@@ -0,0 +1,103 @@
import { expect, test, vi } from "vitest";
import { calculateCropOutputSize, calculateCropSourceRect, createCroppedImageFile } from "@/shared/lib/crop-image";
type RenderedCrop = {
readonly bottomAlpha: number;
readonly height: number;
readonly sourceHeight: number;
readonly sourceWidth: number;
readonly sourceX: number;
readonly sourceY: number;
readonly topAlpha: number;
readonly width: number;
};
function restoreDescriptor(property: "getContext" | "toBlob", descriptor: PropertyDescriptor | undefined): void {
if (descriptor === undefined) {
Reflect.deleteProperty(HTMLCanvasElement.prototype, property);
return;
}
Object.defineProperty(HTMLCanvasElement.prototype, property, descriptor);
}
test("calculateCropOutputSize caps width at maxWidth and keeps aspect height", () => {
expect(calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200 })).toEqual({ height: 600, width: 600 });
expect(calculateCropOutputSize({ aspect: 210 / 297, maxWidth: 1000, noUpscale: true, sourceHeight: 1600, sourceWidth: 1200 })).toEqual({ height: 1414, width: 1000 });
});
test("calculateCropOutputSize never upscales when noUpscale is true", () => {
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: true, sourceHeight: 800, sourceWidth: 600 })).toEqual({ height: 300, width: 600 });
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: false, sourceHeight: 800, sourceWidth: 600 })).toEqual({ height: 400, width: 800 });
});
test("calculateCropSourceRect crops the largest centered source rectangle for the requested aspect", () => {
expect(calculateCropSourceRect({ aspect: 1, offsetX: 0, offsetY: 0, sourceHeight: 600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 });
expect(calculateCropSourceRect({ aspect: 1, offsetX: 10, offsetY: -20, sourceHeight: 600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 600, sourceX: 290, sourceY: 0, width: 600 });
});
test("createCroppedImageFile keeps a wide no-upscale crop opaque at the top and bottom", async () => {
let renderedCrop: RenderedCrop | null = null;
const imageFile = new File(["wide"], "wide.png", { type: "image/png" });
const outputSize = calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200 });
const originalGetContext = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "getContext");
const originalToBlob = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "toBlob");
try {
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
configurable: true,
value: () => ({
drawImage: (...args: readonly unknown[]) => {
const [sourceImage, sourceX, sourceY, sourceWidth, sourceHeight, targetX, targetY, targetWidth, targetHeight] = args;
if (!(sourceImage instanceof EventTarget) || typeof sourceX !== "number" || typeof sourceY !== "number" || typeof sourceWidth !== "number" || typeof sourceHeight !== "number" || typeof targetX !== "number" || typeof targetY !== "number" || typeof targetWidth !== "number" || typeof targetHeight !== "number") {
throw new Error("Unexpected crop render call");
}
renderedCrop = {
bottomAlpha: sourceY + sourceHeight <= 600 && targetY + targetHeight <= outputSize.height ? 255 : 0,
height: targetHeight,
sourceHeight,
sourceWidth,
sourceX,
sourceY,
topAlpha: sourceY >= 0 && targetY === 0 ? 255 : 0,
width: targetWidth,
};
},
}),
});
Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", {
configurable: true,
value: (callback: BlobCallback) => {
callback(new Blob([JSON.stringify(renderedCrop)], { type: imageFile.type }));
},
});
vi.stubGlobal(
"Image",
class FakeImage extends EventTarget {
set src(_value: string) {
queueMicrotask(() => this.dispatchEvent(new Event("load")));
}
},
);
const croppedFile = await createCroppedImageFile({
aspect: 1,
file: imageFile,
offsetX: 0,
offsetY: 0,
outputHeight: outputSize.height,
outputWidth: outputSize.width,
previewUrl: "blob:wide",
sourceHeight: 600,
sourceWidth: 1200,
zoom: 1,
});
await expect(croppedFile.text()).resolves.toBe(JSON.stringify({ bottomAlpha: 255, height: 600, sourceHeight: 600, sourceWidth: 600, sourceX: 300, sourceY: 0, topAlpha: 255, width: 600 }));
} finally {
restoreDescriptor("getContext", originalGetContext);
restoreDescriptor("toBlob", originalToBlob);
}
});

View File

@@ -0,0 +1,101 @@
export type CropOutputSizeRequest = {
readonly aspect: number | "free";
readonly maxWidth: number;
readonly noUpscale: boolean;
readonly sourceHeight: number;
readonly sourceWidth: number;
};
export type CropOutputSize = {
readonly height: number;
readonly width: number;
};
export type CropRenderRequest = {
readonly aspect: number | "free";
readonly file: File;
readonly offsetX: number;
readonly offsetY: number;
readonly outputHeight: number;
readonly outputWidth: number;
readonly previewUrl: string;
readonly sourceHeight: number;
readonly sourceWidth: number;
readonly zoom: number;
};
export type CropSourceRectRequest = {
readonly aspect: number | "free";
readonly offsetX: number;
readonly offsetY: number;
readonly sourceHeight: number;
readonly sourceWidth: number;
readonly zoom: number;
};
export type CropSourceRect = {
readonly height: number;
readonly sourceX: number;
readonly sourceY: number;
readonly width: number;
};
function getAspect(aspect: number | "free", sourceWidth: number, sourceHeight: number): number {
return aspect === "free" ? sourceWidth / sourceHeight : aspect;
}
export function calculateCropSourceRect({ aspect, offsetX, offsetY, sourceHeight, sourceWidth, zoom }: CropSourceRectRequest): CropSourceRect {
const cropAspect = getAspect(aspect, sourceWidth, sourceHeight);
const sourceAspect = sourceWidth / sourceHeight;
const baseWidth = sourceAspect > cropAspect ? Math.round(sourceHeight * cropAspect) : sourceWidth;
const baseHeight = sourceAspect > cropAspect ? sourceHeight : Math.round(sourceWidth / cropAspect);
const width = Math.round(baseWidth / zoom);
const height = Math.round(baseHeight / zoom);
const maxSourceX = sourceWidth - width;
const maxSourceY = sourceHeight - height;
const centeredX = Math.round((sourceWidth - width) / 2 - offsetX / zoom);
const centeredY = Math.round((sourceHeight - height) / 2 - offsetY / zoom);
return {
height,
sourceX: Math.min(Math.max(centeredX, 0), maxSourceX),
sourceY: Math.min(Math.max(centeredY, 0), maxSourceY),
width,
};
}
export function calculateCropOutputSize({ aspect, maxWidth, noUpscale, sourceHeight, sourceWidth }: CropOutputSizeRequest): CropOutputSize {
const cropAspect = getAspect(aspect, sourceWidth, sourceHeight);
const cropRect = calculateCropSourceRect({ aspect, offsetX: 0, offsetY: 0, sourceHeight, sourceWidth, zoom: 1 });
const width = noUpscale ? Math.min(maxWidth, cropRect.width) : maxWidth;
return { height: Math.round(width / cropAspect), width };
}
export function createCroppedImageFile(request: CropRenderRequest): Promise<File> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => {
const canvas = document.createElement("canvas");
canvas.width = request.outputWidth;
canvas.height = request.outputHeight;
const context = canvas.getContext("2d");
if (context === null) {
reject(new Error("Canvas context unavailable"));
return;
}
const cropRect = calculateCropSourceRect(request);
context.drawImage(image, cropRect.sourceX, cropRect.sourceY, cropRect.width, cropRect.height, 0, 0, request.outputWidth, request.outputHeight);
canvas.toBlob((blob) => {
if (blob === null) {
reject(new Error("Canvas result unavailable"));
return;
}
resolve(new File([blob], request.file.name, { type: request.file.type }));
}, request.file.type);
});
image.addEventListener("error", () => reject(new Error("Image preview unavailable")));
image.src = request.previewUrl;
});
}

View File

@@ -0,0 +1,23 @@
const seoulDateTimeFormatter = new Intl.DateTimeFormat("ko-KR", {
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
minute: "2-digit",
month: "2-digit",
timeZone: "Asia/Seoul",
year: "numeric",
});
const canAmountFormatter = new Intl.NumberFormat("ko-KR", {
maximumFractionDigits: 0,
});
export function formatSeoulDateTime(utcDateTime: string | Date): string {
const parts = Object.fromEntries(seoulDateTimeFormatter.formatToParts(new Date(utcDateTime)).map((part) => [part.type, part.value]));
return `${parts.year}. ${parts.month}. ${parts.day}. ${parts.hour}:${parts.minute}`;
}
export function formatCanAmount(amount: number): string {
return `${canAmountFormatter.format(Math.max(0, Math.trunc(amount)))}`;
}

View File

@@ -0,0 +1,3 @@
import { setupServer } from "msw/node";
export const server = setupServer();

View File

@@ -0,0 +1,11 @@
import { expect, test, vi } from "vitest";
test("test setup can stub a global in one test", () => {
vi.stubGlobal("indexedDB", { open: vi.fn() });
expect(indexedDB.open).toBeDefined();
});
test("test setup restores stubbed globals before the next test", () => {
expect("indexedDB" in globalThis).toBe(false);
});

View File

@@ -1,10 +1,23 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { server } from "./server";
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
vi.unstubAllGlobals();
sessionStorage.clear();
localStorage.clear();
server.resetHandlers();
});
afterAll(() => {
server.close();
});

View File

@@ -0,0 +1,117 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
import { AudioPlaybackProvider } from "@/shared/ui/audio-playback-provider";
let playSpy: ReturnType<typeof vi.spyOn>;
let pauseSpy: ReturnType<typeof vi.spyOn>;
let loadSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
playSpy = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
pauseSpy = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
loadSpy = vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => undefined);
});
afterEach(() => {
playSpy.mockRestore();
pauseSpy.mockRestore();
loadSpy.mockRestore();
});
test("AdminAudioPlayer wraps native audio with controls and no download or autoplay", () => {
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
const audio = document.querySelector("audio");
expect(audio).toHaveAttribute("src", "https://cdn.example.com/signed/audio.m4a?token=secret");
expect(audio).not.toHaveAttribute("autoplay");
expect(audio).toHaveAttribute("controlsList", "nodownload");
expect(screen.getByRole("button", { name: "재생" })).toBeInTheDocument();
expect(screen.getByRole("slider", { name: "재생 위치" })).toBeInTheDocument();
expect(screen.getByRole("slider", { name: "볼륨" })).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "재생 속도" })).toBeInTheDocument();
});
test("AudioPlaybackProvider keeps only one player active by player id", () => {
render(
<AudioPlaybackProvider>
<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/one.m4a?token=secret-one" title="첫 번째" />
<AdminAudioPlayer playerId="two" src="https://cdn.example.com/signed/two.m4a?token=secret-two" title="두 번째" />
</AudioPlaybackProvider>,
);
const playButtons = screen.getAllByRole("button", { name: "재생" });
const firstPlayButton = playButtons[0];
const secondPlayButton = playButtons[1];
if (firstPlayButton === undefined || secondPlayButton === undefined) {
throw new Error("expected two play buttons");
}
fireEvent.click(firstPlayButton);
fireEvent.click(secondPlayButton);
expect(playSpy).toHaveBeenCalledTimes(2);
expect(pauseSpy).toHaveBeenCalled();
});
test("AdminAudioPlayer supports keyboard play, seek, volume, speed, generic error, and manual retry only", () => {
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
const player = screen.getByRole("group", { name: "샘플 오디오 오디오 플레이어" });
fireEvent.keyDown(player, { key: " " });
fireEvent.change(screen.getByRole("slider", { name: "재생 위치" }), { target: { value: "12" } });
fireEvent.change(screen.getByRole("slider", { name: "볼륨" }), { target: { value: "0.5" } });
fireEvent.change(screen.getByRole("combobox", { name: "재생 속도" }), { target: { value: "1.5" } });
const audio = document.querySelector("audio");
if (!(audio instanceof HTMLAudioElement)) {
throw new Error("expected native audio element");
}
fireEvent.error(audio);
expect(playSpy).toHaveBeenCalledTimes(1);
expect(screen.getByRole("alert")).toHaveTextContent("오디오를 재생할 수 없습니다");
fireEvent.click(screen.getByRole("button", { name: "오디오 다시 시도" }));
expect(loadSpy).toHaveBeenCalledTimes(1);
expect(playSpy).toHaveBeenCalledTimes(1);
});
test("AdminAudioPlayer ignores Enter and Space from descendant controls", () => {
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
fireEvent.keyDown(screen.getByRole("combobox", { name: "재생 속도" }), { key: "Enter" });
fireEvent.keyDown(screen.getByRole("button", { name: "재생" }), { key: " " });
expect(playSpy).not.toHaveBeenCalled();
});
test("AudioPlaybackProvider and AdminAudioPlayer do not log or persist signed URLs", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const localStorageSpy = vi.spyOn(Storage.prototype, "setItem");
const signedUrl = "https://cdn.example.com/signed/audio.m4a?token=secret";
render(
<AudioPlaybackProvider>
<AdminAudioPlayer playerId="one" src={signedUrl} title="샘플 오디오" />
</AudioPlaybackProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "재생" }));
const audio = document.querySelector("audio");
if (!(audio instanceof HTMLAudioElement)) {
throw new Error("expected native audio element");
}
fireEvent.error(audio);
expect(logSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
expect(localStorageSpy).not.toHaveBeenCalledWith(expect.any(String), expect.stringContaining(signedUrl));
logSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
localStorageSpy.mockRestore();
});

View File

@@ -0,0 +1,60 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { expect, test, vi } from "vitest";
import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
test("ConfirmDeactivateDialog confirms deactivation with target and impact copy, not a switch", () => {
const onCancel = vi.fn();
const onConfirm = vi.fn();
render(
<ConfirmDeactivateDialog
impactDescription="사용자는 이 캐릭터를 더 이상 선택할 수 없습니다."
onCancel={onCancel}
onConfirm={onConfirm}
open
targetName="루나"
/>,
);
expect(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" })).toHaveTextContent("사용자는 이 캐릭터를 더 이상 선택할 수 없습니다.");
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "취소" }));
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
test("ConfirmDeactivateDialog traps focus and returns it to the trigger after cancel", async () => {
function Harness() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)} type="button">
</button>
<ConfirmDeactivateDialog impactDescription="사용자는 이 캐릭터를 더 이상 선택할 수 없습니다." onCancel={() => setOpen(false)} onConfirm={() => setOpen(false)} open={open} targetName="루나" />
</>
);
}
render(<Harness />);
const trigger = screen.getByRole("button", { name: "비활성화 열기" });
trigger.focus();
fireEvent.click(trigger);
const cancel = screen.getByRole("button", { name: "취소" });
const confirm = screen.getByRole("button", { name: "비활성화" });
await waitFor(() => expect(cancel).toHaveFocus());
fireEvent.keyDown(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" }), { key: "Tab", shiftKey: true });
expect(confirm).toHaveFocus();
fireEvent.keyDown(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" }), { key: "Tab" });
expect(cancel).toHaveFocus();
fireEvent.click(cancel);
await waitFor(() => expect(trigger).toHaveFocus());
});

View File

@@ -0,0 +1,41 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { FileField } from "@/shared/ui/file-field";
test("FileField exposes label, description, error, accept guidance, keyboard file input, and controlled value", () => {
const onChange = vi.fn();
const value = new File(["image"], "profile.png", { type: "image/png" });
render(
<FileField
accept="image/png"
acceptDescription="PNG만 업로드할 수 있습니다."
description="프로필 이미지를 선택하세요."
error="파일이 너무 큽니다."
label="대표 이미지"
onChange={onChange}
value={value}
/>,
);
const input = screen.getByLabelText("대표 이미지");
expect(input).toHaveAttribute("accept", "image/png");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다.");
expect(screen.getByText("profile.png")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "선택 취소" })).toBeInTheDocument();
});
test("FileField emits File or null and clear selection without owning upload policy", () => {
const onChange = vi.fn();
const selected = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={null} />);
fireEvent.change(screen.getByLabelText("오디오"), { target: { files: [selected] } });
expect(onChange).toHaveBeenCalledWith(selected);
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={selected} />);
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
expect(onChange).toHaveBeenCalledWith(null);
});

View File

@@ -0,0 +1,28 @@
import { readFile } from "node:fs/promises";
import { expect, test } from "vitest";
const sharedFileMediaFiles = [
"src/shared/validation/file-validation.ts",
"src/shared/validation/audio-file-policy.ts",
"src/shared/validation/image-policy.ts",
"src/shared/lib/crop-image.ts",
"src/shared/ui/file-field.tsx",
"src/shared/ui/image-crop-dialog.tsx",
"src/shared/ui/upload-progress.tsx",
"src/shared/ui/admin-audio-player.tsx",
"src/shared/ui/audio-playback-provider.tsx",
"src/shared/ui/audio-playback-context.ts",
"src/shared/ui/use-audio-playback.ts",
] as const;
test("shared file media primitives do not import endpoints, query cache, or domain DTOs", async () => {
const contents = await Promise.all(sharedFileMediaFiles.map((filePath) => readFile(filePath, "utf8")));
for (const content of contents) {
expect(content).not.toMatch(/@\/features\//);
expect(content).not.toMatch(/@tanstack\/react-query/);
expect(content).not.toMatch(/@\/shared\/api/);
expect(content).not.toMatch(/endpoint|DTO/iu);
}
});

View File

@@ -0,0 +1,21 @@
import { render, screen } from "@testing-library/react";
import { IconOnlyAction } from "@/shared/ui/icon-only-action";
describe("IconOnlyAction", () => {
test("has an accessible name and a tooltip without duplicating the name as description", () => {
render(
<IconOnlyAction label="새로고침">
<span aria-hidden="true">R</span>
</IconOnlyAction>,
);
const button = screen.getByRole("button", { name: "새로고침" });
const tooltip = screen.getByRole("tooltip");
expect(tooltip).toHaveTextContent("새로고침");
expect(button).not.toHaveAccessibleDescription("새로고침");
expect(button).not.toHaveAttribute("aria-describedby");
expect(button).toHaveAttribute("type", "button");
});
});

View File

@@ -0,0 +1,68 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
const image = {
file: new File(["image"], "profile.png", { type: "image/png" }),
height: 600,
previewUrl: "blob:profile",
width: 600,
};
test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply controls", async () => {
const onApply = vi.fn();
const onCancel = vi.fn();
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={image} onApply={onApply} onCancel={onCancel} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
expect(screen.getByText("예상 결과 600 × 600px")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "초기화" }));
fireEvent.click(screen.getByRole("button", { name: "적용" }));
await screen.findByText("예상 결과 600 × 600px");
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 0, offsetY: 0, outputHeight: 600, outputWidth: 600, zoom: 1 }));
expect(onApply).toHaveBeenCalledWith(expect.any(File));
fireEvent.click(screen.getByRole("button", { name: "취소" }));
expect(onCancel).toHaveBeenCalled();
});
test("ImageCropDialog supports keyboard movement and no-upscale sizing", async () => {
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 2, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
fireEvent.keyDown(preview, { key: "ArrowRight" });
fireEvent.keyDown(preview, { key: "+" });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
expect(await screen.findByText("예상 결과 600 × 300px")).toBeInTheDocument();
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 300, outputWidth: 600, zoom: 1.1 }));
});
test("ImageCropDialog supports free ratio output and pointer drag movement", async () => {
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX},${request.offsetY}`], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={{ ...image, height: 600, width: 1200 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: "free", maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 });
fireEvent.pointerMove(preview, { clientX: 130, clientY: 115, pointerId: 1 });
fireEvent.pointerUp(preview, { pointerId: 1 });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
expect(await screen.findByText("예상 결과 800 × 400px")).toBeInTheDocument();
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 30, offsetY: 15, outputHeight: 400, outputWidth: 800 }));
});
test("ImageCropDialog renders nothing when closed", () => {
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open={false} policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { PageState } from "@/shared/ui/page-state";
test("PageState exposes accessible loading, empty, error, retry, and content states", () => {
const onRetry = vi.fn();
const { rerender } = render(<PageState description="자료를 불러오는 중입니다." state="loading" title="불러오는 중" />);
expect(screen.getByRole("status")).toHaveTextContent("불러오는 중");
rerender(<PageState description="조건에 맞는 자료가 없습니다." state="empty" title="자료 없음" />);
expect(screen.getByRole("status")).toHaveTextContent("자료 없음");
rerender(<PageState description="잠시 후 다시 시도하세요." onRetry={onRetry} state="error" title="불러오지 못했습니다" />);
expect(screen.getByRole("alert")).toHaveTextContent("불러오지 못했습니다");
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
expect(onRetry).toHaveBeenCalledTimes(1);
rerender(
<PageState state="content">
<p> </p>
</PageState>,
);
expect(screen.getByText("공유 콘텐츠")).toBeInTheDocument();
});

View File

@@ -0,0 +1,42 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import type { PageData } from "@/shared/api/pagination";
import { ResourcePagination } from "@/shared/ui/resource-pagination";
const pageData: PageData<string> = {
totalCount: 42,
page: 1,
size: 20,
hasNext: true,
items: [],
};
test("ResourcePagination uses PageData and real buttons for accessible page movement", () => {
const onPageChange = vi.fn();
const onSizeChange = vi.fn();
render(<ResourcePagination data={pageData} onPageChange={onPageChange} onSizeChange={onSizeChange} />);
const previous = screen.getByRole("button", { name: "이전 페이지" });
const next = screen.getByRole("button", { name: "다음 페이지" });
expect(previous.tagName).toBe("BUTTON");
expect(next).toBeEnabled();
expect(screen.getByText("총 42개 · 2페이지")).toBeInTheDocument();
fireEvent.click(previous);
fireEvent.click(next);
fireEvent.change(screen.getByLabelText("페이지 크기"), { target: { value: "50" } });
expect(onPageChange).toHaveBeenNthCalledWith(1, 0);
expect(onPageChange).toHaveBeenNthCalledWith(2, 2);
expect(onSizeChange).toHaveBeenCalledWith(50);
});
test("ResourcePagination disables unavailable previous and next actions", () => {
render(<ResourcePagination data={{ ...pageData, page: 0, hasNext: false }} onPageChange={vi.fn()} onSizeChange={vi.fn()} />);
expect(screen.getByRole("button", { name: "이전 페이지" })).toBeDisabled();
expect(screen.getByRole("button", { name: "다음 페이지" })).toBeDisabled();
});

View File

@@ -0,0 +1,18 @@
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { ResponsiveResourceList } from "@/shared/ui/responsive-resource-list";
test("ResponsiveResourceList renders only desktop and mobile slots without domain props", () => {
render(
<ResponsiveResourceList
ariaLabel="공유 자료 목록"
desktop={<table><tbody><tr><td> </td></tr></tbody></table>}
mobile={<ul><li> </li></ul>}
/>,
);
expect(screen.getByRole("region", { name: "공유 자료 목록" })).toBeInTheDocument();
expect(screen.getByText("데스크톱 슬롯")).toBeInTheDocument();
expect(screen.getByText("모바일 슬롯")).toBeInTheDocument();
});

View File

@@ -0,0 +1,42 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { SearchToolbar } from "@/shared/ui/search-toolbar";
afterEach(() => {
vi.useRealTimers();
});
test("SearchToolbar keeps search controlled, renders filters, and emits only a debounced generic query", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
const onSearchChange = vi.fn();
const { rerender } = render(
<SearchToolbar
filters={<select aria-label="상태 필터"><option></option></select>}
onQueryChange={onQueryChange}
onSearchChange={onSearchChange}
search=""
/>,
);
fireEvent.change(screen.getByRole("searchbox", { name: "검색어" }), { target: { value: "루나" } });
expect(onSearchChange).toHaveBeenCalledWith("루나");
expect(screen.getByLabelText("상태 필터")).toBeInTheDocument();
expect(onQueryChange).not.toHaveBeenCalled();
rerender(
<SearchToolbar
filters={<select aria-label="상태 필터"><option></option></select>}
onQueryChange={onQueryChange}
onSearchChange={onSearchChange}
search="루나"
/>,
);
act(() => vi.advanceTimersByTime(299));
expect(onQueryChange).not.toHaveBeenCalled();
act(() => vi.advanceTimersByTime(1));
expect(onQueryChange).toHaveBeenCalledWith("루나");
});

View File

@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import { StatusBadge } from "@/shared/ui/status-badge";
describe("StatusBadge", () => {
test("renders visible Korean text labels for every state", () => {
render(
<>
<StatusBadge status="OPEN" />
<StatusBadge status="SCHEDULED" />
<StatusBadge status="INACTIVE" />
</>,
);
expect(screen.getByLabelText("상태: 공개")).toHaveTextContent("공개");
expect(screen.getByLabelText("상태: 예약")).toHaveTextContent("예약");
expect(screen.getByLabelText("상태: 비활성")).toHaveTextContent("비활성");
});
test("renders a domain label with optional icon and description without relying on color meaning", () => {
render(
<StatusBadge
description={<span> 18:00 </span>}
icon={<span aria-hidden="true">S</span>}
label="검수 대기"
tone="warning"
/>,
);
const badge = screen.getByLabelText("상태: 검수 대기, 오늘 18:00 자동 전환");
expect(badge).toHaveTextContent("검수 대기");
expect(badge).toHaveTextContent("오늘 18:00 자동 전환");
});
});

View File

@@ -0,0 +1,48 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
test("UnsavedChangesGuard blocks only dirty route leave and returns focus to the trigger on cancel", async () => {
const onLeave = vi.fn();
const { rerender } = render(
<UnsavedChangesGuard dirty impactDescription="저장하지 않은 변경사항이 사라집니다." title="이 화면을 떠나시겠습니까?">
{(requestRouteLeave) => (
<button onClick={(event) => requestRouteLeave(event.currentTarget, onLeave)} type="button">
</button>
)}
</UnsavedChangesGuard>,
);
const trigger = screen.getByRole("button", { name: "목록으로 이동" });
fireEvent.click(trigger);
expect(onLeave).not.toHaveBeenCalled();
const dialog = screen.getByRole("alertdialog", { name: "이 화면을 떠나시겠습니까?" });
expect(dialog).toBeInTheDocument();
const cancel = screen.getByRole("button", { name: "계속 편집" });
const leave = screen.getByRole("button", { name: "떠나기" });
await waitFor(() => expect(cancel).toHaveFocus());
fireEvent.keyDown(dialog, { key: "Tab", shiftKey: true });
expect(leave).toHaveFocus();
fireEvent.keyDown(dialog, { key: "Tab" });
expect(cancel).toHaveFocus();
fireEvent.click(cancel);
expect(trigger).toHaveFocus();
rerender(
<UnsavedChangesGuard dirty={false} impactDescription="저장하지 않은 변경사항이 사라집니다." title="이 화면을 떠나시겠습니까?">
{(requestRouteLeave) => (
<button onClick={(event) => requestRouteLeave(event.currentTarget, onLeave)} type="button">
</button>
)}
</UnsavedChangesGuard>,
);
fireEvent.click(screen.getByRole("button", { name: "목록으로 이동" }));
expect(onLeave).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,25 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { UploadProgress } from "@/shared/ui/upload-progress";
test("UploadProgress displays status and progress without owning an upload client", () => {
render(<UploadProgress fileName="voice.mp3" progress={45} status="uploading" />);
expect(screen.getByRole("progressbar", { name: "업로드 진행률" })).toHaveAttribute("aria-valuenow", "45");
expect(screen.getByText("voice.mp3")).toBeInTheDocument();
expect(screen.getByText("업로드 중")).toBeInTheDocument();
});
test("UploadProgress exposes cancel and retry callbacks only", () => {
const onCancel = vi.fn();
const onRetry = vi.fn();
render(<UploadProgress onCancel={onCancel} onRetry={onRetry} progress={0} status="error" />);
fireEvent.click(screen.getByRole("button", { name: "업로드 취소" }));
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,141 @@
import { useRef, useState } from "react";
import { useAudioPlayback } from "./use-audio-playback";
export type AdminAudioPlayerProps = {
readonly playerId: string;
readonly src: string;
readonly title: string;
};
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) {
return "0:00";
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60).toString().padStart(2, "0");
return `${minutes}:${remainingSeconds}`;
}
export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [hasError, setHasError] = useState(false);
const { clearPlayer, requestPlay } = useAudioPlayback(playerId, audioRef);
function play() {
const audio = audioRef.current;
if (audio === null) {
return;
}
setHasError(false);
requestPlay();
void audio.play().catch(() => setHasError(true));
}
function pause() {
audioRef.current?.pause();
clearPlayer();
}
function togglePlay() {
if (isPlaying) {
pause();
return;
}
play();
}
function retry() {
setHasError(false);
audioRef.current?.load();
}
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
if (event.currentTarget !== event.target) {
return;
}
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
togglePlay();
}
}
function changeCurrentTime(nextTime: number) {
const audio = audioRef.current;
setCurrentTime(nextTime);
if (audio !== null) {
audio.currentTime = nextTime;
}
}
function changeVolume(nextVolume: number) {
if (audioRef.current !== null) {
audioRef.current.volume = nextVolume;
}
}
function changePlaybackRate(nextPlaybackRate: number) {
if (audioRef.current !== null) {
audioRef.current.playbackRate = nextPlaybackRate;
}
}
return (
<section aria-label={`${title} 오디오 플레이어`} className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
<audio
controlsList="nodownload"
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
onEnded={() => {
setIsPlaying(false);
clearPlayer();
}}
onError={() => {
setHasError(true);
setIsPlaying(false);
}}
onPause={() => setIsPlaying(false)}
onPlay={() => setIsPlaying(true)}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
preload="metadata"
ref={audioRef}
src={src}
/>
<div className="flex flex-wrap items-center gap-2">
<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={togglePlay} type="button">
{isPlaying ? "일시정지" : "재생"}
</button>
<span className="text-sm text-muted-foreground">{formatTime(currentTime)} / {formatTime(duration)}</span>
</div>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-label="재생 위치" max={duration || 0} min="0" onChange={(event) => changeCurrentTime(Number(event.currentTarget.value))} step="1" type="range" value={currentTime} />
</label>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-label="볼륨" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
</label>
<label className="flex flex-col gap-2 text-sm font-semibold">
<select aria-label="재생 속도" className="rounded-md border border-input bg-card px-3 py-2 text-base" onChange={(event) => changePlaybackRate(Number(event.currentTarget.value))} defaultValue="1">
<option value="0.75">0.75×</option>
<option value="1">1×</option>
<option value="1.25">1.25×</option>
<option value="1.5">1.5×</option>
<option value="2">2×</option>
</select>
</label>
{hasError ? (
<div className="flex flex-col gap-2 rounded-md border border-destructive bg-card p-3 text-sm text-destructive" role="alert">
<p> . .</p>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={retry} type="button"> </button>
</div>
) : null}
</section>
);
}

View File

@@ -0,0 +1,9 @@
import { createContext } from "react";
export type AudioPlaybackContextValue = {
readonly activePlayerId: string | null;
readonly clearPlayer: (playerId: string) => void;
readonly requestPlay: (playerId: string) => void;
};
export const AudioPlaybackContext = createContext<AudioPlaybackContextValue | null>(null);

View File

@@ -0,0 +1,22 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { AudioPlaybackContext } from "./audio-playback-context";
export type AudioPlaybackProviderProps = {
readonly children: ReactNode;
};
export function AudioPlaybackProvider({ children }: AudioPlaybackProviderProps) {
const [activePlayerId, setActivePlayerId] = useState<string | null>(null);
function clearPlayer(playerId: string) {
setActivePlayerId((current) => (current === playerId ? null : current));
}
return (
<AudioPlaybackContext value={{ activePlayerId, clearPlayer, requestPlay: setActivePlayerId }}>
{children}
</AudioPlaybackContext>
);
}

View File

@@ -0,0 +1,38 @@
import { useModalFocus } from "@/shared/ui/use-modal-focus";
export type ConfirmDeactivateDialogProps = {
readonly impactDescription: string;
readonly onCancel: () => void;
readonly onConfirm: () => void;
readonly open: boolean;
readonly targetName: string;
};
export function ConfirmDeactivateDialog({ impactDescription, onCancel, onConfirm, open, targetName }: ConfirmDeactivateDialogProps) {
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
if (!open) {
return null;
}
const title = `${targetName} 비활성화 확인`;
return (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="alertdialog" aria-label={title}>
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{impactDescription}</p>
</div>
<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={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={onConfirm} type="button">
</button>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { useId, useRef } from "react";
export type FileFieldProps = {
readonly accept: string;
readonly acceptDescription: string;
readonly description?: string;
readonly error?: string;
readonly label: string;
readonly onChange: (file: File | null) => void;
readonly value: File | null;
};
export function FileField({ accept, acceptDescription, description, error, label, onChange, value }: FileFieldProps) {
const inputId = useId();
const descriptionId = useId();
const acceptId = useId();
const errorId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const describedBy = [description === undefined ? null : descriptionId, acceptId, error === undefined ? null : errorId].filter((id): id is string => id !== null).join(" ");
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.currentTarget.files;
onChange(files === null ? null : files[0] ?? null);
}
function clearSelection() {
if (inputRef.current !== null) {
inputRef.current.value = "";
}
onChange(null);
}
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4">
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
{description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
<p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{value === null ? "선택된 파일 없음" : value.name}</span>
{value === null ? null : (
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button"> </button>
)}
</div>
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { useId } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
export type IconOnlyActionProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "children"> & {
readonly children: ReactNode;
readonly label: string;
};
export function IconOnlyAction({ children, className, label, type = "button", ...buttonProps }: IconOnlyActionProps) {
const tooltipId = useId();
const classes = ["icon-only-action", className].filter(Boolean).join(" ");
return (
<span className="icon-only-action-wrap">
<button {...buttonProps} aria-label={label} className={classes} type={type}>
{children}
<span className="sr-only">{label}</span>
</button>
<span className="icon-only-action-tooltip" id={tooltipId} role="tooltip">
{label}
</span>
</span>
);
}

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>
);
}

View File

@@ -0,0 +1,66 @@
import type { ReactNode } from "react";
type LoadingPageStateProps = {
readonly description?: string;
readonly state: "loading";
readonly title: string;
};
type EmptyPageStateProps = {
readonly description?: string;
readonly state: "empty";
readonly title: string;
};
type ErrorPageStateProps = {
readonly description?: string;
readonly onRetry?: () => void;
readonly state: "error";
readonly title: string;
};
type ContentPageStateProps = {
readonly children: ReactNode;
readonly state: "content";
};
export type PageStateProps = LoadingPageStateProps | EmptyPageStateProps | ErrorPageStateProps | ContentPageStateProps;
function assertNever(value: never): never {
throw new Error(`Unhandled page state: ${String(value)}`);
}
export function PageState(props: PageStateProps) {
switch (props.state) {
case "content":
return <>{props.children}</>;
case "loading":
return (
<section className="rounded-lg border border-border bg-card p-6" role="status">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
</section>
);
case "empty":
return (
<section className="rounded-lg border border-border bg-card p-6" role="status">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
</section>
);
case "error":
return (
<section className="rounded-lg border border-destructive bg-card p-6 text-destructive" role="alert">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm">{props.description}</p>}
{props.onRetry === undefined ? null : (
<button className="mt-4 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={props.onRetry} type="button">
</button>
)}
</section>
);
default:
return assertNever(props);
}
}

View File

@@ -0,0 +1,32 @@
import type { PageData } from "@/shared/api/pagination";
export type ResourcePaginationProps = {
readonly data: PageData<unknown>;
readonly onPageChange: (page: number) => void;
readonly onSizeChange: (size: number) => void;
readonly sizeOptions?: readonly number[];
};
export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptions = [20, 50] }: ResourcePaginationProps) {
return (
<nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm font-semibold text-muted-foreground"> {data.totalCount.toLocaleString("ko-KR")} · {data.page + 1}</p>
<div className="flex flex-wrap items-center gap-2">
<label className="text-sm font-semibold" htmlFor="resource-page-size">
</label>
<select className="rounded-md border border-input bg-card px-3 py-2 text-base" id="resource-page-size" onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
{sizeOptions.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button">
</button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
</button>
</div>
</nav>
);
}

View File

@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
export type ResponsiveResourceListProps = {
readonly ariaLabel: string;
readonly desktop: ReactNode;
readonly mobile: ReactNode;
};
export function ResponsiveResourceList({ ariaLabel, desktop, mobile }: ResponsiveResourceListProps) {
return (
<section aria-label={ariaLabel} className="rounded-lg border border-border bg-card" role="region">
<div className="hidden overflow-x-auto md:block">{desktop}</div>
<div className="md:hidden">{mobile}</div>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useId, useRef } from "react";
import type { ReactNode } from "react";
export type SearchToolbarProps = {
readonly filters?: ReactNode;
readonly onQueryChange: (query: string) => void;
readonly onSearchChange: (search: string) => void;
readonly search: string;
};
export function SearchToolbar({ filters, onQueryChange, onSearchChange, search }: SearchToolbarProps) {
const searchId = useId();
const didMountRef = useRef(false);
useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
return undefined;
}
const timeoutId = window.setTimeout(() => onQueryChange(search), 300);
return () => window.clearTimeout(timeoutId);
}, [onQueryChange, search]);
return (
<section aria-label="검색 도구" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-end sm:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<label className="text-sm font-semibold" htmlFor={searchId}>
</label>
<input className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground" id={searchId} onChange={(event) => onSearchChange(event.currentTarget.value)} type="search" value={search} />
</div>
{filters === undefined ? null : <div className="flex flex-col gap-2 sm:min-w-48">{filters}</div>}
</section>
);
}

View File

@@ -0,0 +1,91 @@
import { isValidElement } from "react";
import type { ReactNode } from "react";
const STATUS_BADGE = {
INACTIVE: {
className: "status-badge status-badge--inactive",
label: "비활성",
tone: "inactive",
},
OPEN: {
className: "status-badge status-badge--success",
label: "공개",
tone: "success",
},
SCHEDULED: {
className: "status-badge status-badge--warning",
label: "예약",
tone: "warning",
},
} as const;
export type StatusBadgeStatus = keyof typeof STATUS_BADGE;
export type StatusBadgeTone = "inactive" | "success" | "warning";
type PresetStatusBadgeProps = {
readonly status: StatusBadgeStatus;
readonly description?: never;
readonly icon?: never;
readonly label?: never;
readonly tone?: never;
};
type DomainStatusBadgeProps = {
readonly description?: ReactNode;
readonly icon?: ReactNode;
readonly label: string;
readonly status?: never;
readonly tone: StatusBadgeTone;
};
export type StatusBadgeProps = PresetStatusBadgeProps | DomainStatusBadgeProps;
function getNodeText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(getNodeText).join("");
}
if (isValidElement<{ readonly children?: ReactNode }>(node)) {
return getNodeText(node.props.children);
}
return "";
}
function getBadge(props: StatusBadgeProps): {
readonly className: string;
readonly description: ReactNode;
readonly label: string;
readonly icon: ReactNode;
} {
if (props.status !== undefined) {
const badge = STATUS_BADGE[props.status];
return { className: badge.className, description: null, icon: null, label: badge.label };
}
return {
className: `status-badge status-badge--${props.tone}`,
description: props.description ?? null,
icon: props.icon ?? null,
label: props.label,
};
}
export function StatusBadge(props: StatusBadgeProps) {
const badge = getBadge(props);
const descriptionText = getNodeText(badge.description);
const ariaLabel = descriptionText.length > 0 ? `상태: ${badge.label}, ${descriptionText}` : `상태: ${badge.label}`;
return (
<span aria-label={ariaLabel} className={badge.className}>
{badge.icon ?? <span aria-hidden="true" className="status-badge__dot" />}
{badge.label}
{badge.description === null ? null : <span className="font-normal">{badge.description}</span>}
</span>
);
}

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { useModalFocus } from "@/shared/ui/use-modal-focus";
export type RequestRouteLeave = (trigger: HTMLElement, leaveRoute: () => void) => void;
export type UnsavedChangesGuardProps = {
readonly children: (requestRouteLeave: RequestRouteLeave) => ReactNode;
readonly dirty: boolean;
readonly impactDescription: string;
readonly title: string;
};
type PendingRouteLeave = {
readonly leaveRoute: () => void;
readonly trigger: HTMLElement;
};
export function UnsavedChangesGuard({ children, dirty, impactDescription, title }: UnsavedChangesGuardProps) {
const [pendingRouteLeave, setPendingRouteLeave] = useState<PendingRouteLeave | null>(null);
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(pendingRouteLeave !== null);
function closeDialog() {
const trigger = pendingRouteLeave?.trigger;
setPendingRouteLeave(null);
trigger?.focus();
}
function confirmLeave() {
const leaveRoute = pendingRouteLeave?.leaveRoute;
setPendingRouteLeave(null);
leaveRoute?.();
}
function requestRouteLeave(trigger: HTMLElement, leaveRoute: () => void) {
if (!dirty) {
leaveRoute();
return;
}
setPendingRouteLeave({ leaveRoute, trigger });
}
return (
<>
{children(requestRouteLeave)}
{dirty && pendingRouteLeave !== null ? (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-label={title} aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="alertdialog">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{impactDescription}</p>
</div>
<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={closeDialog} 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={confirmLeave} type="button">
</button>
</div>
</section>
</div>
) : null}
</>
);
}

View File

@@ -0,0 +1,39 @@
const STATUS_LABEL = {
canceled: "취소됨",
error: "업로드 실패",
idle: "대기 중",
success: "업로드 완료",
uploading: "업로드 중",
} as const;
export type UploadProgressStatus = keyof typeof STATUS_LABEL;
export type UploadProgressProps = {
readonly fileName?: string;
readonly onCancel?: () => void;
readonly onRetry?: () => void;
readonly progress: number;
readonly status: UploadProgressStatus;
};
export function UploadProgress({ fileName, onCancel, onRetry, progress, status }: UploadProgressProps) {
const safeProgress = Math.min(100, Math.max(0, Math.round(progress)));
return (
<section aria-label="업로드 상태" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-col gap-1">
{fileName === undefined ? null : <p className="text-sm font-semibold">{fileName}</p>}
<p className="text-sm text-muted-foreground">{STATUS_LABEL[status]}</p>
</div>
<div className="flex gap-2">
{onCancel === undefined ? null : <button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button"> </button>}
{onRetry === undefined ? null : <button className="rounded-md border border-input bg-primary px-3 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={onRetry} type="button"> </button>}
</div>
</div>
<div aria-label="업로드 진행률" aria-valuemax={100} aria-valuemin={0} aria-valuenow={safeProgress} className="h-3 overflow-hidden rounded-sm bg-muted" role="progressbar">
<div className="h-full bg-primary" style={{ inlineSize: `${safeProgress}%` }} />
</div>
</section>
);
}

View File

@@ -0,0 +1,19 @@
import { useContext, useEffect } from "react";
import type { RefObject } from "react";
import { AudioPlaybackContext } from "./audio-playback-context";
export function useAudioPlayback(playerId: string, audioRef: RefObject<HTMLAudioElement | null>) {
const context = useContext(AudioPlaybackContext);
useEffect(() => {
if (context?.activePlayerId !== null && context?.activePlayerId !== undefined && context.activePlayerId !== playerId) {
audioRef.current?.pause();
}
}, [audioRef, context?.activePlayerId, playerId]);
return {
clearPlayer: () => context?.clearPlayer(playerId),
requestPlay: () => context?.requestPlay(playerId),
};
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef } from "react";
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
export function useModalFocus<T extends HTMLElement>(open: boolean) {
const dialogRef = useRef<T>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) {
return undefined;
}
triggerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusableElements = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
focusableElements[0]?.focus();
return () => {
triggerRef.current?.focus();
triggerRef.current = null;
};
}, [open]);
function trapFocus(event: React.KeyboardEvent<T>) {
if (event.key !== "Tab") {
return;
}
const focusableElements = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
const firstElement = focusableElements[0];
const lastElement = focusableElements.at(-1);
if (firstElement === undefined || lastElement === undefined) {
return;
}
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
return;
}
if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
return { dialogRef, trapFocus };
}

View File

@@ -0,0 +1,31 @@
import { getFileExtension, validateFile } from "./file-validation";
import type { FileValidationResult } from "./file-validation";
export const AUDIO_FILE_POLICY = {
allowedExtensions: [".mp3", ".aac", ".m4a"],
allowedMimeTypes: ["audio/mpeg", "audio/aac", "audio/mp4", "audio/x-m4a"],
maxBytes: 1_024_000_000,
} as const;
const allowedMimeByExtension = {
".aac": ["audio/aac"],
".m4a": ["audio/mp4", "audio/x-m4a"],
".mp3": ["audio/mpeg"],
} satisfies Record<string, readonly string[]>;
export type AudioFileValidationResult = FileValidationResult | { readonly ok: false; readonly reason: "mimeExtensionCombination" };
export function validateAudioFile(file: File): AudioFileValidationResult {
const baseResult = validateFile(file, AUDIO_FILE_POLICY);
if (!baseResult.ok) {
return baseResult;
}
const extension = getFileExtension(file.name);
const allowedMimeTypes = allowedMimeByExtension[extension as keyof typeof allowedMimeByExtension];
if (allowedMimeTypes === undefined || !allowedMimeTypes.includes(file.type)) {
return { ok: false, reason: "mimeExtensionCombination" };
}
return { ok: true };
}

View File

@@ -0,0 +1,93 @@
import { expect, test, vi } from "vitest";
import { createImagePolicy, IMAGE_MAX_BYTES } from "@/shared/validation/image-policy";
import { AUDIO_FILE_POLICY, validateAudioFile } from "@/shared/validation/audio-file-policy";
import { validateFile } from "@/shared/validation/file-validation";
function fileWithSize(name: string, type: string, size: number): File {
const file = new File(["x"], name, { type });
Object.defineProperty(file, "size", { value: size });
return file;
}
test("validateFile checks injected extension, MIME, and maxBytes together", () => {
const policy = {
allowedExtensions: [".png"] as const,
allowedMimeTypes: ["image/png"] as const,
maxBytes: 10,
};
expect(validateFile(fileWithSize("cover.png", "image/png", 10), policy)).toEqual({ ok: true });
expect(validateFile(fileWithSize("cover.jpg", "image/png", 10), policy)).toEqual({ ok: false, reason: "extension" });
expect(validateFile(fileWithSize("cover.png", "image/jpeg", 10), policy)).toEqual({ ok: false, reason: "mime" });
expect(validateFile(fileWithSize("cover.png", "image/png", 11), policy)).toEqual({ ok: false, reason: "size" });
});
test("validateFile lets callers inject a 10MB byte boundary without owning image domain policy", () => {
const tenMegabytes = 10 * 1024 * 1024;
const policy = {
allowedExtensions: [".jpg"] as const,
allowedMimeTypes: ["image/jpeg"] as const,
maxBytes: tenMegabytes,
};
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", tenMegabytes), policy)).toEqual({ ok: true });
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", tenMegabytes + 1), policy)).toEqual({ ok: false, reason: "size" });
});
test("validateAudioFile accepts MP3, AAC, M4A including audio/x-m4a at 1,024,000,000 bytes", () => {
expect(AUDIO_FILE_POLICY.maxBytes).toBe(1_024_000_000);
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.aac", "audio/aac", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/mp4", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/x-m4a", 1_024_000_000))).toEqual({ ok: true });
});
test("validateAudioFile rejects WAV, oversized files, and audio/x-m4a without .m4a without sniffing codecs", async () => {
const wav = fileWithSize("voice.wav", "audio/wav", 10);
const sniff = vi.spyOn(wav, "arrayBuffer");
expect(validateAudioFile(wav)).toEqual({ ok: false, reason: "extension" });
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_001))).toEqual({ ok: false, reason: "size" });
expect(validateAudioFile(fileWithSize("voice.aac", "audio/x-m4a", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(sniff).not.toHaveBeenCalled();
});
test("validateAudioFile rejects mismatched canonical MIME and extension combinations", () => {
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mp4", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(validateAudioFile(fileWithSize("voice.aac", "audio/mpeg", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/aac", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
});
test("createImagePolicy records only a domain-neutral crop contract", () => {
expect(createImagePolicy({ aspect: 1, cropRequired: true, maxWidth: 800, noUpscale: true })).toEqual({
aspect: 1,
cropRequired: true,
maxBytes: 10_485_760,
maxWidth: 800,
noUpscale: true,
});
});
test("createImagePolicy records the confirmed 10MiB image byte boundary", () => {
const policy = createImagePolicy({ aspect: 1, cropRequired: true, maxWidth: 800, noUpscale: true });
expect(IMAGE_MAX_BYTES).toBe(10_485_760);
expect(policy.maxBytes).toBe(10_485_760);
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_759), {
allowedExtensions: [".jpg"] as const,
allowedMimeTypes: ["image/jpeg"] as const,
maxBytes: policy.maxBytes,
})).toEqual({ ok: true });
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_760), {
allowedExtensions: [".jpg"] as const,
allowedMimeTypes: ["image/jpeg"] as const,
maxBytes: policy.maxBytes,
})).toEqual({ ok: true });
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_761), {
allowedExtensions: [".jpg"] as const,
allowedMimeTypes: ["image/jpeg"] as const,
maxBytes: policy.maxBytes,
})).toEqual({ ok: false, reason: "size" });
});

View File

@@ -0,0 +1,31 @@
export type FileValidationPolicy = {
readonly allowedExtensions: readonly string[];
readonly allowedMimeTypes: readonly string[];
readonly maxBytes: number;
};
export type FileValidationResult =
| { readonly ok: true }
| { readonly ok: false; readonly reason: "extension" | "mime" | "size" };
export function getFileExtension(fileName: string): string {
const dotIndex = fileName.lastIndexOf(".");
return dotIndex < 0 ? "" : fileName.slice(dotIndex).toLowerCase();
}
export function validateFile(file: File, policy: FileValidationPolicy): FileValidationResult {
if (file.size > policy.maxBytes) {
return { ok: false, reason: "size" };
}
if (!policy.allowedExtensions.includes(getFileExtension(file.name))) {
return { ok: false, reason: "extension" };
}
if (!policy.allowedMimeTypes.includes(file.type)) {
return { ok: false, reason: "mime" };
}
return { ok: true };
}

View File

@@ -0,0 +1,15 @@
export type ImageAspect = number | "free";
export const IMAGE_MAX_BYTES = 10_485_760;
export type ImagePolicy = {
readonly aspect: ImageAspect;
readonly cropRequired: boolean;
readonly maxBytes: number;
readonly maxWidth: number;
readonly noUpscale: boolean;
};
export function createImagePolicy(policy: Omit<ImagePolicy, "maxBytes">): ImagePolicy {
return { ...policy, maxBytes: IMAGE_MAX_BYTES };
}