Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/pages/mypage/mypage.css
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,45 @@
font-weight: 500;
}

.session-copy-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}

.session-copy-btn {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #ffffff;
color: #374151;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}

.session-copy-btn:hover {
background: #f0f8ff;
border-color: #2491c9;
color: #2491c9;
}

.session-copy-btn.copied {
background: #dcfce7;
border-color: #16a34a;
color: #16a34a;
}

.session-copy-btn.error {
background: #fee2e2;
border-color: #dc2626;
color: #dc2626;
}

.view-more-btn {
padding: 8px 20px;
border-radius: 8px;
Expand Down
60 changes: 60 additions & 0 deletions src/pages/practice-session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Link, useParams, useNavigate, useSearchParams } from 'react-router-dom'
import QRCode from 'qrcode';
import { usePracticeSessionDetailQuery } from '@entities/user';
import type { PracticeAttemptResult } from '@entities/user';
import { drawSessionCard } from '@shared/lib/sessionCardUtils';
import '@pages/mypage/mypage.css';
import '@pages/practice-results/practice-results.css';

Expand Down Expand Up @@ -38,6 +39,7 @@ const PracticeSessionDetail: React.FC = () => {
const [searchParams] = useSearchParams();
const fromHome = searchParams.get('from') === 'home';
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');

const { data: sessionDetail, isLoading } = usePracticeSessionDetailQuery(
Number(sessionId)
Expand Down Expand Up @@ -75,6 +77,36 @@ const PracticeSessionDetail: React.FC = () => {
};
}, [sessionDetail]);

const handleCopyImage = async () => {
if (!sessionDetail) return;

const compactData = {
d: sessionDetail.practiceAt,
t: sessionDetail.totalAttempts,
s: sessionDetail.successCount,
a: sessionDetail.attempts.map((a) => ({
c: a.courseTitle,
r: a.reactionTime,
p: a.percentile,
ok: a.isSuccess,
})),
};

try {
const dataUrl = drawSessionCard(compactData);
const res = await fetch(dataUrl);
const blob = await res.blob();
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
setCopyStatus('copied');
setTimeout(() => setCopyStatus('idle'), 2000);
} catch {
setCopyStatus('error');
setTimeout(() => setCopyStatus('idle'), 2000);
}
};

if (isLoading) {
return (
<div className={fromHome ? 'containerX' : 'mypage-page'}>
Expand Down Expand Up @@ -114,6 +146,34 @@ const PracticeSessionDetail: React.FC = () => {
<div className="results-header">
<h2 className="results-title">연습 세션 상세 조회</h2>
<div className="session-detail-header-right">
{sessionDetail && (
<div className="session-copy-box">
<button
className={`session-copy-btn ${copyStatus !== 'idle' ? copyStatus : ''}`}
onClick={handleCopyImage}
aria-label="사진 클립보드에 복사"
>
{copyStatus === 'copied' ? (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : copyStatus === 'error' ? (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
) : (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
<span className="session-qr-label">
{copyStatus === 'copied' ? '복사 완료!' : copyStatus === 'error' ? '복사 실패' : '사진 복사'}
</span>
</div>
)}
{qrDataUrl && (
<div className="session-qr-box">
<img
Expand Down
206 changes: 1 addition & 205 deletions src/pages/session-share/index.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,8 @@
import React, { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { drawSessionCard, type ShareData } from '@shared/lib/sessionCardUtils';
import './session-share.css';

interface ShareAttempt {
c: string; // courseTitle
r: number; // reactionTime
p: number; // percentile
ok: boolean; // isSuccess
}

interface ShareData {
d: string; // practiceAt
t: number; // totalAttempts
s: number; // successCount
a: ShareAttempt[];
}

function decodeShareData(encoded: string): ShareData {
const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(base64);
Expand All @@ -27,197 +14,6 @@ function decodeShareData(encoded: string): ShareData {
return JSON.parse(decoder.decode(bytes)) as ShareData;
}

function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number
) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}

function drawSessionCard(data: ShareData): string {
const dpr = 2;
const W = 390;
const pagePad = 16;
const rowH = 70;
const brandH = 52;
const titleH = 50;
const sepH = 1;
const infoH = 56;
const tableHeaderH = 44;
const footerH = 44;
const cardPad = 0;

const totalH =
pagePad * 2 +
brandH +
titleH +
sepH +
infoH +
tableHeaderH +
data.a.length * rowH +
footerH +
cardPad;

const canvas = document.createElement('canvas');
canvas.width = W * dpr;
canvas.height = totalH * dpr;

const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);

// Page background
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(0, 0, W, totalH);

// White card
const cardX = pagePad;
const cardY = pagePad;
const cardW = W - pagePad * 2;
const cardH = totalH - pagePad * 2;
ctx.fillStyle = '#ffffff';
roundRect(ctx, cardX, cardY, cardW, cardH, 16);
ctx.fill();

// Card drop shadow (via inner glow trick - just a subtle border)
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
roundRect(ctx, cardX, cardY, cardW, cardH, 16);
ctx.stroke();

// Brand: SNUCLEAR
let cursor = cardY;
ctx.fillStyle = '#2491c9';
ctx.font = `bold 20px -apple-system, BlinkMacSystemFont, "Malgun Gothic", "Apple SD Gothic Neo", sans-serif`;
ctx.textAlign = 'left';
ctx.fillText('SNUCLEAR', cardX + 20, cursor + 34);
cursor += brandH;

// Title
ctx.fillStyle = '#111827';
ctx.font = `600 22px -apple-system, BlinkMacSystemFont, "Malgun Gothic", "Apple SD Gothic Neo", sans-serif`;
ctx.fillText('연습 세션 상세 조회', cardX + 20, cursor + 30);
cursor += titleH;

// Separator
ctx.fillStyle = '#e5e7eb';
ctx.fillRect(cardX + 20, cursor, cardW - 40, 1);
cursor += sepH;

// Info bar
ctx.fillStyle = '#f9fafb';
ctx.fillRect(cardX, cursor, cardW, infoH);
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cardX, cursor);
ctx.lineTo(cardX + cardW, cursor);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(cardX, cursor + infoH);
ctx.lineTo(cardX + cardW, cursor + infoH);
ctx.stroke();

const dateStr = new Date(data.d).toLocaleDateString('ko-KR');
ctx.fillStyle = '#374151';
ctx.font = `13px -apple-system, BlinkMacSystemFont, "Malgun Gothic", "Apple SD Gothic Neo", sans-serif`;
const infoText = `${dateStr} 연습 총 시도: ${data.t}회 성공: ${data.s}회 실패: ${data.t - data.s}회`;
ctx.fillText(infoText, cardX + 20, cursor + 32);
cursor += infoH;

// Table header
ctx.fillStyle = '#f8f9fa';
ctx.fillRect(cardX, cursor, cardW, tableHeaderH);
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cardX, cursor + tableHeaderH);
ctx.lineTo(cardX + cardW, cursor + tableHeaderH);
ctx.stroke();

ctx.fillStyle = '#6b7280';
ctx.font = `600 13px -apple-system, BlinkMacSystemFont, "Malgun Gothic", "Apple SD Gothic Neo", sans-serif`;
ctx.textAlign = 'left';
ctx.fillText('과목이름', cardX + 20, cursor + 27);
ctx.textAlign = 'right';
ctx.fillText('반응속도', cardX + cardW - 90, cursor + 27);
ctx.fillText('상위%', cardX + cardW - 20, cursor + 27);
cursor += tableHeaderH;

// Rows
data.a.forEach((attempt, index) => {
const rowY = cursor + index * rowH;

ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cardX, rowY + rowH);
ctx.lineTo(cardX + cardW, rowY + rowH);
ctx.stroke();

// Course title (truncate if needed)
const maxTitleW = cardW * 0.48;
ctx.font = `14px -apple-system, BlinkMacSystemFont, "Malgun Gothic", "Apple SD Gothic Neo", sans-serif`;
ctx.textAlign = 'left';
ctx.fillStyle = '#111827';
let title = attempt.c;
while (ctx.measureText(title).width > maxTitleW && title.length > 1) {
title = title.slice(0, -1);
}
if (title !== attempt.c) title += '…';
ctx.fillText(title, cardX + 20, rowY + 26);

// Badge
const badgeText = attempt.ok ? '성공' : '실패';
const badgeBg = attempt.ok ? '#dcfce7' : '#fee2e2';
const badgeTextColor = attempt.ok ? '#16a34a' : '#dc2626';
ctx.font = `bold 11px -apple-system, BlinkMacSystemFont, "Malgun Gothic", "Apple SD Gothic Neo", sans-serif`;
const badgeW = ctx.measureText(badgeText).width + 14;
const badgeX = cardX + 20;
const badgeY = rowY + 36;
ctx.fillStyle = badgeBg;
roundRect(ctx, badgeX, badgeY, badgeW, 20, 10);
ctx.fill();
ctx.fillStyle = badgeTextColor;
ctx.fillText(badgeText, badgeX + 7, badgeY + 14);

// Reaction time
ctx.textAlign = 'right';
ctx.fillStyle = '#374151';
ctx.font = `14px -apple-system, BlinkMacSystemFont, "Malgun Gothic", "Apple SD Gothic Neo", sans-serif`;
ctx.fillText(`${attempt.r}ms`, cardX + cardW - 90, rowY + 38);

// Percentile
const pctText = attempt.p ? `${(attempt.p * 100).toFixed(1)}%` : '-';
ctx.fillText(pctText, cardX + cardW - 20, rowY + 38);
ctx.textAlign = 'left';
});

cursor += data.a.length * rowH;

// Footer
ctx.fillStyle = '#9ca3af';
ctx.font = `12px -apple-system, BlinkMacSystemFont, "Malgun Gothic", "Apple SD Gothic Neo", sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('snuclear.wafflestudio.com', W / 2, cursor + 26);
ctx.textAlign = 'left';

return canvas.toDataURL('image/png');
}

const SessionSharePage: React.FC = () => {
const [searchParams] = useSearchParams();

Expand Down
Loading
Loading