style(study): visual refresh matching phase 7D theme — 7D.12

This commit is contained in:
reloop
2026-04-12 05:20:55 +09:00
parent 22f3be27e2
commit 22ee6ab659

View File

@@ -2,7 +2,8 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import styled from 'styled-components';
import styled, { css } from 'styled-components';
import { Icon } from '@/components/ui/Icon';
import { theme } from '@/styles/theme';
import {
api,
@@ -20,6 +21,7 @@ import {
HelpText,
Input,
Label,
PageHeader,
SectionTitle,
Select,
Stack,
@@ -165,10 +167,12 @@ function StudyBody() {
if (subjects.length === 0) {
return (
<Empty>
<EmptyEmoji>📚</EmptyEmoji>
<EmptyIconWrap>
<Icon name="book-open" weight="duotone" size={36} />
</EmptyIconWrap>
<EmptyTitle> </EmptyTitle>
<EmptySub> .</EmptySub>
<Button $size="md" onClick={() => router.push('/subjects')}>
<Button $size="md" $variant="primary" onClick={() => router.push('/subjects')}>
</Button>
</Empty>
@@ -177,32 +181,45 @@ function StudyBody() {
return (
<Wrap>
<Header>
<Title> </Title>
<Sub> .</Sub>
</Header>
<HeaderCard>
<PageHeader
eyebrow="Study Log"
title="새 학습 기록"
subtitle="문제를 풀고 바로 기록하면 다음 복습이 자동으로 잡혀."
right={
<HeaderBadge>
<Icon name="clock-counter-clockwise" size={16} />
</HeaderBadge>
}
/>
</HeaderCard>
<ModeTabs>
<ModeTabs role="tablist" aria-label="학습 기록 방식">
<TabBtn
type="button"
$active={mode === 'manual'}
aria-pressed={mode === 'manual'}
aria-selected={mode === 'manual'}
onClick={() => setMode('manual')}
>
<Icon name="pencil-simple" size={16} weight={mode === 'manual' ? 'fill' : 'regular'} />
</TabBtn>
<TabBtn
type="button"
$active={mode === 'problemset'}
aria-pressed={mode === 'problemset'}
aria-selected={mode === 'problemset'}
onClick={() => setMode('problemset')}
>
📚
<Icon name="book-open" size={16} weight={mode === 'problemset' ? 'fill' : 'regular'} />
</TabBtn>
</ModeTabs>
<form onSubmit={submit}>
<Card>
<StudyCard>
<Stack $gap={theme.space.md}>
{mode === 'manual' ? (
<ManualHeader
@@ -237,7 +254,9 @@ function StudyBody() {
$tone="success"
onClick={() => setResult('correct')}
>
<ResultMark></ResultMark>
<ResultMark>
<Icon name="check" weight="bold" size={18} />
</ResultMark>
<ResultLabel></ResultLabel>
</ResultChoice>
<ResultChoice
@@ -246,7 +265,9 @@ function StudyBody() {
$tone="warning"
onClick={() => setResult('partial')}
>
<ResultMark></ResultMark>
<ResultMark>
<Icon name="triangle" weight="fill" size={18} />
</ResultMark>
<ResultLabel></ResultLabel>
</ResultChoice>
<ResultChoice
@@ -255,21 +276,25 @@ function StudyBody() {
$tone="danger"
onClick={() => setResult('incorrect')}
>
<ResultMark></ResultMark>
<ResultMark>
<Icon name="x" weight="bold" size={18} />
</ResultMark>
<ResultLabel></ResultLabel>
</ResultChoice>
</ResultGrid>
<div>
<Label> · {Math.round(difficulty * 100)}%</Label>
<input
<SliderHeader>
<Label> </Label>
<SliderValue>{Math.round(difficulty * 100)}%</SliderValue>
</SliderHeader>
<RangeInput
type="range"
min={0}
max={1}
step={0.05}
value={difficulty}
onChange={(e) => setDifficulty(Number(e.target.value))}
style={{ width: '100%' }}
/>
<HelpText>0% , 100% .</HelpText>
</div>
@@ -277,39 +302,50 @@ function StudyBody() {
<Row>
<Field>
<Label> ()</Label>
<Input
type="number"
min={0}
max={100}
value={baseCorrectRate}
onChange={(e) => setBaseCorrectRate(e.target.value)}
placeholder="예) 45"
/>
<FieldFrame>
<FieldInput
type="number"
min={0}
max={100}
value={baseCorrectRate}
onChange={(e) => setBaseCorrectRate(e.target.value)}
placeholder="예) 45"
/>
</FieldFrame>
<HelpText> (%) .</HelpText>
</Field>
<Field>
<Label> (, )</Label>
<Input
type="number"
min={0}
value={timeSpent}
onChange={(e) => setTimeSpent(e.target.value)}
placeholder="예) 180"
/>
<FieldFrame>
<FieldInput
type="number"
min={0}
value={timeSpent}
onChange={(e) => setTimeSpent(e.target.value)}
placeholder="예) 180"
/>
</FieldFrame>
</Field>
</Row>
<div>
<Label> ()</Label>
<Textarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="어디서 막혔는지, 무엇을 배웠는지 짧게 남겨봐."
/>
<FieldFrame>
<FieldTextarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="어디서 막혔는지, 무엇을 배웠는지 짧게 남겨봐."
/>
</FieldFrame>
</div>
{err && <ErrorText>{err}</ErrorText>}
{err && (
<ErrorBox role="alert" aria-live="polite">
<Icon name="info" size={16} weight="bold" />
<ErrorText as="span">{err}</ErrorText>
</ErrorBox>
)}
<ButtonRow>
<Button
@@ -321,12 +357,12 @@ function StudyBody() {
>
</Button>
<Button type="submit" $size="md" disabled={submitting}>
<PrimarySubmit type="submit" $size="lg" $block disabled={submitting}>
{submitting ? '저장 중...' : '기록하고 복습 예약'}
</Button>
</PrimarySubmit>
</ButtonRow>
</Stack>
</Card>
</StudyCard>
</form>
</Wrap>
);
@@ -357,42 +393,48 @@ function ManualHeader({
<Row>
<Field>
<Label></Label>
<Select
value={subjectId}
onChange={(e) => setSubjectId(Number(e.target.value))}
>
{subjects.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
<FieldFrame>
<FieldSelect
value={subjectId}
onChange={(e) => setSubjectId(Number(e.target.value))}
>
{subjects.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</FieldSelect>
</FieldFrame>
</Field>
<Field>
<Label> ()</Label>
<Select
value={tagId}
onChange={(e) =>
setTagId(e.target.value === '' ? '' : Number(e.target.value))
}
>
<option value=""> </option>
{currentTags?.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</Select>
<FieldFrame>
<FieldSelect
value={tagId}
onChange={(e) =>
setTagId(e.target.value === '' ? '' : Number(e.target.value))
}
>
<option value=""> </option>
{currentTags?.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</FieldSelect>
</FieldFrame>
</Field>
</Row>
<div>
<Label> </Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예) 2024 수능 수학 15번"
required
/>
<FieldFrame>
<FieldInput
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예) 2024 수능 수학 15번"
required
/>
</FieldFrame>
</div>
</>
);
@@ -434,18 +476,20 @@ function ProblemSetHeader({
<>
<div>
<Label></Label>
<Select
value={selectedSetId}
onChange={(e) =>
setSelectedSetId(e.target.value === '' ? '' : Number(e.target.value))
}
>
{problemSets.map((ps) => (
<option key={ps.id} value={ps.id}>
[{ps.year}·{ps.subjectName}] {ps.title} ({ps._count.problems})
</option>
))}
</Select>
<FieldFrame>
<FieldSelect
value={selectedSetId}
onChange={(e) =>
setSelectedSetId(e.target.value === '' ? '' : Number(e.target.value))
}
>
{problemSets.map((ps) => (
<option key={ps.id} value={ps.id}>
[{ps.year}·{ps.subjectName}] {ps.title} ({ps._count.problems})
</option>
))}
</FieldSelect>
</FieldFrame>
</div>
{setDetail && (
<>
@@ -476,15 +520,21 @@ function ProblemSetHeader({
})}
</ProblemList>
{matchedSubject ? (
<HelpText>
<b>{matchedSubject.name}</b> .
</HelpText>
<MappingHint>
<Icon name="check-circle" size={15} weight="fill" />
<span>
<b>{matchedSubject.name}</b> .
</span>
</MappingHint>
) : (
<HelpText>
(<b>{setDetail.subjectName}</b>)
.
, .
</HelpText>
<MappingHint $tone="warning">
<Icon name="info" size={15} weight="bold" />
<span>
(<b>{setDetail.subjectName}</b>)
. ,
.
</span>
</MappingHint>
)}
</div>
</>
@@ -503,38 +553,64 @@ const Wrap = styled.div`
max-width: 720px;
margin: 0 auto;
width: 100%;
color: ${theme.color.textBright};
`;
const Header = styled.header``;
const Title = styled.h1`
font-size: 24px;
font-weight: 700;
const HeaderCard = styled.div`
position: relative;
padding: ${theme.space.lg};
border-radius: 24px;
border: 1px solid ${theme.color.borderSoftAlpha};
background:
radial-gradient(circle at top right, rgba(79, 70, 229, 0.16), transparent 34%),
linear-gradient(180deg, rgba(21, 21, 28, 0.96) 0%, rgba(17, 17, 24, 0.98) 100%);
box-shadow: ${theme.shadow.cardElevated};
overflow: hidden;
`;
const Sub = styled.p`
font-size: 13px;
const HeaderBadge = styled.div`
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 0 14px;
border-radius: ${theme.radius.pill};
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.03);
color: ${theme.color.textSub};
margin-top: 4px;
font-size: 13px;
font-weight: 600;
`;
const ModeTabs = styled.div`
display: inline-flex;
background: ${theme.color.surface2};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.pill};
padding: 4px;
gap: 4px;
align-self: flex-start;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
`;
const TabBtn = styled.button<{ $active: boolean }>`
padding: 8px 16px;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 0 16px;
border-radius: ${theme.radius.pill};
font-size: 13px;
font-weight: 600;
color: ${({ $active }) => ($active ? 'white' : theme.color.textSub)};
background: ${({ $active }) => ($active ? theme.color.accent : 'transparent')};
transition:
background 0.15s ease,
color 0.15s ease;
border: 1px solid
${({ $active }) =>
$active ? 'rgba(129, 140, 248, 0.42)' : theme.color.borderSoftAlpha};
background: ${({ $active }) =>
$active ? 'rgba(79, 70, 229, 0.2)' : 'rgba(255, 255, 255, 0.02)'};
color: ${({ $active }) => ($active ? theme.color.textBright : theme.color.textSub)};
font-size: 14px;
font-weight: 700;
transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease;
&:hover {
color: ${theme.color.textBright};
border-color: ${theme.color.borderBrightAlpha};
background: rgba(79, 70, 229, 0.14);
}
`;
const Row = styled.div`
@@ -551,10 +627,87 @@ const Field = styled.div`
min-width: 0;
`;
const StudyCard = styled(Card)`
background:
linear-gradient(180deg, rgba(21, 21, 28, 0.96) 0%, rgba(17, 17, 24, 0.98) 100%);
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: 24px;
box-shadow: ${theme.shadow.cardElevated};
`;
const FieldFrame = styled.div<{ $invalid?: boolean }>`
position: relative;
border-radius: 16px;
background: rgba(255, 255, 255, 0.03);
transition: background-color 0.2s ease, box-shadow 0.2s ease;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: ${({ $invalid }) =>
$invalid ? theme.color.danger : theme.color.brandGradient};
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
&:focus-within {
background: rgba(255, 255, 255, 0.015);
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.12);
}
&:focus-within::before {
opacity: 1;
}
`;
const sharedFieldStyles = css`
background: transparent;
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: 16px;
color: ${theme.color.textBright};
min-height: 52px;
padding: 14px 16px;
&::placeholder {
color: ${theme.color.textMute};
}
&:focus {
border-color: transparent;
}
`;
const FieldInput = styled(Input)`
${sharedFieldStyles}
`;
const FieldSelect = styled(Select)`
${sharedFieldStyles}
`;
const FieldTextarea = styled(Textarea)`
${sharedFieldStyles}
min-height: 132px;
resize: vertical;
`;
const ResultGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: ${theme.space.sm};
@media (max-width: ${theme.breakpoint.mobile}) {
grid-template-columns: 1fr;
}
`;
const ResultChoice = styled.button<{
@@ -565,20 +718,20 @@ const ResultChoice = styled.button<{
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
gap: 8px;
padding: ${theme.space.md};
border-radius: ${theme.radius.md};
border-radius: 18px;
background: ${({ $active }) =>
$active ? theme.color.surfaceHover : theme.color.surface2};
$active ? 'rgba(255, 255, 255, 0.05)' : 'rgba(255, 255, 255, 0.025)'};
border: 1px solid
${({ $active, $tone }) =>
$active
? $tone === 'success'
? theme.color.success
? 'rgba(34, 197, 94, 0.55)'
: $tone === 'warning'
? theme.color.warning
: theme.color.danger
: theme.color.border};
? 'rgba(245, 158, 11, 0.55)'
: 'rgba(239, 68, 68, 0.55)'
: theme.color.borderSoftAlpha};
color: ${({ $active, $tone }) =>
$active
? $tone === 'success'
@@ -591,16 +744,19 @@ const ResultChoice = styled.button<{
min-height: 72px;
transition:
border-color 0.15s ease,
background 0.15s ease;
background 0.15s ease,
transform 0.15s ease;
&:hover {
border-color: ${theme.color.accent};
transform: translateY(-1px);
border-color: ${theme.color.borderBrightAlpha};
}
`;
const ResultMark = styled.span`
font-size: 22px;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
`;
const ResultLabel = styled.span`
@@ -613,8 +769,76 @@ const ResultLabel = styled.span`
const ButtonRow = styled.div`
display: flex;
gap: ${theme.space.sm};
justify-content: flex-end;
align-items: stretch;
margin-top: ${theme.space.sm};
> :first-child {
flex: 0 0 auto;
}
> :last-child {
flex: 1 1 auto;
}
@media (max-width: ${theme.breakpoint.mobile}) {
flex-direction: column-reverse;
> :first-child,
> :last-child {
flex: 1 1 auto;
width: 100%;
}
}
`;
const PrimarySubmit = styled(Button)`
border: 1px solid rgba(255, 255, 255, 0.12);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.02)),
${theme.color.brandGradient};
color: white;
box-shadow: ${theme.shadow.glowIndigo};
&:hover:not(:disabled) {
filter: brightness(1.06);
box-shadow: ${theme.shadow.glowIndigoStrong};
}
`;
const SliderHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: ${theme.space.sm};
`;
const SliderValue = styled.span`
font-family: ${theme.font.display};
font-size: 14px;
font-weight: 700;
color: ${theme.color.textBright};
`;
const RangeInput = styled.input`
width: 100%;
accent-color: ${theme.color.brandIndigo};
margin: 6px 0 8px;
`;
const ErrorBox = styled.div`
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.22);
color: #fca5a5;
${ErrorText} {
margin-top: 0;
color: inherit;
}
`;
const ProblemList = styled.div`
@@ -625,26 +849,31 @@ const ProblemList = styled.div`
overflow-y: auto;
padding-right: 4px;
`;
const ProblemItem = styled.button<{ $active: boolean }>`
display: flex;
align-items: center;
gap: ${theme.space.sm};
padding: ${theme.space.sm} ${theme.space.md};
background: ${({ $active }) =>
$active ? theme.color.surfaceHover : theme.color.surface2};
$active ? 'rgba(79, 70, 229, 0.12)' : 'rgba(255, 255, 255, 0.025)'};
border: 1px solid
${({ $active }) => ($active ? theme.color.accent : theme.color.border)};
border-radius: ${theme.radius.md};
${({ $active }) =>
$active ? 'rgba(129, 140, 248, 0.42)' : theme.color.borderSoftAlpha};
border-radius: 18px;
text-align: left;
cursor: pointer;
transition:
border-color 0.15s ease,
background 0.15s ease;
background 0.15s ease,
transform 0.15s ease;
&:hover {
border-color: ${theme.color.accent};
transform: translateY(-1px);
border-color: ${theme.color.borderBrightAlpha};
}
`;
const ProblemNum = styled.div`
font-size: 12px;
font-weight: 700;
@@ -662,7 +891,7 @@ const ProblemMeta = styled.div`
const ProblemTitle = styled.div`
font-size: 13px;
font-weight: 600;
color: ${theme.color.textMain};
color: ${theme.color.textBright};
`;
const ProblemSub = styled.div`
font-size: 11px;
@@ -670,6 +899,28 @@ const ProblemSub = styled.div`
font-family: ${theme.font.mono};
`;
const MappingHint = styled.div<{ $tone?: 'default' | 'warning' }>`
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid
${({ $tone = 'default' }) =>
$tone === 'warning' ? 'rgba(245, 158, 11, 0.22)' : 'rgba(79, 70, 229, 0.2)'};
background: ${({ $tone = 'default' }) =>
$tone === 'warning' ? 'rgba(245, 158, 11, 0.08)' : 'rgba(79, 70, 229, 0.08)'};
color: ${({ $tone = 'default' }) =>
$tone === 'warning' ? '#fcd34d' : theme.color.textSub};
font-size: 13px;
line-height: 1.5;
b {
color: ${theme.color.textBright};
}
`;
const Loading = styled.div`
padding: ${theme.space.xl};
text-align: center;
@@ -679,20 +930,39 @@ const Loading = styled.div`
const Empty = styled.div`
padding: ${theme.space.xxl};
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: 24px;
background:
radial-gradient(circle at top center, rgba(79, 70, 229, 0.12), transparent 40%),
linear-gradient(180deg, rgba(21, 21, 28, 0.96) 0%, rgba(17, 17, 24, 0.98) 100%);
box-shadow: ${theme.shadow.cardElevated};
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: ${theme.space.md};
`;
const EmptyEmoji = styled.div`
font-size: 56px;
const EmptyIconWrap = styled.div`
width: 72px;
height: 72px;
border-radius: 22px;
display: grid;
place-items: center;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.03)),
${theme.color.brandGradient};
color: white;
box-shadow: ${theme.shadow.glowIndigoStrong};
`;
const EmptyTitle = styled.h2`
font-size: 20px;
font-weight: 700;
color: ${theme.color.textBright};
`;
const EmptySub = styled.p`
max-width: 360px;
font-size: 13px;
color: ${theme.color.textSub};
`;