style: 대시보드·과제 페이지 디자인 개편 — 히어로·진행률 통계·일차 그룹핑
- 대시보드: 인사+응원 문구 히어로, 진행률 % 큰 숫자+바, 일차별 완료 카운트 - 과제: 제출률 히어로, 일차별 그룹핑, 제출 완료 카드 청록 띠, 제출 폼 접기(details) - global.css: compact 히어로·stat·day-head·assign-card·fold 클래스 추가 (기존 클래스 무변경) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c6d723824b
commit
b834afad4b
@ -1,8 +1,7 @@
|
||||
// 이 파일이 하는 일: 주차별 과제 목록을 보여주고, 각 과제에 대해
|
||||
// 이 파일이 하는 일: 주차별 과제 목록을 요일(일차)별로 묶어 보여주고, 각 과제에 대해
|
||||
// 제출 폼(내용 + 링크)과 내 제출 상태·멘토 피드백을 표시한다.
|
||||
import { useEffect, useState } from 'react';
|
||||
import client from '../api/client';
|
||||
import Card from '../components/Card';
|
||||
import Badge from '../components/Badge';
|
||||
|
||||
const WEEKS = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
@ -43,67 +42,67 @@ function AssignmentCard({ assignment, submission, onSubmitted }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8 }}>
|
||||
// 제출된 과제는 카드 왼쪽에 청록 띠가 붙는다 (.assign-card.submitted — global.css 참고)
|
||||
<div className={`card assign-card${submission ? ' submitted' : ''}`}>
|
||||
<div className="assign-title-row">
|
||||
<Badge value={assignment.kind} />
|
||||
<span className="badge badge-primary">{assignment.day}일차</span>
|
||||
{submission && <Badge value={submission.status} />}
|
||||
{submission ? (
|
||||
<Badge value={submission.status} />
|
||||
) : (
|
||||
<span className="badge badge-rose">미제출</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontWeight: 800, fontSize: 16 }}>{assignment.title}</div>
|
||||
<p className="muted" style={{ margin: '6px 0 14px', whiteSpace: 'pre-line' }}>
|
||||
<div className="assign-title">{assignment.title}</div>
|
||||
<p className="muted" style={{ margin: '6px 0 12px', whiteSpace: 'pre-line', fontSize: 14 }}>
|
||||
{assignment.summary}
|
||||
</p>
|
||||
|
||||
{/* 멘토 피드백이 있으면 눈에 띄게 보여준다 */}
|
||||
{submission?.feedback && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--primary-soft)',
|
||||
borderRadius: 10,
|
||||
padding: '10px 14px',
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--primary)' }}>
|
||||
멘토 피드백
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-line' }}>{submission.feedback}</div>
|
||||
<div className="tip" style={{ marginBottom: 12 }}>
|
||||
<b>멘토 피드백</b>
|
||||
<div style={{ whiteSpace: 'pre-line', marginTop: 4 }}>{submission.feedback}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label>제출 내용</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="무엇을 했고, 무엇을 배웠는지 적어 주세요."
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>링크 (선택)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
placeholder="예: Gitea 저장소 주소, 결과물 링크"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{savedMessage && (
|
||||
<p style={{ color: 'var(--teal)', fontSize: 14, marginBottom: 8 }}>{savedMessage}</p>
|
||||
)}
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||
{saving ? '제출 중...' : submission ? '다시 제출하기' : '제출하기'}
|
||||
</button>
|
||||
{submission && (
|
||||
<span className="muted" style={{ fontSize: 13, marginLeft: 10 }}>
|
||||
마지막 제출: {new Date(submission.submittedAt).toLocaleString('ko-KR')}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
</Card>
|
||||
{/* 학습 포인트: <details>는 JS 없이도 접기/펼치기가 되는 HTML 기본 태그.
|
||||
제출 완료된 과제는 폼을 접어두고, 미제출 과제는 펼쳐서 바로 쓰게 한다. */}
|
||||
<details className="fold" open={!submission}>
|
||||
<summary>{submission ? '다시 제출하기' : '제출하기'}</summary>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label>제출 내용</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="무엇을 했고, 무엇을 배웠는지 적어 주세요."
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>링크 (선택)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
placeholder="예: Gitea 저장소 주소, 결과물 링크"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{savedMessage && (
|
||||
<p style={{ color: 'var(--teal)', fontSize: 14, marginBottom: 8 }}>{savedMessage}</p>
|
||||
)}
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||
{saving ? '제출 중...' : submission ? '다시 제출하기' : '제출하기'}
|
||||
</button>
|
||||
{submission && (
|
||||
<span className="muted" style={{ fontSize: 13, marginLeft: 10 }}>
|
||||
마지막 제출: {new Date(submission.submittedAt).toLocaleString('ko-KR')}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -144,10 +143,40 @@ export default function AssignmentsPage() {
|
||||
return mySubmissions.find((s) => s.assignmentId === assignmentId) || null;
|
||||
}
|
||||
|
||||
// 이번 주 제출 현황 요약 (히어로에 표시)
|
||||
const submittedCount = assignments.filter((a) => findSubmission(a.id)).length;
|
||||
const percent =
|
||||
assignments.length === 0 ? 0 : Math.round((submittedCount / assignments.length) * 100);
|
||||
|
||||
// 요일(일차)별로 묶는다 — 대시보드와 같은 방식이라 화면 리듬이 익숙해진다.
|
||||
const groups = new Map();
|
||||
for (const assignment of assignments) {
|
||||
const key = `${assignment.day}일차`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(assignment);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">과제</h1>
|
||||
<p className="page-desc">주차를 선택해 과제를 확인하고 제출하세요.</p>
|
||||
<div className="hero compact">
|
||||
<div className="eyebrow">Assignments</div>
|
||||
<h1>{week}주차 과제</h1>
|
||||
<p>
|
||||
매일 16:30까지 제출해요. 본 과제가 일찍 끝나면 예비 과제로 —
|
||||
예비 과제도 평가에 반영됩니다.
|
||||
</p>
|
||||
<div className="chip-row" style={{ alignItems: 'center' }}>
|
||||
<div className="stat">
|
||||
<span className="stat-num">{percent}%</span>
|
||||
<span className="stat-label">
|
||||
{submittedCount} / {assignments.length} 제출
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress-track" style={{ flex: 1, minWidth: 160 }}>
|
||||
<div className="progress-fill" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs">
|
||||
{WEEKS.map((w) => (
|
||||
@ -164,18 +193,37 @@ export default function AssignmentsPage() {
|
||||
{loading ? (
|
||||
<p className="empty">불러오는 중...</p>
|
||||
) : assignments.length === 0 ? (
|
||||
<p className="empty">이 주차에는 과제가 없어요.</p>
|
||||
<div className="card">
|
||||
<p className="empty">
|
||||
이 주차에는 등록된 과제가 없어요.
|
||||
<br />
|
||||
<span style={{ fontSize: 13 }}>5주차부터는 실전 티켓이 과제를 대신해요.</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
assignments.map((assignment) => (
|
||||
<AssignmentCard
|
||||
// 학습 포인트: key에 "과제id-제출여부"를 함께 넣어,
|
||||
// 제출 데이터가 도착하면 카드가 새 초기값(내 제출 내용)으로 다시 만들어지게 한다.
|
||||
key={`${assignment.id}-${findSubmission(assignment.id)?.id ?? 'none'}`}
|
||||
assignment={assignment}
|
||||
submission={findSubmission(assignment.id)}
|
||||
onSubmitted={loadMySubmissions}
|
||||
/>
|
||||
))
|
||||
[...groups.entries()].map(([groupName, groupAssignments]) => {
|
||||
const groupSubmitted = groupAssignments.filter((a) => findSubmission(a.id)).length;
|
||||
return (
|
||||
<div key={groupName}>
|
||||
<div className="day-head">
|
||||
<span className="day-badge">{groupName}</span>
|
||||
<span className="day-count">
|
||||
{groupSubmitted} / {groupAssignments.length} 제출
|
||||
</span>
|
||||
</div>
|
||||
{groupAssignments.map((assignment) => (
|
||||
<AssignmentCard
|
||||
// 학습 포인트: key에 "과제id-제출여부"를 함께 넣어,
|
||||
// 제출 데이터가 도착하면 카드가 새 초기값(내 제출 내용)으로 다시 만들어지게 한다.
|
||||
key={`${assignment.id}-${findSubmission(assignment.id)?.id ?? 'none'}`}
|
||||
assignment={assignment}
|
||||
submission={findSubmission(assignment.id)}
|
||||
onSubmitted={loadMySubmissions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,13 +1,24 @@
|
||||
// 이 파일이 하는 일: 주차(1~8) 탭을 고르면 그 주의 체크리스트를 보여주고,
|
||||
// 체크박스를 누르면 서버에 진행 상황을 저장한다. 상단에 진행률 바를 표시한다.
|
||||
// 체크박스를 누르면 서버에 진행 상황을 저장한다. 상단에 진행률을 크게 보여준다.
|
||||
import { useEffect, useState } from 'react';
|
||||
import client from '../api/client';
|
||||
import ProgressBar from '../components/ProgressBar';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import Card from '../components/Card';
|
||||
|
||||
const WEEKS = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
|
||||
// 진행률(%)에 따라 다른 응원 문구를 고른다.
|
||||
// 학습 포인트: 이런 "표시용 계산"은 컴포넌트 밖 순수 함수로 빼두면 테스트하기 쉽다.
|
||||
function cheerMessage(percent) {
|
||||
if (percent === 100) return '이번 주 완주! 정말 수고했어요 🎉';
|
||||
if (percent >= 70) return '거의 다 왔어요, 조금만 더!';
|
||||
if (percent >= 30) return '좋은 흐름이에요. 이대로 쭉!';
|
||||
if (percent > 0) return '시작이 반이에요. 하나씩 채워봐요.';
|
||||
return '이번 주 첫 체크를 눌러볼까요?';
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
const [week, setWeek] = useState(1);
|
||||
const [items, setItems] = useState([]); // 이번 주 체크리스트 항목
|
||||
const [checkedIds, setCheckedIds] = useState(new Set()); // 내가 체크한 항목 id들
|
||||
@ -53,6 +64,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
const doneCount = items.filter((item) => checkedIds.has(item.id)).length;
|
||||
const percent = items.length === 0 ? 0 : Math.round((doneCount / items.length) * 100);
|
||||
|
||||
// day별로 묶어서 보여준다 (day가 null인 항목은 "주간 공통"으로).
|
||||
const groups = new Map();
|
||||
@ -64,8 +76,25 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">대시보드</h1>
|
||||
<p className="page-desc">주차를 선택하고, 완료한 항목을 체크하세요.</p>
|
||||
{/* 히어로: 인사 + 이번 주 진행률을 한눈에 */}
|
||||
<div className="hero compact">
|
||||
<div className="eyebrow">Dashboard</div>
|
||||
<h1>
|
||||
{user?.name}님, {week}주차 진행 상황이에요
|
||||
</h1>
|
||||
<p>{cheerMessage(percent)}</p>
|
||||
<div className="chip-row" style={{ alignItems: 'center' }}>
|
||||
<div className="stat">
|
||||
<span className="stat-num">{percent}%</span>
|
||||
<span className="stat-label">
|
||||
{doneCount} / {items.length} 완료
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress-track" style={{ flex: 1, minWidth: 160 }}>
|
||||
<div className="progress-fill" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs">
|
||||
{WEEKS.map((w) => (
|
||||
@ -79,36 +108,46 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<ProgressBar value={doneCount} total={items.length} />
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<p className="empty">불러오는 중...</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="empty">이 주차에는 체크리스트가 없어요.</p>
|
||||
<Card>
|
||||
<p className="empty">
|
||||
이 주차에는 체크리스트가 없어요.
|
||||
<br />
|
||||
<span style={{ fontSize: 13 }}>궁금하면 멘토에게 물어보세요!</span>
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
[...groups.entries()].map(([groupName, groupItems]) => (
|
||||
<div key={groupName}>
|
||||
<h2 className="section-title">{groupName}</h2>
|
||||
<Card style={{ padding: '8px 16px' }}>
|
||||
{groupItems.map((item) => {
|
||||
const done = checkedIds.has(item.id);
|
||||
return (
|
||||
// 학습 포인트: label로 감싸면 글자를 눌러도 체크박스가 토글된다 (접근성).
|
||||
<label key={item.id} className={`check-row${done ? ' done' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={done}
|
||||
onChange={() => toggle(item.id)}
|
||||
/>
|
||||
<span className="check-label">{item.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
))
|
||||
[...groups.entries()].map(([groupName, groupItems]) => {
|
||||
const groupDone = groupItems.filter((i) => checkedIds.has(i.id)).length;
|
||||
return (
|
||||
<div key={groupName}>
|
||||
<div className="day-head">
|
||||
<span className="day-badge">{groupName}</span>
|
||||
<span className="day-count">
|
||||
{groupDone} / {groupItems.length} 완료
|
||||
</span>
|
||||
</div>
|
||||
<Card style={{ padding: '8px 16px' }}>
|
||||
{groupItems.map((item) => {
|
||||
const done = checkedIds.has(item.id);
|
||||
return (
|
||||
// 학습 포인트: label로 감싸면 글자를 눌러도 체크박스가 토글된다 (접근성).
|
||||
<label key={item.id} className={`check-row${done ? ' done' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={done}
|
||||
onChange={() => toggle(item.id)}
|
||||
/>
|
||||
<span className="check-label">{item.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -626,3 +626,120 @@ inline-code, .icode {
|
||||
border-radius: 5px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
/* 컴팩트 히어로 — 대시보드·과제처럼 매일 보는 페이지용 (가이드 히어로보다 낮게) */
|
||||
.hero.compact {
|
||||
padding: 22px 24px 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.hero.compact h1 {
|
||||
font-size: clamp(21px, 3.2vw, 26px);
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
|
||||
/* 큰 숫자 통계 (진행률 등) */
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat .stat-num {
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat .stat-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 요일(일차) 섹션 헤더 — 배지 + 제목 */
|
||||
.day-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 22px 0 10px;
|
||||
}
|
||||
|
||||
.day-head .day-badge {
|
||||
flex: none;
|
||||
padding: 3px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.day-head .day-count {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 제출 완료된 과제 카드 표시 — 왼쪽에 청록 띠 */
|
||||
.assign-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.assign-card.submitted::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--teal);
|
||||
}
|
||||
|
||||
.assign-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.assign-title {
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* 접었다 펴는 상세 — 학습 포인트: <details>는 JS 없이도 접기/펼치기가 되는 HTML 기본 기능 */
|
||||
.fold summary {
|
||||
cursor: pointer;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fold summary::before {
|
||||
content: "▸ ";
|
||||
}
|
||||
|
||||
.fold[open] summary::before {
|
||||
content: "▾ ";
|
||||
}
|
||||
|
||||
.fold[open] summary {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user