feat(study): 문제 첨부 이미지 (PR-8)
문제별 N개 이미지 첨부. 회로도/그래프 등이 필요한 시험 문제 지원.
입력·편집·복습 모두에서 표시.
데이터 모델 (migration 198):
- study_question_images: id, user_id FK CASCADE, study_question_id FK CASCADE,
file_path, file_size, mime_type, sort_order, created_at
- partial idx (study_question_id, sort_order, id)
저장: NAS /documents/study_question_images/{topic_id}/{qid}/{img_id}.{ext}
file_watcher 가 보는 PKM 경로와 분리 — 자동 인덱싱 안 됨.
API:
- POST /api/study-questions/{qid}/images (multipart, MIME PNG/JPEG/WEBP/GIF,
10MB/파일 제한, sort_order 자동 max+1)
- GET /api/study-questions/{qid}/images/{img_id}/raw (FileResponse, Bearer 인증)
- DELETE /api/study-questions/{qid}/images/{img_id} (DB row + 파일 시스템 정리)
- StudyQuestionResponse / ReviewQuestionItem 응답에 images 배열 포함
- StudyQuestionSummary 응답에 has_images bool 추가
프론트:
- 신규 lib/components/ImgAuth.svelte — Bearer 인증 endpoint 의 이미지를 fetch +
blob URL 로 변환해 <img> 표시. unmount 시 URL.revokeObjectURL.
- /questions/new: 입력 폼에 이미지 dropzone (client-side 보유) → POST
/questions 받은 qid 로 자동 multipart 업로드. "저장 후 계속 입력" 시 reset.
- /questions/[qid]/edit: 별도 카드 — 기존 이미지 grid + 추가/삭제. 즉시 업로드.
- /review: 문제 본문 아래 이미지 grid (max-h-72 object-contain).
- 모든 표시는 ImgAuth 컴포넌트 — accessToken 만료 케이스 대비.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+216
-1
@@ -15,15 +15,18 @@ import random
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, case, func, select, text as sql_text, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai.client import AIClient
|
||||
from core.auth import get_current_user
|
||||
from core.config import settings
|
||||
from core.database import get_session
|
||||
from models.study_question import StudyQuestion, StudyQuestionAttempt
|
||||
from models.study_question_image import StudyQuestionImage
|
||||
from models.study_topic import StudyTopic
|
||||
from models.user import User
|
||||
from services.search.llm_gate import get_mlx_gate
|
||||
@@ -97,6 +100,15 @@ class QuestionAttemptStats(BaseModel):
|
||||
wrong_count: int
|
||||
|
||||
|
||||
class StudyQuestionImageItem(BaseModel):
|
||||
"""문제 첨부 이미지 메타. raw bytes 는 별도 GET endpoint."""
|
||||
id: int
|
||||
sort_order: int
|
||||
mime_type: str
|
||||
file_size: int
|
||||
served_url: str # 클라이언트 표시용 — /api/study-questions/{qid}/images/{img_id}/raw
|
||||
|
||||
|
||||
class StudyQuestionResponse(BaseModel):
|
||||
"""편집·관리용 — 정답·해설 모두 노출."""
|
||||
|
||||
@@ -121,6 +133,8 @@ class StudyQuestionResponse(BaseModel):
|
||||
ai_explanation_status: str = "none"
|
||||
ai_explanation_generated_at: datetime | None = None
|
||||
ai_explanation_model: str | None = None
|
||||
# PR-8: 첨부 이미지
|
||||
images: list[StudyQuestionImageItem] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
stats: QuestionAttemptStats
|
||||
@@ -136,6 +150,7 @@ class StudyQuestionSummary(BaseModel):
|
||||
exam_name: str | None
|
||||
exam_round: str | None
|
||||
is_active: bool
|
||||
has_images: bool = False
|
||||
attempt_count: int
|
||||
last_correct: bool | None
|
||||
created_at: datetime
|
||||
@@ -162,6 +177,7 @@ class ReviewQuestionItem(BaseModel):
|
||||
subject: str | None
|
||||
scope: str | None
|
||||
stats: QuestionAttemptStats
|
||||
images: list[StudyQuestionImageItem] = []
|
||||
|
||||
|
||||
class ReviewQuestionListResponse(BaseModel):
|
||||
@@ -234,6 +250,64 @@ class AIExplanationResponse(BaseModel):
|
||||
# ─── 통계 헬퍼 ───
|
||||
|
||||
|
||||
# PR-8: 이미지 저장 root + 헬퍼
|
||||
from pathlib import Path as _Path
|
||||
|
||||
STUDY_IMG_ROOT = _Path(settings.nas_mount_path) / "study_question_images"
|
||||
ALLOWED_IMAGE_MIME = {"image/png", "image/jpeg", "image/webp", "image/gif"}
|
||||
MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10MB / 파일
|
||||
|
||||
|
||||
def _image_url(qid: int, img_id: int) -> str:
|
||||
return f"/api/study-questions/{qid}/images/{img_id}/raw"
|
||||
|
||||
|
||||
def _image_to_item(img: StudyQuestionImage) -> StudyQuestionImageItem:
|
||||
return StudyQuestionImageItem(
|
||||
id=img.id,
|
||||
sort_order=img.sort_order,
|
||||
mime_type=img.mime_type,
|
||||
file_size=img.file_size,
|
||||
served_url=_image_url(img.study_question_id, img.id),
|
||||
)
|
||||
|
||||
|
||||
async def _images_for_question(
|
||||
session: AsyncSession, question_id: int
|
||||
) -> list[StudyQuestionImageItem]:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(StudyQuestionImage)
|
||||
.where(StudyQuestionImage.study_question_id == question_id)
|
||||
.order_by(StudyQuestionImage.sort_order.asc(), StudyQuestionImage.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [_image_to_item(r) for r in rows]
|
||||
|
||||
|
||||
async def _images_for_questions_batch(
|
||||
session: AsyncSession, question_ids: list[int]
|
||||
) -> dict[int, list[StudyQuestionImageItem]]:
|
||||
"""N+1 회피 — 여러 question 의 이미지 한 번에 조회."""
|
||||
if not question_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(StudyQuestionImage)
|
||||
.where(StudyQuestionImage.study_question_id.in_(question_ids))
|
||||
.order_by(
|
||||
StudyQuestionImage.study_question_id,
|
||||
StudyQuestionImage.sort_order.asc(),
|
||||
StudyQuestionImage.id.asc(),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
out: dict[int, list[StudyQuestionImageItem]] = {qid: [] for qid in question_ids}
|
||||
for r in rows:
|
||||
out.setdefault(r.study_question_id, []).append(_image_to_item(r))
|
||||
return out
|
||||
|
||||
|
||||
async def _attempt_stats(
|
||||
session: AsyncSession, user_id: int, question_id: int
|
||||
) -> QuestionAttemptStats:
|
||||
@@ -351,6 +425,18 @@ async def list_questions_in_topic(
|
||||
for r in latest_rows:
|
||||
last_correct_map[r.study_question_id] = bool(r.is_correct)
|
||||
|
||||
# PR-8: has_images batch
|
||||
has_image_set: set[int] = set()
|
||||
if qids:
|
||||
img_rows = (
|
||||
await session.execute(
|
||||
select(StudyQuestionImage.study_question_id)
|
||||
.where(StudyQuestionImage.study_question_id.in_(qids))
|
||||
.distinct()
|
||||
)
|
||||
).scalars().all()
|
||||
has_image_set = set(img_rows)
|
||||
|
||||
items = [
|
||||
StudyQuestionSummary(
|
||||
id=q.id,
|
||||
@@ -362,6 +448,7 @@ async def list_questions_in_topic(
|
||||
is_active=q.is_active,
|
||||
attempt_count=stats_map.get(q.id, (0, 0))[0],
|
||||
last_correct=last_correct_map.get(q.id),
|
||||
has_images=q.id in has_image_set,
|
||||
created_at=q.created_at,
|
||||
)
|
||||
for q in rows
|
||||
@@ -442,6 +529,7 @@ async def create_question_in_topic(
|
||||
ai_explanation_status=q.ai_explanation_status,
|
||||
ai_explanation_generated_at=q.ai_explanation_generated_at,
|
||||
ai_explanation_model=q.ai_explanation_model,
|
||||
images=await _images_for_question(session, q.id),
|
||||
created_at=q.created_at,
|
||||
updated_at=q.updated_at,
|
||||
stats=stats,
|
||||
@@ -579,6 +667,9 @@ async def review_questions_for_topic(
|
||||
attempt_count=t, correct_count=c, wrong_count=t - c
|
||||
)
|
||||
|
||||
# PR-8: 이미지 batch
|
||||
images_map = await _images_for_questions_batch(session, qids)
|
||||
|
||||
items = [
|
||||
ReviewQuestionItem(
|
||||
id=q.id,
|
||||
@@ -594,6 +685,7 @@ async def review_questions_for_topic(
|
||||
stats=stats_map.get(
|
||||
q.id, QuestionAttemptStats(attempt_count=0, correct_count=0, wrong_count=0)
|
||||
),
|
||||
images=images_map.get(q.id, []),
|
||||
)
|
||||
for q in selected
|
||||
]
|
||||
@@ -637,6 +729,7 @@ async def get_question(
|
||||
ai_explanation_status=q.ai_explanation_status,
|
||||
ai_explanation_generated_at=q.ai_explanation_generated_at,
|
||||
ai_explanation_model=q.ai_explanation_model,
|
||||
images=await _images_for_question(session, q.id),
|
||||
created_at=q.created_at,
|
||||
updated_at=q.updated_at,
|
||||
stats=stats,
|
||||
@@ -705,6 +798,7 @@ async def update_question(
|
||||
ai_explanation_status=q.ai_explanation_status,
|
||||
ai_explanation_generated_at=q.ai_explanation_generated_at,
|
||||
ai_explanation_model=q.ai_explanation_model,
|
||||
images=await _images_for_question(session, q.id),
|
||||
created_at=q.created_at,
|
||||
updated_at=q.updated_at,
|
||||
stats=stats,
|
||||
@@ -875,6 +969,127 @@ async def list_similar_questions(
|
||||
return SimilarQuestionsResponse(items=items, source_status="ready", source_id=question_id)
|
||||
|
||||
|
||||
# ─── PR-8: 이미지 업로드/조회/삭제 ───
|
||||
|
||||
|
||||
@router.post(
|
||||
"/study-questions/{question_id}/images",
|
||||
response_model=StudyQuestionImageItem,
|
||||
status_code=201,
|
||||
)
|
||||
async def upload_question_image(
|
||||
question_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""문제 첨부 이미지 업로드. 1파일 = 1행. 여러 호출로 여러 이미지 추가.
|
||||
|
||||
경로: /documents/study_question_images/{topic_id}/{qid}/{img_id}.{ext}
|
||||
인증: 같은 user 의 question 만. 다른 사용자는 404.
|
||||
제한: PNG/JPEG/WEBP/GIF, 10MB/파일.
|
||||
"""
|
||||
q = await session.get(StudyQuestion, question_id)
|
||||
q = _verify_question_ownership(q, user)
|
||||
|
||||
# MIME 검증
|
||||
mime = (file.content_type or "").lower()
|
||||
if mime not in ALLOWED_IMAGE_MIME:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail=f"지원하지 않는 이미지 형식: {mime} (PNG/JPEG/WEBP/GIF 만 허용)",
|
||||
)
|
||||
|
||||
# 파일 읽기 + 크기 검증
|
||||
raw = await file.read()
|
||||
if len(raw) > MAX_IMAGE_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"이미지 크기 초과 ({len(raw)} > {MAX_IMAGE_BYTES} 바이트, 10MB 제한)",
|
||||
)
|
||||
if len(raw) == 0:
|
||||
raise HTTPException(status_code=400, detail="빈 파일")
|
||||
|
||||
# 확장자 결정
|
||||
ext_map = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
}
|
||||
ext = ext_map[mime]
|
||||
|
||||
# DB 행 먼저 만들고 id 받음 (파일명에 사용)
|
||||
img = StudyQuestionImage(
|
||||
user_id=user.id,
|
||||
study_question_id=q.id,
|
||||
file_path="", # 일단 빈 값, id 받은 후 채움
|
||||
file_size=len(raw),
|
||||
mime_type=mime,
|
||||
sort_order=0,
|
||||
)
|
||||
session.add(img)
|
||||
await session.flush() # img.id 확보
|
||||
|
||||
# 파일 시스템 저장
|
||||
target_dir = STUDY_IMG_ROOT / str(q.study_topic_id) / str(q.id)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_path = target_dir / f"{img.id}.{ext}"
|
||||
target_path.write_bytes(raw)
|
||||
|
||||
img.file_path = str(target_path)
|
||||
# sort_order = 같은 question 의 max+1
|
||||
max_sort = (
|
||||
await session.execute(
|
||||
select(func.coalesce(func.max(StudyQuestionImage.sort_order), -1))
|
||||
.where(StudyQuestionImage.study_question_id == q.id)
|
||||
.where(StudyQuestionImage.id != img.id)
|
||||
)
|
||||
).scalar() or -1
|
||||
img.sort_order = int(max_sort) + 1
|
||||
await session.commit()
|
||||
|
||||
return _image_to_item(img)
|
||||
|
||||
|
||||
@router.get("/study-questions/{question_id}/images/{image_id}/raw")
|
||||
async def get_question_image_raw(
|
||||
question_id: int,
|
||||
image_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""이미지 raw bytes. 같은 user 의 question 첨부만 응답."""
|
||||
img = await session.get(StudyQuestionImage, image_id)
|
||||
if img is None or img.user_id != user.id or img.study_question_id != question_id:
|
||||
raise HTTPException(status_code=404, detail="이미지를 찾을 수 없습니다")
|
||||
file_path = _Path(img.file_path)
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=410, detail="파일이 사라졌습니다")
|
||||
return FileResponse(str(file_path), media_type=img.mime_type)
|
||||
|
||||
|
||||
@router.delete("/study-questions/{question_id}/images/{image_id}", status_code=204)
|
||||
async def delete_question_image(
|
||||
question_id: int,
|
||||
image_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
img = await session.get(StudyQuestionImage, image_id)
|
||||
if img is None or img.user_id != user.id or img.study_question_id != question_id:
|
||||
raise HTTPException(status_code=404, detail="이미지를 찾을 수 없습니다")
|
||||
# 파일 시스템 삭제 (실패해도 DB row 는 정리)
|
||||
try:
|
||||
file_path = _Path(img.file_path)
|
||||
if file_path.is_file():
|
||||
file_path.unlink()
|
||||
except Exception as e:
|
||||
logger.warning("study_q_image_unlink_failed id=%s: %s", image_id, e)
|
||||
await session.delete(img)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# ─── PR-3: AI 풀이 생성 엔드포인트 ───
|
||||
|
||||
# MLX 호출 timeout (초). MLX gate + 26B 추론 평균 ~10s, 안전 마진.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""study_question_images ORM (PR-8) — 문제별 첨부 이미지.
|
||||
|
||||
저장: NAS /documents/study_question_images/{topic_id}/{qid}/{img_id}.{ext}
|
||||
표시: GET /api/study-questions/{qid}/images/{img_id}/raw (인증 필요)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class StudyQuestionImage(Base):
|
||||
__tablename__ = "study_question_images"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
study_question_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("study_questions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
file_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
mime_type: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, nullable=False
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
<script>
|
||||
/**
|
||||
* Bearer 인증 이미지 endpoint 를 <img> 로 표시.
|
||||
* fetch + blob URL 패턴 — img 태그가 Authorization 헤더를 못 보내는 한계 우회.
|
||||
* unmount 시 URL.revokeObjectURL 로 메모리 정리.
|
||||
*/
|
||||
import { getAccessToken } from '$lib/api';
|
||||
|
||||
let { src, alt = '', class: klass = '' } = $props();
|
||||
let blobUrl = $state('');
|
||||
let errored = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
let active = true;
|
||||
let url = '';
|
||||
if (!src) return () => {};
|
||||
(async () => {
|
||||
try {
|
||||
const accessToken = getAccessToken();
|
||||
const res = await fetch(src, {
|
||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (active) errored = true;
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
url = URL.createObjectURL(blob);
|
||||
if (active) blobUrl = url;
|
||||
} catch {
|
||||
if (active) errored = true;
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if errored}
|
||||
<div class={`bg-error/10 border border-error/40 text-error text-[10px] flex items-center justify-center ${klass}`}>
|
||||
이미지 로드 실패
|
||||
</div>
|
||||
{:else if blobUrl}
|
||||
<img src={blobUrl} {alt} class={klass} />
|
||||
{:else}
|
||||
<div class={`bg-bg/40 animate-pulse ${klass}`}></div>
|
||||
{/if}
|
||||
@@ -9,15 +9,16 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { api, uploadFile } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { ArrowLeft, Save, Trash2, AlertCircle, Sparkles, GitCompare, ArrowRight, CheckCircle2, XCircle } from 'lucide-svelte';
|
||||
import { ArrowLeft, Save, Trash2, AlertCircle, Sparkles, GitCompare, ArrowRight, CheckCircle2, XCircle, ImagePlus, X } from 'lucide-svelte';
|
||||
import { renderMathMarkdown } from '$lib/utils/mathMarkdown';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import TextInput from '$lib/components/ui/TextInput.svelte';
|
||||
import Textarea from '$lib/components/ui/Textarea.svelte';
|
||||
import ImgAuth from '$lib/components/ImgAuth.svelte';
|
||||
|
||||
let topicId = $derived(Number($page.params.id));
|
||||
let questionId = $derived(Number($page.params.qid));
|
||||
@@ -36,6 +37,58 @@
|
||||
let is_active = $state(true);
|
||||
let stats = $state({ attempt_count: 0, correct_count: 0, wrong_count: 0 });
|
||||
|
||||
// PR-8: 첨부 이미지
|
||||
let images = $state([]); // [{id, served_url, mime_type, file_size, sort_order}]
|
||||
let imgUploading = $state(false);
|
||||
|
||||
async function refreshImages() {
|
||||
try {
|
||||
const q = await api(`/study-questions/${questionId}`);
|
||||
images = q.images ?? [];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function uploadImages(e) {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
imgUploading = true;
|
||||
let ok = 0;
|
||||
for (const f of files) {
|
||||
if (!f.type.startsWith('image/')) {
|
||||
addToast('error', `이미지 파일이 아닙니다: ${f.name}`);
|
||||
continue;
|
||||
}
|
||||
if (f.size > 10 * 1024 * 1024) {
|
||||
addToast('error', `${f.name} 10MB 초과`);
|
||||
continue;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('file', f, f.name);
|
||||
try {
|
||||
await uploadFile(`/study-questions/${questionId}/images`, fd);
|
||||
ok += 1;
|
||||
} catch (err) {
|
||||
addToast('error', `${f.name} 업로드 실패: ${err?.detail || err?.status || '알수없음'}`);
|
||||
}
|
||||
}
|
||||
imgUploading = false;
|
||||
e.target.value = '';
|
||||
if (ok > 0) {
|
||||
await refreshImages();
|
||||
addToast('success', `${ok}건 업로드됨`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteImage(imgId) {
|
||||
if (!confirm('이미지를 삭제합니다.')) return;
|
||||
try {
|
||||
await api(`/study-questions/${questionId}/images/${imgId}`, { method: 'DELETE' });
|
||||
images = images.filter((x) => x.id !== imgId);
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '삭제 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// PR-5: 비슷한 문제 검색 결과
|
||||
let similarLoading = $state(false);
|
||||
let similarItems = $state([]);
|
||||
@@ -112,6 +165,8 @@
|
||||
aiStatus = q.ai_explanation_status ?? 'none';
|
||||
aiGeneratedAt = q.ai_explanation_generated_at;
|
||||
aiModel = q.ai_explanation_model;
|
||||
// PR-8: 이미지 prefill
|
||||
images = q.images ?? [];
|
||||
// PR-5: 비슷한 문제 자동 로드 (페이지 로드와 병렬)
|
||||
loadSimilar();
|
||||
} catch (err) {
|
||||
@@ -244,6 +299,43 @@
|
||||
{/snippet}
|
||||
</Card>
|
||||
|
||||
<!-- PR-8: 첨부 이미지 -->
|
||||
<Card class="mb-3">
|
||||
{#snippet children()}
|
||||
<div class="p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<ImagePlus size={16} class="text-accent" />
|
||||
<span class="text-sm font-semibold text-text">첨부 이미지</span>
|
||||
<span class="text-[11px] text-dim">{images.length}건</span>
|
||||
</div>
|
||||
<label class="cursor-pointer text-xs text-accent hover:underline flex items-center gap-1">
|
||||
{#if imgUploading}<span>업로드 중...</span>{:else}<><ImagePlus size={14} /> 이미지 추가</>{/if}
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple
|
||||
onchange={uploadImages} disabled={imgUploading} class="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
{#if images.length === 0}
|
||||
<div class="text-[11px] text-dim">첨부된 이미지가 없습니다. PNG/JPEG/WEBP/GIF, 10MB/파일.</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{#each images as img (img.id)}
|
||||
<div class="relative border border-default rounded overflow-hidden bg-bg/30">
|
||||
<ImgAuth src={img.served_url} class="w-full h-28 object-cover" />
|
||||
<div class="px-2 py-1 text-[10px] text-dim truncate">{Math.round(img.file_size/1024)} KB</div>
|
||||
<button type="button" onclick={() => deleteImage(img.id)}
|
||||
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center hover:bg-error"
|
||||
aria-label="삭제">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Card>
|
||||
|
||||
<!-- PR-3: AI 풀이 섹션 -->
|
||||
<Card class="mb-3">
|
||||
{#snippet children()}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { api, uploadFile } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { ArrowLeft, Save, Repeat, ListChecks, AlertCircle } from 'lucide-svelte';
|
||||
import { ArrowLeft, Save, Repeat, ListChecks, AlertCircle, ImagePlus, X } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import TextInput from '$lib/components/ui/TextInput.svelte';
|
||||
@@ -61,6 +61,59 @@
|
||||
let saving = $state(false);
|
||||
let isCompleteRound = $state(false); // 회차 도달 여부
|
||||
|
||||
// PR-8: 첨부 이미지 (저장 전 client-side 보유 → 저장 후 자동 업로드)
|
||||
/** @type {{file: File, previewUrl: string}[]} */
|
||||
let pendingImages = $state([]);
|
||||
let uploading = $state(false);
|
||||
|
||||
function onPickImages(e) {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
for (const f of files) {
|
||||
if (!f.type.startsWith('image/')) {
|
||||
addToast('error', `이미지 파일이 아닙니다: ${f.name}`);
|
||||
continue;
|
||||
}
|
||||
if (f.size > 10 * 1024 * 1024) {
|
||||
addToast('error', `${f.name} 10MB 초과`);
|
||||
continue;
|
||||
}
|
||||
pendingImages = [...pendingImages, { file: f, previewUrl: URL.createObjectURL(f) }];
|
||||
}
|
||||
e.target.value = ''; // 같은 파일 재선택 가능
|
||||
}
|
||||
|
||||
function removePending(idx) {
|
||||
const item = pendingImages[idx];
|
||||
if (item) URL.revokeObjectURL(item.previewUrl);
|
||||
pendingImages = pendingImages.filter((_, i) => i !== idx);
|
||||
}
|
||||
|
||||
async function uploadPendingImages(qid) {
|
||||
if (pendingImages.length === 0) return 0;
|
||||
uploading = true;
|
||||
let success = 0;
|
||||
try {
|
||||
for (const it of pendingImages) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', it.file, it.file.name);
|
||||
try {
|
||||
await uploadFile(`/study-questions/${qid}/images`, fd);
|
||||
success += 1;
|
||||
} catch (err) {
|
||||
addToast('error', `${it.file.name} 업로드 실패: ${err?.detail || err?.status || '알수없음'}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
function clearPendingImages() {
|
||||
for (const it of pendingImages) URL.revokeObjectURL(it.previewUrl);
|
||||
pendingImages = [];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = $derived(`study_q_persist_v2_${topicId}`);
|
||||
|
||||
function persist() {
|
||||
@@ -276,9 +329,18 @@
|
||||
// 응답의 실제 저장값으로 표시 동기화 (서버가 결정한 qnum)
|
||||
const actualQnum = saved?.exam_question_number ?? null;
|
||||
if (actualQnum) f_qnum = actualQnum;
|
||||
addToast('success', `문제 저장됨${f_exam_round && actualQnum ? ` (${f_exam_round} ${actualQnum}번)` : ''}`);
|
||||
|
||||
// PR-8: 첨부 이미지 자동 업로드 (qid 받은 후)
|
||||
let imgMsg = '';
|
||||
if (pendingImages.length > 0 && saved?.id) {
|
||||
const okCount = await uploadPendingImages(saved.id);
|
||||
imgMsg = ` · 이미지 ${okCount}/${pendingImages.length}건 첨부`;
|
||||
}
|
||||
|
||||
addToast('success', `문제 저장됨${f_exam_round && actualQnum ? ` (${f_exam_round} ${actualQnum}번)` : ''}${imgMsg}`);
|
||||
if (continueAfter) {
|
||||
clearForCont();
|
||||
clearPendingImages();
|
||||
// 다음 표시값 = 방금 저장된 qnum + 1. 사용자가 다시 수정하기 전까지는
|
||||
// 자동(서버 max+1) 모드 유지.
|
||||
if (actualQnum) f_qnum = actualQnum + 1;
|
||||
@@ -449,6 +511,35 @@
|
||||
<Textarea label="해설 (선택)" bind:value={explanation} rows={2} placeholder="정답 근거 요약" />
|
||||
<TextInput label="출처/메모 (자동: '회차 N번', 수정 가능)" bind:value={source_note}
|
||||
placeholder={autoSourceNote() || "예: 산업안전기사 2023 1회 기출 7번"} />
|
||||
|
||||
<!-- PR-8: 이미지 첨부 -->
|
||||
<div class="flex flex-col gap-2 pt-3 border-t border-default">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-dim">이미지 (선택, PNG/JPEG/WEBP/GIF, 10MB/파일)</span>
|
||||
<label class="cursor-pointer text-xs text-accent hover:underline flex items-center gap-1">
|
||||
<ImagePlus size={14} /> 이미지 추가
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple
|
||||
onchange={onPickImages} class="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
{#if pendingImages.length > 0}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{#each pendingImages as it, idx (it.previewUrl)}
|
||||
<div class="relative border border-default rounded overflow-hidden bg-bg/30">
|
||||
<img src={it.previewUrl} alt={it.file.name} class="w-full h-24 object-cover" />
|
||||
<div class="px-2 py-1 text-[10px] text-dim truncate">{it.file.name}</div>
|
||||
<button type="button" onclick={() => removePending(idx)}
|
||||
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center hover:bg-error"
|
||||
aria-label="제거">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-[11px] text-dim">첨부할 이미지가 없습니다.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Card>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import TextInput from '$lib/components/ui/TextInput.svelte';
|
||||
import ImgAuth from '$lib/components/ImgAuth.svelte';
|
||||
|
||||
let topicId = $derived(Number($page.params.id));
|
||||
let topicName = $state('');
|
||||
@@ -271,6 +272,15 @@
|
||||
<div class="p-5 flex flex-col gap-4">
|
||||
<div class="text-base text-text leading-relaxed math-area">{@html renderMathMarkdown(currentQ.question_text)}</div>
|
||||
|
||||
<!-- PR-8: 첨부 이미지 (문제 본문 아래) -->
|
||||
{#if currentQ.images?.length > 0}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-1">
|
||||
{#each currentQ.images as img (img.id)}
|
||||
<ImgAuth src={img.served_url} class="w-full max-h-72 object-contain rounded border border-default bg-bg/30" />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each currentQ.choices as ch (ch.number)}
|
||||
{@const isSelected = selected === ch.number}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 198_study_question_images.sql
|
||||
-- 문제별 첨부 이미지 (PR-8). 한 문제에 여러 이미지 가능 (회로도+그래프 등).
|
||||
--
|
||||
-- 저장 위치: NAS `/documents/study_question_images/{topic_id}/{question_id}/{image_id}.{ext}`
|
||||
-- (file_watcher 가 보는 PKM 경로와 분리 — 자동 인덱싱 안 됨).
|
||||
--
|
||||
-- soft delete 미사용 (이미지는 hard delete + 파일 시스템 정리). 충분히 작은 자원.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS study_question_images (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
study_question_id BIGINT NOT NULL REFERENCES study_questions(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
mime_type VARCHAR(80) NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_study_question_images_qid
|
||||
ON study_question_images (study_question_id, sort_order, id);
|
||||
Reference in New Issue
Block a user