Skip to content

Commit 011f96f

Browse files
bug fix: 0207 11:09
1 parent b3792e8 commit 011f96f

File tree

5 files changed

+103
-27
lines changed

5 files changed

+103
-27
lines changed

src/App.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,21 @@ export default function App() {
140140
);
141141
}, []);
142142

143+
// Keep header nickname in sync when MyPage updates localStorage user (same-tab).
144+
useEffect(() => {
145+
const onSession = () => {
146+
try {
147+
const raw = localStorage.getItem('user');
148+
if (raw) setUser(JSON.parse(raw));
149+
} catch {
150+
// ignore
151+
}
152+
};
153+
window.addEventListener('snutoto:session', onSession as EventListener);
154+
return () =>
155+
window.removeEventListener('snutoto:session', onSession as EventListener);
156+
}, []);
157+
143158
const handleLoginSuccess = (loggedInUser: User) => {
144159
setIsLoggedIn(true);
145160
setUser(loggedInUser);

src/components/EmailVerifyModal.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,11 @@ export default function EmailVerifyModal({
8080
try {
8181
await confirmVerificationCode(code.trim());
8282
localStorage.removeItem('verification_token');
83-
setInfo('인증이 완료되었습니다. 이제 로그인할 수 있어요.');
84-
onSuccess?.();
83+
setInfo('인증 완료! 잠시 후 로그인 화면으로 이동합니다.');
84+
// Give the user a moment to see the success message.
85+
window.setTimeout(() => {
86+
onSuccess?.();
87+
}, 650);
8588
} catch (e) {
8689
const err = e as AxiosError<{ error_code?: string }>;
8790
if (err.response?.data?.error_code === 'ERR_012') {

src/components/EventCreateForm.tsx

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,36 @@ const EventCreateForm = ({ onCreated, onCancel }: Props) => {
2020
const [error, setError] = useState<string | null>(null);
2121
const [message, setMessage] = useState<string | null>(null);
2222

23+
// README 2-1: per-image size limit 5MB
24+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
25+
26+
const describeTooLarge = (files: File[]) => {
27+
const over = files.filter((f) => f.size > MAX_IMAGE_BYTES);
28+
if (over.length === 0) return null;
29+
const names = over
30+
.slice(0, 3)
31+
.map((f) => f.name)
32+
.join(', ');
33+
const more = over.length > 3 ? ` 외 ${over.length - 3}개` : '';
34+
return `이미지 용량이 너무 커요. (개별 5MB 이하) : ${names}${more}`;
35+
};
36+
2337
const nowLocal = new Date();
2438
const minStartLocal = new Date(nowLocal.getTime() + 60_000)
2539
.toISOString()
2640
.slice(0, 16);
2741

42+
const fmtLocal = (dtLocal: string) => {
43+
if (!dtLocal) return '';
44+
// Convert yyyy-mm-ddThh:mm to a readable label in ko-KR.
45+
try {
46+
const d = new Date(dtLocal);
47+
return d.toLocaleString('ko-KR');
48+
} catch {
49+
return dtLocal;
50+
}
51+
};
52+
2853
const addOption = () =>
2954
setOptions((prev) => [...prev, { name: '', option_image_files: [] }]);
3055
const removeOption = (idx: number) =>
@@ -34,17 +59,13 @@ const EventCreateForm = ({ onCreated, onCancel }: Props) => {
3459
prev.map((v, i) => (i === idx ? { ...v, name: val } : v))
3560
);
3661

37-
const addOptionImage = (idx: number, files: FileList | null) => {
38-
if (!files || files.length === 0) return;
62+
const setOptionImage = (idx: number, file: File | null) => {
3963
setOptions((prev) =>
4064
prev.map((v, i) =>
4165
i === idx
4266
? {
4367
...v,
44-
option_image_files: [
45-
...v.option_image_files,
46-
...Array.from(files),
47-
],
68+
option_image_files: file ? [file] : [],
4869
}
4970
: v
5071
)
@@ -91,11 +112,11 @@ const EventCreateForm = ({ onCreated, onCancel }: Props) => {
91112
}
92113

93114
const optionImageBaseIndex = imageFiles.length;
94-
// Flatten option images in option order (their indices are derived from this order)
115+
// Flatten option images in option order.
116+
// API supports a single option_image_index per option, so only the first selected image is appended.
95117
for (const opt of options) {
96-
for (const f of opt.option_image_files ?? []) {
97-
imageFiles.push(f);
98-
}
118+
const first = (opt.option_image_files ?? [])[0];
119+
if (first) imageFiles.push(first);
99120
}
100121

101122
// ===== Client-side validation (mirrors README constraints) =====
@@ -151,13 +172,11 @@ const EventCreateForm = ({ onCreated, onCancel }: Props) => {
151172
images: images.length > 0 ? images : undefined,
152173
options: normalizedOptions.map((o) => {
153174
// If no image for this option, -1. Otherwise map to shared image_files index.
154-
const mapped =
155-
o.option_image_files.length > 0 ? runningOptImageIndex : -1;
175+
const hasImage = (o.option_image_files ?? []).length > 0;
176+
const mapped = hasImage ? runningOptImageIndex : -1;
156177

157-
// Each option can have 0..N images selected, but API supports a single index.
158-
// We'll use the first selected image and still upload the rest (harmless).
159-
// Advance by the number of files we appended for this option.
160-
runningOptImageIndex += o.option_image_files.length;
178+
// We appended at most 1 file per option above.
179+
if (hasImage) runningOptImageIndex += 1;
161180

162181
return { name: o.name, option_image_index: mapped };
163182
}),
@@ -231,6 +250,10 @@ const EventCreateForm = ({ onCreated, onCancel }: Props) => {
231250
}}
232251
required
233252
/>
253+
<p className="page-sub" style={{ margin: 0 }}>
254+
현재 시각 기준 1분 이후부터 선택할 수 있어요.
255+
{minStartLocal ? ` (최소: ${fmtLocal(minStartLocal)})` : ''}
256+
</p>
234257
</div>
235258
<div className="form-row">
236259
<label htmlFor="ev-desc">설명</label>
@@ -254,6 +277,9 @@ const EventCreateForm = ({ onCreated, onCancel }: Props) => {
254277
onChange={(e) => setEndAt(e.target.value)}
255278
required
256279
/>
280+
<p className="page-sub" style={{ margin: 0 }}>
281+
시작 시각 이후로만 선택할 수 있어요.
282+
</p>
257283
</div>
258284

259285
<div className="image-input-grid span-2">
@@ -265,18 +291,30 @@ const EventCreateForm = ({ onCreated, onCancel }: Props) => {
265291
type="file"
266292
accept="image/*"
267293
multiple
268-
onChange={(e) => setEventImages(e.target.files)}
294+
onChange={(e) => {
295+
const files = Array.from(e.target.files ?? []);
296+
const tooLargeMsg = describeTooLarge(files);
297+
if (tooLargeMsg) {
298+
setError(tooLargeMsg);
299+
setEventImages(null);
300+
// reset input so user can reselect
301+
e.currentTarget.value = '';
302+
return;
303+
}
304+
setError(null);
305+
setEventImages(e.target.files);
306+
}}
269307
/>
270308
<p className="page-sub" style={{ margin: 0 }}>
271-
선택한 순서대로 image_index가 0부터 부여됩니다.
309+
이미지 파일은 개별 5MB 이하만 업로드할 수 있어요.
272310
</p>
273311
</div>
274312

275313
<div className="form-row">
276314
<label>옵션 이미지</label>
277315
<p className="page-sub" style={{ margin: 0 }}>
278-
옵션 행에서 “이미지 선택”을 눌러 업로드할 이미지를 고르세요.
279-
(없으면 이미지 없이 생성됩니다.)
316+
옵션마다 이미지는 1장만 선택할 수 있어요. (없으면 이미지 없이
317+
생성됩니다.)
280318
</p>
281319
</div>
282320
</div>
@@ -302,16 +340,26 @@ const EventCreateForm = ({ onCreated, onCancel }: Props) => {
302340
accept="image/*"
303341
style={{ display: 'none' }}
304342
onChange={(e) => {
305-
addOptionImage(idx, e.target.files);
343+
const file = e.target.files?.[0] ?? null;
344+
if (file) {
345+
const tooLargeMsg = describeTooLarge([file]);
346+
if (tooLargeMsg) {
347+
setError(tooLargeMsg);
348+
setOptionImage(idx, null);
349+
e.currentTarget.value = '';
350+
return;
351+
}
352+
setError(null);
353+
}
354+
355+
setOptionImage(idx, file);
306356
// reset input so selecting same file again triggers change
307357
e.currentTarget.value = '';
308358
}}
309359
/>
310360
</label>
311361
<small className="page-sub" style={{ margin: 0 }}>
312-
{opt.option_image_files.length > 0
313-
? `${opt.option_image_files.length}개 선택됨`
314-
: '없음'}
362+
{opt.option_image_files.length > 0 ? '선택됨' : '없음'}
315363
</small>
316364
{opt.option_image_files.length > 0 ? (
317365
<button

src/components/MyPage.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
updateMyNickname,
88
updateMyPassword,
99
} from '../api/me';
10+
import { notifySessionChanged } from '../auth/session';
1011
import type { MeBet, MeProfile, MeRanking, PointHistoryItem } from '../types';
1112
import Modal from './Modal';
1213

@@ -132,6 +133,9 @@ export default function MyPage({ onBack }: { onBack: () => void }) {
132133
} catch {
133134
// ignore storage errors
134135
}
136+
137+
// Same-tab notify so header reacts immediately.
138+
notifySessionChanged();
135139
} catch (e) {
136140
setError(e instanceof Error ? e.message : '닉네임 변경 실패');
137141
return false;
@@ -215,6 +219,12 @@ export default function MyPage({ onBack }: { onBack: () => void }) {
215219
<h2 className="event-title">{title}</h2>
216220
{profile ? (
217221
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
222+
{!ranking || ranking.total_users === 0 ? (
223+
<p className="page-sub" style={{ margin: '6px 0 0' }}>
224+
랭킹은 매 정각에 산정되기 때문에 그 전까지는 유효하지 않은
225+
등수로 표시될 수 있습니다.
226+
</p>
227+
) : null}
218228
<div className="stat" style={{ minWidth: 220 }}>
219229
<div className="stat-label">잔여 코인</div>
220230
<div className="stat-value">

src/components/SignupModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export default function SignupModal({ onSignupSuccess, onNeedVerify }: Props) {
9494
<input
9595
id="signup-nickname"
9696
className="input"
97-
placeholder="닉네임"
97+
placeholder="닉네임 (2-20자)"
9898
autoComplete="nickname"
9999
onChange={(e) => setNickname(e.target.value)}
100100
/>

0 commit comments

Comments
 (0)