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