feat(ui): render problem images in exam/review/detail — 7H.2

This commit is contained in:
reloop
2026-04-12 08:12:58 +09:00
parent acc35e9284
commit 8a91ab46f7
4 changed files with 247 additions and 17 deletions

View File

@@ -10,9 +10,11 @@ import { useToast } from '@/components/ui/Toast';
import { Button, KbdHint } from '@/components/ui/primitives';
import {
api,
resolveUploadUrl,
type DashboardSummary,
type MeUser,
type QueueItem,
type StudyLogDetail,
type StudyLog,
type StudyResult,
} from '@/lib/api';
@@ -58,6 +60,7 @@ export default function ReviewPage() {
const [memoOpen, setMemoOpen] = useState<Record<number, boolean>>({});
const [dashboardSummary, setDashboardSummary] = useState<DashboardSummary | null>(null);
const [activityLogs, setActivityLogs] = useState<StudyLog[]>([]);
const [detailCache, setDetailCache] = useState<Record<number, StudyLogDetail>>({});
const load = useCallback(async () => {
if (!hasToken()) {
@@ -106,6 +109,12 @@ export default function ReviewPage() {
}, [load]);
const currentItem = queue?.[0] ?? null;
const currentDetail = currentItem ? detailCache[currentItem.studyLogId] ?? null : null;
const currentProblem = currentDetail?.problem ?? null;
const currentProblemImageUrl = resolveUploadUrl(
currentProblem?.imageUrl ?? currentProblem?.pageImageUrl,
);
const currentProblemBodyText = normalizeOptionalText(currentProblem?.bodyText);
const ghostItems = queue?.slice(1, 3) ?? [];
const currentIteration = (currentItem?.iteration ?? 0) + 1;
const progressCurrent = queue?.length ? completedCount + 1 : completedCount;
@@ -146,6 +155,31 @@ export default function ReviewPage() {
? 1
: 0;
useEffect(() => {
if (!currentItem) return;
if (detailCache[currentItem.studyLogId]) return;
let cancelled = false;
void api
.get<StudyLogDetail>(`/study-logs/${currentItem.studyLogId}`)
.then((response) => {
if (cancelled) return;
setDetailCache((prev) => {
if (prev[currentItem.studyLogId]) return prev;
return {
...prev,
[currentItem.studyLogId]: response.data,
};
});
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [currentItem, detailCache]);
const restoreLastEntry = useCallback(async () => {
const lastEntry = history[history.length - 1];
if (!lastEntry) return;
@@ -477,6 +511,26 @@ export default function ReviewPage() {
</CardTitle>
<CardBody>
{currentProblem && (
<PromptBlock>
{currentProblemImageUrl ? (
<PromptImage
src={currentProblemImageUrl}
alt={`${currentProblem.problemSet.subjectName} ${currentProblem.number}`}
/>
) : null}
{currentProblemImageUrl ? (
currentProblemBodyText ? (
<PromptText $muted>{currentProblemBodyText}</PromptText>
) : null
) : (
<PromptText>
{currentProblemBodyText ?? '문제 본문이 저장되어 있지 않습니다.'}
</PromptText>
)}
</PromptBlock>
)}
<MemoShell>
<MemoToggle
type="button"
@@ -647,6 +701,12 @@ function formatRelative(date: Date): string {
return `${Math.round(hours / 24)}일 지남`;
}
function normalizeOptionalText(value: string | null | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
const Page = styled.div`
min-height: 100vh;
background: ${theme.color.bgDeep};
@@ -1051,6 +1111,33 @@ const CardBody = styled.div`
justify-content: space-between;
`;
const PromptBlock = styled.div`
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
border-radius: 22px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(11, 16, 32, 0.42);
`;
const PromptImage = styled.img`
display: block;
width: 100%;
max-width: 100%;
border-radius: 20px;
border: 1px solid rgba(148, 163, 184, 0.26);
background: rgba(15, 23, 42, 0.72);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.26);
`;
const PromptText = styled.div<{ $muted?: boolean }>`
white-space: pre-wrap;
line-height: 1.8;
font-size: 15px;
color: ${({ $muted }) => ($muted ? theme.color.textSub : theme.color.textMain)};
`;
const MemoShell = styled.div`
border-radius: 20px;
background: rgba(11, 16, 32, 0.48);

View File

@@ -7,7 +7,13 @@ import styled, { css } from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import { Icon } from '@/components/ui/Icon';
import { Button, Card, Textarea } from '@/components/ui/primitives';
import { api, type ReviewStatus, type StudyLogDetail, type StudyResult } from '@/lib/api';
import {
api,
resolveUploadUrl,
type ReviewStatus,
type StudyLogDetail,
type StudyResult,
} from '@/lib/api';
import { theme } from '@/styles/theme';
export default function StudyLogDetailPage() {
@@ -99,6 +105,8 @@ function StudyLogDetailBody() {
const problem = detail.problem;
const problemSet = problem?.problemSet;
const problemChoices = normalizeChoices(problem?.choices);
const problemImageUrl = resolveUploadUrl(problem?.imageUrl ?? problem?.pageImageUrl);
const problemBodyText = normalizeOptionalText(problem?.bodyText);
return (
<Page>
@@ -131,10 +139,17 @@ function StudyLogDetailBody() {
<MainColumn>
<SectionCard>
<SectionTitle> </SectionTitle>
{problem?.imageUrl ? (
<ProblemImage src={problem.imageUrl} alt={`${problem.number}번 문제 이미지`} />
{problemImageUrl ? (
<ProblemImage
src={problemImageUrl}
alt={`${problemSet?.subjectName ?? detail.subject.name} ${problem?.number ?? ''}`}
/>
) : null}
<ProblemBody>{problem?.bodyText ?? '문제 본문이 저장되어 있지 않습니다.'}</ProblemBody>
{problemImageUrl ? (
problemBodyText ? <ProblemBody $muted>{problemBodyText}</ProblemBody> : null
) : (
<ProblemBody>{problemBodyText ?? '문제 본문이 저장되어 있지 않습니다.'}</ProblemBody>
)}
{problemChoices.length > 0 && (
<ChoiceList>
{problemChoices.map((choice) => {
@@ -298,6 +313,12 @@ function formatDifficulty(value: number): string {
return `${Math.round(value * 100)} / 100`;
}
function normalizeOptionalText(value: string | null | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
const Page = styled.div`
display: flex;
flex-direction: column;
@@ -385,15 +406,18 @@ const SectionTitle = styled.h2`
`;
const ProblemImage = styled.img`
display: block;
width: 100%;
border-radius: 18px;
margin-bottom: 16px;
border: 1px solid ${theme.color.borderSoftAlpha};
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.24);
background: rgba(15, 23, 42, 0.72);
`;
const ProblemBody = styled.div`
const ProblemBody = styled.div<{ $muted?: boolean }>`
white-space: pre-wrap;
color: ${theme.color.textMain};
color: ${({ $muted }) => ($muted ? theme.color.textSub : theme.color.textMain)};
line-height: 1.8;
font-size: 15px;
`;

View File

@@ -7,7 +7,12 @@ import AppShell from '@/components/layout/AppShell';
import { Icon } from '@/components/ui/Icon';
import { ConfirmDialog } from '@/components/ui/Modal';
import { useToast } from '@/components/ui/Toast';
import { getProblemSet, submitProblemSetStudy, type ProblemSetDetail } from '@/lib/api';
import {
getProblemSet,
resolveUploadUrl,
submitProblemSetStudy,
type ProblemSetDetail,
} from '@/lib/api';
import { hasToken } from '@/lib/auth';
import { theme } from '@/styles/theme';
import {
@@ -75,6 +80,17 @@ export default function ExamPage() {
).length,
[answers, problems],
);
const currentProblemImageUrl = useMemo(
() => resolveUploadUrl(currentProblem?.imageUrl ?? currentProblem?.pageImageUrl),
[currentProblem],
);
const currentProblemBodyText = useMemo(
() => normalizeOptionalText(currentProblem?.bodyText),
[currentProblem],
);
const hasProblemModalContent = Boolean(
passage || currentProblemImageUrl || currentProblemBodyText,
);
const loadProblemSet = async () => {
if (!Number.isFinite(problemSetId)) {
@@ -369,7 +385,7 @@ export default function ExamPage() {
};
const openProblemModal = () => {
if (!isMobile) return;
if (!isMobile || !hasProblemModalContent) return;
setProblemModalOpen(true);
};
@@ -483,9 +499,9 @@ export default function ExamPage() {
<Workspace>
<ProblemPanel
$width={isMobile ? undefined : panelWidth}
$clickable={isMobile}
$clickable={isMobile && !currentProblemImageUrl && hasProblemModalContent}
aria-label={isMobile ? '문제 본문 크게 보기' : undefined}
onClick={openProblemModal}
onClick={currentProblemImageUrl ? undefined : openProblemModal}
>
{passage && (
<PassageCard>
@@ -510,12 +526,41 @@ export default function ExamPage() {
{isMobile && (
<ReadHint>
<Icon name="export" size={14} />
{currentProblemImageUrl ? '이미지 확대' : '탭해서 전체화면'}
</ReadHint>
)}
</ProblemMetaRow>
<ProblemTitle>{currentProblem.title}</ProblemTitle>
<ProblemBody>{currentProblem.bodyText ?? '본문 텍스트가 비어 있어.'}</ProblemBody>
{currentProblemImageUrl ? (
<>
<ProblemImageButton
type="button"
onClick={(event) => {
event.stopPropagation();
openProblemModal();
}}
aria-label={`${problemSet.subjectName} ${currentProblem.number}번 이미지 크게 보기`}
>
<ProblemImage
src={currentProblemImageUrl}
alt={`${problemSet.subjectName} ${currentProblem.number}`}
/>
{isMobile && (
<ProblemImageHint>
<Icon name="export" size={14} />
</ProblemImageHint>
)}
</ProblemImageButton>
{currentProblemBodyText && (
<ProblemBody $muted>{currentProblemBodyText}</ProblemBody>
)}
</>
) : (
<ProblemBody>
{currentProblemBodyText ?? '본문 텍스트가 비어 있어.'}
</ProblemBody>
)}
{currentProblem.topic && <TopicChip>{currentProblem.topic}</TopicChip>}
</ProblemTextCard>
</ProblemPanel>
@@ -700,7 +745,17 @@ export default function ExamPage() {
)}
<ReadSection>
<ReadSectionTitle>{currentProblem.title}</ReadSectionTitle>
<ReadText>{currentProblem.bodyText ?? '본문 텍스트가 비어 있어.'}</ReadText>
{currentProblemImageUrl ? (
<>
<ReadImage
src={currentProblemImageUrl}
alt={`${problemSet.subjectName} ${currentProblem.number}`}
/>
{currentProblemBodyText && <ReadText $muted>{currentProblemBodyText}</ReadText>}
</>
) : (
<ReadText>{currentProblemBodyText ?? '본문 텍스트가 비어 있어.'}</ReadText>
)}
</ReadSection>
</ReadModal>
@@ -735,6 +790,12 @@ function toWholeSeconds(ms: number): number {
return Math.max(1, Math.round(ms / 1000));
}
function normalizeOptionalText(value: string | null | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function clampPanelWidth(width: number): number {
return Math.min(PANEL_WIDTH_MAX, Math.max(PANEL_WIDTH_MIN, Math.round(width)));
}
@@ -1036,11 +1097,47 @@ const ProblemTitle = styled.h2`
}
`;
const ProblemBody = styled.div`
const ProblemBody = styled.div<{ $muted?: boolean }>`
white-space: pre-wrap;
line-height: 2;
font-size: 18px;
color: ${theme.color.textMain};
color: ${({ $muted }) => ($muted ? theme.color.textSub : theme.color.textMain)};
`;
const ProblemImageButton = styled.button`
position: relative;
width: 100%;
padding: 0;
border: none;
background: transparent;
text-align: left;
cursor: zoom-in;
`;
const ProblemImage = styled.img`
display: block;
width: 100%;
max-width: 100%;
border-radius: 20px;
border: 1px solid rgba(148, 163, 184, 0.26);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.26);
background: rgba(15, 23, 42, 0.72);
`;
const ProblemImageHint = styled.span`
position: absolute;
right: 14px;
bottom: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 999px;
background: rgba(11, 16, 32, 0.82);
border: 1px solid ${theme.color.borderSoftAlpha};
color: ${theme.color.textBright};
font-size: 12px;
font-weight: 700;
`;
const TopicChip = styled.span`
@@ -1394,11 +1491,22 @@ const ReadSectionTitle = styled.h4`
line-height: 1.6;
`;
const ReadText = styled.div`
const ReadText = styled.div<{ $muted?: boolean }>`
white-space: pre-wrap;
font-size: 18px;
line-height: 1.7;
color: ${theme.color.textMain};
color: ${({ $muted }) => ($muted ? theme.color.textSub : theme.color.textMain)};
`;
const ReadImage = styled.img`
display: block;
width: 100%;
max-height: calc(100vh - 260px);
object-fit: contain;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.26);
background: rgba(15, 23, 42, 0.72);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.3);
`;
const StateScreen = styled.div<{ $embedded: boolean }>`

View File

@@ -63,6 +63,14 @@ export function resolveAssetUrl(path: string | null | undefined) {
return `${API_ASSET_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
}
export function resolveUploadUrl(path: string | null | undefined): string | null {
if (!path) return null;
if (/^https?:\/\//.test(path)) return path;
const base = process.env.NEXT_PUBLIC_API_URL || '';
const serverRoot = base.replace(/\/api\/?$/, '');
return `${serverRoot}${path.startsWith('/') ? path : `/${path}`}`;
}
export interface ProblemSetSummary {
id: number;
title: string;
@@ -84,6 +92,8 @@ export interface Problem {
baseCorrectRate: number | null;
topic: string | null;
bodyText?: string | null;
imageUrl?: string | null;
pageImageUrl?: string | null;
choices?: Record<string, string> | null;
answerNumber?: number | null;
passageId?: number | null;
@@ -157,6 +167,7 @@ export interface StudyLogDetail {
choices: Record<string, string> | null;
answerNumber: number | null;
imageUrl: string | null;
pageImageUrl: string | null;
problemSet: {
id: number;
title: string;