feat(content): 시리즈 커버 이미지 크롭 기능을 추가함
This commit is contained in:
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
## 작업 계획 문서 규칙 (docs)
|
## 작업 계획 문서 규칙 (docs)
|
||||||
- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.
|
- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.
|
||||||
- 동일한 세션에서 문서에 내용 추가가 필요하면 동일한 문서에 작성한다.
|
- **기능 추가 중 발생한 오류 수정이나 보완 작업은 새로운 문서를 만들지 않고, 기존 기능 추가 계획 문서에 내용을 추가한다.**
|
||||||
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
|
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
|
||||||
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
|
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
|
||||||
- 파일명 예시: `20260101_구글계정으로로그인.md`
|
- 파일명 예시: `20260101_구글계정으로로그인.md`
|
||||||
|
|||||||
38
docs/20260227_시리즈커버이미지크롭기능추가.md
Normal file
38
docs/20260227_시리즈커버이미지크롭기능추가.md
Normal file
@@ -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` 실행 결과 이상 없음 확인
|
||||||
@@ -337,6 +337,43 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="show_cropper_dialog"
|
||||||
|
max-width="800px"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>이미지 크롭 (A4 비율)</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="cropper-wrapper">
|
||||||
|
<img
|
||||||
|
ref="cropper_image"
|
||||||
|
:src="cropper_image_url"
|
||||||
|
alt="Cropper Image"
|
||||||
|
class="cropper-image"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="grey darken-1"
|
||||||
|
text
|
||||||
|
@click="cancelCropper"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="blue darken-1"
|
||||||
|
text
|
||||||
|
@click="cropImage"
|
||||||
|
>
|
||||||
|
크롭 완료
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
<v-dialog
|
<v-dialog
|
||||||
v-model="is_loading"
|
v-model="is_loading"
|
||||||
max-width="400px"
|
max-width="400px"
|
||||||
@@ -356,6 +393,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import * as api from '@/api/audio_content_series';
|
import * as api from '@/api/audio_content_series';
|
||||||
import Draggable from "vuedraggable";
|
import Draggable from "vuedraggable";
|
||||||
|
import Cropper from 'cropperjs';
|
||||||
|
import 'cropperjs/dist/cropper.css';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ContentSeriesList",
|
name: "ContentSeriesList",
|
||||||
@@ -369,6 +408,9 @@ export default {
|
|||||||
series_list: [],
|
series_list: [],
|
||||||
selected_series: null,
|
selected_series: null,
|
||||||
series_genre_list: [],
|
series_genre_list: [],
|
||||||
|
show_cropper_dialog: false,
|
||||||
|
cropper_image_url: '',
|
||||||
|
cropper: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
total_page: 0,
|
total_page: 0,
|
||||||
days_of_week: [
|
days_of_week: [
|
||||||
@@ -400,13 +442,63 @@ export default {
|
|||||||
imageAdd(payload) {
|
imageAdd(payload) {
|
||||||
const file = payload;
|
const file = payload;
|
||||||
if (file) {
|
if (file) {
|
||||||
this.series.cover_image_url = URL.createObjectURL(file)
|
if (file._isCropped) return;
|
||||||
URL.revokeObjectURL(file)
|
|
||||||
|
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 {
|
} else {
|
||||||
this.series.cover_image_url = null
|
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) {
|
notifyError(message) {
|
||||||
this.$dialog.notify.error(message)
|
this.$dialog.notify.error(message)
|
||||||
},
|
},
|
||||||
@@ -742,6 +834,16 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cover-image {
|
.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%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user