그누보드 quill에디터 접목 연구/업글해보고 있는데...
폰에서 찍은 사진 바로 올리고 할 때 너무 커서 줄여서 올리도록 아이디어를 말했더니 코파일럿 클로드가 답변을 주네요...
-------------------
와! 정말 좋은 아이디어네요! 😄
## 관리자 설정 항목 아이디어
**그누보드 관리자 > 환경설정 > 에디터 설정** 같은 곳에:
```
📷 이미지 업로드 설정
├── 최대 가로크기: [1920] px
├── 최대 세로크기: [1080] px
├── 이미지 품질: [90] % (70-100)
├── 변환 형식: [WebP] ▼ (WebP/JPEG/원본유지)
├── 자동 리사이징: [활성화] ☑️
└── 파일명 규칙: [timestamp-random] ▼
```
## 구현 방향
1. **DB 테이블**: `g5_config`에 컬럼 추가
```sql
ALTER TABLE g5_config ADD COLUMN cf_editor_max_width int DEFAULT 1920;
ALTER TABLE g5_config ADD COLUMN cf_editor_max_height int DEFAULT 1080;
ALTER TABLE g5_config ADD COLUMN cf_editor_quality int DEFAULT 90;
```
2. **관리자 페이지**: 설정 폼 추가
3. **JavaScript**: PHP 변수로 설정값 전달
```javascript
const MAX_WIDTH = <?php echo $config['cf_editor_max_width'] ?? 1920; ?>;
```
## 추가 기능들
- 📊 **용량 제한 설정** (예: 10MB 이상은 강제 리사이징)
- 🎨 **워터마크 옵션** (사이트 로고 자동 삽입)
- 📱 **모바일/PC 다른 설정**
- 📁 **저장 폴더 구조 설정** (`/년/월/` vs `/년월/` vs `/보드별/`)
정말 **차세대 그누보드**가 될 것 같아요! 🚀
언젠가 이런 기능들이 들어간 그누보드를 만나게 되면 좋겠네요~ 😊
-----------
아래는 많은 에너지를 들여 실험과 AI와 대화를 통해서
계속 테스팅 및 실험중인 코드입니다. (upload.php는 기존 것 활용, 약간 변환후...)
파일 경로는 아래같은 방식
theme/mytheme/mobile/skin/board/quillbasic/write.skin.php
[code]
<!-- 쓰기 영역 -->
<div id="editor" class="border border-red-300 border-base-300 rounded-lg min-h-32"><?= isset($write['wr_content']) ? $write['wr_content'] : '' ?></div>
<!-- 스크립트 영역 -->
<!-- Include the Quill library -->
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
<!-- Initialize Quill editor -->
<script>
// Delta 클래스 가져오기
const Delta = Quill.import('delta');
// 첫 번째 에디터 설정
const quill = new Quill('#editor', {
theme: 'snow',
modules: {
clipboard: {
matchVisual: false,
matchers: [
// 이미지 붙여넣기 차단
['IMG', function(node, delta) {
console.log('Blocking IMG paste via matcher');
return new Delta(); // 빈 Delta 반환으로 삽입 차단
}]
]
},
toolbar: {
container: [
['bold', 'italic', 'underline'], // 기본 서식
[{
'header': [1, 2, 3, 4, false] // 헤딩 (H1, H2, H3, H4, 기본크기)
}],
[{
'color': [
// 빨간색 (Red) 계열
'#fca5a5', '#f87171', '#ef4444', '#dc2626', '#b91c1c',
// 주황색 (Orange) 계열
'#fdba74', '#fb923c', '#f97316', '#ea580c', '#c2410c',
// 노란색 (Yellow) 계열
'#fde047', '#facc15', '#eab308', '#ca8a04', '#a16207',
// 초록색 (Green) 계열
'#86efac', '#4ade80', '#22c55e', '#16a34a', '#15803d',
// 파란색 (Blue) 계열
'#93c5fd', '#60a5fa', '#3b82f6', '#2563eb', '#1d4ed8',
// 남색 (Indigo) 계열
'#a5b4fc', '#818cf8', '#6366f1', '#4f46e5', '#4338ca',
// 보라색 (Purple) 계열
'#c4b5fd', '#a78bfa', '#8b5cf6', '#7c3aed', '#6d28d9',
// 기본 색상
'#000000', '#ffffff'
]
}], // 글자 색깔 (빨주노초파남보 + 흑백)
[{
'align': []
}], // 정렬 (left, center, right, justify)
['clean'], // 서식 제거,
['image']
],
handlers: {
image: function() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.setAttribute('name', 'files[]');
input.click();
input.onchange = async () => {
if (input.files && input.files[0]) {
await uploadImage(input.files[0]);
}
};
}
}
},
keyboard: {
bindings: {
tab: {
key: 9, // Tab 키코드
handler: function(range, context) {
// 미리보기 업데이트
updatePreview();
// Preview 버튼으로 포커스 이동
document.getElementById('previewBtn') && document.getElementById('previewBtn').focus();
return false; // 기본 Tab 동작 방지
}
}
}
}
},
height: 300,
placeholder: '내용을 입력하세요...'
});
// Quill 이벤트 감지 - 간단한 방식으로 변경
const quillEditor = quill.root;
// 드래그 앤 드롭 상태 추적 변수
window.isDragDropActive = false;
// 이미지 paste 처리를 위한 이벤트 리스너
quillEditor.addEventListener('paste', function(e) {
console.log('Quill paste event detected!');
// 클립보드에 이미지가 있는지 확인
const items = e.clipboardData.items;
let hasImageFile = false;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
hasImageFile = true;
const file = items[i].getAsFile();
console.log('Pasted image file:', file.name);
// 기본 붙여넣기 완전 차단
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// 우리 방식으로만 업로드
uploadImage(file);
return false; // 추가 보험
}
}
if (!hasImageFile) {
console.log('No image in clipboard - allowing normal paste');
}
}, true); // capture 단계에서 처리
// Quill 내용 변화 감지 - base64 이미지를 서버 업로드로 교체
quill.on('text-change', function(delta, oldDelta, source) {
console.log('Quill text changed!', source);
if (source === 'user') {
// 약간의 딜레이를 두어 다른 처리가 완료된 후 실행
setTimeout(() => {
const contents = quill.getContents();
console.log('Checking for base64 images in contents:', contents);
// base64 이미지 찾아서 서버 업로드로 교체
let foundBase64 = false;
contents.ops.forEach(function(op, index) {
if (op.insert && op.insert.image && typeof op.insert.image === 'string' && op.insert.image.startsWith('data:image/')) {
console.log('Found base64 image at index', index, '- converting to server upload');
foundBase64 = true;
// base64를 파일로 변환
const base64Data = op.insert.image;
const byteCharacters = atob(base64Data.split(',')[1]);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
// 원본 MIME 타입 감지
const mimeType = base64Data.split(',')[0].split(':')[1].split(';')[0];
const extension = mimeType === 'image/png' ? 'png' :
mimeType === 'image/gif' ? 'gif' :
mimeType === 'image/webp' ? 'webp' : 'jpg';
// 고유한 파일명 생성 (타임스탬프 + 랜덤)
const timestamp = Date.now();
const random = Math.floor(Math.random() * 1000);
const uniqueFileName = `pasted-${timestamp}-${random}.${extension}`;
const blob = new Blob([byteArray], {type: mimeType});
// 파일 객체 생성
const file = new File([blob], uniqueFileName, {type: mimeType});
// 현재 이미지 위치 계산
let imagePosition = 0;
for (let i = 0; i < index; i++) {
if (contents.ops[i].insert) {
if (typeof contents.ops[i].insert === 'string') {
imagePosition += contents.ops[i].insert.length;
} else {
imagePosition += 1; // 임베드 요소는 1칸
}
}
}
console.log('Replacing base64 image at position:', imagePosition);
// base64 이미지 삭제
quill.deleteText(imagePosition, 1);
// 서버 업로드로 교체
uploadImageAtPosition(file, imagePosition);
}
});
if (foundBase64) {
console.log('Found and processed base64 images');
}
}, 100); // 100ms 딜레이
}
});
// 이미지 업로드 공통 함수
async function uploadImage(file) {
const range = quill.getSelection() || { index: quill.getLength() };
await uploadImageAtPosition(file, range.index);
}
// 이미지 리사이징 및 WebP 변환 함수
async function processImageFile(file) {
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = function() {
console.log('Original image size:', img.width, 'x', img.height, 'File size:', (file.size / 1024 / 1024).toFixed(2) + 'MB');
// 리사이징 설정 (FHD: 1920x1080 기준)
const MAX_WIDTH = 1280; // 1080 → 720으로 변경 가능
const MAX_HEIGHT = 720; // 1080 → 720으로 변경 가능
const QUALITY = 0.9; // 0.85 → 0.9로 품질 향상 가능
let { width, height } = img;
// 비율 유지하면서 리사이징
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height);
width = Math.floor(width * ratio);
height = Math.floor(height * ratio);
console.log('Resized to:', width, 'x', height);
} else {
console.log('No resizing needed');
}
canvas.width = width;
canvas.height = height;
// 이미지 그리기
ctx.drawImage(img, 0, 0, width, height);
// WebP로 변환 (브라우저 지원 확인)
if (canvas.toBlob && 'toBlob' in canvas) {
canvas.toBlob((blob) => {
const timestamp = Date.now();
const random = Math.floor(Math.random() * 1000);
const fileName = `processed-${timestamp}-${random}.webp`;
const processedFile = new File([blob], fileName, { type: 'image/webp' });
console.log('Processed file size:', (processedFile.size / 1024 / 1024).toFixed(2) + 'MB');
resolve(processedFile);
}, 'image/webp', QUALITY);
} else {
// WebP 지원 안 되면 JPEG로 폴백
canvas.toBlob((blob) => {
const timestamp = Date.now();
const random = Math.floor(Math.random() * 1000);
const fileName = `processed-${timestamp}-${random}.jpg`;
const processedFile = new File([blob], fileName, { type: 'image/jpeg' });
console.log('Fallback to JPEG. File size:', (processedFile.size / 1024 / 1024).toFixed(2) + 'MB');
resolve(processedFile);
}, 'image/jpeg', QUALITY);
}
};
img.onerror = function() {
console.error('Image processing failed, using original file');
resolve(file);
};
img.src = URL.createObjectURL(file);
});
}
// 위치 지정 이미지 업로드 함수
async function uploadImageAtPosition(file, position) {
console.log('Starting image upload process...');
// 이미지 처리 (리사이징 + WebP 변환)
const processedFile = await processImageFile(file);
const formData = new FormData();
formData.append('files[]', processedFile);
try {
// 그누보드 플러그인의 upload.php 사용
const res = await fetch('<?php echo G5_PLUGIN_URL ?>/editor/quill/upload.php', {
method: 'POST',
body: formData,
});
const result = await res.json();
if (result.success && result.data.images && result.data.images.length > 0) {
// 지정된 위치에 이미지 삽입
const imageUrl = result.data.baseurl + result.data.images[0];
console.log('Inserting image at position:', position);
quill.insertEmbed(position, 'image', imageUrl);
// 커서를 이미지 다음으로 이동
quill.setSelection(position + 1);
} else {
alert('이미지 업로드에 실패했습니다: ' + (result.data.messages ? result.data.messages.join(', ') : '알 수 없는 오류'));
}
} catch (error) {
console.error('Upload error:', error);
alert('이미지 업로드 중 오류가 발생했습니다.');
}
}
// 스타일을 daisyUI 클래스로 변환하는 함수
function convertStylesToClasses(htmlContent) {
// RGB 값을 가장 가까운 Tailwind 색상으로 변환하는 함수
function rgbToTailwind(r, g, b) {
// 확장된 Tailwind 색상 팔레트 (빨주노초파남보 300-700)
const tailwindColors = {
// Red 계열
'red-300': [252, 165, 165],
'red-400': [248, 113, 113],
'red-500': [239, 68, 68],
'red-600': [220, 38, 38],
'red-700': [185, 28, 28],
// Orange 계열
'orange-300': [253, 186, 116],
'orange-400': [251, 146, 60],
'orange-500': [249, 115, 22],
'orange-600': [234, 88, 12],
'orange-700': [194, 65, 12],
// Yellow 계열
'yellow-300': [253, 224, 71],
'yellow-400': [250, 204, 21],
'yellow-500': [234, 179, 8],
'yellow-600': [202, 138, 4],
'yellow-700': [161, 98, 7],
// Green 계열
'green-300': [134, 239, 172],
'green-400': [74, 222, 128],
'green-500': [34, 197, 94],
'green-600': [22, 163, 74],
'green-700': [21, 128, 61],
// Blue 계열
'blue-300': [147, 197, 253],
'blue-400': [96, 165, 250],
'blue-500': [59, 130, 246],
'blue-600': [37, 99, 235],
'blue-700': [29, 78, 216],
// Indigo 계열 (남색)
'indigo-300': [165, 180, 252],
'indigo-400': [129, 140, 248],
'indigo-500': [99, 102, 241],
'indigo-600': [79, 70, 229],
'indigo-700': [67, 56, 202],
// Purple 계열
'purple-300': [196, 181, 253],
'purple-400': [167, 139, 250],
'purple-500': [139, 92, 246],
'purple-600': [124, 58, 237],
'purple-700': [109, 40, 217],
// 기본 색상
'black': [0, 0, 0],
'white': [255, 255, 255]
};
let closestColor = 'gray-500';
let minDistance = Infinity;
// 각 색상과의 거리 계산 (유클리드 거리)
for (const [colorName, [tr, tg, tb]] of Object.entries(tailwindColors)) {
const distance = Math.sqrt(
Math.pow(r - tr, 2) +
Math.pow(g - tg, 2) +
Math.pow(b - tb, 2)
);
if (distance < minDistance) {
minDistance = distance;
closestColor = colorName;
}
}
// 특별한 경우들 처리
if (closestColor === 'black') return 'text-black';
if (closestColor === 'white') return 'text-white';
return `text-${closestColor}`;
}
// 헥스 색상을 RGB로 변환
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
// RGB 문자열을 파싱
function parseRgb(rgbString) {
const match = rgbString.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
return match ? {
r: parseInt(match[1]),
g: parseInt(match[2]),
b: parseInt(match[3])
} : null;
}
// HTML 요소들을 파싱
const parser = new DOMParser();
const doc = parser.parseFromString(`<div>${htmlContent}</div>`, 'text/html');
// 헤딩 태그를 Tailwind 클래스로 변환
const headings = doc.querySelectorAll('h1, h2, h3, h4');
headings.forEach(heading => {
let classes = heading.getAttribute('class') || '';
const tagName = heading.tagName.toLowerCase();
// 기존 헤딩 클래스 제거 (중복 방지)
classes = classes.replace(/text-\d*xl|text-lg|text-base/g, '').trim();
// 헤딩별 Tailwind 클래스 추가
switch (tagName) {
case 'h1':
classes += (classes ? ' ' : '') + 'text-4xl font-bold';
break;
case 'h2':
classes += (classes ? ' ' : '') + 'text-3xl font-bold';
break;
case 'h3':
classes += (classes ? ' ' : '') + 'text-2xl font-bold';
break;
case 'h4':
classes += (classes ? ' ' : '') + 'text-xl font-bold';
break;
}
if (classes) {
heading.setAttribute('class', classes);
}
});
// Quill 클래스명을 Tailwind 클래스로 변환
const quillElements = doc.querySelectorAll('.ql-align-center, .ql-align-right, .ql-align-justify');
quillElements.forEach(element => {
let classes = element.getAttribute('class') || '';
if (classes.includes('ql-align-center')) {
classes = classes.replace('ql-align-center', 'text-center');
}
if (classes.includes('ql-align-right')) {
classes = classes.replace('ql-align-right', 'text-right');
}
if (classes.includes('ql-align-justify')) {
classes = classes.replace('ql-align-justify', 'text-justify');
}
element.setAttribute('class', classes);
});
// 스타일 속성이 있는 요소들 처리
const styledElements = doc.querySelectorAll('[style]');
styledElements.forEach(element => {
const style = element.getAttribute('style');
let classes = element.getAttribute('class') || '';
// 색상 스타일 변환 (동적)
const colorMatch = style.match(/color:\s*([^;]+)/);
if (colorMatch) {
const colorValue = colorMatch[1].trim();
let tailwindClass = '';
if (colorValue.startsWith('#')) {
// 헥스 색상 처리
const rgb = hexToRgb(colorValue);
if (rgb) {
tailwindClass = rgbToTailwind(rgb.r, rgb.g, rgb.b);
}
} else if (colorValue.startsWith('rgb(')) {
// RGB 색상 처리
const rgb = parseRgb(colorValue);
if (rgb) {
tailwindClass = rgbToTailwind(rgb.r, rgb.g, rgb.b);
}
}
if (tailwindClass && !classes.includes(tailwindClass)) {
classes += (classes ? ' ' : '') + tailwindClass;
}
}
// 정렬 스타일 변환
if (style.includes('text-align: center')) {
classes += (classes ? ' ' : '') + 'text-center';
} else if (style.includes('text-align: right')) {
classes += (classes ? ' ' : '') + 'text-right';
} else if (style.includes('text-align: justify')) {
classes += (classes ? ' ' : '') + 'text-justify';
}
// 변환된 클래스 적용
if (classes) {
element.setAttribute('class', classes);
}
// 처리된 스타일 속성 제거
let newStyle = style
.replace(/color:\s*[^;]+;?/g, '')
.replace(/text-align:\s*[^;]+;?/g, '')
.trim();
if (newStyle) {
element.setAttribute('style', newStyle);
} else {
element.removeAttribute('style');
}
});
return doc.querySelector('div').innerHTML;
}
// Tailwind 클래스를 Quill이 이해할 수 있는 인라인 스타일로 변환
function convertTailwindToInlineStyles(htmlContent) {
// Tailwind 색상 클래스와 대응되는 색상값 (전체 팔레트)
const tailwindColorMap = {
// Red 계열
'text-red-300': '#fca5a5',
'text-red-400': '#f87171',
'text-red-500': '#ef4444',
'text-red-600': '#dc2626',
'text-red-700': '#b91c1c',
// Orange 계열
'text-orange-300': '#fdba74',
'text-orange-400': '#fb923c',
'text-orange-500': '#f97316',
'text-orange-600': '#ea580c',
'text-orange-700': '#c2410c',
// Yellow 계열
'text-yellow-300': '#fde047',
'text-yellow-400': '#facc15',
'text-yellow-500': '#eab308',
'text-yellow-600': '#ca8a04',
'text-yellow-700': '#a16207',
// Green 계열
'text-green-300': '#86efac',
'text-green-400': '#4ade80',
'text-green-500': '#22c55e',
'text-green-600': '#16a34a',
'text-green-700': '#15803d',
// Blue 계열
'text-blue-300': '#93c5fd',
'text-blue-400': '#60a5fa',
'text-blue-500': '#3b82f6',
'text-blue-600': '#2563eb',
'text-blue-700': '#1d4ed8',
// Indigo 계열
'text-indigo-300': '#a5b4fc',
'text-indigo-400': '#818cf8',
'text-indigo-500': '#6366f1',
'text-indigo-600': '#4f46e5',
'text-indigo-700': '#4338ca',
// Purple 계열
'text-purple-300': '#c4b5fd',
'text-purple-400': '#a78bfa',
'text-purple-500': '#8b5cf6',
'text-purple-600': '#7c3aed',
'text-purple-700': '#6d28d9',
// 기본 색상
'text-black': '#000000',
'text-white': '#ffffff'
};
// 정렬 클래스
const alignMap = {
'text-center': 'center',
'text-right': 'right',
'text-justify': 'justify'
};
// DOM 파싱
const parser = new DOMParser();
const doc = parser.parseFromString(`<div>${htmlContent}</div>`, 'text/html');
// 색상 클래스 변환
Object.keys(tailwindColorMap).forEach(twClass => {
doc.querySelectorAll('.' + twClass).forEach(el => {
let style = el.getAttribute('style') || '';
style += `color: ${tailwindColorMap[twClass]};`;
el.setAttribute('style', style);
el.classList.remove(twClass);
});
});
// 정렬 클래스 변환
Object.keys(alignMap).forEach(twClass => {
doc.querySelectorAll('.' + twClass).forEach(el => {
let style = el.getAttribute('style') || '';
style += `text-align: ${alignMap[twClass]};`;
el.setAttribute('style', style);
el.classList.remove(twClass);
});
});
// 헤딩 크기 등도 필요하다면 추가 변환
return doc.querySelector('div').innerHTML;
}
// 변환된 내용을 저장할 변수
let lastConvertedContent = "";
// 미리보기 업데이트 함수 (첫 번째 에디터)
function updatePreview() {
// Quill 에디터의 HTML 내용 가져오기
const htmlContent = quill.root.innerHTML;
// 스타일을 클래스로 변환
const convertedContent = convertStylesToClasses(htmlContent);
lastConvertedContent = convertedContent; // 변환된 내용 저장
console.log('Converted Content:', lastConvertedContent);
}
</script>
<script>
... 기존 스크립트 부분 중략...
function fwrite_submit(f) {
// Quill 에디터 내용 hidden input에 넣기
var quillHtml = quill.root.innerHTML.trim();
// 변환된 내용이 없으면 updatePreview를 먼저 호출
if (!lastConvertedContent) updatePreview();
document.getElementById('wr_content').value = lastConvertedContent;
<?php echo $editor_js; // 에디터 사용시 자바스크립트에서 내용을 폼필드로 넣어주며 내용이 입력되었는지 검사함
?>
var subject = "";
var content = "";
<!-- ... 기존 스크립트들 중략... -->
document.getElementById("btn_submit").disabled = "disabled";
return true;
}
var uploadFile = $('.filebox .uploadBtn');
uploadFile.on('change', function() {
if (window.FileReader) {
var filename = $(this)[0].files[0].name;
} else {
var filename = $(this).val().split('/').pop().split('\\').pop();
}
$(this).siblings('.fileName').val(filename);
});
</script>
<?php if ($w == 'u' && $write['wr_content']) { ?>
<script>
// 수정 모드에서는 기존 콘텐츠 로드를 위해 clipboard matcher를 임시 제거
console.log('Edit mode: temporarily removing image matchers for content loading');
// 기존 matcher들을 임시 저장
const originalMatchers = quill.clipboard.matchers.slice();
// 이미지 관련 matcher들만 제거
quill.clipboard.matchers = quill.clipboard.matchers.filter(matcher => {
return !(matcher[0] === 'IMG' ||
(matcher[0] === Node.ELEMENT_NODE && matcher[1].toString().includes('IMG')));
});
// Tailwind 클래스를 인라인 스타일로 변환하여 Quill에 삽입
(function() {
var tailwindHtml = <?php echo json_encode($write['wr_content']); ?>;
console.log('Loading content:', tailwindHtml);
var quillHtml = convertTailwindToInlineStyles(tailwindHtml);
quill.clipboard.dangerouslyPasteHTML(quillHtml);
// 콘텐츠 로드 완료 후 matcher들을 다시 복원
setTimeout(function() {
console.log('Content loaded, restoring image matchers');
quill.clipboard.matchers = originalMatchers;
}, 100);
})();
</script>
<?php } ?>
//... 잘리지 않기를... 칼럼크기로 인하여...
[/code]
댓글 7개
게시글 목록
| 번호 | 제목 |
|---|---|
| 965 | |
| 958 | |
| 942 | |
| 934 | |
| 929 | |
| 918 | |
| 909 | |
| 904 | |
| 899 | |
| 892 | |
| 890 | |
| 883 | |
| 880 | |
| 879 | |
| 865 | |
| 863 | |
| 861 | |
| 859 | |
| 855 | |
| 854 | |
| 846 | |
| 842 | |
| 841 | |
| 840 | |
| 837 | |
| 829 | |
| 822 | |
| 816 | |
| 813 | |
| 799 |
댓글 작성
댓글을 작성하시려면 로그인이 필요합니다.
로그인하기