feat(ux): slow animations + audio player + styled slider + pricing nav — 7I.4b
This commit is contained in:
@@ -59,6 +59,9 @@ async function bootstrap() {
|
||||
app.useStaticAssets(join(__dirname, '..', 'uploads'), {
|
||||
prefix: '/uploads/',
|
||||
});
|
||||
app.useStaticAssets(join(__dirname, '..', '..', 'data', 'kice'), {
|
||||
prefix: '/data/kice/',
|
||||
});
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
|
||||
@@ -707,11 +707,13 @@ const HeroTitle = styled.h1`
|
||||
${animateIn}
|
||||
animation-delay: 0.2s;
|
||||
margin: 0 0 1.5rem;
|
||||
min-height: 2.2em;
|
||||
min-height: 3em;
|
||||
font-family: ${theme.font.display};
|
||||
font-size: clamp(3.25rem, 9vw, 5.5rem);
|
||||
line-height: 1.04;
|
||||
letter-spacing: -0.04em;
|
||||
overflow: visible;
|
||||
word-break: keep-all;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
font-size: clamp(2.5rem, 12vw, 4rem);
|
||||
@@ -719,10 +721,15 @@ const HeroTitle = styled.h1`
|
||||
`;
|
||||
|
||||
const HeroGradient = styled.span`
|
||||
display: block;
|
||||
overflow: visible;
|
||||
min-height: 3em;
|
||||
background: linear-gradient(135deg, #a78bfa 0%, #818cf8 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
white-space: normal;
|
||||
word-break: keep-all;
|
||||
`;
|
||||
|
||||
const HeroSubtitle = styled.p`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styled, { css } from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
@@ -577,40 +578,18 @@ function ProfileBody() {
|
||||
<SectionTitle>학년/등급</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SliderGroup>
|
||||
<SliderBlock>
|
||||
<SliderTop>
|
||||
<SliderLabel>현재 등급</SliderLabel>
|
||||
<SliderValue>{currentGradeDraft}등급</SliderValue>
|
||||
</SliderTop>
|
||||
<GradeSlider
|
||||
min={1}
|
||||
max={9}
|
||||
step={1}
|
||||
value={currentGradeDraft}
|
||||
onChange={(event) => setCurrentGradeDraft(Number(event.target.value))}
|
||||
onMouseUp={queueGradeSave}
|
||||
onTouchEnd={queueGradeSave}
|
||||
onKeyUp={queueGradeSave}
|
||||
$value={currentGradeDraft}
|
||||
/>
|
||||
</SliderBlock>
|
||||
<SliderBlock>
|
||||
<SliderTop>
|
||||
<SliderLabel>목표 등급</SliderLabel>
|
||||
<SliderValue>{targetGradeDraft}등급</SliderValue>
|
||||
</SliderTop>
|
||||
<GradeSlider
|
||||
min={1}
|
||||
max={9}
|
||||
step={1}
|
||||
value={targetGradeDraft}
|
||||
onChange={(event) => setTargetGradeDraft(Number(event.target.value))}
|
||||
onMouseUp={queueGradeSave}
|
||||
onTouchEnd={queueGradeSave}
|
||||
onKeyUp={queueGradeSave}
|
||||
$value={targetGradeDraft}
|
||||
/>
|
||||
</SliderBlock>
|
||||
<GradeSliderField
|
||||
label="현재 등급"
|
||||
value={currentGradeDraft}
|
||||
onChange={(value) => setCurrentGradeDraft(value)}
|
||||
onCommit={queueGradeSave}
|
||||
/>
|
||||
<GradeSliderField
|
||||
label="목표 등급"
|
||||
value={targetGradeDraft}
|
||||
onChange={(value) => setTargetGradeDraft(value)}
|
||||
onCommit={queueGradeSave}
|
||||
/>
|
||||
</SliderGroup>
|
||||
</CardSection>
|
||||
</Grid>
|
||||
@@ -739,6 +718,27 @@ function ProfileBody() {
|
||||
<SectionTitle>계정</SectionTitle>
|
||||
</SectionHeader>
|
||||
<AccountActionList>
|
||||
<PlanManagementCard>
|
||||
<PlanManagementBody>
|
||||
<PlanManagementMeta>
|
||||
<PlanManagementLabel>
|
||||
<Icon name="crown" size={18} />
|
||||
플랜 관리
|
||||
</PlanManagementLabel>
|
||||
<PlanManagementTitle>현재 플랜을 확인하고 변경하세요</PlanManagementTitle>
|
||||
<PlanManagementDesc>
|
||||
현재 이용 중인 요금제와 업그레이드 옵션을 `/pricing`에서 바로 확인할 수
|
||||
있습니다.
|
||||
</PlanManagementDesc>
|
||||
</PlanManagementMeta>
|
||||
<PlanManagementActions>
|
||||
<PlanTierBadge>{planLabel(user.subscriptionTier)}</PlanTierBadge>
|
||||
<Button as={Link} href="/pricing" $variant="secondary">
|
||||
플랜 변경
|
||||
</Button>
|
||||
</PlanManagementActions>
|
||||
</PlanManagementBody>
|
||||
</PlanManagementCard>
|
||||
<AccountAction type="button" onClick={handleExportData}>
|
||||
<ActionLeft>
|
||||
<Icon name="export" size={18} />
|
||||
@@ -824,6 +824,56 @@ function planLabel(tier: MeUser['subscriptionTier']) {
|
||||
return 'Free Plan';
|
||||
}
|
||||
|
||||
interface GradeSliderFieldProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
function GradeSliderField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
}: GradeSliderFieldProps) {
|
||||
const percent = ((value - 1) / 8) * 100;
|
||||
|
||||
return (
|
||||
<SliderBlock>
|
||||
<SliderTop>
|
||||
<SliderLabel>{label}</SliderLabel>
|
||||
<SliderValue>{value}등급</SliderValue>
|
||||
</SliderTop>
|
||||
<SliderVisual>
|
||||
<SliderBubble style={{ left: `calc(${percent}% + ${(0.5 - percent / 100) * 22}px)` }}>
|
||||
{value}등급
|
||||
</SliderBubble>
|
||||
<GradeSlider
|
||||
min={1}
|
||||
max={9}
|
||||
step={1}
|
||||
value={value}
|
||||
aria-label={label}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
onMouseUp={onCommit}
|
||||
onTouchEnd={onCommit}
|
||||
onKeyUp={onCommit}
|
||||
$value={value}
|
||||
/>
|
||||
<TickRow aria-hidden="true">
|
||||
{GRADE_OPTIONS.map((grade) => (
|
||||
<TickItem key={grade}>
|
||||
<TickMark $active={grade <= value} />
|
||||
<TickLabel>{grade}</TickLabel>
|
||||
</TickItem>
|
||||
))}
|
||||
</TickRow>
|
||||
</SliderVisual>
|
||||
</SliderBlock>
|
||||
);
|
||||
}
|
||||
|
||||
const surface = css`
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02)),
|
||||
@@ -1088,6 +1138,8 @@ const SliderGroup = styled.div`
|
||||
gap: 18px;
|
||||
`;
|
||||
|
||||
const GRADE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
|
||||
|
||||
const SliderBlock = styled.div`
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
@@ -1112,8 +1164,129 @@ const SliderValue = styled.div`
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const SliderVisual = styled.div`
|
||||
position: relative;
|
||||
padding-top: 34px;
|
||||
`;
|
||||
|
||||
const SliderBubble = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
min-width: 64px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: ${theme.color.brandGradient};
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
box-shadow: ${theme.shadow.glowIndigo};
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const GradeSlider = styled.input<{ $value: number }>`
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
--slider-fill: ${({ $value }) => (($value - 1) / 8) * 100}%;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#4f46e5 0%,
|
||||
#7c3aed var(--slider-fill),
|
||||
${theme.color.surfaceHover} var(--slider-fill),
|
||||
${theme.color.surfaceHover} 100%
|
||||
);
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: -8px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #ffffff;
|
||||
background: ${theme.color.brandIndigo};
|
||||
box-shadow: ${theme.shadow.glowIndigo};
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: ${theme.color.surfaceHover};
|
||||
}
|
||||
|
||||
&::-moz-range-progress {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: ${theme.color.brandGradient};
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
background: ${theme.color.brandIndigo};
|
||||
box-shadow: ${theme.shadow.glowIndigo};
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
height: 36px;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const TickRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
margin-top: 14px;
|
||||
`;
|
||||
|
||||
const TickItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const TickMark = styled.span<{ $active: boolean }>`
|
||||
width: 2px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: ${({ $active }) =>
|
||||
$active ? theme.color.brandIndigo : theme.color.borderBrightAlpha};
|
||||
`;
|
||||
|
||||
const TickLabel = styled.span`
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 11px;
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const PersonaGrid = styled.div`
|
||||
@@ -1293,6 +1466,74 @@ const AccountActionList = styled.div`
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const PlanManagementCard = styled.div`
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.2);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(124, 58, 237, 0.2), transparent 42%),
|
||||
rgba(79, 70, 229, 0.08);
|
||||
`;
|
||||
|
||||
const PlanManagementBody = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
const PlanManagementMeta = styled.div`
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const PlanManagementLabel = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #c7d2fe;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const PlanManagementTitle = styled.h3`
|
||||
margin: 0;
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 18px;
|
||||
`;
|
||||
|
||||
const PlanManagementDesc = styled.p`
|
||||
margin: 0;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const PlanManagementActions = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const PlanTierBadge = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(11, 16, 32, 0.35);
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const AccountAction = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/components/exam/ProblemVisual';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { ConfirmDialog } from '@/components/ui/Modal';
|
||||
import Select from '@/components/ui/Select';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
getProblemSet,
|
||||
@@ -36,6 +37,7 @@ const DEFAULT_PANEL_WIDTH = 560;
|
||||
const PANEL_WIDTH_MIN = 320;
|
||||
const PANEL_WIDTH_MAX = 900;
|
||||
const TABLET_BREAKPOINT = 768;
|
||||
const ENGLISH_LISTENING_PROBLEM_MAX = 17;
|
||||
|
||||
export default function ExamPage() {
|
||||
const params = useParams<{ problemSetId: string }>();
|
||||
@@ -63,6 +65,10 @@ export default function ExamPage() {
|
||||
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
|
||||
const [isDraggingPanel, setIsDraggingPanel] = useState(false);
|
||||
const [passageCollapsed, setPassageCollapsed] = useState(false);
|
||||
const [selectedTrackIndex, setSelectedTrackIndex] = useState(0);
|
||||
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
||||
const [audioDuration, setAudioDuration] = useState(0);
|
||||
const [audioPlaying, setAudioPlaying] = useState(false);
|
||||
|
||||
const examStartedAtRef = useRef<number>(0);
|
||||
const problemEnteredAtRef = useRef<number>(0);
|
||||
@@ -71,6 +77,8 @@ export default function ExamPage() {
|
||||
const pageRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragPointerIdRef = useRef<number | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioShouldResumeRef = useRef(false);
|
||||
|
||||
const problems = problemSet?.problems ?? [];
|
||||
const currentProblem = problems[currentIndex] ?? null;
|
||||
@@ -100,6 +108,9 @@ export default function ExamPage() {
|
||||
(passage && currentPassageImageUrl) || currentProblemImageUrl,
|
||||
);
|
||||
const currentProblemIsShortAnswer = isShortAnswerProblem(currentProblem);
|
||||
const audioTracks = useMemo(() => buildAudioTracks(problemSet?.audioUrls), [problemSet]);
|
||||
const selectedTrack = audioTracks[selectedTrackIndex] ?? null;
|
||||
const hasAudioTracks = audioTracks.length > 0;
|
||||
|
||||
const loadProblemSet = async () => {
|
||||
if (!Number.isFinite(problemSetId)) {
|
||||
@@ -133,6 +144,10 @@ export default function ExamPage() {
|
||||
setJumpOpen(false);
|
||||
setProblemModalOpen(false);
|
||||
setPassageCollapsed(false);
|
||||
setSelectedTrackIndex(0);
|
||||
setAudioCurrentTime(0);
|
||||
setAudioDuration(0);
|
||||
setAudioPlaying(false);
|
||||
} catch {
|
||||
setError('문제집을 불러오지 못했어. 네트워크 상태를 확인해줘.');
|
||||
} finally {
|
||||
@@ -188,6 +203,80 @@ export default function ExamPage() {
|
||||
window.localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(clampPanelWidth(panelWidth)));
|
||||
}, [panelWidth, isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioTracks.length) {
|
||||
setSelectedTrackIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTrackIndex((current) =>
|
||||
current >= 0 && current < audioTracks.length ? current : 0,
|
||||
);
|
||||
}, [audioTracks]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const onTimeUpdate = () => setAudioCurrentTime(audio.currentTime);
|
||||
const onLoadedMetadata = () => {
|
||||
setAudioDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
|
||||
setAudioCurrentTime(audio.currentTime);
|
||||
};
|
||||
const onDurationChange = () => {
|
||||
setAudioDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
|
||||
};
|
||||
const onPlay = () => setAudioPlaying(true);
|
||||
const onPause = () => setAudioPlaying(false);
|
||||
const onEnded = () => setAudioPlaying(false);
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate);
|
||||
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.addEventListener('durationchange', onDurationChange);
|
||||
audio.addEventListener('play', onPlay);
|
||||
audio.addEventListener('pause', onPause);
|
||||
audio.addEventListener('ended', onEnded);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate);
|
||||
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.removeEventListener('durationchange', onDurationChange);
|
||||
audio.removeEventListener('play', onPlay);
|
||||
audio.removeEventListener('pause', onPause);
|
||||
audio.removeEventListener('ended', onEnded);
|
||||
};
|
||||
}, [selectedTrackIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !selectedTrack) return;
|
||||
|
||||
const shouldResume = audioShouldResumeRef.current && !audio.paused;
|
||||
audio.pause();
|
||||
audio.load();
|
||||
setAudioCurrentTime(0);
|
||||
setAudioDuration(0);
|
||||
setAudioPlaying(false);
|
||||
|
||||
if (shouldResume) {
|
||||
void audio.play().catch(() => {
|
||||
audioShouldResumeRef.current = false;
|
||||
});
|
||||
}
|
||||
}, [selectedTrack]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProblem || problemSet?.subjectName !== '영어') return;
|
||||
if (currentProblem.number < 1 || currentProblem.number > ENGLISH_LISTENING_PROBLEM_MAX) return;
|
||||
|
||||
const matchedIndex = audioTracks.findIndex(
|
||||
(track) => track.problemNumber === currentProblem.number,
|
||||
);
|
||||
if (matchedIndex >= 0) {
|
||||
setSelectedTrackIndex(matchedIndex);
|
||||
}
|
||||
}, [audioTracks, currentProblem, problemSet?.subjectName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!problemSet || submitting || submittedRef.current) return;
|
||||
|
||||
@@ -417,6 +506,34 @@ export default function ExamPage() {
|
||||
setIsDraggingPanel(true);
|
||||
};
|
||||
|
||||
const toggleAudioPlayback = async () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !selectedTrack) return;
|
||||
|
||||
if (audio.paused) {
|
||||
audioShouldResumeRef.current = true;
|
||||
try {
|
||||
await audio.play();
|
||||
} catch {
|
||||
audioShouldResumeRef.current = false;
|
||||
showToast({ message: '음원을 재생하지 못했어. 파일 경로를 확인해줘.', variant: 'warning' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
audioShouldResumeRef.current = false;
|
||||
audio.pause();
|
||||
};
|
||||
|
||||
const handleAudioProgressChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const nextTime = Number(event.target.value);
|
||||
audio.currentTime = nextTime;
|
||||
setAudioCurrentTime(nextTime);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
const loadingState = (
|
||||
<StateScreen $embedded={!focusMode}>
|
||||
@@ -516,6 +633,63 @@ export default function ExamPage() {
|
||||
</TopBarActions>
|
||||
</TopBar>
|
||||
|
||||
{hasAudioTracks ? (
|
||||
<AudioBarSection>
|
||||
<AudioPlayerCard>
|
||||
<AudioPlayerMain>
|
||||
<PlayButton
|
||||
type="button"
|
||||
onClick={() => void toggleAudioPlayback()}
|
||||
aria-label={audioPlaying ? '음원 일시정지' : '음원 재생'}
|
||||
>
|
||||
<Icon
|
||||
name={audioPlaying ? 'pause' : 'caret-right'}
|
||||
size={audioPlaying ? 22 : 24}
|
||||
weight="fill"
|
||||
color="#ffffff"
|
||||
/>
|
||||
</PlayButton>
|
||||
|
||||
<AudioMeta>
|
||||
<AudioLabelRow>
|
||||
<AudioTitle>영어 듣기 음원</AudioTitle>
|
||||
<AudioTime>
|
||||
{formatAudioClock(audioCurrentTime)} / {formatAudioClock(audioDuration)}
|
||||
</AudioTime>
|
||||
</AudioLabelRow>
|
||||
<AudioProgress
|
||||
type="range"
|
||||
min={0}
|
||||
max={audioDuration > 0 ? audioDuration : 0}
|
||||
step={0.1}
|
||||
value={audioCurrentTime}
|
||||
onChange={handleAudioProgressChange}
|
||||
aria-label="음원 진행도"
|
||||
$progress={audioDuration > 0 ? (audioCurrentTime / audioDuration) * 100 : 0}
|
||||
/>
|
||||
</AudioMeta>
|
||||
|
||||
<TrackSelectWrap>
|
||||
<TrackSelect
|
||||
value={selectedTrack?.index ?? null}
|
||||
onChange={(value) => setSelectedTrackIndex(value as number)}
|
||||
options={audioTracks.map((track) => ({
|
||||
label: track.label,
|
||||
value: track.index,
|
||||
}))}
|
||||
placeholder="음원 선택"
|
||||
aria-label="듣기 음원 선택"
|
||||
/>
|
||||
</TrackSelectWrap>
|
||||
</AudioPlayerMain>
|
||||
|
||||
<AudioElement ref={audioRef} preload="metadata">
|
||||
{selectedTrack ? <source src={selectedTrack.url} type="audio/mpeg" /> : null}
|
||||
</AudioElement>
|
||||
</AudioPlayerCard>
|
||||
</AudioBarSection>
|
||||
) : null}
|
||||
|
||||
<Content>
|
||||
<Workspace>
|
||||
<ProblemPanel
|
||||
@@ -798,6 +972,50 @@ function clampPanelWidth(width: number): number {
|
||||
return Math.min(PANEL_WIDTH_MAX, Math.max(PANEL_WIDTH_MIN, Math.round(width)));
|
||||
}
|
||||
|
||||
function buildAudioTracks(audioUrls: string[] | null | undefined) {
|
||||
if (!Array.isArray(audioUrls)) return [];
|
||||
|
||||
return audioUrls
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value, index) => {
|
||||
const normalized = value.trim().replace(/^\/+/, '');
|
||||
const filename = normalized.split('/').pop() ?? normalized;
|
||||
const filenameBase = filename.replace(/\.mp3$/i, '');
|
||||
const problemNumber = extractLeadingNumber(filenameBase);
|
||||
const label = filenameBase.replace(/^\d+[_-]?\s*/, '').trim() || filenameBase;
|
||||
return {
|
||||
index,
|
||||
problemNumber,
|
||||
label,
|
||||
url: resolveUploadUrl(`/data/kice/${encodePathSegments(normalized)}`) ?? '',
|
||||
};
|
||||
})
|
||||
.filter((track) => track.url.length > 0);
|
||||
}
|
||||
|
||||
function encodePathSegments(path: string): string {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function extractLeadingNumber(value: string): number | null {
|
||||
const match = value.match(/^(\d{1,2})/);
|
||||
if (!match) return null;
|
||||
|
||||
const parsed = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function formatAudioClock(totalSeconds: number): string {
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return '00:00';
|
||||
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const Page = styled.div<{ $focusMode: boolean }>`
|
||||
min-height: 100vh;
|
||||
background: ${({ $focusMode }) =>
|
||||
@@ -977,6 +1195,149 @@ const Content = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const AudioBarSection = styled.div`
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 24px 0;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const AudioPlayerCard = styled.section`
|
||||
background: ${theme.color.surfaceDeep};
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
border-radius: 20px;
|
||||
box-shadow: ${theme.shadow.cardElevated};
|
||||
padding: 16px 18px;
|
||||
`;
|
||||
|
||||
const AudioPlayerMain = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr) minmax(220px, 280px);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.breakpoint.mobile}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const PlayButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: ${theme.color.brandIndigo};
|
||||
box-shadow: ${theme.shadow.glowIndigo};
|
||||
`;
|
||||
|
||||
const AudioMeta = styled.div`
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const AudioLabelRow = styled.div`
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const AudioTitle = styled.div`
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
`;
|
||||
|
||||
const AudioTime = styled.div`
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
|
||||
const AudioProgress = styled.input<{ $progress: number }>`
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
--progress: ${({ $progress }) => `${Math.max(0, Math.min(100, $progress))}%`};
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#4f46e5 0%,
|
||||
#7c3aed var(--progress),
|
||||
rgba(255, 255, 255, 0.12) var(--progress),
|
||||
rgba(255, 255, 255, 0.12) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -5px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
background: ${theme.color.brandIndigo};
|
||||
box-shadow: ${theme.shadow.glowIndigo};
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
&::-moz-range-progress {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: ${theme.color.brandGradient};
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
background: ${theme.color.brandIndigo};
|
||||
box-shadow: ${theme.shadow.glowIndigo};
|
||||
}
|
||||
`;
|
||||
|
||||
const TrackSelectWrap = styled.div`
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
`;
|
||||
|
||||
const TrackSelect = styled(Select)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const AudioElement = styled.audio`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
const Workspace = styled.main`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, auto) 4px minmax(0, 1fr);
|
||||
|
||||
@@ -54,6 +54,12 @@ const NAV_ITEMS: Array<{
|
||||
icon: 'folders',
|
||||
match: (pathname) => pathname.startsWith('/subjects'),
|
||||
},
|
||||
{
|
||||
href: '/pricing',
|
||||
label: '플랜',
|
||||
icon: 'crown',
|
||||
match: (pathname) => pathname.startsWith('/pricing'),
|
||||
},
|
||||
];
|
||||
|
||||
const SUBSCRIPTION_LABELS: Record<SubscriptionTier, string> = {
|
||||
@@ -246,8 +252,8 @@ const LogoMark = styled.div`
|
||||
background: ${theme.color.brandGradient};
|
||||
box-shadow: ${theme.shadow.glowIndigo};
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
box-shadow 0.25s ease,
|
||||
transform 0.25s ease;
|
||||
|
||||
${Brand}:hover & {
|
||||
box-shadow: ${theme.shadow.glowIndigoStrong};
|
||||
@@ -284,9 +290,9 @@ const NavItem = styled(Link)<{ $active: boolean }>`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
color 0.25s ease,
|
||||
background-color 0.25s ease,
|
||||
border-color 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
@@ -307,7 +313,7 @@ const NavIconWrap = styled.span<{ $active: boolean }>`
|
||||
justify-content: center;
|
||||
color: ${({ $active }) =>
|
||||
$active ? theme.color.brandIndigo : 'rgba(255, 255, 255, 0.5)'};
|
||||
transition: color 0.15s ease;
|
||||
transition: color 0.25s ease;
|
||||
|
||||
${NavItem}:hover & {
|
||||
color: ${({ $active }) =>
|
||||
@@ -345,7 +351,7 @@ const UserButton = styled.button`
|
||||
text-align: left;
|
||||
color: ${theme.color.textBright};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
transition: background-color 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
@@ -406,8 +412,8 @@ const CaretWrap = styled.span<{ $open: boolean }>`
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
opacity 0.25s ease,
|
||||
color 0.25s ease;
|
||||
opacity: ${({ $open }) => ($open ? 1 : 0.72)};
|
||||
|
||||
${UserButton}:hover & {
|
||||
@@ -441,8 +447,8 @@ const DropdownLink = styled(Link)`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
background-color 0.25s ease,
|
||||
color 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
@@ -464,8 +470,8 @@ const LogoutButton = styled.button`
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
background-color 0.25s ease,
|
||||
color 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Users, ChalkboardTeacher, Sliders, FilePdf, MoonStars, Devices, Keyboard, Brain, Funnel,
|
||||
MagnifyingGlass, DownloadSimple, CalendarBlank, CalendarCheck, BellRinging, Bell, Sparkle,
|
||||
Minus, DotsThree, DotsThreeVertical, Translate, BookOpenText, SkipForward, Info, SignOut, Camera, Crown,
|
||||
Export, Gear, ArrowRight, type IconProps as PhIconProps, type IconWeight,
|
||||
Export, Gear, ArrowRight, Pause, type IconProps as PhIconProps, type IconWeight,
|
||||
Scales, Feather, Clock, Flag, CheckSquare, Circle, ArrowLeft, User, Books,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
@@ -74,6 +74,7 @@ const ICON_MAP = {
|
||||
'export': Export,
|
||||
'gear': Gear,
|
||||
'arrow-right': ArrowRight,
|
||||
'pause': Pause,
|
||||
'arrow-left': ArrowLeft,
|
||||
'scales': Scales,
|
||||
'feather': Feather,
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function ScrollReveal({
|
||||
setRevealed(true);
|
||||
observer.disconnect();
|
||||
},
|
||||
{ rootMargin: '0px 0px -80px 0px' },
|
||||
{ rootMargin: '0px 0px -60px 0px' },
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
@@ -86,7 +86,7 @@ const RevealRoot = styled.div<{
|
||||
$revealed || $reducedMotion ? 'translateY(0)' : 'translateY(20px)'};
|
||||
animation: ${({ $revealed, $reducedMotion }) =>
|
||||
$revealed && !$reducedMotion
|
||||
? css`${animations.fadeInUp} 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards`
|
||||
? css`${animations.fadeInUp} 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards`
|
||||
: 'none'};
|
||||
animation-delay: ${({ $delay }) => `${$delay}ms`};
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ interface TypingEffectProps {
|
||||
|
||||
export default function TypingEffect({
|
||||
phrases,
|
||||
typingSpeedMs = 55,
|
||||
holdMs = 2200,
|
||||
deletingSpeedMs = 30,
|
||||
typingSpeedMs = 90,
|
||||
holdMs = 3500,
|
||||
deletingSpeedMs = 45,
|
||||
}: TypingEffectProps) {
|
||||
const [phraseIndex, setPhraseIndex] = useState(0);
|
||||
const [displayText, setDisplayText] = useState('');
|
||||
@@ -119,17 +119,17 @@ export default function TypingEffect({
|
||||
}
|
||||
|
||||
const Root = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.08em;
|
||||
display: inline;
|
||||
min-height: 1.2em;
|
||||
white-space: normal;
|
||||
`;
|
||||
|
||||
const Caret = styled.span<{ $visible: boolean }>`
|
||||
display: inline-block;
|
||||
color: ${theme.color.brandViolet};
|
||||
font-weight: 500;
|
||||
opacity: ${({ $visible }) => ($visible ? 1 : 0)};
|
||||
transition: opacity 120ms linear;
|
||||
transition: opacity 180ms linear;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
|
||||
@@ -17,7 +17,7 @@ export const Card = styled.div<{
|
||||
${({ $hoverable, $variant }) =>
|
||||
($hoverable || $variant === 'hoverable') &&
|
||||
css`
|
||||
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||
transition: border-color 0.25s ease, transform 0.25s ease;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
border-color: ${theme.color.accent};
|
||||
|
||||
Reference in New Issue
Block a user