feat(ui): render problem images in exam/review/detail — 7H.2
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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 }>`
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user