diff --git a/DESIGN.md b/DESIGN.md index 5b5fb55..39edbe5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -151,7 +151,7 @@ Primary font stack: `Pretendard`, `Noto Sans KR`, `Apple SD Gothic Neo`, `system - Accessibility: label targets the input; description, accept guidance, and error are connected with `aria-describedby`; clear is a native button. - Motion: none. -### TagChipInput +### TagInput - Structure: controlled comma-separated string boundary that renders committed values as visible wrapping chips and preserves an inline draft input. A visible label, help text, and error text accompany the field. - Controlled value: the caller owns one comma-separated string. Enter or a comma commits the non-empty draft into that string; each chip's native remove button removes only its value. Duplicate prevention and maximum-count limits are caller policy, not this pattern's policy. diff --git a/docs/20260725_AI캐릭터관리자웹/plan-task.md b/docs/20260725_AI캐릭터관리자웹/plan-task.md index 5159a1a..3ce3b3e 100644 --- a/docs/20260725_AI캐릭터관리자웹/plan-task.md +++ b/docs/20260725_AI캐릭터관리자웹/plan-task.md @@ -10,12 +10,12 @@ | 문서 항목 | 내용 | |---|---| -| 상태 | 2026-08-04 `P4-R10` Audio form UX 회귀 자동 검증 완료. `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17` 자동 보완 완료, 실제 crop pixel·stale ADMIN·Series/FanTalk/Comments/file policy 수동 QA 대기 | +| 상태 | 2026-08-04 `P5-R6` Series form 이미지 순서·keyword chip UX 자동 검증 완료. `P4-R10`, `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17` 자동 보완 완료, 실제 crop pixel·stale ADMIN·Series/FanTalk/Comments/file policy 수동 QA 대기 | | 최초 작성일 | 2026-07-25 | | 재작성일 | 2026-07-26 | | 요구사항 기준 | [prd.md](./prd.md) | | API 기준 | [api-contract.openapi.json](./api-contract.openapi.json) | -| 현재 활성 Goal | `P4-R10` 자동 검증 완료 (`P4-R9` 완료) | +| 현재 활성 Goal | `P5-R6` 자동 검증 완료 (`P5-R5` 완료) | ## 목표 @@ -2885,6 +2885,30 @@ reorder를 확인한다. genre lookup과 상세 수정용 원본값 제공 후 - **GREEN:** `SeriesForm`에 image selection token, `isImagePreparing`, 준비/crop 중 submit guard, preview reject inline 오류를 추가했다. `image`는 crop 적용 결과만 commit하고, 수정 화면 crop 취소는 기존 서버 image 유지 계약을 보존한다. focused 재실행 결과 `series-form.test.tsx`와 `series-form-crop.test.tsx` 2 files / 13 tests passed였다. - **REFACTOR/회귀:** crop lifecycle test를 `series-form-crop.test.tsx`로 분리해 `SeriesForm.tsx` 220 LOC, `series-form.test.tsx` 230 LOC, `series-form-crop.test.tsx` 85 LOC로 유지했다. `npm run test:run -- src/features/series` 결과 8 files / 37 tests passed였다. 사용자 지시에 따라 mock E2E는 전체 Task 완료 전까지 보류했다. `npm run typecheck`, `npm run lint`, `npm run build`, `git diff --check`는 모두 exit 0 또는 no output이었다. Series directory LSP diagnostics는 17 TSX files / 오류 0건이었다. +### Task R5.6 — Series 생성 form 이미지·keyword 입력 UX 정렬 + +**Goal 실행 `P5-R6`:** Series 생성 form의 image 입력을 최상단에 배치하고 Audio tags와 동일한 공용 `TagInput`으로 keyword를 입력하되 기존 comma-separated request 계약을 유지한다. + +- **시작 조건:** `P4-R10`, `P5-R5`, `SERIES-014`, 사용자 승인 설계. +- **완료 증거:** image-first field order와 Enter/comma chip·remove 동작의 실패 test, 공용 `TagInput` 재사용, Series comma-separated `keyword` request, Audio tag 회귀, Chromium mock browser와 시각 QA 증거. +- **Files:** Move/Modify: `src/features/audio-contents/components/AudioTagInput.tsx` → `src/shared/ui/tag-input.tsx`; Modify: `src/features/audio-contents/components/AudioContentForm.tsx`, `src/features/series/components/SeriesForm.tsx`, `src/features/series/tests/series-form.test.tsx`, `src/shared/ui/__tests__/tag-input.test.tsx`, `tests/e2e/series.spec.ts`, `DESIGN.md`, `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`. +- **Interfaces:** `TagInput({label,value,onChange,error,errorId})`는 comma-separated string을 consume/emit하고 Enter/comma로 non-empty trimmed chip을 확정하며 remove button으로 한 chip만 삭제한다. Series create는 `keyword`, Audio create/update는 `tags` field명을 그대로 유지한다. +- **범위 밖:** Series 수정·상세 keyword, duplicate/max-count 정책, API schema·endpoint·multipart part 변경, 새 dependency. + +- [x] **RED:** Series image-first order와 keyword chip→`"달빛,상담"` request, 공용 input의 blank/remove 동작 실패 test를 작성하고 의도한 실패를 확인했다. +- [x] **GREEN:** `AudioTagInput`을 공용 `TagInput`으로 이름·위치를 정리하고 Audio/Series에서 재사용하며 Series `FileField`를 form 첫 field로 이동했다. +- [x] **REFACTOR:** feature 간 역참조와 중복 chip 구현이 없는지 확인하고 focused·전체 회귀, typecheck·lint·build, Chromium mock E2E와 시각 QA를 실행했다. +- **실행 명령:** `npm run test:run -- src/shared/ui/__tests__/tag-input.test.tsx src/features/series/tests/series-form.test.tsx src/features/audio-contents/tests/audio-form.test.tsx`; `npm run test:run`; `npm run typecheck`; `npm run lint`; `npm run build:prod`; `npm run e2e:mock -- tests/e2e/series.spec.ts --project=chromium`. +- **기대 결과:** 모든 명령 exit 0, Series image-first order와 `keyword="달빛,상담"`, Audio `tags="상담,힐링"`, 빈 chip 0건, 삭제 대상 외 chip 손실 0건, API 계약 변경 0건. +- **수동 확인:** Chromium 1280px Series 생성 화면에서 image가 첫 입력이고 keyword chip 추가·삭제 및 Korean text clipping/overflow가 없는지 확인한다. + +**P5-R6 완료 Progress — 2026-08-04:** + +- **RED:** shared `TagInput` import 부재, Series image가 title 뒤에 있는 DOM 순서, plain keyword input이 첫 값을 덮어 `"상담"`만 보내는 실패를 확인했다. validation focus 정렬은 image 대신 title이 focus되는 실패로, FileField visible focus는 `focus-within` ring class 부재 실패로 각각 고정했다. +- **GREEN:** `AudioTagInput`을 `src/shared/ui/tag-input.tsx`의 공용 `TagInput`으로 이동·일반화하고 Audio `tags`와 Series `keyword`가 같은 chip UI를 사용하게 했다. Series image를 form 첫 field로 이동하고 request는 `keyword: "달빛,상담"` comma-separated string을 유지했다. image-first validation focus가 보이도록 공용 `FileField` 카드에 기존 ring token의 `focus-within` 상태만 추가했다. +- **REFACTOR/회귀:** focused 3 files / 20 tests와 FileField 포함 focused 3 files / 15 tests를 통과했다. 최종 `npm run test:run`은 82 files / 437 tests, `npm run typecheck`, `npm run lint`, `npm run build:prod`, `git diff --check`는 모두 통과했고 build에는 기존 500kB chunk warning만 남았다. `npm run e2e:mock -- tests/e2e/audio-content.spec.ts tests/e2e/series.spec.ts --project=chromium`은 13 tests passed였다. +- **수동·시각 QA:** Playwright Chromium 768×900·1280×900에서 image-first, Enter/comma chip 2개, remove 후 나머지 값, 빈 draft, 가로 overflow 0건을 확인했다. validation 후 sr-only file input focus와 visible cyan FileField ring을 768×900에서 확인했다. 독립 visual functional/CJK reviewer와 고엄격 diff reviewer의 최종 판정은 모두 PASS/APPROVE, blocker 0건이었다. Mock Preview sticky banner 아래로 static header가 이동한 프레임은 `scrollY=47`의 정상 문서 스크롤로 측정했고 `scrollY=0`에서 동일 focus/ring과 온전한 header를 재확인했다. + --- ## Phase 6. Community vertical slice diff --git a/docs/20260725_AI캐릭터관리자웹/prd.md b/docs/20260725_AI캐릭터관리자웹/prd.md index 40313fa..d46bb32 100644 --- a/docs/20260725_AI캐릭터관리자웹/prd.md +++ b/docs/20260725_AI캐릭터관리자웹/prd.md @@ -285,7 +285,7 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘 | SERIES-011 | 확정 | 장르 목록 endpoint는 검색·페이지 query 없이 활성 장르 전체를 반환한다. 생성 초기 state의 정확한 기본값은 프론트엔드 의존사항이 아니다. | | SERIES-012 | 확정 | 시리즈 목록 API는 `isActive=true`인 항목만 반환한다. 프론트엔드는 활성 상태 query나 client-side filter를 추가하지 않는다. | | SERIES-013 | 확정 | 시리즈 soft delete 성공 시 선택 캐릭터의 시리즈 목록으로 이동해 재조회하고 성공 알림을 표시한다. 현재 상세 화면에 머물지 않으며 서버의 active-only 목록에서 비활성 항목이 제외돼야 한다. | -| SERIES-014 | 확정 | 생성 multipart의 `image`와 `request`는 필수다. 생성 request는 `keyword` 단일 문자열을 사용하며 `keywords` 배열을 보내지 않는다. | +| SERIES-014 | 확정 | 생성 multipart의 `image`와 `request`는 필수다. 생성 화면은 image 입력을 폼 최상단에 두고 keyword를 Enter/comma로 확정·삭제하는 chip UI로 제공한다. 생성 request는 chip 값을 comma-separated `keyword` 단일 문자열로 보내며 `keywords` 배열을 보내지 않는다. | | SERIES-015 | 확정 | 목록과 상세는 동일한 `SeriesListItem` schema를 사용하고 `genreId`, enum 배열 `publishedDaysOfWeek`, enum `state`를 반환한다. 화면 label은 클라이언트에서 표시용으로 변환하되 원본 enum과 ID를 수정 payload에 사용한다. | | SERIES-016 | 확정 | 연결 후보는 `GET .../contents/search?search_word=...`, 연결은 `{ "contentIdList": [...] }`, 해제는 body 없는 DELETE를 사용한다. | | SERIES-017 | 확정 | 시리즈 상세 응답은 목록 item과 동일한 수정용 원본값 `genreId`, enum `publishedDaysOfWeek`, enum `state`를 제공하므로 별도 edit DTO가 필요 없다. 수정 화면은 상세 응답으로 기존 선택값을 초기화하고, 장르 API는 option 목록 표시용으로 호출한다. | @@ -937,3 +937,4 @@ vertical slice와 남은 수동 QA에서 검증한다. `EXT-006`은 현재 확 | 2026-07-31 | 사용자 직접 지시에 따라 로컬 자동 Gate와 지원 browser 범위는 데스크톱 Chrome·모바일 Chrome의 Chrome 2종으로 한정한다. Playwright는 Chromium/mobile Chrome project만 유지하고, 현재 제품 지원 대상이 아닌 WebKit·Mobile Safari 자동 실행과 수동 QA는 테스트 시간을 크게 늘리므로 현재 릴리스 범위에서 제외한다. | | 2026-08-03 | 공통 `AdminAudioPlayer`의 native media 동작과 단일 재생·오류 계약은 유지하고, Plyr·Media Chrome의 audio-only control 배치를 참고한 compact 가로형 UI로 개선한다. 플레이어 내부 image·video 영역과 새 외부 라이브러리·waveform은 추가하지 않으며 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet에 동일하게 적용한다. | | 2026-08-04 | Audio form은 tags를 chip으로 입력하고 Enter/comma 추가와 remove button 삭제를 제공하되 payload는 comma-separated string으로 유지한다. 가격은 숫자만 표시·저장하는 `0..99999` 정수이며 0은 무료다. 가격이 0보다 클 때만 purchase option, preview 생성·시간, point 사용 가능 여부를 보이고, 0 전환 시 `purchaseOption=BOTH`, `isGeneratePreview=false`, `isPointAvailable=false`, preview times=`null`로 초기화한다. `limited`는 문서화되지 않은 NullableInt32이므로 UI를 제거하고 `null`을 보내며, `languageCode` UI는 제거하고 `null`, 독립 `isOnlyRental` UI는 제거하고 `false`를 보낸다. preview 시작·종료는 오디오 duration offset으로 text 입력하며 완전한 `HH:MM:SS`만 안내하고 request에는 입력한 `HH:mm:ss` 값을 그대로 보낸다. 예약 일시는 예약 공개에서만 native `datetime-local`로 렌더링하고 custom `showPicker()`는 사용하지 않는다. native radio/checkbox는 하나의 선택 카드 시각 문법을 사용한다. | +| 2026-08-04 | Series 생성 form은 image 입력을 최상단에 배치하고 keyword를 Audio tags와 같은 공용 `TagInput` chip UI로 입력한다. Enter/comma로 확정하고 remove button으로 삭제하며 server request는 기존 comma-separated `keyword` 문자열 계약을 유지한다. | diff --git a/src/features/audio-contents/components/AudioContentForm.tsx b/src/features/audio-contents/components/AudioContentForm.tsx index 0e26e23..c75d80a 100644 --- a/src/features/audio-contents/components/AudioContentForm.tsx +++ b/src/features/audio-contents/components/AudioContentForm.tsx @@ -8,7 +8,6 @@ import type { UploadAudioContentOptions, UploadAuthDependencies } from "@/featur import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, formatPrice, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers"; import type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers"; import { AudioContentCreateOptions } from "@/features/audio-contents/components/AudioContentCreateOptions"; -import { AudioTagInput } from "@/features/audio-contents/components/AudioTagInput"; import { AudioContentThemeSelect } from "@/features/audio-contents/components/AudioContentThemeSelect"; import { ReleaseScheduleField } from "@/features/audio-contents/components/ReleaseScheduleField"; import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField"; @@ -25,6 +24,7 @@ import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog"; import { FileField } from "@/shared/ui/file-field"; import { ImageCropDialog } from "@/shared/ui/image-crop-dialog"; import type { CropSourceImage } from "@/shared/ui/image-crop-dialog"; +import { TagInput } from "@/shared/ui/tag-input"; import { CAN_PRICE_MAX } from "@/shared/validation/can-price"; import { AUDIO_FILE_POLICY } from "@/shared/validation/audio-file-policy"; import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard"; @@ -234,7 +234,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour {errors.title === undefined ? null :
{errors.title}
} {errors.detail === undefined ? null :{errors.detail}
} -테마: {audio?.themeStr ?? "-"} (수정 불가)
}단위: 캔
{errors.price === undefined ? null :{errors.price}
} diff --git a/src/features/series/components/SeriesForm.tsx b/src/features/series/components/SeriesForm.tsx index 6df422b..dc27142 100644 --- a/src/features/series/components/SeriesForm.tsx +++ b/src/features/series/components/SeriesForm.tsx @@ -16,6 +16,7 @@ import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog"; import { FileField } from "@/shared/ui/file-field"; import { ImageCropDialog } from "@/shared/ui/image-crop-dialog"; import type { CropSourceImage } from "@/shared/ui/image-crop-dialog"; +import { TagInput } from "@/shared/ui/tag-input"; import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard"; type FieldErrors = { @@ -221,8 +222,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m {errors.title === undefined ? null :{errors.title}
} {errors.introduction === undefined ? null :{errors.introduction}
} - {mode === "create" ? : null} - {errors.keyword === undefined ? null :{errors.keyword}
} + {mode === "create" ?쉼표 또는 Enter로 태그를 추가하세요.
+쉼표 또는 Enter로 {label}를 추가하세요.
{error === undefined ? null :{error}
}