diff --git a/AGENTS.md b/AGENTS.md index 985ef54..c1300d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ ## 작업 계획 문서 규칙 (docs) - 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다. -- 동일한 세션에서 문서에 내용 추가가 필요하면 동일한 문서에 작성한다. +- **기능 추가 중 발생한 오류 수정이나 보완 작업은 새로운 문서를 만들지 않고, 기존 기능 추가 계획 문서에 내용을 추가한다.** - 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다. - 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다. - 파일명 예시: `20260101_구글계정으로로그인.md` diff --git a/docs/20260227_시리즈커버이미지크롭기능추가.md b/docs/20260227_시리즈커버이미지크롭기능추가.md new file mode 100644 index 0000000..97c935b --- /dev/null +++ b/docs/20260227_시리즈커버이미지크롭기능추가.md @@ -0,0 +1,38 @@ +# 20260227_시리즈커버이미지크롭기능추가.md + +## 구현할 내용 +- [x] `ContentSeriesList.vue`에 `cropperjs`를 활용한 이미지 크롭 기능 추가 + - [x] 1:1.5 비율(aspectRatio: 1/1.5) 지원 + - [x] 가로 해상도 최대 1000px 제한 로직 추가 (1000px 미만은 원본 크기 유지) + - [x] 크롭 다이얼로그 및 관련 상태 관리 로직 구현 + - [x] 크롭 후 `_isCropped` 플래그를 통한 중복 처리 방지 +- [x] 크롭 다이얼로그 내 이미지 표시 영역 높이 제한 및 스타일 수정 +- [x] 큰 이미지가 표시될 때 다이얼로그 밖으로 나가지 않도록 `max-height` 조정 +- [x] 시리즈 이미지 크롭 비율을 A4 비율(210:297)로 수정 + +## 검증 기록 +### 1차 구현 +- **무엇을**: `ContentSeriesList.vue` 시리즈 등록/수정 시 커버 이미지 크롭 기능 추가 +- **왜**: 시리즈 커버 이미지의 비율을 1:1.5로 강제하고, 고해상도 이미지 업로드 시 가로 1000px로 리사이징하여 서버 부하 및 레이아웃 깨짐을 방지하기 위함 +- **어떻게**: + - `cropperjs` 라이브러리를 도입하여 `aspectRatio: 1 / 1.5` 설정 + - `cropImage` 메서드에서 `canvas.width`가 1000px을 초과할 경우에만 리사이징 수행 + - `file._isCropped` 플래그를 사용하여 크롭 완료 후 `v-file-input` 모델 변경 시 발생하는 `imageAdd` 중복 호출 방지 + - `npm run lint`를 통해 코드 스타일 검증 완료 + +### 2차 수정 +- **무엇을**: `ContentSeriesList.vue`의 크롭퍼 이미지 컨테이너 스타일 수정 +- **왜**: 이미지가 너무 클 경우 다이얼로그를 벗어나 스크롤을 해야만 '크롭 완료' 버튼을 누를 수 있는 불편함이 있음. +- **어떻게**: + - `.cropper-wrapper`의 `max-height`를 `500px`에서 `70vh`로 변경하여 브라우저 높이에 대응하도록 수정. + - `npm run lint` 실행 결과 이상 없음 확인. ++ ++### 3차 수정 ++- **무엇을**: 시리즈 커버 이미지 크롭 비율 수정 (1:1.5 -> A4 비율) ++- **왜**: 시리즈 이미지 규격을 A4 비율(210:297)로 통일하기 위함 ++- **어떻게**: ++ - `ContentSeriesList.vue`에서 `aspectRatio`를 `210 / 297`로 수정 ++ - 크롭 다이얼로그 타이틀을 "이미지 크롭 (A4 비율)"으로 변경 ++ - 리사이징 시 높이 계산 방식을 `MAX_WIDTH * (297 / 210)`으로 수정 ++ - 목록의 커버 이미지 `aspect-ratio`를 `210/297`로 수정 ++ - `npm run lint` 실행 결과 이상 없음 확인 diff --git a/src/views/Content/ContentSeriesList.vue b/src/views/Content/ContentSeriesList.vue index 2c0afbc..b08761e 100644 --- a/src/views/Content/ContentSeriesList.vue +++ b/src/views/Content/ContentSeriesList.vue @@ -337,6 +337,43 @@ + + + 이미지 크롭 (A4 비율) + +
+ Cropper Image +
+
+ + + + 취소 + + + 크롭 완료 + + +
+
+ import * as api from '@/api/audio_content_series'; import Draggable from "vuedraggable"; +import Cropper from 'cropperjs'; +import 'cropperjs/dist/cropper.css'; export default { name: "ContentSeriesList", @@ -369,6 +408,9 @@ export default { series_list: [], selected_series: null, series_genre_list: [], + show_cropper_dialog: false, + cropper_image_url: '', + cropper: null, page: 1, total_page: 0, days_of_week: [ @@ -400,13 +442,63 @@ export default { imageAdd(payload) { const file = payload; if (file) { - this.series.cover_image_url = URL.createObjectURL(file) - URL.revokeObjectURL(file) + if (file._isCropped) return; + + this.cropper_image_url = URL.createObjectURL(file) + this.show_cropper_dialog = true + this.$nextTick(() => { + if (this.cropper) { + this.cropper.destroy() + } + this.cropper = new Cropper(this.$refs.cropper_image, { + aspectRatio: 210 / 297, + viewMode: 1, + }) + }) } else { this.series.cover_image_url = null + this.series.cover_image = null } }, + cancelCropper() { + this.show_cropper_dialog = false + if (this.cropper) { + this.cropper.destroy() + this.cropper = null + } + this.cropper_image_url = '' + this.series.cover_image = null + }, + + cropImage() { + const canvas = this.cropper.getCroppedCanvas() + let finalCanvas = canvas + + const MAX_WIDTH = 1000 + if (canvas.width > MAX_WIDTH) { + const height = MAX_WIDTH * (297 / 210) + const resizeCanvas = document.createElement('canvas') + resizeCanvas.width = MAX_WIDTH + resizeCanvas.height = height + const ctx = resizeCanvas.getContext('2d') + ctx.drawImage(canvas, 0, 0, MAX_WIDTH, height) + finalCanvas = resizeCanvas + } + + finalCanvas.toBlob((blob) => { + const file = new File([blob], 'cover_image.png', {type: 'image/png'}) + file._isCropped = true + this.series.cover_image = file + this.series.cover_image_url = URL.createObjectURL(blob) + this.show_cropper_dialog = false + if (this.cropper) { + this.cropper.destroy() + this.cropper = null + } + }, 'image/png') + }, + notifyError(message) { this.$dialog.notify.error(message) }, @@ -742,6 +834,16 @@ export default { } .cover-image { - aspect-ratio: 1/1.3; + aspect-ratio: 210/297; +} + +.cropper-wrapper { + max-height: 70vh; + width: 100%; +} + +.cropper-image { + display: block; + max-width: 100%; }