43
AGENTS.md
Normal file
43
AGENTS.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## 문서 목적
|
||||||
|
- 이 문서는 `/Users/klaus/Develop/sodalive/Admin/soda-creator-admin` 저장소에서 작업하는 에이전트용 실행 가이드다.
|
||||||
|
- 목표는 "추측 최소화 + 기존 패턴 준수 + 검증 우선"이다.
|
||||||
|
- 이 문서의 규칙은 코드/테스트/문서 변경 모두에 적용한다.
|
||||||
|
|
||||||
|
## 커뮤니케이션 규칙
|
||||||
|
- **"질문에 대한 답변과 설명은 한국어로 한다."**
|
||||||
|
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
|
||||||
|
- 코드 식별자, 경로, 명령어는 원문(영문) 그대로 유지한다.
|
||||||
|
|
||||||
|
## 커밋 메시지 규칙 (표준 Conventional Commits)
|
||||||
|
- 기본 형식은 `<type>(scope): <description>`를 사용한다.
|
||||||
|
- `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등)를 사용한다.
|
||||||
|
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
|
||||||
|
|
||||||
|
### 커밋 메시지 검증 절차
|
||||||
|
- `git commit` 실행 직전에 `work/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
|
||||||
|
- `git commit` 실행 직후에도 `work/check-commit-message-rules.sh`를 다시 실행해 최종 메시지를 재검증한다.
|
||||||
|
- 스크립트 결과가 `[FAIL]`이면 커밋 메시지를 규칙에 맞게 수정한 뒤 다시 검증한다.
|
||||||
|
|
||||||
|
## 작업 계획 문서 규칙 (docs)
|
||||||
|
- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.
|
||||||
|
- **기능 추가 중 발생한 오류 수정이나 보완 작업은 새로운 문서를 만들지 않고, 기존 기능 추가 계획 문서에 내용을 추가한다.**
|
||||||
|
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
|
||||||
|
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
|
||||||
|
- 파일명 예시: `20260101_구글계정으로로그인.md`
|
||||||
|
- 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다.
|
||||||
|
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 각 항목이 정상 구현되었는지 확인한다.
|
||||||
|
- 작업 도중 범위가 변경되면 계획 문서의 체크박스 항목을 먼저 업데이트한 뒤 구현을 진행한다.
|
||||||
|
- 모든 구현이 끝난 후 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 기록한다.
|
||||||
|
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰지 않고 누적한다(예: `1차 구현`, `2차 수정`).
|
||||||
|
- 검증 기록은 단계별로 `무엇을/왜/어떻게`를 유지해 작성하고, 이전 단계와 구분이 되도록 명시한다.
|
||||||
|
- 단계별 `어떻게`에는 실제 실행한 검증 명령과 결과(성공/실패/불가 사유)를 함께 기록한다.
|
||||||
|
- 기존 기록 정정이 필요하면 원문을 지우지 말고 `정정` 항목을 추가해 사유와 변경 내용을 남긴다.
|
||||||
|
|
||||||
|
## 에이전트 동작 원칙
|
||||||
|
- 추측하지 말고, 근거 파일을 읽고 결정한다.
|
||||||
|
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
|
||||||
|
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
|
||||||
|
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.
|
||||||
|
-
|
||||||
37
docs/20260227_시그니처이미지크롭기능추가.md
Normal file
37
docs/20260227_시그니처이미지크롭기능추가.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# 20260227 시그니처 이미지 크롭 기능 추가
|
||||||
|
|
||||||
|
## 구현 목표
|
||||||
|
시그니처 이미지를 등록할 때 사용자 편의를 위해 크롭 기능을 추가한다.
|
||||||
|
- 해상도 가로 최대 800px (800px보다 작은 경우 원본 크기 유지)
|
||||||
|
- 비율 1:1 지원
|
||||||
|
- GIF 이미지는 크롭 없이 바로 업로드
|
||||||
|
- 기타 이미지 포맷은 크롭 기능 적용
|
||||||
|
|
||||||
|
## 작업 내역
|
||||||
|
- [x] `SignatureManagement.vue` 수정
|
||||||
|
- [x] `cropperjs` 임포트 및 스타일 추가
|
||||||
|
- [x] 이미지 크롭 다이얼로그 UI 구현
|
||||||
|
- [x] `imageAdd` 메서드 수정 (GIF 구분 및 크롭 처리)
|
||||||
|
- [x] `cropImage` 메서드 구현 (1:1 비율 및 800px 리사이징 로직 포함)
|
||||||
|
- [x] `cancelCropper` 메서드 구현
|
||||||
|
- [x] `data`에 크롭 관련 변수 추가
|
||||||
|
- [x] CSS 스타일 정의
|
||||||
|
|
||||||
|
## 검증 계획
|
||||||
|
- [x] GIF 이미지 업로드 테스트: 크롭 다이얼로그 없이 바로 등록되는지 확인 (코드 로직 검증)
|
||||||
|
- [x] 일반 이미지(JPG/PNG) 업로드 테스트: 1:1 비율 크롭 다이얼로그 표시 확인 (코드 로직 검증)
|
||||||
|
- [x] 이미지 리사이징 테스트:
|
||||||
|
- [x] 가로 800px 초과 이미지 크롭 시 가로 800px로 리사이징되는지 확인 (코드 로직 검증)
|
||||||
|
- [x] 가로 800px 이하 이미지 크롭 시 원본 가로 크기가 유지되는지 확인 (코드 로직 검증)
|
||||||
|
- [x] 크롭 후 최종 등록 및 서버 업로드 확인 (코드 로직 검증)
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
### 1차 구현
|
||||||
|
- 무엇을: 시그니처 이미지 등록 시 1:1 크롭 기능 구현
|
||||||
|
- 왜: 시그니처 이미지 해상도 관리 및 사용자 편의 제공
|
||||||
|
- 어떻게:
|
||||||
|
- `cropperjs`를 사용하여 1:1 비율 크롭 다이얼로그 구현
|
||||||
|
- GIF 포맷은 `file.type === 'image/gif'` 조건으로 크롭 제외
|
||||||
|
- `getCroppedCanvas()` 이후 가로가 800px을 초과할 경우 `canvas`를 사용하여 800x800으로 리사이징 수행
|
||||||
|
- 800px 이하인 경우 원본 크롭 해상도 유지
|
||||||
|
- `SignatureManagement.vue` 파일 수정 및 관련 스타일 추가
|
||||||
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` 실행 결과 이상 없음 확인
|
||||||
14
docs/20260227_이미지크롭포커스이슈수정.md
Normal file
14
docs/20260227_이미지크롭포커스이슈수정.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# 20260227_이미지크롭포커스이슈수정.md
|
||||||
|
|
||||||
|
## 구현할 내용
|
||||||
|
- [x] 이미지 크롭 후 포커스가 다른 곳으로 가면 다시 크롭화면이 표시되는 문제 해결
|
||||||
|
- [x] 크롭 후 `v-file-input` 값 초기화 로직 추가
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
### 1차 수정
|
||||||
|
- **무엇을**: `ContentList.vue`의 이미지 선택 및 크롭 로직 수정
|
||||||
|
- **왜**: `v-file-input`에 선택된 파일이 남아 있어 다른 곳에 포커스가 갔다가 돌아오거나 입력이 발생할 때 `@change` 이벤트가 예기치 않게 발생할 가능성이 있고, 사용자 경험상 불편함이 있음.
|
||||||
|
- **어떻게**:
|
||||||
|
- `imageAdd` 메서드에서 파일을 처리하기 전에 `file._isCropped` 플래그를 확인하여 이미 크롭된 파일인 경우 중복 처리를 방지함.
|
||||||
|
- `cropImage` 메서드에서 크롭된 새로운 `File` 객체를 생성할 때 `_isCropped = true` 플래그를 설정하여 `audio_content.cover_image`가 업데이트될 때 발생하는 `@change` 이벤트에 의해 크롭 다이얼로그가 다시 열리는 것을 방지함.
|
||||||
|
- `npm run lint` 실행 결과 이상 없음.
|
||||||
18
docs/20260227_초기환경설정및가이드준수.md
Normal file
18
docs/20260227_초기환경설정및가이드준수.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 20260227_초기환경설정및가이드준수
|
||||||
|
|
||||||
|
## 구현 항목
|
||||||
|
- [x] AGENTS.md 파일 확인 및 내용 숙지
|
||||||
|
- [x] work/check-commit-message-rules.sh 파일 확인 및 실행 권한 확인
|
||||||
|
- [x] 변경 사항 스테이징
|
||||||
|
- [x] 커밋 메시지 규칙 검증
|
||||||
|
- [x] 가이드에 따른 커밋 수행
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
### 1차 구현
|
||||||
|
#### 무엇을
|
||||||
|
- 초기 프로젝트 설정 파일(`AGENTS.md`, `work/check-commit-message-rules.sh`)을 커밋한다.
|
||||||
|
#### 왜
|
||||||
|
- 에이전트 작업 가이드를 준수하고, 이후 작업의 기반을 마련하기 위함이다.
|
||||||
|
#### 어떻게
|
||||||
|
- `work/check-commit-message-rules.sh --message "docs: 에이전트 작업 가이드 및 검증 스크립트 추가"` 명령으로 메시지 규칙을 검증했다.
|
||||||
|
- `git commit -m "docs: 에이전트 작업 가이드 및 검증 스크립트 추가"` 명령으로 커밋을 시도한다.
|
||||||
39
docs/20260227_콘텐츠커버이미지크롭기능추가.md
Normal file
39
docs/20260227_콘텐츠커버이미지크롭기능추가.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 20260227_콘텐츠커버이미지크롭기능추가.md
|
||||||
|
|
||||||
|
## 작업 개요
|
||||||
|
콘텐츠 등록 및 수정 시 커버 이미지를 1:1 비율로 크롭하여 등록할 수 있는 기능을 추가한다.
|
||||||
|
|
||||||
|
## 작업 항목
|
||||||
|
- [x] cropperjs 라이브러리 설치
|
||||||
|
- [x] ContentList.vue에 크롭용 다이얼로그 및 UI 추가
|
||||||
|
- [x] cropperjs 연동 및 1:1 비율 설정 구현
|
||||||
|
- [x] 이미지 리사이징 로직 구현 (800x800 제한)
|
||||||
|
- [x] 콘텐츠 등록(save) 및 수정(modify) 로직에 크롭된 이미지 적용
|
||||||
|
- [x] 기존 이미지 선택 로직(imageAdd) 수정
|
||||||
|
|
||||||
|
## 검증 계획
|
||||||
|
### 무엇을
|
||||||
|
- 커버 이미지 선택 시 크롭 다이얼로그가 정상적으로 표시되는지 확인
|
||||||
|
- 1:1 비율로 크롭이 제한되는지 확인
|
||||||
|
- 해상도가 800px 이상인 이미지가 800x800으로 리사이징되는지 확인
|
||||||
|
- 해상도가 800px 미만인 이미지가 원래 해상도를 유지하며 1:1로 크롭되는지 확인
|
||||||
|
- 최종적으로 서버에 크롭된 이미지가 정상 전달되는지 확인
|
||||||
|
|
||||||
|
### 왜
|
||||||
|
- 사용자 요구사항(1:1 비율, 800x800 제한) 충족 여부 확인
|
||||||
|
|
||||||
|
### 결과 보고
|
||||||
|
1차 구현 완료.
|
||||||
|
- 무엇을: 커버 이미지 크롭 기능 및 800x800 리사이징 로직 구현
|
||||||
|
- 왜: 1:1 비율 유지 및 서버 저장 용량 최적화를 위해
|
||||||
|
- 어떻게:
|
||||||
|
- `npm install cropperjs` 실행
|
||||||
|
- `ContentList.vue`에 `cropperjs` 연동 및 `canvas`를 이용한 리사이징 로직 추가
|
||||||
|
- `npm run lint`를 통해 코드 스타일 검증 (성공)
|
||||||
|
|
||||||
|
2차 수정 완료.
|
||||||
|
- 무엇을: `Module not found: Error: Can't resolve 'cropperjs/dist/cropper.css'` 에러 수정
|
||||||
|
- 왜: `cropperjs` 2.x 버전에서는 CSS 파일이 포함되어 있지 않아 빌드 에러 발생
|
||||||
|
- 어떻게:
|
||||||
|
- `cropperjs`를 1.x 버전(`^1.5.13`)으로 재설치하여 CSS 파일을 포함하도록 수정
|
||||||
|
- `npm run lint`를 통해 코드 스타일 검증 (성공)
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"vue": "^2.6.11",
|
"vue": "^2.6.11",
|
||||||
"vue-router": "^3.2.0",
|
"vue-router": "^3.2.0",
|
||||||
"vue-show-more-text": "^2.0.2",
|
"vue-show-more-text": "^2.0.2",
|
||||||
@@ -4839,6 +4840,11 @@
|
|||||||
"sha.js": "^2.4.8"
|
"sha.js": "^2.4.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cropperjs": {
|
||||||
|
"version": "1.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
|
||||||
|
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA=="
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "6.0.5",
|
"version": "6.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
|
||||||
@@ -19522,6 +19528,11 @@
|
|||||||
"sha.js": "^2.4.8"
|
"sha.js": "^2.4.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"cropperjs": {
|
||||||
|
"version": "1.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
|
||||||
|
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA=="
|
||||||
|
},
|
||||||
"cross-spawn": {
|
"cross-spawn": {
|
||||||
"version": "6.0.5",
|
"version": "6.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"vue": "^2.6.11",
|
"vue": "^2.6.11",
|
||||||
"vue-router": "^3.2.0",
|
"vue-router": "^3.2.0",
|
||||||
"vue-show-more-text": "^2.0.2",
|
"vue-show-more-text": "^2.0.2",
|
||||||
|
|||||||
@@ -711,6 +711,45 @@
|
|||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="show_cropper_dialog"
|
||||||
|
max-width="800px"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>
|
||||||
|
이미지 크롭
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="cropper-wrapper">
|
||||||
|
<img
|
||||||
|
ref="cropper_image"
|
||||||
|
:src="cropper_image_url"
|
||||||
|
alt="Cropper Image"
|
||||||
|
style="max-width: 100%;"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="blue 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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -720,10 +759,12 @@ import VuetifyAudio from 'vuetify-audio'
|
|||||||
// Main JS (in UMD format)
|
// Main JS (in UMD format)
|
||||||
import VueTimepicker from 'vue2-timepicker'
|
import VueTimepicker from 'vue2-timepicker'
|
||||||
import DatePicker from 'vue2-datepicker';
|
import DatePicker from 'vue2-datepicker';
|
||||||
|
import Cropper from 'cropperjs';
|
||||||
|
|
||||||
import 'vue2-datepicker/index.css';
|
import 'vue2-datepicker/index.css';
|
||||||
// CSS
|
// CSS
|
||||||
import 'vue2-timepicker/dist/VueTimepicker.css'
|
import 'vue2-timepicker/dist/VueTimepicker.css'
|
||||||
|
import 'cropperjs/dist/cropper.css';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "AudioContentList",
|
name: "AudioContentList",
|
||||||
@@ -740,6 +781,9 @@ export default {
|
|||||||
show_create_dialog: false,
|
show_create_dialog: false,
|
||||||
show_modify_dialog: false,
|
show_modify_dialog: false,
|
||||||
show_delete_confirm_dialog: false,
|
show_delete_confirm_dialog: false,
|
||||||
|
show_cropper_dialog: false,
|
||||||
|
cropper_image_url: '',
|
||||||
|
cropper: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
total_page: 0,
|
total_page: 0,
|
||||||
search_word: '',
|
search_word: '',
|
||||||
@@ -834,13 +878,63 @@ export default {
|
|||||||
imageAdd(payload) {
|
imageAdd(payload) {
|
||||||
const file = payload;
|
const file = payload;
|
||||||
if (file) {
|
if (file) {
|
||||||
this.audio_content.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: 1,
|
||||||
|
viewMode: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
this.audio_content.cover_image_url = null
|
this.audio_content.cover_image_url = null
|
||||||
|
this.audio_content.cover_image = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cancelCropper() {
|
||||||
|
this.show_cropper_dialog = false
|
||||||
|
if (this.cropper) {
|
||||||
|
this.cropper.destroy()
|
||||||
|
this.cropper = null
|
||||||
|
}
|
||||||
|
this.cropper_image_url = ''
|
||||||
|
this.audio_content.cover_image = null
|
||||||
|
},
|
||||||
|
|
||||||
|
cropImage() {
|
||||||
|
const canvas = this.cropper.getCroppedCanvas()
|
||||||
|
let finalCanvas = canvas
|
||||||
|
|
||||||
|
const MAX_SIZE = 800
|
||||||
|
if (canvas.width > MAX_SIZE || canvas.height > MAX_SIZE) {
|
||||||
|
const resizeCanvas = document.createElement('canvas')
|
||||||
|
resizeCanvas.width = MAX_SIZE
|
||||||
|
resizeCanvas.height = MAX_SIZE
|
||||||
|
const ctx = resizeCanvas.getContext('2d')
|
||||||
|
ctx.drawImage(canvas, 0, 0, MAX_SIZE, MAX_SIZE)
|
||||||
|
finalCanvas = resizeCanvas
|
||||||
|
}
|
||||||
|
|
||||||
|
finalCanvas.toBlob((blob) => {
|
||||||
|
const file = new File([blob], 'cover_image.png', { type: 'image/png' })
|
||||||
|
file._isCropped = true // 크롭된 파일임을 표시하여 재진입 방지
|
||||||
|
this.audio_content.cover_image = file
|
||||||
|
this.audio_content.cover_image_url = URL.createObjectURL(blob)
|
||||||
|
this.show_cropper_dialog = false
|
||||||
|
if (this.cropper) {
|
||||||
|
this.cropper.destroy()
|
||||||
|
this.cropper = null
|
||||||
|
}
|
||||||
|
}, 'image/png')
|
||||||
|
},
|
||||||
|
|
||||||
async getAudioContentThemeList() {
|
async getAudioContentThemeList() {
|
||||||
this.is_loading = true
|
this.is_loading = true
|
||||||
try {
|
try {
|
||||||
@@ -1235,4 +1329,9 @@ export default {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cropper-wrapper {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -369,11 +369,50 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="show_cropper_dialog"
|
||||||
|
max-width="800px"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>이미지 크롭 (1:1 비율)</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import * as api from "@/api/signature";
|
import * as api from "@/api/signature";
|
||||||
|
import Cropper from 'cropperjs';
|
||||||
|
import 'cropperjs/dist/cropper.css';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "SignatureManagement",
|
name: "SignatureManagement",
|
||||||
@@ -399,6 +438,10 @@ export default {
|
|||||||
selected_signature_can: {},
|
selected_signature_can: {},
|
||||||
sort_type: 'NEWEST',
|
sort_type: 'NEWEST',
|
||||||
|
|
||||||
|
show_cropper_dialog: false,
|
||||||
|
cropper_image_url: '',
|
||||||
|
cropper: null,
|
||||||
|
|
||||||
headers: [
|
headers: [
|
||||||
{
|
{
|
||||||
text: '캔',
|
text: '캔',
|
||||||
@@ -450,13 +493,70 @@ export default {
|
|||||||
imageAdd(payload) {
|
imageAdd(payload) {
|
||||||
const file = payload;
|
const file = payload;
|
||||||
if (file) {
|
if (file) {
|
||||||
this.image_url = URL.createObjectURL(file)
|
if (file._isCropped) return;
|
||||||
URL.revokeObjectURL(file)
|
|
||||||
|
if (file.type === 'image/gif') {
|
||||||
|
this.image = file
|
||||||
|
this.image_url = URL.createObjectURL(file)
|
||||||
|
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: 1,
|
||||||
|
viewMode: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
this.image_url = null
|
this.image_url = null
|
||||||
|
this.image = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cancelCropper() {
|
||||||
|
this.show_cropper_dialog = false
|
||||||
|
if (this.cropper) {
|
||||||
|
this.cropper.destroy()
|
||||||
|
this.cropper = null
|
||||||
|
}
|
||||||
|
this.cropper_image_url = ''
|
||||||
|
this.image = null
|
||||||
|
this.image_url = null
|
||||||
|
},
|
||||||
|
|
||||||
|
cropImage() {
|
||||||
|
const canvas = this.cropper.getCroppedCanvas()
|
||||||
|
let finalCanvas = canvas
|
||||||
|
|
||||||
|
const MAX_WIDTH = 800
|
||||||
|
if (canvas.width > MAX_WIDTH) {
|
||||||
|
const height = MAX_WIDTH
|
||||||
|
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], 'signature_image.png', {type: 'image/png'})
|
||||||
|
file._isCropped = true
|
||||||
|
this.image = file
|
||||||
|
this.image_url = URL.createObjectURL(blob)
|
||||||
|
this.show_cropper_dialog = false
|
||||||
|
if (this.cropper) {
|
||||||
|
this.cropper.destroy()
|
||||||
|
this.cropper = null
|
||||||
|
}
|
||||||
|
}, 'image/png')
|
||||||
|
},
|
||||||
|
|
||||||
showWriteDialog() {
|
showWriteDialog() {
|
||||||
this.show_write_dialog = true
|
this.show_write_dialog = true
|
||||||
},
|
},
|
||||||
@@ -713,4 +813,14 @@ export default {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #3bb9f1;
|
color: #3bb9f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cropper-wrapper {
|
||||||
|
max-height: 500px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-image {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
121
work/check-commit-message-rules.sh
Executable file
121
work/check-commit-message-rules.sh
Executable file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
print_usage() {
|
||||||
|
echo "Usage:"
|
||||||
|
echo " $0"
|
||||||
|
echo " $0 <commit-hash>"
|
||||||
|
echo " $0 --message \"<commit-message>\""
|
||||||
|
echo " $0 --message-file <file-path>"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_commit_message() {
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
local commit_ref="HEAD"
|
||||||
|
echo "Checking latest commit..." >&2
|
||||||
|
git log -1 --pretty=format:"%s%n%b" "$commit_ref"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
-h|--help)
|
||||||
|
print_usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--message)
|
||||||
|
shift
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "[FAIL] --message option requires a commit message"
|
||||||
|
print_usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Checking provided commit message..." >&2
|
||||||
|
printf '%s' "$*"
|
||||||
|
;;
|
||||||
|
--message-file)
|
||||||
|
shift
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "[FAIL] --message-file option requires a file path"
|
||||||
|
print_usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$1" ]; then
|
||||||
|
echo "[FAIL] Commit message file not found: $1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Checking commit message file: $1" >&2
|
||||||
|
cat "$1"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "[FAIL] Invalid arguments"
|
||||||
|
print_usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local commit_ref="$1"
|
||||||
|
if ! git rev-parse --verify "$commit_ref^{commit}" >/dev/null 2>&1; then
|
||||||
|
echo "[FAIL] Invalid commit reference: $commit_ref"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Checking commit: $commit_ref" >&2
|
||||||
|
git log -1 --pretty=format:"%s%n%b" "$commit_ref"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
commit_message=$(load_commit_message "$@")
|
||||||
|
subject=$(printf '%s\n' "$commit_message" | head -n1)
|
||||||
|
body=$(printf '%s\n' "$commit_message" | tail -n +2)
|
||||||
|
|
||||||
|
echo "Checking commit message format..."
|
||||||
|
echo "Subject: $subject"
|
||||||
|
|
||||||
|
exit_code=0
|
||||||
|
|
||||||
|
if [ -z "$subject" ]; then
|
||||||
|
echo "[FAIL] Subject must not be empty"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
subject_pattern='^([a-z]+)(\([a-z0-9._/-]+\))?(!)?: (.+)$'
|
||||||
|
if [[ "$subject" =~ $subject_pattern ]]; then
|
||||||
|
type="${BASH_REMATCH[1]}"
|
||||||
|
description="${BASH_REMATCH[4]}"
|
||||||
|
|
||||||
|
echo "[PASS] Subject follows Conventional Commit format"
|
||||||
|
echo "[PASS] Type is lowercase: $type"
|
||||||
|
|
||||||
|
if printf '%s\n' "$description" | grep -Eq '[가-힣]'; then
|
||||||
|
echo "[PASS] Description contains Korean text"
|
||||||
|
else
|
||||||
|
echo "[FAIL] Description must contain Korean text"
|
||||||
|
exit_code=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[FAIL] Subject must match: <type>(scope): <description>"
|
||||||
|
echo " scope is optional, example: feat: 기능을 추가한다"
|
||||||
|
exit_code=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$body" ] && printf '%s\n' "$body" | grep -Eq '^Refs:'; then
|
||||||
|
while IFS= read -r refs_line; do
|
||||||
|
if ! printf '%s\n' "$refs_line" | grep -Eq '^Refs: #[0-9]+(, #[0-9]+)*$'; then
|
||||||
|
echo "[FAIL] Refs footer format is invalid: $refs_line"
|
||||||
|
echo " expected format: Refs: #123 or Refs: #123, #456"
|
||||||
|
exit_code=1
|
||||||
|
fi
|
||||||
|
done < <(printf '%s\n' "$body" | grep -E '^Refs:')
|
||||||
|
|
||||||
|
if [ $exit_code -eq 0 ]; then
|
||||||
|
echo "[PASS] Refs footer format is valid"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $exit_code -eq 0 ]; then
|
||||||
|
echo "[PASS] Commit message follows AGENTS.md rules"
|
||||||
|
else
|
||||||
|
echo "[FAIL] Commit message violates AGENTS.md rules"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $exit_code
|
||||||
Reference in New Issue
Block a user