tkadkghdlf commited on
Commit
ebfcdf6
·
verified ·
1 Parent(s): ac3ca97

Sync from GitHub via hub-sync

Browse files
api/ai-service/public/generated_routes/Gangnam_gu_Seoul_South_Korea_000.png CHANGED
api/ai-service/public/generated_routes/Gangnam_gu_Seoul_South_Korea_001.png CHANGED
api/ai-service/public/generated_routes/Gangnam_gu_Seoul_South_Korea_002.png CHANGED
main/app/_utils/generatedRouteDetails.ts CHANGED
@@ -60,12 +60,29 @@ export type GeneratedRouteDetailRecord = {
60
  const GENERATED_ROUTE_DETAILS_STORAGE_KEY = "tranimal-generated-route-details";
61
  const MAX_STORED_ROUTE_DETAILS = 30;
62
 
63
- export const formatGeneratedRouteLabel = (raw: string): string =>
64
- raw
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  .replace(/_/g, " ")
66
  .split(" ")
67
  .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
68
  .join(" ");
 
69
 
70
  const routeScoreValue = (row: RouteScore): number =>
71
  typeof row.rerank_score === "number" ? row.rerank_score : row.score;
 
60
  const GENERATED_ROUTE_DETAILS_STORAGE_KEY = "tranimal-generated-route-details";
61
  const MAX_STORED_ROUTE_DETAILS = 30;
62
 
63
+ const ROUTE_LABELS: Record<string, string> = {
64
+ apple: "사과",
65
+ candle: "양초",
66
+ cloud: "구름",
67
+ cup: "컵",
68
+ face: "얼굴",
69
+ fish: "물고기",
70
+ ice_cream: "아이스크림",
71
+ t_shirt: "티셔츠",
72
+ };
73
+
74
+ export const formatGeneratedRouteLabel = (raw: string): string => {
75
+ const normalized = raw.trim().toLowerCase().replace(/[\s-]+/g, "_");
76
+ if (ROUTE_LABELS[normalized]) {
77
+ return ROUTE_LABELS[normalized];
78
+ }
79
+
80
+ return raw
81
  .replace(/_/g, " ")
82
  .split(" ")
83
  .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
84
  .join(" ");
85
+ };
86
 
87
  const routeScoreValue = (row: RouteScore): number =>
88
  typeof row.rerank_score === "number" ? row.rerank_score : row.score;
main/app/community/new/page.tsx CHANGED
@@ -189,11 +189,11 @@ function CommunityNewPageContent() {
189
  };
190
 
191
  return (
192
- <main className="min-h-screen bg-white">
193
  <TopBar />
194
 
195
- <section className="mx-auto w-full max-w-3xl px-6 sm:px-10 py-8 flex flex-col gap-6">
196
- <div className="flex items-center justify-between">
197
  <h2 className="text-2xl font-bold text-black">러닝 결과 공유</h2>
198
  <Link
199
  href="/recent-shared-routes"
@@ -210,7 +210,7 @@ function CommunityNewPageContent() {
210
  type="text"
211
  value={nickname}
212
  onChange={(event) => setNickname(event.target.value)}
213
- className="h-11 rounded-xl border border-black/10 bg-white px-4 text-sm"
214
  />
215
  </label>
216
 
@@ -220,7 +220,7 @@ function CommunityNewPageContent() {
220
  type="text"
221
  value={title}
222
  onChange={(event) => setTitle(event.target.value)}
223
- className="h-11 rounded-xl border border-black/10 bg-white px-4 text-sm"
224
  />
225
  </label>
226
 
@@ -231,11 +231,11 @@ function CommunityNewPageContent() {
231
  onChange={(event) => setContent(event.target.value)}
232
  rows={4}
233
  placeholder="오늘 러닝 어땠는지 적어보세요."
234
- className="rounded-xl border border-black/10 bg-white px-4 py-3 text-sm resize-none"
235
  />
236
  </label>
237
 
238
- <article className="rounded-2xl border border-black/10 bg-white overflow-hidden shadow-sm">
239
  <div className="px-4 py-3 border-b border-black/10">
240
  <div className="text-sm font-semibold text-black">{nickname}</div>
241
  <div className="text-xs text-[#7f7f7f]">{locationLabel}</div>
@@ -267,7 +267,7 @@ function CommunityNewPageContent() {
267
 
268
  <button
269
  type="submit"
270
- className="mt-1 h-12 rounded-full bg-black text-white text-base font-semibold"
271
  >
272
  게시판에 올리기
273
  </button>
 
189
  };
190
 
191
  return (
192
+ <main className="rts-page">
193
  <TopBar />
194
 
195
+ <section className="rts-shell rts-shell-narrow flex flex-col gap-6">
196
+ <div className="rts-page-header justify-between">
197
  <h2 className="text-2xl font-bold text-black">러닝 결과 공유</h2>
198
  <Link
199
  href="/recent-shared-routes"
 
210
  type="text"
211
  value={nickname}
212
  onChange={(event) => setNickname(event.target.value)}
213
+ className="rts-input"
214
  />
215
  </label>
216
 
 
220
  type="text"
221
  value={title}
222
  onChange={(event) => setTitle(event.target.value)}
223
+ className="rts-input"
224
  />
225
  </label>
226
 
 
231
  onChange={(event) => setContent(event.target.value)}
232
  rows={4}
233
  placeholder="오늘 러닝 어땠는지 적어보세요."
234
+ className="rts-textarea"
235
  />
236
  </label>
237
 
238
+ <article className="rts-card rts-card-clip overflow-hidden">
239
  <div className="px-4 py-3 border-b border-black/10">
240
  <div className="text-sm font-semibold text-black">{nickname}</div>
241
  <div className="text-xs text-[#7f7f7f]">{locationLabel}</div>
 
267
 
268
  <button
269
  type="submit"
270
+ className="rts-primary-button mt-1 h-12 text-base"
271
  >
272
  게시판에 올리기
273
  </button>
main/app/globals.css CHANGED
@@ -53,6 +53,402 @@
53
 
54
  :root {
55
  --animate-spin: spin 1s linear infinite;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
 
58
  .animate-fade-in {
@@ -125,6 +521,18 @@
125
  }
126
  }
127
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  @keyframes shimmer {
129
  0%,
130
  90%,
 
53
 
54
  :root {
55
  --animate-spin: spin 1s linear infinite;
56
+ --rts-bg: #ffffff;
57
+ --rts-bg-soft: #f6f6f6;
58
+ --rts-text: #111111;
59
+ --rts-muted: #777777;
60
+ --rts-muted-2: #8a8a8a;
61
+ --rts-line: #e5e5e5;
62
+ --rts-line-strong: #111111;
63
+ --rts-accent: #bfdcff;
64
+ --rts-accent-soft: #f1f6ff;
65
+ --rts-card-radius: 28px;
66
+ --rts-inner-radius: 18px;
67
+ --rts-shell: 1120px;
68
+ --rts-shell-narrow: 760px;
69
+ }
70
+
71
+ .rts-page {
72
+ min-height: 100vh;
73
+ background: var(--rts-bg);
74
+ color: var(--rts-text);
75
+ font-family: Arial, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
76
+ overflow-x: hidden;
77
+ }
78
+
79
+ .rts-page-soft {
80
+ background: var(--rts-bg-soft);
81
+ }
82
+
83
+ .rts-shell {
84
+ width: 100%;
85
+ max-width: var(--rts-shell);
86
+ margin: 0 auto;
87
+ padding: 22px 18px 48px;
88
+ box-sizing: border-box;
89
+ }
90
+
91
+ .rts-shell-narrow {
92
+ max-width: var(--rts-shell-narrow);
93
+ }
94
+
95
+ .rts-page-header {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 12px;
99
+ padding: 18px 0 20px;
100
+ border-bottom: 1px solid var(--rts-line-strong);
101
+ box-sizing: border-box;
102
+ }
103
+
104
+ .rts-title-block {
105
+ min-width: 0;
106
+ flex: 1;
107
+ }
108
+
109
+ .rts-eyebrow {
110
+ margin: 0 0 4px;
111
+ font-size: 11px;
112
+ font-weight: 700;
113
+ letter-spacing: 0.1em;
114
+ color: var(--rts-muted-2);
115
+ }
116
+
117
+ .rts-page-header h1 {
118
+ margin: 0;
119
+ font-size: 24px;
120
+ font-weight: 700;
121
+ letter-spacing: -0.03em;
122
+ }
123
+
124
+ .rts-subtitle {
125
+ margin: 6px 0 0;
126
+ font-size: 13px;
127
+ line-height: 1.45;
128
+ color: var(--rts-muted);
129
+ }
130
+
131
+ .rts-header-actions {
132
+ display: flex;
133
+ align-items: center;
134
+ gap: 8px;
135
+ flex-shrink: 0;
136
+ }
137
+
138
+ .rts-back-button {
139
+ width: 36px;
140
+ height: 36px;
141
+ border: 1px solid #dedede;
142
+ border-radius: 999px;
143
+ background: #ffffff;
144
+ color: var(--rts-text);
145
+ cursor: pointer;
146
+ display: flex;
147
+ align-items: center;
148
+ justify-content: center;
149
+ padding: 0;
150
+ flex-shrink: 0;
151
+ }
152
+
153
+ .rts-card,
154
+ .rts-stat-card {
155
+ border: 1px solid var(--rts-line);
156
+ border-radius: var(--rts-card-radius);
157
+ background: #ffffff;
158
+ padding: 22px;
159
+ box-sizing: border-box;
160
+ }
161
+
162
+ .rts-card-clip {
163
+ overflow: hidden;
164
+ padding: 0;
165
+ }
166
+
167
+ .rts-inner-panel {
168
+ border-radius: var(--rts-inner-radius);
169
+ background: #f7f7f7;
170
+ padding: 14px;
171
+ box-sizing: border-box;
172
+ }
173
+
174
+ .rts-map-frame {
175
+ overflow: hidden;
176
+ border: 1px solid var(--rts-line);
177
+ border-radius: 22px;
178
+ background: #f5f5f5;
179
+ }
180
+
181
+ .rts-section-title {
182
+ margin: 0;
183
+ font-size: 22px;
184
+ font-weight: 600;
185
+ letter-spacing: -0.03em;
186
+ }
187
+
188
+ .rts-label {
189
+ font-size: 13px;
190
+ font-weight: 600;
191
+ color: var(--rts-text);
192
+ }
193
+
194
+ .rts-muted {
195
+ color: var(--rts-muted);
196
+ }
197
+
198
+ .rts-input,
199
+ .rts-select,
200
+ .rts-textarea {
201
+ width: 100%;
202
+ border: 1px solid var(--rts-line);
203
+ border-radius: var(--rts-inner-radius);
204
+ background: #ffffff;
205
+ color: var(--rts-text);
206
+ box-sizing: border-box;
207
+ font-family: inherit;
208
+ }
209
+
210
+ .rts-input,
211
+ .rts-select {
212
+ height: 44px;
213
+ padding: 0 14px;
214
+ font-size: 14px;
215
+ }
216
+
217
+ .rts-textarea {
218
+ min-height: 128px;
219
+ padding: 14px;
220
+ font-size: 14px;
221
+ line-height: 1.5;
222
+ resize: vertical;
223
+ }
224
+
225
+ .rts-input:focus,
226
+ .rts-select:focus,
227
+ .rts-textarea:focus {
228
+ border-color: #4a90e2;
229
+ box-shadow: 0 0 0 3px rgba(191, 220, 255, 0.8);
230
+ }
231
+
232
+ .rts-primary-button,
233
+ .rts-secondary-button {
234
+ min-height: 40px;
235
+ border-radius: 999px;
236
+ padding: 0 16px;
237
+ font-size: 14px;
238
+ font-weight: 700;
239
+ cursor: pointer;
240
+ display: inline-flex;
241
+ align-items: center;
242
+ justify-content: center;
243
+ gap: 8px;
244
+ transition:
245
+ transform 160ms ease,
246
+ box-shadow 160ms ease,
247
+ background-color 160ms ease,
248
+ border-color 160ms ease;
249
+ }
250
+
251
+ .rts-primary-button {
252
+ border: 0;
253
+ background: var(--rts-accent);
254
+ color: var(--rts-text);
255
+ }
256
+
257
+ .rts-primary-button:active,
258
+ .rts-secondary-button:active {
259
+ transform: scale(0.98);
260
+ }
261
+
262
+ .rts-primary-button:disabled,
263
+ .rts-secondary-button:disabled {
264
+ cursor: not-allowed;
265
+ }
266
+
267
+ .rts-secondary-button {
268
+ border: 1px solid var(--rts-line);
269
+ background: #ffffff;
270
+ color: #555555;
271
+ }
272
+
273
+ .rts-empty-state {
274
+ border: 1px dashed #d8d8d8;
275
+ border-radius: 24px;
276
+ background: #ffffff;
277
+ padding: 36px 24px;
278
+ text-align: center;
279
+ color: var(--rts-muted);
280
+ font-size: 14px;
281
+ line-height: 1.5;
282
+ }
283
+
284
+ .rts-data-grid {
285
+ display: grid;
286
+ grid-template-columns: repeat(3, minmax(0, 1fr));
287
+ gap: 14px;
288
+ }
289
+
290
+ .rts-data-item {
291
+ min-width: 0;
292
+ border-radius: var(--rts-inner-radius);
293
+ background: #f7f7f7;
294
+ padding: 14px;
295
+ box-sizing: border-box;
296
+ }
297
+
298
+ .rts-data-value {
299
+ display: block;
300
+ overflow: hidden;
301
+ text-overflow: ellipsis;
302
+ white-space: nowrap;
303
+ margin-bottom: 6px;
304
+ font-size: 13px;
305
+ font-weight: 800;
306
+ color: var(--rts-text);
307
+ }
308
+
309
+ .rts-data-label {
310
+ margin: 0;
311
+ font-size: 12px;
312
+ color: #555555;
313
+ }
314
+
315
+ .rts-mobile-sticky-cta {
316
+ display: none;
317
+ }
318
+
319
+ .rts-result-summary-grid {
320
+ display: grid;
321
+ grid-template-columns: repeat(3, minmax(0, 1fr));
322
+ gap: 8px;
323
+ }
324
+
325
+ .rts-result-summary-grid div {
326
+ min-width: 0;
327
+ border-radius: 16px;
328
+ background: #f7f7f7;
329
+ padding: 12px;
330
+ }
331
+
332
+ .rts-result-summary-grid strong,
333
+ .rts-result-summary-grid span {
334
+ display: block;
335
+ }
336
+
337
+ .rts-result-summary-grid strong {
338
+ overflow: hidden;
339
+ text-overflow: ellipsis;
340
+ white-space: nowrap;
341
+ font-size: 15px;
342
+ color: var(--rts-text);
343
+ }
344
+
345
+ .rts-result-summary-grid span {
346
+ margin-top: 4px;
347
+ font-size: 12px;
348
+ color: var(--rts-muted);
349
+ }
350
+
351
+ .rts-route-map-stage {
352
+ position: relative;
353
+ overflow: hidden;
354
+ }
355
+
356
+ .rts-route-rank {
357
+ position: absolute;
358
+ left: 12px;
359
+ top: 12px;
360
+ z-index: 500;
361
+ border-radius: 999px;
362
+ background: rgba(0, 0, 0, 0.72);
363
+ color: #ffffff;
364
+ padding: 7px 11px;
365
+ font-size: 12px;
366
+ font-weight: 700;
367
+ line-height: 1;
368
+ }
369
+
370
+ .rts-route-card-enter {
371
+ animation: rts-card-enter 420ms ease both;
372
+ }
373
+
374
+ @media (max-width: 900px) {
375
+ .rts-shell {
376
+ max-width: 430px;
377
+ padding: 0 8px 36px;
378
+ }
379
+
380
+ .rts-mobile-cta-page .rts-shell {
381
+ padding-bottom: 118px;
382
+ }
383
+
384
+ .rts-page-header {
385
+ min-height: 78px;
386
+ align-items: flex-end;
387
+ padding: 0 2px 14px;
388
+ }
389
+
390
+ .rts-eyebrow {
391
+ display: none;
392
+ }
393
+
394
+ .rts-page-header h1 {
395
+ font-size: 20px;
396
+ line-height: 1.25;
397
+ letter-spacing: 0;
398
+ word-break: keep-all;
399
+ }
400
+
401
+ .rts-card,
402
+ .rts-stat-card {
403
+ border-radius: 24px;
404
+ padding: 18px;
405
+ }
406
+
407
+ .rts-data-grid {
408
+ gap: 8px;
409
+ }
410
+
411
+ .rts-data-item {
412
+ padding: 12px 8px;
413
+ }
414
+
415
+ .rts-data-value {
416
+ font-size: 11.5px;
417
+ }
418
+
419
+ .rts-data-label {
420
+ font-size: 11px;
421
+ }
422
+
423
+ .rts-desktop-submit {
424
+ display: none;
425
+ }
426
+
427
+ .rts-mobile-sticky-cta {
428
+ position: fixed;
429
+ left: 0;
430
+ right: 0;
431
+ bottom: 0;
432
+ z-index: 900;
433
+ display: grid;
434
+ gap: 8px;
435
+ border-top: 1px solid rgba(0, 0, 0, 0.08);
436
+ background: rgba(255, 255, 255, 0.94);
437
+ padding: 10px max(14px, env(safe-area-inset-left)) calc(12px + env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-right));
438
+ box-shadow: 0 -10px 28px rgba(0, 0, 0, 0.08);
439
+ backdrop-filter: blur(12px);
440
+ }
441
+
442
+ .rts-mobile-sticky-cta p {
443
+ margin: 0;
444
+ overflow: hidden;
445
+ text-overflow: ellipsis;
446
+ white-space: nowrap;
447
+ text-align: center;
448
+ font-size: 12px;
449
+ font-weight: 700;
450
+ color: #555555;
451
+ }
452
  }
453
 
454
  .animate-fade-in {
 
521
  }
522
  }
523
 
524
+ @keyframes rts-card-enter {
525
+ from {
526
+ opacity: 0;
527
+ transform: translateY(10px);
528
+ }
529
+
530
+ to {
531
+ opacity: 1;
532
+ transform: none;
533
+ }
534
+ }
535
+
536
  @keyframes shimmer {
537
  0%,
538
  90%,
main/app/loading-preview/page.tsx CHANGED
@@ -19,7 +19,7 @@ const ROUTE_STEPS = [
19
  "OSM 도로망 불러오는 중",
20
  "템플릿 윤곽 분석 중",
21
  "후보 경로 생성 중",
22
- "CLIP 유사도 평가 중",
23
  "결과 정리 중",
24
  ];
25
 
 
19
  "OSM 도로망 불러오는 중",
20
  "템플릿 윤곽 분석 중",
21
  "후보 경로 생성 중",
22
+ "경로 모양 확인 중",
23
  "결과 정리 중",
24
  ];
25
 
main/app/lobby/page.tsx CHANGED
@@ -1,21 +1,15 @@
1
  "use client";
2
 
3
- import type { ReactNode } from "react";
4
  import { useEffect, useState } from "react";
5
  import { useRouter } from "next/navigation";
6
  import TopBar from "../_components/TopBar";
7
  import LeafletMap from "../_components/LeafletMap";
 
8
  import { FLOW_KEYS, issueUiFlowPermit } from "../_utils/uiFlowGuard";
9
 
10
  const RECENT_ROUTE_LIMIT = 2;
11
  const DEFAULT_CENTER: [number, number] = [37.5665, 126.978];
12
 
13
- const features = [
14
- "동물 모양 러닝 루트",
15
- "실제 지도 기반 경로",
16
- "AI 유사도 분석 추천",
17
- ];
18
-
19
  type Point = [number, number];
20
 
21
  type RecentRoute = {
@@ -116,10 +110,11 @@ function formatCompletedAt(value: string | null): string {
116
  }
117
 
118
  function RatingStars({ rating }: { rating: number | null }) {
119
- const normalized =
120
  typeof rating === "number"
121
- ? Math.max(0, Math.min(5, Math.round(rating)))
122
- : 0;
 
123
 
124
  return (
125
  <div className="flex items-center gap-1">
@@ -127,49 +122,19 @@ function RatingStars({ rating }: { rating: number | null }) {
127
  {Array.from({ length: 5 }).map((_, index) => (
128
  <span
129
  key={index}
130
- className={index < normalized ? "text-[#bfdcff]" : "text-[#d1d5db]"}
131
  >
132
  ★
133
  </span>
134
  ))}
135
  </div>
136
  <span className="text-xs text-[#666]">
137
- {normalized > 0 ? `${normalized}점` : "별점 미설정"}
138
  </span>
139
  </div>
140
  );
141
  }
142
 
143
- function TagButton({
144
- title,
145
- subtitle,
146
- icon,
147
- onClick,
148
- }: {
149
- title: string;
150
- subtitle: string;
151
- icon: ReactNode;
152
- onClick?: () => void;
153
- }) {
154
- return (
155
- <button
156
- type="button"
157
- onClick={onClick}
158
- className="relative h-[50px] w-full rounded-xl border border-black/10 bg-white px-4 py-2 text-left shadow-sm transition-shadow hover:shadow-md"
159
- >
160
- <div className="flex items-center gap-3">
161
- <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#f2f4f8]">
162
- {icon}
163
- </div>
164
- <div className="flex flex-col">
165
- <span className="text-xs font-semibold text-black">{title}</span>
166
- <span className="text-[10px] text-[#7f7f7f]">{subtitle}</span>
167
- </div>
168
- </div>
169
- </button>
170
- );
171
- }
172
-
173
  export default function Page() {
174
  const router = useRouter();
175
  const [displayName, setDisplayName] = useState("러너");
@@ -263,184 +228,175 @@ export default function Page() {
263
  router.push(`/route-search?flowToken=${encodeURIComponent(flowToken)}`);
264
  };
265
 
266
- const handleStartManualRoute = () => {
267
- const flowToken = issueUiFlowPermit(FLOW_KEYS.manualRoute);
268
- router.push(`/manual-route?flowToken=${encodeURIComponent(flowToken)}`);
269
- };
270
-
271
  return (
272
- <main className="min-h-screen bg-white">
273
  <TopBar userName={displayName} title="Run The Shape" largeBrand />
274
 
275
- <section className="mx-auto grid w-full max-w-6xl items-center gap-8 px-6 py-8 sm:px-10 md:grid-cols-2">
276
- <div className="flex flex-col gap-4">
277
- <h2 className="[font-family:'Inter-Bold',Helvetica] text-2xl font-bold leading-snug text-black sm:text-3xl">
278
- 안녕, {displayName}!
279
- <br />
280
- 같이 뛰자
281
- </h2>
282
-
283
- <div className="flex items-center gap-2">
284
- <div className="flex items-center justify-center text-2xl leading-none">
285
- 🏃
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  </div>
287
- <span className="[font-family:'Inter-SemiBold',Helvetica] text-base font-semibold text-black">
288
- 루트를 추천해드려요!
289
- </span>
290
- </div>
291
 
292
- <ul className="flex flex-col gap-2">
293
- {features.map((text) => (
294
- <li key={text} className="flex items-center gap-2">
295
- <span className="h-2 w-2 rounded-full bg-black" />
296
- <span className="[font-family:'Inter-SemiBold',Helvetica] text-sm font-semibold text-black sm:text-base">
297
- {text}
298
- </span>
299
- </li>
300
- ))}
301
- </ul>
302
 
 
303
  <button
304
  type="button"
305
- onClick={handleStartRouteSearch}
306
- className="mt-2 inline-flex w-full items-center justify-center rounded-full bg-[#bfdcff] px-6 py-3 font-semibold text-black shadow-sm transition-shadow hover:shadow-md sm:w-[320px]"
307
  >
308
- 루트 찾기 시작하기
 
 
 
 
309
  </button>
 
310
  <button
311
  type="button"
312
- onClick={handleStartManualRoute}
313
- className="inline-flex w-full items-center justify-center rounded-full bg-[#bfdcff] px-6 py-3 font-semibold text-black shadow-sm transition-shadow hover:shadow-md sm:w-[320px]"
314
  >
315
- 직접 만들기
 
 
 
 
316
  </button>
317
- </div>
318
-
319
- <div className="flex items-center justify-center">
320
- <div className="relative aspect-[1.25] w-[320px] max-w-full">
321
- <video
322
- className="h-full w-full rounded-3xl object-cover"
323
- src="/loading/lobbyrunning.mp4"
324
- autoPlay
325
- muted
326
- loop
327
- playsInline
328
- preload="metadata"
329
- />
330
- </div>
331
- </div>
332
- </section>
333
-
334
- <section className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 pb-10 sm:px-10">
335
- <div className="flex items-center justify-between">
336
- <h3 className="text-lg font-semibold sm:text-xl">최근 러닝 루트</h3>
337
- </div>
338
-
339
- <div className="grid gap-4 sm:grid-cols-2">
340
- {isRecentLoading &&
341
- Array.from({ length: RECENT_ROUTE_LIMIT }).map((_, index) => (
342
- <article
343
- key={`recent-loading-${index}`}
344
- className="overflow-hidden rounded-2xl border border-black/10 bg-white shadow-sm"
345
- >
346
- <div className="h-28 w-full animate-pulse bg-[#f2f4f8]" />
347
- <div className="px-4 py-3">
348
- <div className="h-3 w-32 animate-pulse rounded bg-[#f2f4f8]" />
349
- </div>
350
- </article>
351
- ))}
352
-
353
- {!isRecentLoading &&
354
- recentRoutes.map((route) => {
355
- const hasCenter =
356
- Number.isFinite(route.center_lat) &&
357
- Number.isFinite(route.center_lng);
358
-
359
- const hasPolyline = route.polyline.length > 1;
360
-
361
- const mapCenter: Point = hasCenter
362
- ? [route.center_lat as number, route.center_lng as number]
363
- : hasPolyline
364
- ? route.polyline[0]
365
- : DEFAULT_CENTER;
366
-
367
- return (
368
  <article
369
- key={`recent-route-${route.run_session_id}`}
370
- className="overflow-hidden rounded-2xl border border-black/10 bg-white shadow-sm"
371
  >
372
- <div className="relative h-28 w-full overflow-hidden bg-[#f2f4f8]">
373
- {hasPolyline ? (
374
- <LeafletMap
375
- center={mapCenter}
376
- route={route.polyline}
377
- className="lobby-route-map h-full w-full pointer-events-none"
378
- zoom={14}
379
- />
380
- ) : (
381
- <img
382
- src={resolveMapImage(route.map_image_url)}
383
- alt={route.route_title}
384
- className="h-full w-full object-cover"
385
- onError={(event) => {
386
- const target = event.currentTarget;
387
- target.onerror = null;
388
- target.src = "/icon.svg";
389
- }}
390
- />
391
- )}
392
-
393
- <div className="absolute inset-0 z-[500] bg-gradient-to-t from-black/55 via-transparent to-transparent" />
394
- <div className="absolute bottom-2 right-2 z-[500] rounded-md bg-black/65 px-2 py-1 text-xs font-semibold text-white">
395
- {route.route_title}
396
- </div>
397
- </div>
398
-
399
  <div className="px-4 py-3">
400
- <div className="flex items-center gap-3 text-sm text-black">
401
- <span className="[font-family:'Inter-SemiBold',Helvetica] font-semibold">
402
- {route.actual_distance_km.toFixed(2)}km
403
- </span>
404
- <span className="h-4 w-px bg-black/10" />
405
- <span className="[font-family:'Inter-SemiBold',Helvetica] font-semibold">
406
- 코스 {route.route_length_km.toFixed(2)}km
407
- </span>
408
- </div>
409
-
410
- <div className="mt-1">
411
- <RatingStars rating={route.user_rating} />
412
- </div>
413
-
414
- <p className="mt-1 text-xs text-[#666]">
415
- {formatCompletedAt(route.completed_at)}
416
- </p>
417
  </div>
418
  </article>
419
- );
420
- })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
 
422
- {!isRecentLoading && recentRoutes.length === 0 && (
423
- <div className="col-span-full rounded-2xl border border-dashed border-black/20 px-4 py-8 text-center text-sm text-[#666]">
424
- 아직 저장된 러닝 코스가 없습니다. 루트를 완주하면 여기에 자동으로 표시됩니다.
425
- </div>
426
- )}
427
- </div>
428
-
429
- <div className="grid gap-4 pt-2 sm:grid-cols-2">
430
- <TagButton
431
- title="실시간 최근 공유 된 루트"
432
- subtitle="Top 10"
433
- icon={<span className="text-sm">★</span>}
434
- onClick={() => router.push("/recent-shared-routes")}
435
- />
436
- <TagButton
437
- title="이번 주 인기 루트"
438
- subtitle="Top 3"
439
- icon={<span className="text-sm">▲</span>}
440
- onClick={() => router.push("/trend-routes")}
441
- />
442
- </div>
443
- </section>
 
 
 
 
 
 
 
 
 
444
 
445
  <style jsx global>{`
446
  .lobby-route-map .leaflet-control-container {
@@ -449,4 +405,4 @@ export default function Page() {
449
  `}</style>
450
  </main>
451
  );
452
- }
 
1
  "use client";
2
 
 
3
  import { useEffect, useState } from "react";
4
  import { useRouter } from "next/navigation";
5
  import TopBar from "../_components/TopBar";
6
  import LeafletMap from "../_components/LeafletMap";
7
+ import { PageContainer, PageHeader, SectionCard } from "../_components/RunShapeUI";
8
  import { FLOW_KEYS, issueUiFlowPermit } from "../_utils/uiFlowGuard";
9
 
10
  const RECENT_ROUTE_LIMIT = 2;
11
  const DEFAULT_CENTER: [number, number] = [37.5665, 126.978];
12
 
 
 
 
 
 
 
13
  type Point = [number, number];
14
 
15
  type RecentRoute = {
 
110
  }
111
 
112
  function RatingStars({ rating }: { rating: number | null }) {
113
+ const normalizedRating =
114
  typeof rating === "number"
115
+ ? Math.max(0, Math.min(5, rating))
116
+ : 4;
117
+ const filledStars = Math.round(normalizedRating);
118
 
119
  return (
120
  <div className="flex items-center gap-1">
 
122
  {Array.from({ length: 5 }).map((_, index) => (
123
  <span
124
  key={index}
125
+ className={index < filledStars ? "text-[#1E40AF]" : "text-[#d1d5db]"}
126
  >
127
  ★
128
  </span>
129
  ))}
130
  </div>
131
  <span className="text-xs text-[#666]">
132
+ 별점 {normalizedRating.toFixed(1)}점
133
  </span>
134
  </div>
135
  );
136
  }
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  export default function Page() {
139
  const router = useRouter();
140
  const [displayName, setDisplayName] = useState("러너");
 
228
  router.push(`/route-search?flowToken=${encodeURIComponent(flowToken)}`);
229
  };
230
 
 
 
 
 
 
231
  return (
232
+ <main className="rts-page">
233
  <TopBar userName={displayName} title="Run The Shape" largeBrand />
234
 
235
+ <PageContainer className="flex flex-col gap-5">
236
+ <PageHeader
237
+ title={`안녕하세요, ${displayName}님`}
238
+ subtitle="지역, 거리, 모양을 고르면 달릴 경로를 바로 생성할 수 있어요."
239
+ />
240
+
241
+ <SectionCard className="p-5 sm:p-6">
242
+ <div className="flex flex-col gap-5">
243
+ <div>
244
+ <h2
245
+ className="text-[22px] font-bold leading-[1.25] text-black sm:text-3xl"
246
+ style={{ wordBreak: "keep-all" }}
247
+ >
248
+ 원하는 모양으로 달릴 경로 만들기
249
+ </h2>
250
+ <p className="mt-3 text-sm leading-6 text-[#666] sm:max-w-2xl sm:text-base">
251
+ 위치와 목표 거리, 그리고 원하는 모양을 선택하면 지도 위에 러닝 경로를 생성합니다.
252
+ </p>
253
+ </div>
254
+
255
+ <div className="grid grid-cols-3 gap-2 text-center text-xs sm:max-w-lg">
256
+ <div className="rounded-[16px] bg-[#f7f7f7] px-2 py-3">
257
+ <strong className="block text-black">지역</strong>
258
+ <span className="mt-1 block text-[#666]">동네 선택</span>
259
+ </div>
260
+ <div className="rounded-[16px] bg-[#f7f7f7] px-2 py-3">
261
+ <strong className="block text-black">모양</strong>
262
+ <span className="mt-1 block text-[#666]">그릴 형태</span>
263
+ </div>
264
+ <div className="rounded-[16px] bg-[#f7f7f7] px-2 py-3">
265
+ <strong className="block text-black">거리</strong>
266
+ <span className="mt-1 block text-[#666]">목표 km</span>
267
+ </div>
268
  </div>
 
 
 
 
269
 
270
+ <button
271
+ type="button"
272
+ onClick={handleStartRouteSearch}
273
+ className="flex h-12 w-full items-center justify-center rounded-full bg-[#1E40AF] text-base font-bold text-white shadow-[0_12px_28px_rgba(30,64,175,0.28)] transition active:scale-[0.98]"
274
+ >
275
+ 경로 생성하기
276
+ </button>
277
+ </div>
278
+ </SectionCard>
 
279
 
280
+ <section className="grid gap-3 sm:grid-cols-2">
281
  <button
282
  type="button"
283
+ className="rts-card flex min-h-[76px] items-center justify-between gap-4 px-5 py-4 text-left"
284
+ onClick={() => router.push("/recent-shared-routes")}
285
  >
286
+ <div>
287
+ <h2 className="text-base font-bold text-black">최근 공유 루트</h2>
288
+ <p className="mt-1 text-sm text-[#777]">다른 러너가 공유한 경로 보기</p>
289
+ </div>
290
+ <span className="text-sm font-semibold text-[#777]">보기</span>
291
  </button>
292
+
293
  <button
294
  type="button"
295
+ className="rts-card flex min-h-[76px] items-center justify-between gap-4 px-5 py-4 text-left"
296
+ onClick={() => router.push("/trend-routes")}
297
  >
298
+ <div>
299
+ <h2 className="text-base font-bold text-black">인기 루트</h2>
300
+ <p className="mt-1 text-sm text-[#777]">이번 주 많이 본 경로 보기</p>
301
+ </div>
302
+ <span className="text-sm font-semibold text-[#777]">보기</span>
303
  </button>
304
+ </section>
305
+
306
+ <section className="flex w-full flex-col gap-3">
307
+ <h3 className="rts-section-title">최근 러닝 루트</h3>
308
+
309
+ <div className="grid gap-3 sm:grid-cols-2">
310
+ {isRecentLoading &&
311
+ Array.from({ length: RECENT_ROUTE_LIMIT }).map((_, index) => (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  <article
313
+ key={`recent-loading-${index}`}
314
+ className="rts-card rts-card-clip"
315
  >
316
+ <div className="h-28 w-full animate-pulse bg-[#f2f4f8]" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  <div className="px-4 py-3">
318
+ <div className="h-3 w-32 animate-pulse rounded bg-[#f2f4f8]" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  </div>
320
  </article>
321
+ ))}
322
+
323
+ {!isRecentLoading &&
324
+ recentRoutes.map((route) => {
325
+ const hasCenter =
326
+ Number.isFinite(route.center_lat) &&
327
+ Number.isFinite(route.center_lng);
328
+
329
+ const hasPolyline = route.polyline.length > 1;
330
+
331
+ const mapCenter: Point = hasCenter
332
+ ? [route.center_lat as number, route.center_lng as number]
333
+ : hasPolyline
334
+ ? route.polyline[0]
335
+ : DEFAULT_CENTER;
336
+
337
+ return (
338
+ <article
339
+ key={`recent-route-${route.run_session_id}`}
340
+ className="rts-card rts-card-clip"
341
+ >
342
+ <div className="relative h-32 w-full overflow-hidden bg-[#f2f4f8] sm:h-28">
343
+ {hasPolyline ? (
344
+ <LeafletMap
345
+ center={mapCenter}
346
+ route={route.polyline}
347
+ className="lobby-route-map h-full w-full pointer-events-none"
348
+ zoom={14}
349
+ />
350
+ ) : (
351
+ <img
352
+ src={resolveMapImage(route.map_image_url)}
353
+ alt={route.route_title}
354
+ className="h-full w-full object-cover"
355
+ onError={(event) => {
356
+ const target = event.currentTarget;
357
+ target.onerror = null;
358
+ target.src = "/icon.svg";
359
+ }}
360
+ />
361
+ )}
362
+
363
+ <div className="absolute inset-0 z-[500] bg-gradient-to-t from-black/55 via-transparent to-transparent" />
364
+ <div className="absolute bottom-2 right-2 z-[500] rounded-md bg-black/65 px-2 py-1 text-xs font-semibold text-white">
365
+ {route.route_title}
366
+ </div>
367
+ </div>
368
 
369
+ <div className="px-4 py-3">
370
+ <div className="flex items-center gap-3 text-sm text-black">
371
+ <span className="font-semibold">
372
+ 내 기록 {route.actual_distance_km.toFixed(2)}km
373
+ </span>
374
+ <span className="h-4 w-px bg-black/10" />
375
+ <span className="font-semibold">
376
+ 코스 총 {route.route_length_km.toFixed(2)}km
377
+ </span>
378
+ </div>
379
+
380
+ <div className="mt-1">
381
+ <RatingStars rating={route.user_rating} />
382
+ </div>
383
+
384
+ <p className="mt-1 text-xs text-[#666]">
385
+ {formatCompletedAt(route.completed_at)}
386
+ </p>
387
+ </div>
388
+ </article>
389
+ );
390
+ })}
391
+
392
+ {!isRecentLoading && recentRoutes.length === 0 && (
393
+ <div className="rts-empty-state col-span-full">
394
+ 아직 저장된 러닝 코스가 없습니다. 루트를 완주하면 여기에 자동으로 표시됩니다.
395
+ </div>
396
+ )}
397
+ </div>
398
+ </section>
399
+ </PageContainer>
400
 
401
  <style jsx global>{`
402
  .lobby-route-map .leaflet-control-container {
 
405
  `}</style>
406
  </main>
407
  );
408
+ }
main/app/manual-route/page.tsx CHANGED
@@ -574,7 +574,7 @@ function ManualRoutePageContent() {
574
  : "그리기 대기";
575
 
576
  return (
577
- <main className="min-h-screen bg-[#f7f9fc]">
578
  <TopBar />
579
  <RunStartNoticeModal
580
  open={isRunNoticeOpen}
@@ -585,8 +585,8 @@ function ManualRoutePageContent() {
585
  }}
586
  />
587
 
588
- <section className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-6 sm:px-10">
589
- <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
590
  <div>
591
  <h1 className="text-2xl font-bold text-black">직접 만들기</h1>
592
  <p className="mt-1 text-sm text-[#666]">
@@ -602,8 +602,8 @@ function ManualRoutePageContent() {
602
  onClick={() => setDrawingEnabled(true)}
603
  className={`h-10 rounded-full px-4 text-sm font-semibold ${
604
  drawingEnabled
605
- ? "bg-black text-white"
606
- : "border border-black/15 bg-white text-black"
607
  }`}
608
  >
609
  그리기
@@ -613,8 +613,8 @@ function ManualRoutePageContent() {
613
  onClick={() => setDrawingEnabled(false)}
614
  className={`h-10 rounded-full px-4 text-sm font-semibold ${
615
  !drawingEnabled
616
- ? "bg-black text-white"
617
- : "border border-black/15 bg-white text-black"
618
  }`}
619
  >
620
  지도 이동
@@ -622,7 +622,7 @@ function ManualRoutePageContent() {
622
  </div>
623
  </div>
624
 
625
- <div className="relative h-[62dvh] min-h-[420px] overflow-hidden rounded-2xl border border-black/10 bg-white shadow-sm">
626
  <ManualRouteMap
627
  center={currentPosition ?? center}
628
  currentPosition={currentPosition}
@@ -636,13 +636,13 @@ function ManualRoutePageContent() {
636
  className="h-full w-full"
637
  />
638
 
639
- <div className="pointer-events-none absolute left-3 top-3 z-[500] rounded-xl border border-black/10 bg-white/95 px-3 py-2 text-xs font-semibold text-[#444] shadow-sm">
640
  {statusText} · {formatDistance(displayLengthKm)}
641
  </div>
642
  </div>
643
 
644
  <div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-start">
645
- <div className="rounded-2xl border border-black/10 bg-white px-4 py-3 shadow-sm">
646
  <div className="grid gap-2 text-sm sm:grid-cols-3">
647
  <div>
648
  <p className="text-xs text-[#777]">획</p>
@@ -671,14 +671,14 @@ function ManualRoutePageContent() {
671
  type="button"
672
  onClick={locateCurrentPosition}
673
  disabled={isLocating}
674
- className="h-11 rounded-full border border-black/15 bg-white px-4 text-sm font-semibold text-black disabled:opacity-60"
675
  >
676
  {isLocating ? "확인 중" : "내 위치"}
677
  </button>
678
  <button
679
  type="button"
680
  onClick={handleClear}
681
- className="h-11 rounded-full border border-black/15 bg-white px-4 text-sm font-semibold text-black"
682
  >
683
  지우기
684
  </button>
@@ -691,7 +691,7 @@ function ManualRoutePageContent() {
691
  isCreatingRoute ||
692
  snapStatus === "creating"
693
  }
694
- className="h-11 rounded-full bg-black px-4 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50 sm:col-auto"
695
  >
696
  {isCreatingRoute || snapStatus === "creating" ? "생성 중" : "완료"}
697
  </button>
 
574
  : "그리기 대기";
575
 
576
  return (
577
+ <main className="rts-page">
578
  <TopBar />
579
  <RunStartNoticeModal
580
  open={isRunNoticeOpen}
 
585
  }}
586
  />
587
 
588
+ <section className="rts-shell flex flex-col gap-4">
589
+ <div className="rts-page-header flex-col items-start sm:flex-row sm:items-end sm:justify-between">
590
  <div>
591
  <h1 className="text-2xl font-bold text-black">직접 만들기</h1>
592
  <p className="mt-1 text-sm text-[#666]">
 
602
  onClick={() => setDrawingEnabled(true)}
603
  className={`h-10 rounded-full px-4 text-sm font-semibold ${
604
  drawingEnabled
605
+ ? "bg-[#bfdcff] text-black"
606
+ : "border border-[#e5e5e5] bg-white text-black"
607
  }`}
608
  >
609
  그리기
 
613
  onClick={() => setDrawingEnabled(false)}
614
  className={`h-10 rounded-full px-4 text-sm font-semibold ${
615
  !drawingEnabled
616
+ ? "bg-[#bfdcff] text-black"
617
+ : "border border-[#e5e5e5] bg-white text-black"
618
  }`}
619
  >
620
  지도 이동
 
622
  </div>
623
  </div>
624
 
625
+ <div className="relative h-[62dvh] min-h-[420px] overflow-hidden rounded-[28px] border border-[#e5e5e5] bg-white">
626
  <ManualRouteMap
627
  center={currentPosition ?? center}
628
  currentPosition={currentPosition}
 
636
  className="h-full w-full"
637
  />
638
 
639
+ <div className="pointer-events-none absolute left-3 top-3 z-[500] rounded-full border border-[#e5e5e5] bg-white/95 px-4 py-2 text-xs font-semibold text-[#444]">
640
  {statusText} · {formatDistance(displayLengthKm)}
641
  </div>
642
  </div>
643
 
644
  <div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-start">
645
+ <div className="rts-card px-4 py-3">
646
  <div className="grid gap-2 text-sm sm:grid-cols-3">
647
  <div>
648
  <p className="text-xs text-[#777]">획</p>
 
671
  type="button"
672
  onClick={locateCurrentPosition}
673
  disabled={isLocating}
674
+ className="rts-secondary-button h-11 px-4 disabled:opacity-60"
675
  >
676
  {isLocating ? "확인 중" : "내 위치"}
677
  </button>
678
  <button
679
  type="button"
680
  onClick={handleClear}
681
+ className="rts-secondary-button h-11 px-4"
682
  >
683
  지우기
684
  </button>
 
691
  isCreatingRoute ||
692
  snapStatus === "creating"
693
  }
694
+ className="rts-primary-button h-11 px-4 disabled:cursor-not-allowed disabled:opacity-50 sm:col-auto"
695
  >
696
  {isCreatingRoute || snapStatus === "creating" ? "생성 중" : "완료"}
697
  </button>
main/app/my-posts/page.tsx CHANGED
@@ -1,13 +1,13 @@
1
  "use client";
2
 
3
- import Link from "next/link";
4
  import dynamic from "next/dynamic";
 
5
  import { useEffect, useMemo, useState } from "react";
6
  import TopBar from "../_components/TopBar";
7
  import {
8
  DEFAULT_MAP_CENTER,
9
- formatRelativeKoreanTime,
10
  readCommunityPostsFromStorage,
 
11
  type CommunityPost,
12
  } from "../_utils/communityPosts";
13
  import { fetchCommunityPostsFromApi } from "../_utils/communityApi";
@@ -37,9 +37,7 @@ type SafePostStats = {
37
  };
38
 
39
  function getCookieValue(name: string): string | null {
40
- if (typeof document === "undefined") {
41
- return null;
42
- }
43
 
44
  return (
45
  document.cookie
@@ -50,10 +48,7 @@ function getCookieValue(name: string): string | null {
50
  }
51
 
52
  function getAccessToken(): string | null {
53
- if (typeof window === "undefined") {
54
- return null;
55
- }
56
-
57
  return localStorage.getItem("access_token") ?? getCookieValue("access_token");
58
  }
59
 
@@ -61,6 +56,18 @@ function normalizeText(value: unknown): string {
61
  return String(value ?? "").trim().toLowerCase();
62
  }
63
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  function getPostNumber(post: CommunityPost, keys: string[]): number {
65
  const record = post as unknown as Record<string, unknown>;
66
 
@@ -73,7 +80,6 @@ function getPostNumber(post: CommunityPost, keys: string[]): number {
73
 
74
  if (typeof value === "string") {
75
  const parsed = Number(value);
76
-
77
  if (Number.isFinite(parsed)) {
78
  return Math.max(0, Math.round(parsed));
79
  }
@@ -84,14 +90,8 @@ function getPostNumber(post: CommunityPost, keys: string[]): number {
84
  }
85
 
86
  function formatCount(value: number): string {
87
- if (value >= 10000) {
88
- return `${(value / 10000).toFixed(1)}만`;
89
- }
90
-
91
- if (value >= 1000) {
92
- return `${(value / 1000).toFixed(1)}천`;
93
- }
94
-
95
  return `${value}`;
96
  }
97
 
@@ -140,13 +140,7 @@ function getSafePostStats(post: CommunityPost): SafePostStats {
140
  record.speed ??
141
  "-";
142
 
143
- const calories =
144
- stats.calories ??
145
- record.calories ??
146
- record.calorie ??
147
- record.kcal ??
148
- "-";
149
-
150
  const bpm =
151
  stats.bpm ??
152
  record.bpm ??
@@ -168,13 +162,11 @@ function getSafePostStats(post: CommunityPost): SafePostStats {
168
 
169
  function getDistanceText(post: CommunityPost): string {
170
  const stats = getSafePostStats(post);
171
-
172
  return /km/i.test(stats.distance) ? stats.distance : `${stats.distance}km`;
173
  }
174
 
175
  function getAchievementText(post: CommunityPost): string {
176
  const stats = getSafePostStats(post);
177
-
178
  return stats.achievement.includes("%") ? stats.achievement : `${stats.achievement}%`;
179
  }
180
 
@@ -182,19 +174,11 @@ function isMyPost(post: CommunityPost, user: CurrentUser | null): boolean {
182
  if (!user) return false;
183
 
184
  const record = post as unknown as Record<string, unknown>;
185
-
186
  const userIds = [user.id, user.user_id]
187
  .filter((value) => value !== undefined && value !== null)
188
  .map((value) => normalizeText(value));
189
-
190
  const userEmails = [user.email].filter(Boolean).map((value) => normalizeText(value));
191
-
192
- const userNames = [
193
- user.first_name,
194
- user.nickname,
195
- user.username,
196
- user.email?.split("@")[0],
197
- ]
198
  .filter(Boolean)
199
  .map((value) => normalizeText(value));
200
 
@@ -208,7 +192,6 @@ function isMyPost(post: CommunityPost, user: CurrentUser | null): boolean {
208
  record.owner_id,
209
  record.ownerId,
210
  ].map((value) => normalizeText(value));
211
-
212
  const postEmailCandidates = [
213
  record.email,
214
  record.user_email,
@@ -216,7 +199,6 @@ function isMyPost(post: CommunityPost, user: CurrentUser | null): boolean {
216
  record.author_email,
217
  record.authorEmail,
218
  ].map((value) => normalizeText(value));
219
-
220
  const postNameCandidates = [
221
  post.nickname,
222
  record.author,
@@ -226,19 +208,11 @@ function isMyPost(post: CommunityPost, user: CurrentUser | null): boolean {
226
  record.userName,
227
  ].map((value) => normalizeText(value));
228
 
229
- if (userIds.some((id) => id && postIdCandidates.includes(id))) {
230
- return true;
231
- }
232
-
233
- if (userEmails.some((email) => email && postEmailCandidates.includes(email))) {
234
- return true;
235
- }
236
-
237
- if (userNames.some((name) => name && postNameCandidates.includes(name))) {
238
- return true;
239
- }
240
-
241
- return false;
242
  }
243
 
244
  async function fetchCurrentUser(token: string): Promise<CurrentUser | null> {
@@ -247,17 +221,11 @@ async function fetchCurrentUser(token: string): Promise<CurrentUser | null> {
247
  cache: "no-store",
248
  });
249
 
250
- if (!response.ok) {
251
- return null;
252
- }
253
-
254
  return response.json();
255
  }
256
 
257
- async function fetchMyPosts(
258
- token: string,
259
- user: CurrentUser | null,
260
- ): Promise<CommunityPost[]> {
261
  const myPostEndpoints = [
262
  `${apiBase}/api/v1/community/posts/me`,
263
  `${apiBase}/api/v1/community/my-posts`,
@@ -275,10 +243,7 @@ async function fetchMyPosts(
275
 
276
  const data = await response.json();
277
  const posts = Array.isArray(data) ? data : data.posts ?? data.routes ?? [];
278
-
279
- if (Array.isArray(posts)) {
280
- return posts;
281
- }
282
  } catch {
283
  continue;
284
  }
@@ -316,9 +281,7 @@ async function requestUpdatePost(
316
  body: JSON.stringify(payload),
317
  });
318
 
319
- if (response.ok) {
320
- return true;
321
- }
322
  } catch {
323
  continue;
324
  }
@@ -328,10 +291,7 @@ async function requestUpdatePost(
328
  return false;
329
  }
330
 
331
- async function requestDeletePost(
332
- postId: string | number,
333
- token: string | null,
334
- ): Promise<boolean> {
335
  if (!token) return false;
336
 
337
  const endpoints = [
@@ -346,9 +306,7 @@ async function requestDeletePost(
346
  headers: { Authorization: `Bearer ${token}` },
347
  });
348
 
349
- if (response.ok) {
350
- return true;
351
- }
352
  } catch {
353
  continue;
354
  }
@@ -369,38 +327,17 @@ function MyPostCard({
369
  const center = post.map?.center ?? DEFAULT_MAP_CENTER;
370
  const route = post.map?.polyline;
371
  const safeStats = getSafePostStats(post);
372
-
373
  const placeLabel =
374
  post.map?.mode === "manual"
375
- ? post.map.address || "설정한 지역"
376
- : "내 주변 러닝 코스";
377
-
378
- const viewCount = getPostNumber(post, [
379
- "view_count",
380
- "views",
381
- "viewCount",
382
- "hit",
383
- "hits",
384
- "readCount",
385
- ]);
386
-
387
- const likeCount = getPostNumber(post, [
388
- "like_count",
389
- "likes",
390
- "likeCount",
391
- "likedCount",
392
- "heartCount",
393
- ]);
394
 
395
  return (
396
- <article className="relative z-0 overflow-hidden rounded-2xl border border-black/10 bg-white shadow-sm">
397
- <div className="relative z-0 h-36 w-full overflow-hidden bg-[#f2f4f8]">
398
- <LeafletMap
399
- center={center}
400
- route={route}
401
- className="relative z-0 h-full w-full"
402
- zoom={14}
403
- />
404
  </div>
405
 
406
  <div className="h-px w-full bg-black/10" />
@@ -412,7 +349,7 @@ function MyPostCard({
412
  </h3>
413
 
414
  <div className="flex shrink-0 items-center gap-1 rounded-full bg-[#eef7ff] px-2 py-1 text-[11px] font-semibold text-black">
415
- <span className="text-[#6aaee8]">♥</span>
416
  <span>{formatCount(likeCount)}</span>
417
  </div>
418
  </div>
@@ -423,15 +360,14 @@ function MyPostCard({
423
  <span className="font-semibold text-black">완주율 {getAchievementText(post)}</span>
424
  </div>
425
 
426
- <p className="text-sm text-[#4a4a4a]">
427
- {post.content || "작성된 코스 설명이 없습니다."}
428
  </p>
429
 
430
  <div className="flex flex-wrap items-center gap-2">
431
  <span className="inline-flex items-center gap-1 rounded-full bg-[#f7f8fb] px-2.5 py-1 text-xs font-semibold text-[#555]">
432
- 조회수 <strong className="text-black">{formatCount(viewCount)}</strong>
433
  </span>
434
-
435
  <span className="inline-flex items-center gap-1 rounded-full bg-[#eef7ff] px-2.5 py-1 text-xs font-semibold text-[#555]">
436
  좋아요 <strong className="text-black">{formatCount(likeCount)}</strong>
437
  </span>
@@ -441,15 +377,12 @@ function MyPostCard({
441
  <div className="text-xs text-[#555]">
442
  시간 <span className="ml-1 font-semibold text-black">{safeStats.time}</span>
443
  </div>
444
-
445
  <div className="text-xs text-[#555]">
446
- 속도 <span className="ml-1 font-semibold text-black">{safeStats.pace}</span>
447
  </div>
448
-
449
  <div className="text-xs text-[#555]">
450
  칼로리 <span className="ml-1 font-semibold text-black">{safeStats.calories}</span>
451
  </div>
452
-
453
  <div className="text-xs text-[#555]">
454
  BPM <span className="ml-1 font-semibold text-black">{safeStats.bpm}</span>
455
  </div>
@@ -463,31 +396,20 @@ function MyPostCard({
463
  <span className="truncate text-xs text-[#7f7f7f]">{placeLabel}</span>
464
  </div>
465
 
466
- <span className="shrink-0 text-xs text-[#7f7f7f]">
467
- {post.createdAt ? formatRelativeKoreanTime(post.createdAt) : "작성일 없음"}
468
- </span>
469
  </div>
470
 
471
  <div className="grid grid-cols-3 gap-2">
472
- <Link
473
- href={`/route/${post.id}`}
474
- className="inline-flex h-10 items-center justify-center rounded-xl bg-[#bfdcff] px-3 text-sm font-semibold text-black shadow-sm transition-shadow hover:shadow-md"
475
- >
476
  보기
477
  </Link>
478
-
479
- <button
480
- type="button"
481
- onClick={() => onEdit(post)}
482
- className="inline-flex h-10 items-center justify-center rounded-xl border border-black/10 bg-white px-3 text-sm font-semibold text-black shadow-sm transition-shadow hover:shadow-md"
483
- >
484
  수정
485
  </button>
486
-
487
  <button
488
  type="button"
489
  onClick={() => onDelete(post)}
490
- className="inline-flex h-10 items-center justify-center rounded-xl border border-red-200 bg-red-50 px-3 text-sm font-semibold text-red-600 shadow-sm transition-shadow hover:shadow-md"
491
  >
492
  삭제
493
  </button>
@@ -523,7 +445,6 @@ export default function MyPostsPage() {
523
  setErrorMessage("");
524
 
525
  const token = getAccessToken();
526
-
527
  if (!token) {
528
  setPosts([]);
529
  setIsLoading(false);
@@ -532,23 +453,18 @@ export default function MyPostsPage() {
532
 
533
  try {
534
  const user = await fetchCurrentUser(token);
535
-
536
  if (cancelled) return;
537
 
538
  const myPosts = await fetchMyPosts(token, user);
539
-
540
  if (cancelled) return;
541
 
542
  setPosts(myPosts);
543
  } catch {
544
  if (cancelled) return;
545
-
546
  setErrorMessage("내 게시글을 불러오지 못했습니다.");
547
  setPosts([]);
548
  } finally {
549
- if (!cancelled) {
550
- setIsLoading(false);
551
- }
552
  }
553
  };
554
 
@@ -568,7 +484,6 @@ export default function MyPostsPage() {
568
 
569
  const closeEditModal = () => {
570
  if (isSaving) return;
571
-
572
  setEditingPost(null);
573
  setEditTitle("");
574
  setEditContent("");
@@ -579,7 +494,6 @@ export default function MyPostsPage() {
579
 
580
  const title = editTitle.trim();
581
  const content = editContent.trim();
582
-
583
  if (!title) {
584
  setErrorMessage("제목을 입력해주세요.");
585
  return;
@@ -593,21 +507,17 @@ export default function MyPostsPage() {
593
 
594
  if (!success) {
595
  setIsSaving(false);
596
- setErrorMessage("게시글 수정에 실패했습니다. 서버 API 경로를 확인해주세요.");
597
  return;
598
  }
599
 
600
- setPosts((prev) =>
601
- prev.map((post) =>
602
- post.id === editingPost.id
603
- ? ({
604
- ...post,
605
- title,
606
- content,
607
- } as CommunityPost)
608
- : post,
609
- ),
610
- );
611
 
612
  setIsSaving(false);
613
  closeEditModal();
@@ -623,50 +533,42 @@ export default function MyPostsPage() {
623
  const success = await requestDeletePost(post.id, token);
624
 
625
  if (!success) {
626
- setErrorMessage("게시글 삭제에 실패했습니다. 서버 API 경로를 확인해주세요.");
627
  return;
628
  }
629
 
630
- setPosts((prev) => prev.filter((item) => item.id !== post.id));
 
 
 
 
631
  };
632
 
633
  return (
634
- <main className="min-h-screen bg-white">
635
  <TopBar />
636
 
637
- <section className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-8 sm:px-10">
638
- <section className="rounded-2xl bg-[#f1f6ff] px-6 py-8 sm:px-8">
639
  <div className="flex flex-col gap-3">
640
- <p className="text-xs font-bold tracking-[0.14em] text-[#6b7280]">
641
- MY COMMUNITY
642
- </p>
643
-
644
  <h1 className="text-2xl font-bold leading-snug text-black sm:text-3xl">
645
  내 게시글
646
  </h1>
647
-
648
  <p className="text-sm leading-relaxed text-[#5d5d5d] sm:text-base">
649
  내가 공유한 러닝 루트를 확인하고, 게시글을 수정하거나 삭제할 수 있습니다.
650
  </p>
651
  </div>
652
  </section>
653
 
654
- {errorMessage && (
655
- <div className="rounded-xl border border-[#bfdcff] bg-[#eef7ff] px-4 py-3 text-sm text-[#2459a8]">
656
  {errorMessage}
657
  </div>
658
- )}
659
 
660
  <section className="flex items-center justify-between gap-3">
661
- <div>
662
- <p className="text-xs font-bold tracking-[0.14em] text-[#9b9b9b]">RECENT</p>
663
- <h2 className="text-xl font-bold text-black">작성한 게시글</h2>
664
- </div>
665
-
666
- <Link
667
- href="/community/new"
668
- className="inline-flex h-10 items-center rounded-full bg-[#bfdcff] px-4 text-sm font-semibold text-black shadow-sm transition-shadow hover:shadow-md"
669
- >
670
  새 글 작성
671
  </Link>
672
  </section>
@@ -674,10 +576,7 @@ export default function MyPostsPage() {
674
  {isLoading ? (
675
  <section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
676
  {Array.from({ length: 3 }).map((_, index) => (
677
- <article
678
- key={index}
679
- className="overflow-hidden rounded-2xl border border-black/10 bg-white shadow-sm"
680
- >
681
  <div className="h-36 w-full animate-pulse bg-[#f2f4f8]" />
682
  <div className="flex flex-col gap-3 px-4 py-4">
683
  <div className="h-4 w-2/3 animate-pulse rounded bg-[#f2f4f8]" />
@@ -690,51 +589,37 @@ export default function MyPostsPage() {
690
  ) : sortedPosts.length > 0 ? (
691
  <section className="relative z-0 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
692
  {sortedPosts.map((post) => (
693
- <MyPostCard
694
- key={post.id}
695
- post={post}
696
- onEdit={openEditModal}
697
- onDelete={handleDeletePost}
698
- />
699
  ))}
700
  </section>
701
  ) : (
702
- <section className="rounded-2xl border border-dashed border-black/20 bg-[#fafafa] px-6 py-14 text-center">
703
  <p className="text-base font-semibold text-black">
704
  아직 작성한 게시글이 없습니다.
705
  </p>
706
-
707
  <p className="mt-2 text-sm text-[#666]">
708
- 러닝 루트를 공유하면 이곳에서 내가 올린 게시글을 확인할 수 있습니다.
709
  </p>
710
-
711
- <Link
712
- href="/community/new"
713
- className="mt-5 inline-flex h-11 items-center rounded-full bg-[#bfdcff] px-5 text-sm font-semibold text-black shadow-sm transition-shadow hover:shadow-md"
714
- >
715
  첫 게시글 작성하기
716
  </Link>
717
  </section>
718
  )}
719
  </section>
720
 
721
- {editingPost && (
722
  <div className="fixed inset-0 z-[6000] flex items-center justify-center bg-black/45 px-5">
723
- <section className="w-full max-w-lg rounded-2xl bg-white p-5 shadow-xl">
724
  <div className="flex items-start justify-between gap-4">
725
- <div>
726
- <p className="text-xs font-bold tracking-[0.14em] text-[#9b9b9b]">
727
- EDIT POST
728
- </p>
729
- <h2 className="mt-1 text-xl font-bold text-black">게시글 수정</h2>
730
- </div>
731
 
732
  <button
733
  type="button"
734
  onClick={closeEditModal}
735
  className="flex h-9 w-9 items-center justify-center rounded-full border border-black/10 text-xl"
 
736
  >
737
- ×
738
  </button>
739
  </div>
740
 
@@ -744,7 +629,7 @@ export default function MyPostsPage() {
744
  <input
745
  value={editTitle}
746
  onChange={(event) => setEditTitle(event.target.value)}
747
- className="h-11 rounded-xl border border-black/10 px-3 text-sm font-normal outline-none focus:border-[#bfdcff]"
748
  placeholder="제목을 입력하세요"
749
  />
750
  </label>
@@ -754,18 +639,14 @@ export default function MyPostsPage() {
754
  <textarea
755
  value={editContent}
756
  onChange={(event) => setEditContent(event.target.value)}
757
- className="min-h-[130px] rounded-xl border border-black/10 px-3 py-3 text-sm font-normal outline-none focus:border-[#bfdcff]"
758
  placeholder="게시글 내용을 입력하세요"
759
  />
760
  </label>
761
  </div>
762
 
763
  <div className="mt-5 grid grid-cols-2 gap-2">
764
- <button
765
- type="button"
766
- onClick={closeEditModal}
767
- className="h-11 rounded-xl border border-black/10 bg-white text-sm font-semibold text-black"
768
- >
769
  취소
770
  </button>
771
 
@@ -773,14 +654,14 @@ export default function MyPostsPage() {
773
  type="button"
774
  onClick={handleSaveEdit}
775
  disabled={isSaving}
776
- className="h-11 rounded-xl bg-[#bfdcff] text-sm font-semibold text-black disabled:opacity-60"
777
  >
778
  {isSaving ? "저장 중..." : "저장"}
779
  </button>
780
  </div>
781
  </section>
782
  </div>
783
- )}
784
 
785
  <style jsx global>{`
786
  .leaflet-control-container {
 
1
  "use client";
2
 
 
3
  import dynamic from "next/dynamic";
4
+ import Link from "next/link";
5
  import { useEffect, useMemo, useState } from "react";
6
  import TopBar from "../_components/TopBar";
7
  import {
8
  DEFAULT_MAP_CENTER,
 
9
  readCommunityPostsFromStorage,
10
+ writeCommunityPostsToStorage,
11
  type CommunityPost,
12
  } from "../_utils/communityPosts";
13
  import { fetchCommunityPostsFromApi } from "../_utils/communityApi";
 
37
  };
38
 
39
  function getCookieValue(name: string): string | null {
40
+ if (typeof document === "undefined") return null;
 
 
41
 
42
  return (
43
  document.cookie
 
48
  }
49
 
50
  function getAccessToken(): string | null {
51
+ if (typeof window === "undefined") return null;
 
 
 
52
  return localStorage.getItem("access_token") ?? getCookieValue("access_token");
53
  }
54
 
 
56
  return String(value ?? "").trim().toLowerCase();
57
  }
58
 
59
+ function formatDate(value?: string): string {
60
+ if (!value) return "작성일 없음";
61
+
62
+ const parsed = new Date(value);
63
+ if (!Number.isFinite(parsed.getTime())) return "작성일 없음";
64
+
65
+ return parsed.toLocaleDateString("ko-KR", {
66
+ month: "2-digit",
67
+ day: "2-digit",
68
+ });
69
+ }
70
+
71
  function getPostNumber(post: CommunityPost, keys: string[]): number {
72
  const record = post as unknown as Record<string, unknown>;
73
 
 
80
 
81
  if (typeof value === "string") {
82
  const parsed = Number(value);
 
83
  if (Number.isFinite(parsed)) {
84
  return Math.max(0, Math.round(parsed));
85
  }
 
90
  }
91
 
92
  function formatCount(value: number): string {
93
+ if (value >= 10000) return `${(value / 10000).toFixed(1)}만`;
94
+ if (value >= 1000) return `${(value / 1000).toFixed(1)}천`;
 
 
 
 
 
 
95
  return `${value}`;
96
  }
97
 
 
140
  record.speed ??
141
  "-";
142
 
143
+ const calories = stats.calories ?? record.calories ?? record.calorie ?? record.kcal ?? "-";
 
 
 
 
 
 
144
  const bpm =
145
  stats.bpm ??
146
  record.bpm ??
 
162
 
163
  function getDistanceText(post: CommunityPost): string {
164
  const stats = getSafePostStats(post);
 
165
  return /km/i.test(stats.distance) ? stats.distance : `${stats.distance}km`;
166
  }
167
 
168
  function getAchievementText(post: CommunityPost): string {
169
  const stats = getSafePostStats(post);
 
170
  return stats.achievement.includes("%") ? stats.achievement : `${stats.achievement}%`;
171
  }
172
 
 
174
  if (!user) return false;
175
 
176
  const record = post as unknown as Record<string, unknown>;
 
177
  const userIds = [user.id, user.user_id]
178
  .filter((value) => value !== undefined && value !== null)
179
  .map((value) => normalizeText(value));
 
180
  const userEmails = [user.email].filter(Boolean).map((value) => normalizeText(value));
181
+ const userNames = [user.first_name, user.nickname, user.username, user.email?.split("@")[0]]
 
 
 
 
 
 
182
  .filter(Boolean)
183
  .map((value) => normalizeText(value));
184
 
 
192
  record.owner_id,
193
  record.ownerId,
194
  ].map((value) => normalizeText(value));
 
195
  const postEmailCandidates = [
196
  record.email,
197
  record.user_email,
 
199
  record.author_email,
200
  record.authorEmail,
201
  ].map((value) => normalizeText(value));
 
202
  const postNameCandidates = [
203
  post.nickname,
204
  record.author,
 
208
  record.userName,
209
  ].map((value) => normalizeText(value));
210
 
211
+ return (
212
+ userIds.some((id) => id && postIdCandidates.includes(id)) ||
213
+ userEmails.some((email) => email && postEmailCandidates.includes(email)) ||
214
+ userNames.some((name) => name && postNameCandidates.includes(name))
215
+ );
 
 
 
 
 
 
 
 
216
  }
217
 
218
  async function fetchCurrentUser(token: string): Promise<CurrentUser | null> {
 
221
  cache: "no-store",
222
  });
223
 
224
+ if (!response.ok) return null;
 
 
 
225
  return response.json();
226
  }
227
 
228
+ async function fetchMyPosts(token: string, user: CurrentUser | null): Promise<CommunityPost[]> {
 
 
 
229
  const myPostEndpoints = [
230
  `${apiBase}/api/v1/community/posts/me`,
231
  `${apiBase}/api/v1/community/my-posts`,
 
243
 
244
  const data = await response.json();
245
  const posts = Array.isArray(data) ? data : data.posts ?? data.routes ?? [];
246
+ if (Array.isArray(posts)) return posts;
 
 
 
247
  } catch {
248
  continue;
249
  }
 
281
  body: JSON.stringify(payload),
282
  });
283
 
284
+ if (response.ok) return true;
 
 
285
  } catch {
286
  continue;
287
  }
 
291
  return false;
292
  }
293
 
294
+ async function requestDeletePost(postId: string | number, token: string | null): Promise<boolean> {
 
 
 
295
  if (!token) return false;
296
 
297
  const endpoints = [
 
306
  headers: { Authorization: `Bearer ${token}` },
307
  });
308
 
309
+ if (response.ok) return true;
 
 
310
  } catch {
311
  continue;
312
  }
 
327
  const center = post.map?.center ?? DEFAULT_MAP_CENTER;
328
  const route = post.map?.polyline;
329
  const safeStats = getSafePostStats(post);
 
330
  const placeLabel =
331
  post.map?.mode === "manual"
332
+ ? post.map.address || "설정한 위치"
333
+ : post.map?.address || "내 주변 러닝 코스";
334
+ const viewCount = getPostNumber(post, ["view_count", "views", "viewCount", "hit", "hits", "readCount"]);
335
+ const likeCount = getPostNumber(post, ["like_count", "likes", "likeCount", "likedCount", "heartCount"]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
 
337
  return (
338
+ <article className="rts-card rts-card-clip relative z-0">
339
+ <div className="relative z-0 h-40 w-full overflow-hidden bg-[#f2f4f8]">
340
+ <LeafletMap center={center} route={route} className="relative z-0 h-full w-full" zoom={14} />
 
 
 
 
 
341
  </div>
342
 
343
  <div className="h-px w-full bg-black/10" />
 
349
  </h3>
350
 
351
  <div className="flex shrink-0 items-center gap-1 rounded-full bg-[#eef7ff] px-2 py-1 text-[11px] font-semibold text-black">
352
+ <span>좋아요</span>
353
  <span>{formatCount(likeCount)}</span>
354
  </div>
355
  </div>
 
360
  <span className="font-semibold text-black">완주율 {getAchievementText(post)}</span>
361
  </div>
362
 
363
+ <p className="line-clamp-2 text-sm text-[#4a4a4a]">
364
+ {post.content || "작성한 코스 설명이 없습니다."}
365
  </p>
366
 
367
  <div className="flex flex-wrap items-center gap-2">
368
  <span className="inline-flex items-center gap-1 rounded-full bg-[#f7f8fb] px-2.5 py-1 text-xs font-semibold text-[#555]">
369
+ 조회 <strong className="text-black">{formatCount(viewCount)}</strong>
370
  </span>
 
371
  <span className="inline-flex items-center gap-1 rounded-full bg-[#eef7ff] px-2.5 py-1 text-xs font-semibold text-[#555]">
372
  좋아요 <strong className="text-black">{formatCount(likeCount)}</strong>
373
  </span>
 
377
  <div className="text-xs text-[#555]">
378
  시간 <span className="ml-1 font-semibold text-black">{safeStats.time}</span>
379
  </div>
 
380
  <div className="text-xs text-[#555]">
381
+ 페이스 <span className="ml-1 font-semibold text-black">{safeStats.pace}</span>
382
  </div>
 
383
  <div className="text-xs text-[#555]">
384
  칼로리 <span className="ml-1 font-semibold text-black">{safeStats.calories}</span>
385
  </div>
 
386
  <div className="text-xs text-[#555]">
387
  BPM <span className="ml-1 font-semibold text-black">{safeStats.bpm}</span>
388
  </div>
 
396
  <span className="truncate text-xs text-[#7f7f7f]">{placeLabel}</span>
397
  </div>
398
 
399
+ <span className="shrink-0 text-xs text-[#7f7f7f]">{formatDate(post.createdAt)}</span>
 
 
400
  </div>
401
 
402
  <div className="grid grid-cols-3 gap-2">
403
+ <Link href={`/route/${post.id}`} className="rts-primary-button h-10 px-3 text-sm">
 
 
 
404
  보기
405
  </Link>
406
+ <button type="button" onClick={() => onEdit(post)} className="rts-secondary-button h-10 px-3 text-sm">
 
 
 
 
 
407
  수정
408
  </button>
 
409
  <button
410
  type="button"
411
  onClick={() => onDelete(post)}
412
+ className="inline-flex h-10 items-center justify-center rounded-full border border-red-200 bg-red-50 px-3 text-sm font-semibold text-red-600"
413
  >
414
  삭제
415
  </button>
 
445
  setErrorMessage("");
446
 
447
  const token = getAccessToken();
 
448
  if (!token) {
449
  setPosts([]);
450
  setIsLoading(false);
 
453
 
454
  try {
455
  const user = await fetchCurrentUser(token);
 
456
  if (cancelled) return;
457
 
458
  const myPosts = await fetchMyPosts(token, user);
 
459
  if (cancelled) return;
460
 
461
  setPosts(myPosts);
462
  } catch {
463
  if (cancelled) return;
 
464
  setErrorMessage("내 게시글을 불러오지 못했습니다.");
465
  setPosts([]);
466
  } finally {
467
+ if (!cancelled) setIsLoading(false);
 
 
468
  }
469
  };
470
 
 
484
 
485
  const closeEditModal = () => {
486
  if (isSaving) return;
 
487
  setEditingPost(null);
488
  setEditTitle("");
489
  setEditContent("");
 
494
 
495
  const title = editTitle.trim();
496
  const content = editContent.trim();
 
497
  if (!title) {
498
  setErrorMessage("제목을 입력해주세요.");
499
  return;
 
507
 
508
  if (!success) {
509
  setIsSaving(false);
510
+ setErrorMessage("게시글 수정에 실패했습니다. 잠시 뒤 다시 시도해주세요.");
511
  return;
512
  }
513
 
514
+ setPosts((prev) => {
515
+ const next = prev.map((post) =>
516
+ post.id === editingPost.id ? ({ ...post, title, content } as CommunityPost) : post,
517
+ );
518
+ writeCommunityPostsToStorage(next);
519
+ return next;
520
+ });
 
 
 
 
521
 
522
  setIsSaving(false);
523
  closeEditModal();
 
533
  const success = await requestDeletePost(post.id, token);
534
 
535
  if (!success) {
536
+ setErrorMessage("게시글 삭제에 실패했습니다. 잠시 뒤 다시 시도해주세요.");
537
  return;
538
  }
539
 
540
+ setPosts((prev) => {
541
+ const next = prev.filter((item) => item.id !== post.id);
542
+ writeCommunityPostsToStorage(next);
543
+ return next;
544
+ });
545
  };
546
 
547
  return (
548
+ <main className="rts-page">
549
  <TopBar />
550
 
551
+ <section className="rts-shell flex flex-col gap-6">
552
+ <section className="rts-card bg-[#f1f6ff]">
553
  <div className="flex flex-col gap-3">
 
 
 
 
554
  <h1 className="text-2xl font-bold leading-snug text-black sm:text-3xl">
555
  내 게시글
556
  </h1>
 
557
  <p className="text-sm leading-relaxed text-[#5d5d5d] sm:text-base">
558
  내가 공유한 러닝 루트를 확인하고, 게시글을 수정하거나 삭제할 수 있습니다.
559
  </p>
560
  </div>
561
  </section>
562
 
563
+ {errorMessage ? (
564
+ <div className="rounded-[18px] border border-[#bfdcff] bg-[#eef7ff] px-4 py-3 text-sm text-[#2459a8]">
565
  {errorMessage}
566
  </div>
567
+ ) : null}
568
 
569
  <section className="flex items-center justify-between gap-3">
570
+ <h2 className="text-xl font-bold text-black">작성한 게시글</h2>
571
+ <Link href="/community/new" className="rts-primary-button h-10">
 
 
 
 
 
 
 
572
  새 글 작성
573
  </Link>
574
  </section>
 
576
  {isLoading ? (
577
  <section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
578
  {Array.from({ length: 3 }).map((_, index) => (
579
+ <article key={index} className="rts-card rts-card-clip">
 
 
 
580
  <div className="h-36 w-full animate-pulse bg-[#f2f4f8]" />
581
  <div className="flex flex-col gap-3 px-4 py-4">
582
  <div className="h-4 w-2/3 animate-pulse rounded bg-[#f2f4f8]" />
 
589
  ) : sortedPosts.length > 0 ? (
590
  <section className="relative z-0 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
591
  {sortedPosts.map((post) => (
592
+ <MyPostCard key={post.id} post={post} onEdit={openEditModal} onDelete={handleDeletePost} />
 
 
 
 
 
593
  ))}
594
  </section>
595
  ) : (
596
+ <section className="rts-empty-state">
597
  <p className="text-base font-semibold text-black">
598
  아직 작성한 게시글이 없습니다.
599
  </p>
 
600
  <p className="mt-2 text-sm text-[#666]">
601
+ 완주한 러닝 루트를 공유하면 여기에서 내가 올린 게시글을 확인할 수 있습니다.
602
  </p>
603
+ <Link href="/community/new" className="rts-primary-button mt-5 h-11 px-5">
 
 
 
 
604
  첫 게시글 작성하기
605
  </Link>
606
  </section>
607
  )}
608
  </section>
609
 
610
+ {editingPost ? (
611
  <div className="fixed inset-0 z-[6000] flex items-center justify-center bg-black/45 px-5">
612
+ <section className="rts-card w-full max-w-lg">
613
  <div className="flex items-start justify-between gap-4">
614
+ <h2 className="text-xl font-bold text-black">게시글 수정</h2>
 
 
 
 
 
615
 
616
  <button
617
  type="button"
618
  onClick={closeEditModal}
619
  className="flex h-9 w-9 items-center justify-center rounded-full border border-black/10 text-xl"
620
+ aria-label="닫기"
621
  >
622
+ x
623
  </button>
624
  </div>
625
 
 
629
  <input
630
  value={editTitle}
631
  onChange={(event) => setEditTitle(event.target.value)}
632
+ className="rts-input font-normal"
633
  placeholder="제목을 입력하세요"
634
  />
635
  </label>
 
639
  <textarea
640
  value={editContent}
641
  onChange={(event) => setEditContent(event.target.value)}
642
+ className="rts-textarea font-normal"
643
  placeholder="게시글 내용을 입력하세요"
644
  />
645
  </label>
646
  </div>
647
 
648
  <div className="mt-5 grid grid-cols-2 gap-2">
649
+ <button type="button" onClick={closeEditModal} className="rts-secondary-button h-11">
 
 
 
 
650
  취소
651
  </button>
652
 
 
654
  type="button"
655
  onClick={handleSaveEdit}
656
  disabled={isSaving}
657
+ className="rts-primary-button h-11 disabled:opacity-60"
658
  >
659
  {isSaving ? "저장 중..." : "저장"}
660
  </button>
661
  </div>
662
  </section>
663
  </div>
664
+ ) : null}
665
 
666
  <style jsx global>{`
667
  .leaflet-control-container {
main/app/mypage/page.tsx CHANGED
@@ -8,6 +8,11 @@ import {
8
  pickAudioDirectoryAndReadFiles,
9
  writeAudioStoragePermissionStatus,
10
  } from "../_utils/audioStoragePermission";
 
 
 
 
 
11
 
12
  type MeResponse = {
13
  email?: string;
@@ -18,8 +23,6 @@ type MeResponse = {
18
  };
19
 
20
  const FALLBACK_SIGNUP_DATE = "2026.01.01";
21
- const PROFILE_IMAGE_STORAGE_KEY = "run_the_shape_profile_image_v1";
22
- const PROFILE_IMAGE_UPDATED_EVENT = "profile-image-updated";
23
 
24
  function getCookieValue(name: string): string | null {
25
  const value = document.cookie
@@ -76,14 +79,6 @@ function formatSignupDate(value?: string) {
76
  ).padStart(2, "0")}`;
77
  }
78
 
79
- function getStoredProfileImage(): string {
80
- if (typeof window === "undefined") {
81
- return "";
82
- }
83
-
84
- return window.localStorage.getItem(PROFILE_IMAGE_STORAGE_KEY) ?? "";
85
- }
86
-
87
  export default function Page() {
88
  const router = useRouter();
89
  const profileInputRef = useRef<HTMLInputElement | null>(null);
@@ -157,10 +152,6 @@ export default function Page() {
157
  };
158
  }, [apiBase]);
159
 
160
- const notifyProfileImageChanged = () => {
161
- window.dispatchEvent(new Event(PROFILE_IMAGE_UPDATED_EVENT));
162
- };
163
-
164
  const handleProfileImageChange = (event: ChangeEvent<HTMLInputElement>) => {
165
  const file = event.target.files?.[0];
166
 
@@ -333,6 +324,27 @@ export default function Page() {
333
  <path d="M4 22c1.5-4 14.5-4 16 0" />
334
  </svg>
335
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  </div>
337
 
338
  <div className="profileInfo">
@@ -456,13 +468,20 @@ export default function Page() {
456
  <style jsx>{`
457
  .page {
458
  min-height: 100vh;
459
- background: #f0f2f5;
 
 
 
 
 
 
 
460
  }
461
 
462
  .container {
463
- max-width: 480px;
464
  margin: 0 auto;
465
- padding: 24px 16px 40px;
466
  display: flex;
467
  flex-direction: column;
468
  }
@@ -471,7 +490,9 @@ export default function Page() {
471
  display: flex;
472
  align-items: center;
473
  gap: 12px;
474
- margin-bottom: 16px;
 
 
475
  }
476
 
477
  .header h1 {
@@ -481,24 +502,34 @@ export default function Page() {
481
  }
482
 
483
  .back {
 
 
 
 
 
 
484
  font-size: 24px;
485
- background: none;
486
- border: none;
487
  cursor: pointer;
 
 
 
 
488
  }
489
 
490
  .profileCard {
491
  background: #fff;
492
- border-radius: 18px;
493
- padding: 16px;
 
494
  display: flex;
495
- gap: 14px;
496
  align-items: center;
497
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
498
  margin-bottom: 16px;
499
  }
500
 
501
  .profileImageBox {
 
502
  width: 82px;
503
  height: 82px;
504
  border-radius: 999px;
@@ -512,6 +543,22 @@ export default function Page() {
512
  flex-shrink: 0;
513
  }
514
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  .profileImageBox img {
516
  width: 100%;
517
  height: 100%;
@@ -582,18 +629,18 @@ export default function Page() {
582
  }
583
 
584
  .cardsGrid {
585
- display: flex;
586
- flex-direction: column;
587
  gap: 16px;
588
  }
589
 
590
  .card {
591
  background: #fff;
592
- border-radius: 16px;
 
593
  padding: 16px;
594
  display: flex;
595
  flex-direction: column;
596
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
597
  }
598
 
599
  .card label {
@@ -604,8 +651,8 @@ export default function Page() {
604
  }
605
 
606
  .valueBox {
607
- background: #f5f5f5;
608
- border-radius: 10px;
609
  padding: 10px 12px;
610
  font-size: 15px;
611
  }
@@ -618,31 +665,35 @@ export default function Page() {
618
  }
619
 
620
  .changePasswordButton {
621
- padding: 12px 20px;
622
- border-radius: 10px;
623
- background-color: #2d8cff;
624
- color: #fff;
 
625
  border: none;
626
- font-weight: 500;
 
627
  cursor: pointer;
628
  }
629
 
630
  .changePasswordButton:hover {
631
- background-color: #1a6ed8;
632
  }
633
 
634
  .audioFolderButton {
635
- padding: 12px 20px;
636
- border-radius: 10px;
637
- background-color: #1f1f1f;
638
- color: #fff;
639
- border: none;
640
- font-weight: 500;
 
 
641
  cursor: pointer;
642
  }
643
 
644
  .audioFolderButton:hover {
645
- background-color: #000;
646
  }
647
 
648
  .audioFolderButton:disabled {
@@ -695,12 +746,11 @@ export default function Page() {
695
  width: 100%;
696
  background: #fff;
697
  border: 1px solid #f0d4d4;
698
- border-radius: 12px;
699
  padding: 14px;
700
  display: flex;
701
  flex-direction: column;
702
  gap: 8px;
703
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
704
  }
705
 
706
  .withdrawTitle {
@@ -753,6 +803,21 @@ export default function Page() {
753
  }
754
 
755
  @media (max-width: 640px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
756
  .card {
757
  padding: 14px;
758
  }
@@ -795,4 +860,4 @@ export default function Page() {
795
  `}</style>
796
  </main>
797
  );
798
- }
 
8
  pickAudioDirectoryAndReadFiles,
9
  writeAudioStoragePermissionStatus,
10
  } from "../_utils/audioStoragePermission";
11
+ import {
12
+ getStoredProfileImage,
13
+ notifyProfileImageChanged,
14
+ PROFILE_IMAGE_STORAGE_KEY,
15
+ } from "../_utils/profileImage";
16
 
17
  type MeResponse = {
18
  email?: string;
 
23
  };
24
 
25
  const FALLBACK_SIGNUP_DATE = "2026.01.01";
 
 
26
 
27
  function getCookieValue(name: string): string | null {
28
  const value = document.cookie
 
79
  ).padStart(2, "0")}`;
80
  }
81
 
 
 
 
 
 
 
 
 
82
  export default function Page() {
83
  const router = useRouter();
84
  const profileInputRef = useRef<HTMLInputElement | null>(null);
 
152
  };
153
  }, [apiBase]);
154
 
 
 
 
 
155
  const handleProfileImageChange = (event: ChangeEvent<HTMLInputElement>) => {
156
  const file = event.target.files?.[0];
157
 
 
324
  <path d="M4 22c1.5-4 14.5-4 16 0" />
325
  </svg>
326
  )}
327
+ <button
328
+ type="button"
329
+ className="profileCameraButton"
330
+ aria-label="프로필 사진 변경"
331
+ onClick={() => profileInputRef.current?.click()}
332
+ >
333
+ <svg
334
+ width="15"
335
+ height="15"
336
+ viewBox="0 0 24 24"
337
+ fill="none"
338
+ stroke="currentColor"
339
+ strokeWidth="2"
340
+ strokeLinecap="round"
341
+ strokeLinejoin="round"
342
+ aria-hidden="true"
343
+ >
344
+ <path d="M14.5 4 16 6h3a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3l1.5-2h5z" />
345
+ <circle cx="12" cy="13" r="3.5" />
346
+ </svg>
347
+ </button>
348
  </div>
349
 
350
  <div className="profileInfo">
 
468
  <style jsx>{`
469
  .page {
470
  min-height: 100vh;
471
+ background: #ffffff;
472
+ color: #111111;
473
+ font-family:
474
+ Arial,
475
+ -apple-system,
476
+ BlinkMacSystemFont,
477
+ "Segoe UI",
478
+ sans-serif;
479
  }
480
 
481
  .container {
482
+ max-width: 1120px;
483
  margin: 0 auto;
484
+ padding: 22px 18px 48px;
485
  display: flex;
486
  flex-direction: column;
487
  }
 
490
  display: flex;
491
  align-items: center;
492
  gap: 12px;
493
+ padding: 18px 0 20px;
494
+ margin-bottom: 24px;
495
+ border-bottom: 1px solid #111111;
496
  }
497
 
498
  .header h1 {
 
502
  }
503
 
504
  .back {
505
+ width: 34px;
506
+ height: 34px;
507
+ border: 1px solid #dedede;
508
+ border-radius: 999px;
509
+ background: #ffffff;
510
+ color: #111111;
511
  font-size: 24px;
512
+ line-height: 1;
 
513
  cursor: pointer;
514
+ display: flex;
515
+ align-items: center;
516
+ justify-content: center;
517
+ padding: 0 0 2px;
518
  }
519
 
520
  .profileCard {
521
  background: #fff;
522
+ border: 1px solid #e5e5e5;
523
+ border-radius: 28px;
524
+ padding: 22px;
525
  display: flex;
526
+ gap: 18px;
527
  align-items: center;
 
528
  margin-bottom: 16px;
529
  }
530
 
531
  .profileImageBox {
532
+ position: relative;
533
  width: 82px;
534
  height: 82px;
535
  border-radius: 999px;
 
543
  flex-shrink: 0;
544
  }
545
 
546
+ .profileCameraButton {
547
+ position: absolute;
548
+ right: 0;
549
+ bottom: 0;
550
+ width: 28px;
551
+ height: 28px;
552
+ border: 1px solid #e5e5e5;
553
+ border-radius: 999px;
554
+ background: #bfdcff;
555
+ color: #111111;
556
+ display: flex;
557
+ align-items: center;
558
+ justify-content: center;
559
+ cursor: pointer;
560
+ }
561
+
562
  .profileImageBox img {
563
  width: 100%;
564
  height: 100%;
 
629
  }
630
 
631
  .cardsGrid {
632
+ display: grid;
633
+ grid-template-columns: repeat(2, minmax(0, 1fr));
634
  gap: 16px;
635
  }
636
 
637
  .card {
638
  background: #fff;
639
+ border: 1px solid #e5e5e5;
640
+ border-radius: 28px;
641
  padding: 16px;
642
  display: flex;
643
  flex-direction: column;
 
644
  }
645
 
646
  .card label {
 
651
  }
652
 
653
  .valueBox {
654
+ background: #f7f7f7;
655
+ border-radius: 18px;
656
  padding: 10px 12px;
657
  font-size: 15px;
658
  }
 
665
  }
666
 
667
  .changePasswordButton {
668
+ height: 40px;
669
+ padding: 0 16px;
670
+ border-radius: 999px;
671
+ background-color: #bfdcff;
672
+ color: #111111;
673
  border: none;
674
+ font-size: 14px;
675
+ font-weight: 700;
676
  cursor: pointer;
677
  }
678
 
679
  .changePasswordButton:hover {
680
+ opacity: 0.9;
681
  }
682
 
683
  .audioFolderButton {
684
+ height: 40px;
685
+ padding: 0 16px;
686
+ border-radius: 999px;
687
+ background-color: #ffffff;
688
+ color: #555555;
689
+ border: 1px solid #e5e5e5;
690
+ font-size: 14px;
691
+ font-weight: 700;
692
  cursor: pointer;
693
  }
694
 
695
  .audioFolderButton:hover {
696
+ background-color: #f7f7f7;
697
  }
698
 
699
  .audioFolderButton:disabled {
 
746
  width: 100%;
747
  background: #fff;
748
  border: 1px solid #f0d4d4;
749
+ border-radius: 18px;
750
  padding: 14px;
751
  display: flex;
752
  flex-direction: column;
753
  gap: 8px;
 
754
  }
755
 
756
  .withdrawTitle {
 
803
  }
804
 
805
  @media (max-width: 640px) {
806
+ .container {
807
+ max-width: 430px;
808
+ padding: 0 8px 36px;
809
+ }
810
+
811
+ .header {
812
+ min-height: 78px;
813
+ align-items: flex-end;
814
+ padding: 0 2px 14px;
815
+ }
816
+
817
+ .cardsGrid {
818
+ grid-template-columns: 1fr;
819
+ }
820
+
821
  .card {
822
  padding: 14px;
823
  }
 
860
  `}</style>
861
  </main>
862
  );
863
+ }
main/app/recent-shared-routes/page.tsx CHANGED
@@ -91,8 +91,8 @@ function RouteCard({ post }: { post: CommunityPost }) {
91
  ]);
92
 
93
  return (
94
- <article className="relative z-0 overflow-hidden rounded-2xl border border-black/10 bg-white shadow-sm">
95
- <div className="relative z-0 h-32 w-full overflow-hidden bg-[#f2f4f8]">
96
  <LeafletMap
97
  center={center}
98
  route={route}
@@ -164,7 +164,7 @@ function RouteCard({ post }: { post: CommunityPost }) {
164
 
165
  <Link
166
  href={`/route/${post.id}`}
167
- className="inline-flex h-10 items-center justify-center rounded-xl bg-[#bfdcff] px-4 text-sm font-semibold text-black shadow-sm transition-shadow hover:shadow-md"
168
  >
169
  해당 루트 보러가기
170
  </Link>
@@ -243,11 +243,11 @@ function RecentSharedRoutesPageContent() {
243
  }, [posts, searchKeyword, sortType]);
244
 
245
  return (
246
- <main className="min-h-screen bg-white">
247
  <TopBar />
248
 
249
- <section className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-8 sm:px-10">
250
- <section className="w-full rounded-2xl bg-[#f1f6ff] px-6 py-8 sm:px-8">
251
  <div className="grid items-center gap-6 md:grid-cols-2">
252
  <div className="flex flex-col gap-3">
253
  <h2 className="text-2xl font-bold leading-snug text-black sm:text-3xl">
@@ -262,22 +262,22 @@ function RecentSharedRoutesPageContent() {
262
  </div>
263
 
264
  <div className="flex items-center justify-center">
265
- <div className="flex h-[180px] w-full max-w-sm items-center justify-center rounded-2xl bg-white text-6xl">
266
- 🗺️
267
  </div>
268
  </div>
269
  </div>
270
  </section>
271
 
272
  {posted && (
273
- <div className="rounded-xl border border-[#bfdcff] bg-[#eef7ff] px-4 py-3 text-sm text-[#2459a8]">
274
  게시글이 저장되었습니다.
275
  </div>
276
  )}
277
 
278
  <section className="flex w-full flex-col gap-4">
279
  <div className="relative z-[1000] flex flex-col gap-3 sm:flex-row">
280
- <div className="flex h-[44px] flex-1 items-center gap-2 rounded-xl border border-black/10 bg-white px-4 shadow-sm">
281
  <span className="text-sm text-[#7f7f7f]">🔍</span>
282
 
283
  <input
@@ -294,13 +294,13 @@ function RecentSharedRoutesPageContent() {
294
  <button
295
  type="button"
296
  onClick={() => setIsSortOpen((prev) => !prev)}
297
- className="h-[44px] min-w-[120px] rounded-xl border border-black/10 bg-white px-4 text-sm font-semibold text-black shadow-sm transition-shadow hover:shadow-md"
298
  >
299
  {sortType === "latest" ? "최신순" : "완주율순"} ▼
300
  </button>
301
 
302
  {isSortOpen ? (
303
- <div className="absolute right-0 top-[52px] z-[9999] w-[140px] overflow-hidden rounded-xl border border-black/10 bg-white shadow-md">
304
  <button
305
  type="button"
306
  onClick={() => {
@@ -343,7 +343,7 @@ function RecentSharedRoutesPageContent() {
343
 
344
  <Link
345
  href="/community/new"
346
- className="inline-flex h-10 items-center rounded-full bg-[#bfdcff] px-4 text-sm font-semibold text-black shadow-sm transition-shadow hover:shadow-md"
347
  >
348
  새 글 작성
349
  </Link>
@@ -351,7 +351,7 @@ function RecentSharedRoutesPageContent() {
351
  </div>
352
 
353
  {filteredRoutes.length === 0 ? (
354
- <div className="relative z-0 rounded-2xl border border-dashed border-black/20 bg-[#fafafa] px-6 py-12 text-center">
355
  <p className="text-base font-semibold text-black">공유된 루트가 아직 없습니다.</p>
356
 
357
  <p className="mt-2 text-sm text-[#666]">
@@ -360,7 +360,7 @@ function RecentSharedRoutesPageContent() {
360
 
361
  <Link
362
  href="/community/new"
363
- className="mt-5 inline-flex h-11 items-center rounded-full bg-[#bfdcff] px-5 text-sm font-semibold text-black shadow-sm transition-shadow hover:shadow-md"
364
  >
365
  첫 게시글 작성하기
366
  </Link>
 
91
  ]);
92
 
93
  return (
94
+ <article className="rts-card rts-card-clip relative z-0">
95
+ <div className="relative z-0 h-40 w-full overflow-hidden bg-[#f2f4f8]">
96
  <LeafletMap
97
  center={center}
98
  route={route}
 
164
 
165
  <Link
166
  href={`/route/${post.id}`}
167
+ className="rts-primary-button h-10"
168
  >
169
  해당 루트 보러가기
170
  </Link>
 
243
  }, [posts, searchKeyword, sortType]);
244
 
245
  return (
246
+ <main className="rts-page">
247
  <TopBar />
248
 
249
+ <section className="rts-shell flex flex-col gap-6">
250
+ <section className="rts-card bg-[#f1f6ff]">
251
  <div className="grid items-center gap-6 md:grid-cols-2">
252
  <div className="flex flex-col gap-3">
253
  <h2 className="text-2xl font-bold leading-snug text-black sm:text-3xl">
 
262
  </div>
263
 
264
  <div className="flex items-center justify-center">
265
+ <div className="rts-inner-panel flex h-[180px] w-full max-w-sm items-center justify-center">
266
+ <span className="text-sm font-semibold text-[#777]">Shared routes</span>
267
  </div>
268
  </div>
269
  </div>
270
  </section>
271
 
272
  {posted && (
273
+ <div className="rounded-[18px] border border-[#bfdcff] bg-[#eef7ff] px-4 py-3 text-sm text-[#2459a8]">
274
  게시글이 저장되었습니다.
275
  </div>
276
  )}
277
 
278
  <section className="flex w-full flex-col gap-4">
279
  <div className="relative z-[1000] flex flex-col gap-3 sm:flex-row">
280
+ <div className="flex h-[44px] flex-1 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white px-4">
281
  <span className="text-sm text-[#7f7f7f]">🔍</span>
282
 
283
  <input
 
294
  <button
295
  type="button"
296
  onClick={() => setIsSortOpen((prev) => !prev)}
297
+ className="h-[44px] min-w-[120px] rounded-full border border-[#e5e5e5] bg-white px-4 text-sm font-semibold text-black transition hover:bg-[#f7f7f7]"
298
  >
299
  {sortType === "latest" ? "최신순" : "완주율순"} ▼
300
  </button>
301
 
302
  {isSortOpen ? (
303
+ <div className="absolute right-0 top-[52px] z-[9999] w-[140px] overflow-hidden rounded-[18px] border border-[#e5e5e5] bg-white">
304
  <button
305
  type="button"
306
  onClick={() => {
 
343
 
344
  <Link
345
  href="/community/new"
346
+ className="rts-primary-button h-10"
347
  >
348
  새 글 작성
349
  </Link>
 
351
  </div>
352
 
353
  {filteredRoutes.length === 0 ? (
354
+ <div className="rts-empty-state relative z-0">
355
  <p className="text-base font-semibold text-black">공유된 루트가 아직 없습니다.</p>
356
 
357
  <p className="mt-2 text-sm text-[#666]">
 
360
 
361
  <Link
362
  href="/community/new"
363
+ className="rts-primary-button mt-5 h-11 px-5"
364
  >
365
  첫 게시글 작성하기
366
  </Link>
main/app/route-results/[id]/page.tsx CHANGED
@@ -5,22 +5,25 @@ import { Suspense, useEffect, useMemo, useState } from "react";
5
  import { useParams, useRouter, useSearchParams } from "next/navigation";
6
  import TopBar from "../../_components/TopBar";
7
  import RunStartNoticeModal from "../../_components/RunStartNoticeModal";
 
8
  import { FLOW_KEYS, issueUiFlowPermit } from "../../_utils/uiFlowGuard";
9
  import {
10
  findGeneratedRouteDetail,
11
  formatGeneratedRouteLabel,
12
  getGeneratedRouteAiShapeScore,
13
- getGeneratedRouteClipScore,
14
  type GeneratedRouteDetailRecord,
15
  } from "../../_utils/generatedRouteDetails";
 
 
 
 
 
 
16
  import {
17
  dismissRunStartNoticeForCurrentAccount,
18
  shouldShowRunStartNotice,
19
  } from "../../_utils/runStartNotice";
20
- import {
21
- isNativeAppEnvironment,
22
- sendNativeWearRouteSelection,
23
- } from "../../_utils/nativeWearBridge";
24
 
25
  const LeafletMap = dynamic(() => import("../../_components/LeafletMap"), {
26
  ssr: false,
@@ -29,33 +32,72 @@ const LeafletMap = dynamic(() => import("../../_components/LeafletMap"), {
29
  const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
30
  const DEFAULT_MAP_CENTER: [number, number] = [37.5665, 126.978];
31
 
32
- const getCookieValue = (name: string): string | null => {
33
- if (typeof document === "undefined") {
34
- return null;
35
- }
36
  return (
37
  document.cookie
38
  .split("; ")
39
  .find((row) => row.startsWith(`${name}=`))
40
  ?.split("=")[1] ?? null
41
  );
42
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
- function formatScore(score: number | null): string {
45
- return score === null ? "-" : score.toFixed(6);
 
 
 
 
 
 
 
 
 
 
46
  }
47
 
48
- function RouteStat({
49
- label,
50
- value,
51
- }: {
52
- label: string;
53
- value: string | number;
54
- }) {
55
  return (
56
- <div className="rounded-xl bg-[#f7f8fc] p-4">
57
- <p className="text-xs font-semibold text-[#666]">{label}</p>
58
- <p className="mt-1 text-sm font-semibold text-black">{value}</p>
59
  </div>
60
  );
61
  }
@@ -72,43 +114,47 @@ function GeneratedRouteDetailPageContent() {
72
  const [isStarting, setIsStarting] = useState(false);
73
  const [isRunNoticeOpen, setIsRunNoticeOpen] = useState(false);
74
  const [pendingRunStart, setPendingRunStart] = useState<{ simulate: boolean } | null>(null);
 
75
 
76
  useEffect(() => {
77
- setRecord(findGeneratedRouteDetail(routeId));
 
78
  setHasLoaded(true);
 
 
 
 
79
  }, [routeId]);
80
 
81
  const routeCoords = record?.route.route_coords ?? [];
82
  const center = record?.route.center ?? routeCoords[0] ?? DEFAULT_MAP_CENTER;
83
- const aiShapeScore = record ? getGeneratedRouteAiShapeScore(record.route) : null;
84
- const clipScore = record ? getGeneratedRouteClipScore(record.route) : null;
85
- const gatv2Score =
86
- typeof record?.route.selected_label_gatv2 === "number"
87
- ? record.route.selected_label_gatv2
88
- : typeof record?.route.top1_gatv2 === "number"
89
- ? record.route.top1_gatv2
90
- : null;
91
- const routePointCount = routeCoords.length;
92
-
93
- const actionOrder = useMemo(
94
- () =>
95
- initialIntent === "simulate"
96
- ? [
97
- { label: "시뮬레이션 시작", simulate: true },
98
- { label: "러닝 시작", simulate: false },
99
- ]
100
- : [
101
- { label: "러닝 시작", simulate: false },
102
- { label: "시뮬레이션 시작", simulate: true },
103
- ],
104
- [initialIntent]
105
- );
106
 
107
- const persistRunRoutePayload = (detail: GeneratedRouteDetailRecord) => {
108
- if (typeof window === "undefined") {
109
- return;
110
  }
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  const coords = detail.route.route_coords ?? [];
113
  const payload = {
114
  place: detail.place,
@@ -120,18 +166,11 @@ function GeneratedRouteDetailPageContent() {
120
  window.sessionStorage.setItem("latest_run_route", serialized);
121
  };
122
 
123
- const notifyWatchRouteSelection = async (
124
- detail: GeneratedRouteDetailRecord,
125
- openNativeRun: boolean
126
- ) => {
127
- if (typeof window === "undefined") {
128
- return;
129
- }
130
 
131
  const coords = detail.route.route_coords ?? [];
132
- if (coords.length < 2) {
133
- return;
134
- }
135
 
136
  const routeCenter = detail.route.center ?? coords[0];
137
  const payload = {
@@ -151,17 +190,10 @@ function GeneratedRouteDetailPageContent() {
151
  updatedAt: new Date().toISOString(),
152
  };
153
 
154
- sendNativeWearRouteSelection(payload, { openNativeRun });
155
-
156
- if (openNativeRun) {
157
- return;
158
- }
159
 
160
- const token =
161
- window.localStorage.getItem("access_token") ?? getCookieValue("access_token");
162
- if (!token) {
163
- return;
164
- }
165
 
166
  try {
167
  await fetch(`${apiBase}/api/v1/auth/watch/route-selection`, {
@@ -179,15 +211,11 @@ function GeneratedRouteDetailPageContent() {
179
  };
180
 
181
  const continueStartRun = async (simulate: boolean) => {
182
- if (!record || routeCoords.length < 2) {
183
- return;
184
- }
185
 
186
  setIsStarting(true);
187
  persistRunRoutePayload(record);
188
- // Keep the TWA on the web running screen. This is where MeloTTS and the
189
- // server-backed sensor coach run; the native layer only relays commands.
190
- await notifyWatchRouteSelection(record, false);
191
 
192
  const nextParams = new URLSearchParams(record.runParams);
193
  nextParams.set("runKey", record.runKey);
@@ -203,11 +231,6 @@ function GeneratedRouteDetailPageContent() {
203
  };
204
 
205
  const startRun = async (simulate: boolean) => {
206
- if (!simulate && isNativeAppEnvironment()) {
207
- await continueStartRun(simulate);
208
- return;
209
- }
210
-
211
  if (!simulate && shouldShowRunStartNotice()) {
212
  setPendingRunStart({ simulate });
213
  setIsRunNoticeOpen(true);
@@ -231,9 +254,34 @@ function GeneratedRouteDetailPageContent() {
231
  }
232
  };
233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  if (!hasLoaded) {
235
  return (
236
- <main className="min-h-screen bg-[#f7f8fc]">
237
  <TopBar />
238
  </main>
239
  );
@@ -241,29 +289,19 @@ function GeneratedRouteDetailPageContent() {
241
 
242
  if (!record) {
243
  return (
244
- <main className="min-h-screen bg-[#f7f8fc]">
245
  <TopBar />
246
- <section className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 py-8 sm:px-10">
247
- <section className="rounded-2xl bg-white p-8 shadow-sm">
248
- <h1 className="text-2xl font-bold text-black">경로 정보를 찾을 수 없습니다.</h1>
249
- <p className="mt-2 text-sm text-[#666]">
250
- 생성 결과 화면에서 다시 경로를 선택해 주세요.
251
- </p>
252
- <button
253
- type="button"
254
- onClick={() => router.back()}
255
- className="mt-6 inline-flex h-11 items-center justify-center rounded-xl bg-black px-5 text-sm font-semibold text-white"
256
- >
257
- 이전 화면으로
258
- </button>
259
- </section>
260
- </section>
261
  </main>
262
  );
263
  }
264
 
265
  return (
266
- <main className="min-h-screen bg-[#f7f8fc]">
267
  <TopBar />
268
  <RunStartNoticeModal
269
  open={isRunNoticeOpen}
@@ -277,116 +315,64 @@ function GeneratedRouteDetailPageContent() {
277
  }}
278
  />
279
 
280
- <section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-8 sm:px-10">
281
- <section className="rounded-2xl bg-white p-6 shadow-sm sm:p-8">
282
- <div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
283
- <div>
284
- <p className="text-sm font-semibold text-[#666]">{record.place}</p>
285
- <h1 className="mt-2 text-3xl font-bold text-black">{record.title}</h1>
286
- <p className="mt-2 text-sm text-[#666]">
287
- {formatGeneratedRouteLabel(record.routeLabel)} 기준으로 생성된 추천 경로입니다.
288
- </p>
289
- </div>
290
-
291
- <div className="flex flex-wrap gap-2">
292
- {actionOrder.map((action, index) => (
293
- <button
294
- key={action.label}
295
- type="button"
296
- onClick={() => void startRun(action.simulate)}
297
- disabled={isStarting}
298
- className={`inline-flex h-11 items-center justify-center rounded-xl px-5 text-sm font-semibold transition disabled:opacity-60 ${
299
- index === 0
300
- ? "bg-black text-white hover:opacity-90"
301
- : "border border-black/10 bg-white text-black hover:bg-[#f5f5f5]"
302
- }`}
303
- >
304
- {isStarting && index === 0 ? "시작 준비 중" : action.label}
305
- </button>
306
- ))}
307
- </div>
308
- </div>
309
- </section>
310
 
311
- <section className="overflow-hidden rounded-2xl bg-white shadow-sm">
312
- <div className="h-[420px] w-full bg-[#eef1f4]">
313
- <LeafletMap
314
- center={center}
315
- route={routeCoords}
316
- className="h-full w-full"
317
- zoom={14}
318
- />
319
  </div>
320
- </section>
321
 
322
- <section className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
323
- <div className="rounded-2xl bg-white p-6 shadow-sm sm:p-8">
324
- <h2 className="text-xl font-bold text-black">경로 상세</h2>
325
-
326
- <div className="mt-4 grid grid-cols-2 gap-3 text-sm md:grid-cols-3">
327
- <RouteStat label="거리" value={record.distanceText} />
328
- <RouteStat
329
- label="AI 형태 점수"
330
- value={aiShapeScore === null ? "-" : `${aiShapeScore}점 / 100`}
331
- />
332
- <RouteStat label="CLIP 점수" value={formatScore(clipScore)} />
333
- <RouteStat
334
- label="GATv2"
335
- value={gatv2Score === null ? "-" : gatv2Score.toFixed(3)}
336
- />
337
- <RouteStat
338
- label="모델 분류"
339
- value={formatGeneratedRouteLabel(record.modelTopLabel)}
340
- />
341
- <RouteStat label="좌표 수" value={`${routePointCount}개`} />
342
  </div>
343
- </div>
344
 
345
- <div className="rounded-2xl bg-white p-6 shadow-sm sm:p-8">
346
- <h2 className="text-xl font-bold text-black">선택 정보</h2>
347
- <div className="mt-4 space-y-3 text-sm text-[#555]">
348
- <p>
349
- <span className="font-semibold text-black">코스 번호</span>{" "}
350
- #{record.routeNumber}
351
- </p>
352
- <p>
353
- <span className="font-semibold text-black">루프 여부</span>{" "}
354
- {record.route.is_loop ? "루프형" : "일반형"}
355
- </p>
356
- <p>
357
- <span className="font-semibold text-black">중심 좌표</span>{" "}
358
- {center[0].toFixed(5)}, {center[1].toFixed(5)}
359
- </p>
360
  </div>
361
 
362
- {record.route.quickdraw_preview_data_url ||
363
- record.route.quickdraw_preview_image_path ? (
364
- <div className="mt-5">
365
- <p className="mb-2 text-sm font-semibold text-black">기반 스케치</p>
366
- <img
367
- src={
368
- record.route.quickdraw_preview_data_url ??
369
- record.route.quickdraw_preview_image_path ??
370
- ""
371
- }
372
- alt="quickdraw preview"
373
- className="h-28 w-28 rounded-xl border border-black/10 bg-white object-contain p-2"
374
- />
375
  </div>
376
  ) : null}
377
- </div>
378
- </section>
379
 
380
- <section className="flex flex-wrap items-center gap-3">
381
- <button
382
- type="button"
383
- onClick={() => router.back()}
384
- className="inline-flex h-11 items-center justify-center rounded-xl bg-white px-5 text-sm font-semibold text-black shadow-sm transition hover:bg-[#f5f5f5]"
385
- >
386
- 결과 목록으로
387
- </button>
 
 
 
 
 
 
388
  </section>
389
- </section>
390
  </main>
391
  );
392
  }
 
5
  import { useParams, useRouter, useSearchParams } from "next/navigation";
6
  import TopBar from "../../_components/TopBar";
7
  import RunStartNoticeModal from "../../_components/RunStartNoticeModal";
8
+ import { PageContainer, PageHeader, SectionCard, EmptyState } from "../../_components/RunShapeUI";
9
  import { FLOW_KEYS, issueUiFlowPermit } from "../../_utils/uiFlowGuard";
10
  import {
11
  findGeneratedRouteDetail,
12
  formatGeneratedRouteLabel,
13
  getGeneratedRouteAiShapeScore,
 
14
  type GeneratedRouteDetailRecord,
15
  } from "../../_utils/generatedRouteDetails";
16
+ import {
17
+ readSavedRoutesFromStorage,
18
+ removeSavedRoute,
19
+ type SavedRouteRecord,
20
+ upsertSavedRoute,
21
+ } from "../../_utils/savedRoutes";
22
  import {
23
  dismissRunStartNoticeForCurrentAccount,
24
  shouldShowRunStartNotice,
25
  } from "../../_utils/runStartNotice";
26
+ import { sendNativeWearRouteSelection } from "../../_utils/nativeWearBridge";
 
 
 
27
 
28
  const LeafletMap = dynamic(() => import("../../_components/LeafletMap"), {
29
  ssr: false,
 
32
  const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
33
  const DEFAULT_MAP_CENTER: [number, number] = [37.5665, 126.978];
34
 
35
+ function getCookieValue(name: string): string | null {
36
+ if (typeof document === "undefined") return null;
 
 
37
  return (
38
  document.cookie
39
  .split("; ")
40
  .find((row) => row.startsWith(`${name}=`))
41
  ?.split("=")[1] ?? null
42
  );
43
+ }
44
+
45
+ function hashText(input: string): string {
46
+ let hash = 5381;
47
+ for (let index = 0; index < input.length; index += 1) {
48
+ hash = (hash * 33) ^ input.charCodeAt(index);
49
+ }
50
+ return (hash >>> 0).toString(36);
51
+ }
52
+
53
+ function createSavedRouteId(detail: GeneratedRouteDetailRecord): string {
54
+ const coords = (detail.route.route_coords ?? [])
55
+ .map(([lat, lng]) => `${lat.toFixed(5)},${lng.toFixed(5)}`)
56
+ .join("|");
57
+ return `saved_${hashText(`${detail.place}|${detail.route.top1}|${detail.route.length_km.toFixed(3)}|${coords}`)}`;
58
+ }
59
+
60
+ function formatEstimatedTime(distanceKm: number): string {
61
+ if (!Number.isFinite(distanceKm) || distanceKm <= 0) return "-";
62
+ const minutes = Math.max(1, Math.round((distanceKm / 6.5) * 60));
63
+ if (minutes < 60) return `약 ${minutes}분`;
64
+ const hours = Math.floor(minutes / 60);
65
+ const rest = minutes % 60;
66
+ return rest > 0 ? `약 ${hours}시간 ${rest}분` : `약 ${hours}시간`;
67
+ }
68
+
69
+ function getCompletionLabel(score: number | null): string {
70
+ if (score === null) return "형태 확인 필요";
71
+ if (score >= 78) return "형태 완성도 높음";
72
+ if (score >= 58) return "모양이 비교적 선명한 경로";
73
+ return "일부 구간에서 모양이 달라질 수 있음";
74
+ }
75
+
76
+ function getDifficulty(distanceKm: number): string {
77
+ if (distanceKm < 4) return "쉬움";
78
+ if (distanceKm < 8) return "보통";
79
+ return "어려움";
80
+ }
81
 
82
+ function getRouteFeatures(detail: GeneratedRouteDetailRecord): string[] {
83
+ const features: string[] = [];
84
+ if (detail.route.is_loop) {
85
+ features.push("출발지로 돌아오는 경로예요.");
86
+ }
87
+ if ((detail.route.route_coords?.length ?? 0) > 20) {
88
+ features.push("지도 위에서 모양을 비교적 세밀하게 따라가는 코스예요.");
89
+ }
90
+ if (detail.place) {
91
+ features.push(`${detail.place.split(",")[0]} 주변에서 시작할 수 있어요.`);
92
+ }
93
+ return features;
94
  }
95
 
96
+ function DetailStat({ label, value }: { label: string; value: string }) {
 
 
 
 
 
 
97
  return (
98
+ <div className="rts-data-item">
99
+ <span className="rts-data-value">{value}</span>
100
+ <p className="rts-data-label">{label}</p>
101
  </div>
102
  );
103
  }
 
114
  const [isStarting, setIsStarting] = useState(false);
115
  const [isRunNoticeOpen, setIsRunNoticeOpen] = useState(false);
116
  const [pendingRunStart, setPendingRunStart] = useState<{ simulate: boolean } | null>(null);
117
+ const [isSaved, setIsSaved] = useState(false);
118
 
119
  useEffect(() => {
120
+ const detail = findGeneratedRouteDetail(routeId);
121
+ setRecord(detail);
122
  setHasLoaded(true);
123
+ if (detail) {
124
+ const routeIdForSave = createSavedRouteId(detail);
125
+ setIsSaved(readSavedRoutesFromStorage().some((route) => route.id === routeIdForSave));
126
+ }
127
  }, [routeId]);
128
 
129
  const routeCoords = record?.route.route_coords ?? [];
130
  const center = record?.route.center ?? routeCoords[0] ?? DEFAULT_MAP_CENTER;
131
+ const shapeScore = record ? getGeneratedRouteAiShapeScore(record.route) : null;
132
+ const features = record ? getRouteFeatures(record) : [];
133
+ const encodedBackParams = searchParams.get("back");
134
+ const resultsBackHref = useMemo(() => {
135
+ if (encodedBackParams) {
136
+ return `/route-results?${encodedBackParams}`;
137
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
+ if (!record) {
140
+ return "/route-results";
 
141
  }
142
 
143
+ const fallbackParams = new URLSearchParams({
144
+ place: record.place,
145
+ source: "place",
146
+ label: record.routeLabel,
147
+ routeCount: "8",
148
+ targetDistanceKm: String(Math.max(1, Math.round(record.route.length_km))),
149
+ selectedTopK: "8",
150
+ radiusKm: "2.5",
151
+ });
152
+ return `/route-results?${fallbackParams.toString()}`;
153
+ }, [encodedBackParams, record]);
154
+
155
+ const persistRunRoutePayload = (detail: GeneratedRouteDetailRecord) => {
156
+ if (typeof window === "undefined") return;
157
+
158
  const coords = detail.route.route_coords ?? [];
159
  const payload = {
160
  place: detail.place,
 
166
  window.sessionStorage.setItem("latest_run_route", serialized);
167
  };
168
 
169
+ const notifyWatchRouteSelection = async (detail: GeneratedRouteDetailRecord) => {
170
+ if (typeof window === "undefined") return;
 
 
 
 
 
171
 
172
  const coords = detail.route.route_coords ?? [];
173
+ if (coords.length < 2) return;
 
 
174
 
175
  const routeCenter = detail.route.center ?? coords[0];
176
  const payload = {
 
190
  updatedAt: new Date().toISOString(),
191
  };
192
 
193
+ sendNativeWearRouteSelection(payload);
 
 
 
 
194
 
195
+ const token = window.localStorage.getItem("access_token") ?? getCookieValue("access_token");
196
+ if (!token) return;
 
 
 
197
 
198
  try {
199
  await fetch(`${apiBase}/api/v1/auth/watch/route-selection`, {
 
211
  };
212
 
213
  const continueStartRun = async (simulate: boolean) => {
214
+ if (!record || routeCoords.length < 2) return;
 
 
215
 
216
  setIsStarting(true);
217
  persistRunRoutePayload(record);
218
+ await notifyWatchRouteSelection(record);
 
 
219
 
220
  const nextParams = new URLSearchParams(record.runParams);
221
  nextParams.set("runKey", record.runKey);
 
231
  };
232
 
233
  const startRun = async (simulate: boolean) => {
 
 
 
 
 
234
  if (!simulate && shouldShowRunStartNotice()) {
235
  setPendingRunStart({ simulate });
236
  setIsRunNoticeOpen(true);
 
254
  }
255
  };
256
 
257
+ const toggleSaved = () => {
258
+ if (!record || routeCoords.length < 2) return;
259
+
260
+ const saveId = createSavedRouteId(record);
261
+ if (isSaved) {
262
+ removeSavedRoute(saveId);
263
+ setIsSaved(false);
264
+ return;
265
+ }
266
+
267
+ const savedRecord: SavedRouteRecord = {
268
+ id: saveId,
269
+ title: record.title,
270
+ place: record.place,
271
+ distanceKm: record.route.length_km,
272
+ label: record.routeLabel,
273
+ clipScore: null,
274
+ center: record.route.center ?? routeCoords[0] ?? null,
275
+ routeCoords,
276
+ createdAt: new Date().toISOString(),
277
+ };
278
+ upsertSavedRoute(savedRecord);
279
+ setIsSaved(true);
280
+ };
281
+
282
  if (!hasLoaded) {
283
  return (
284
+ <main className="rts-page">
285
  <TopBar />
286
  </main>
287
  );
 
289
 
290
  if (!record) {
291
  return (
292
+ <main className="rts-page">
293
  <TopBar />
294
+ <PageContainer>
295
+ <EmptyState>
296
+ 경로 정보를 찾을 수 없습니다. 추천 결과 화면에서 다시 경로를 선택해주세요.
297
+ </EmptyState>
298
+ </PageContainer>
 
 
 
 
 
 
 
 
 
 
299
  </main>
300
  );
301
  }
302
 
303
  return (
304
+ <main className="rts-page">
305
  <TopBar />
306
  <RunStartNoticeModal
307
  open={isRunNoticeOpen}
 
315
  }}
316
  />
317
 
318
+ <PageContainer className="flex flex-col gap-6">
319
+ <PageHeader
320
+ title={record.title}
321
+ subtitle={`${record.place.split(",")[0]} 주변 ${formatGeneratedRouteLabel(record.routeLabel)} 모양 경로`}
322
+ backHref={resultsBackHref}
323
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
 
325
+ <SectionCard className="rts-card-clip">
326
+ <div className="h-[330px] w-full bg-[#f5f5f5] sm:h-[460px]">
327
+ <LeafletMap center={center} route={routeCoords} className="h-full w-full" zoom={14} />
 
 
 
 
 
328
  </div>
329
+ </SectionCard>
330
 
331
+ <section className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
332
+ <SectionCard>
333
+ <div>
334
+ <h2 className="rts-section-title">
335
+ {formatGeneratedRouteLabel(record.routeLabel)} 모양
336
+ </h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  </div>
 
338
 
339
+ <div className="rts-data-grid mt-5">
340
+ <DetailStat label="거리" value={record.distanceText} />
341
+ <DetailStat label="예상 시간" value={formatEstimatedTime(record.route.length_km)} />
342
+ <DetailStat label="난이도" value={getDifficulty(record.route.length_km)} />
343
+ <DetailStat label="형태 완성도" value={getCompletionLabel(shapeScore)} />
344
+ <DetailStat label="경로 유형" value={record.route.is_loop ? "순환 코스" : "일반 코스"} />
345
+ <DetailStat label="지도 지점" value={`${routeCoords.length}개`} />
 
 
 
 
 
 
 
 
346
  </div>
347
 
348
+ {features.length > 0 ? (
349
+ <div className="mt-6 border-t border-[#e5e5e5] pt-5">
350
+ <h3 className="text-base font-semibold text-black">경로 특징</h3>
351
+ <ul className="mt-3 grid gap-2 text-sm leading-6 text-[#555]">
352
+ {features.map((feature) => (
353
+ <li key={feature}>{feature}</li>
354
+ ))}
355
+ </ul>
 
 
 
 
 
356
  </div>
357
  ) : null}
358
+ </SectionCard>
 
359
 
360
+ <aside className="grid content-start gap-3">
361
+ <button type="button" onClick={() => void startRun(false)} disabled={isStarting} className="rts-primary-button h-12 w-full text-base disabled:opacity-60">
362
+ {isStarting && initialIntent === "run" ? "시작 준비 중" : "달리기 시작"}
363
+ </button>
364
+ <button type="button" onClick={toggleSaved} className="rts-secondary-button h-11 w-full">
365
+ {isSaved ? "저장 해제" : "경로 저장"}
366
+ </button>
367
+ <button type="button" onClick={() => void startRun(true)} disabled={isStarting} className="rts-secondary-button h-11 w-full disabled:opacity-60">
368
+ 시뮬레이션으로 보기
369
+ </button>
370
+ <button type="button" onClick={() => router.push(resultsBackHref)} className="rts-secondary-button h-11 w-full">
371
+ 결과 목록으로
372
+ </button>
373
+ </aside>
374
  </section>
375
+ </PageContainer>
376
  </main>
377
  );
378
  }
main/app/route-results/page.tsx CHANGED
@@ -4,65 +4,28 @@ import { Suspense, useEffect, useMemo, useState } from "react";
4
  import { useRouter, useSearchParams } from "next/navigation";
5
  import TopBar from "../_components/TopBar";
6
  import LeafletMap from "../_components/LeafletMap";
 
 
 
 
 
 
7
  import {
8
  readSavedRoutesFromStorage,
9
  removeSavedRoute,
10
- SavedRouteRecord,
11
  upsertSavedRoute,
12
  } from "../_utils/savedRoutes";
13
  import {
14
  createGeneratedRouteDetailId,
 
 
 
 
15
  upsertGeneratedRouteDetail,
 
16
  type GeneratedRouteDetailRecord,
17
  } from "../_utils/generatedRouteDetails";
18
- import {
19
- endNavigationLoading,
20
- startNavigationLoading,
21
- updateNavigationLoading,
22
- } from "../_components/navigationLoadingBus";
23
-
24
- type RouteScore = {
25
- label: string;
26
- score: number;
27
- rerank_score?: number;
28
- clip?: number;
29
- clip_image?: number;
30
- clip_text?: number;
31
- shape?: number;
32
- gatv2?: number;
33
- gatv2_coverage?: number;
34
- };
35
-
36
- type GeneratedRoute = {
37
- image_path: string;
38
- top1: string;
39
- top1_score: number;
40
- top1_rerank_score?: number;
41
- top1_clip?: number;
42
- top1_clip_image?: number;
43
- top1_clip_text?: number;
44
- top1_shape?: number;
45
- top1_gatv2?: number;
46
- top1_gatv2_coverage?: number;
47
- selected_label?: string | null;
48
- selected_label_score?: number | null;
49
- selected_label_rerank_score?: number | null;
50
- selected_label_clip?: number | null;
51
- selected_label_clip_image?: number | null;
52
- selected_label_clip_text?: number | null;
53
- selected_label_gatv2?: number | null;
54
- selected_label_gatv2_coverage?: number | null;
55
- selected_label_margin?: number | null;
56
- quickdraw_preview_label?: string | null;
57
- quickdraw_preview_image_path?: string | null;
58
- quickdraw_preview_data_url?: string | null;
59
- quickdraw_preview_similarity?: number | null;
60
- is_loop?: boolean;
61
- length_km: number;
62
- route_coords?: [number, number][];
63
- center?: [number, number] | null;
64
- results: RouteScore[];
65
- };
66
 
67
  type OSMClipResult = {
68
  place: string;
@@ -72,21 +35,6 @@ type OSMClipResult = {
72
  generated_count: number;
73
  target_distance_km: number;
74
  radius_km?: number;
75
- clip_source?:
76
- | "quickdraw_prototype"
77
- | "pgvector_prototype"
78
- | "pgvector_hybrid"
79
- | "prototype_prompt_hybrid"
80
- | "quickdraw_head"
81
- | "text_prompts";
82
- weights?: { clip?: number; shape?: number; gatv2?: number };
83
- clip_components?: {
84
- image_proto_weight?: number;
85
- text_proto_weight?: number;
86
- text_proto_source?: string;
87
- representative_per_label?: number;
88
- representative_top_k?: number;
89
- };
90
  labels: string[];
91
  selected_label?: string | null;
92
  selected_top_k?: number | null;
@@ -95,118 +43,61 @@ type OSMClipResult = {
95
  };
96
 
97
  const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
98
- const AVAILABLE_LABELS = [
99
- "cup",
100
- "face",
101
- "cloud",
102
- "ice_cream",
103
- "t_shirt",
104
- "fish",
105
- "apple",
106
- "candle",
107
- ] as const;
108
- const FILTER_OPTIONS = ["all", ...AVAILABLE_LABELS] as const;
109
- const ALL_FILTER_KEY = FILTER_OPTIONS[0];
110
- const ROUTE_GENERATE_LOADING_STEPS = [
111
- "OSM 도로망을 확인하고 있습니다.",
112
- "후보 경로를 생성하고 점수화하고 있습니다.",
113
- "결과 카드와 지도를 구성하고 있습니다.",
114
  ];
115
 
116
- const parseNumberParam = (value: string | null): number => {
117
- if (value === null || value.trim() === "") {
118
- return Number.NaN;
119
- }
120
  return Number(value);
121
- };
122
-
123
- const parseNumericInput = (value: string): number | null => {
124
- const trimmed = value.trim();
125
- if (trimmed === "") {
126
- return null;
127
- }
128
- const parsed = Number(trimmed);
129
- if (!Number.isFinite(parsed)) {
130
- return null;
131
- }
132
- return parsed;
133
- };
134
-
135
- const formatLabel = (raw: string): string =>
136
- raw
137
- .replace(/_/g, " ")
138
- .split(" ")
139
- .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
140
- .join(" ");
141
-
142
- const normalizeLabelKey = (raw: string): string =>
143
- raw.trim().toLowerCase().replace(/[\s-]+/g, "_");
144
-
145
- const normalizeLabel = (
146
- raw: string | null,
147
- allowedLabels: readonly string[] = AVAILABLE_LABELS
148
- ): string | null => {
149
- if (!raw) return null;
150
- const normalized = normalizeLabelKey(raw);
151
- return allowedLabels.includes(normalized) ? normalized : null;
152
- };
153
-
154
- const routeScoreValue = (row: RouteScore): number =>
155
- typeof row.rerank_score === "number" ? row.rerank_score : row.score;
156
-
157
- const getPrimaryRouteLabel = (route: GeneratedRoute): string => {
158
- if (route.selected_label) {
159
- return route.selected_label;
160
- }
161
- if (!Array.isArray(route.results) || route.results.length === 0) {
162
- return route.top1;
163
- }
164
- const top = route.results.reduce((best, current) =>
165
- routeScoreValue(current) > routeScoreValue(best) ? current : best
166
- );
167
- return top.label || route.top1;
168
- };
169
-
170
- const getModelTopRouteLabel = (route: GeneratedRoute): string => {
171
- if (!Array.isArray(route.results) || route.results.length === 0) {
172
- return route.top1;
173
- }
174
- const top = route.results.reduce((best, current) =>
175
- routeScoreValue(current) > routeScoreValue(best) ? current : best
176
- );
177
- return top.label || route.top1;
178
- };
179
 
180
- const getAiShapeScore = (route: GeneratedRoute): number | null => {
181
- const score =
182
- typeof route.selected_label_clip === "number"
183
- ? route.selected_label_clip
184
- : typeof route.top1_clip === "number"
185
- ? route.top1_clip
186
- : typeof route.selected_label_score === "number"
187
- ? route.selected_label_score
188
- : typeof route.top1_score === "number"
189
- ? route.top1_score
190
- : null;
191
- return score === null ? null : Math.round(Math.max(0, Math.min(1, score)) * 100);
192
- };
193
 
194
- const hashText = (input: string): string => {
195
  let hash = 5381;
196
  for (let index = 0; index < input.length; index += 1) {
197
  hash = (hash * 33) ^ input.charCodeAt(index);
198
  }
199
  return (hash >>> 0).toString(36);
200
- };
201
 
202
- const createSavedRouteId = (route: GeneratedRoute, placeText: string): string => {
203
  const coords = (route.route_coords ?? [])
204
  .map(([lat, lng]) => `${lat.toFixed(5)},${lng.toFixed(5)}`)
205
  .join("|");
206
- return `saved_${hashText(
207
- `${placeText}|${route.top1}|${route.length_km.toFixed(3)}|${coords}`
208
- )}`;
209
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
  function BookmarkIcon({ filled }: { filled: boolean }) {
212
  return (
@@ -214,7 +105,6 @@ function BookmarkIcon({ filled }: { filled: boolean }) {
214
  width="18"
215
  height="18"
216
  viewBox="0 0 24 24"
217
- className={filled ? "text-[#f5c542]" : "text-[#7a7a7a]"}
218
  fill={filled ? "currentColor" : "none"}
219
  stroke="currentColor"
220
  strokeWidth="1.8"
@@ -230,11 +120,8 @@ function BookmarkIcon({ filled }: { filled: boolean }) {
230
  function RouteResultsPageContent() {
231
  const searchParams = useSearchParams();
232
  const router = useRouter();
233
- const cacheKey = useMemo(
234
- () => `route-results:${searchParams.toString()}`,
235
- [searchParams]
236
- );
237
 
 
238
  const initialPlace = searchParams.get("place") || "Gangnam-gu, Seoul, South Korea";
239
  const initialSource = (searchParams.get("source") as "place" | "current" | null) ?? "place";
240
  const initialLatitude = parseNumberParam(searchParams.get("latitude"));
@@ -243,86 +130,32 @@ function RouteResultsPageContent() {
243
  const initialCount = parseNumberParam(searchParams.get("routeCount"));
244
  const initialDistance = parseNumberParam(searchParams.get("targetDistanceKm"));
245
  const initialSelectedTopK = parseNumberParam(searchParams.get("selectedTopK"));
246
- const selectedLabel = useMemo(
247
- () => normalizeLabel(searchParams.get("label"), AVAILABLE_LABELS) ?? "cloud",
248
- [searchParams]
249
- );
250
 
251
- const [source, setSource] = useState<"place" | "current">(initialSource);
252
  const [place, setPlace] = useState(initialPlace);
253
- const [latitude, setLatitude] = useState<number | null>(
254
- Number.isFinite(initialLatitude) ? initialLatitude : null
255
- );
256
- const [longitude, setLongitude] = useState<number | null>(
257
- Number.isFinite(initialLongitude) ? initialLongitude : null
258
- );
259
- const [radiusKm, setRadiusKm] = useState(
260
- Number.isFinite(initialRadiusKm) ? initialRadiusKm : 5
261
- );
262
- const [routeCountInput, setRouteCountInput] = useState(
263
- Number.isFinite(initialCount) ? String(initialCount) : "8"
264
- );
265
- const [targetDistanceInput, setTargetDistanceInput] = useState(
266
- Number.isFinite(initialDistance) ? String(initialDistance) : "6"
267
- );
268
- const [selectedTopK] = useState(
269
- Number.isFinite(initialSelectedTopK) ? initialSelectedTopK : 5
270
- );
271
- const [activeFilter, setActiveFilter] = useState<string>(ALL_FILTER_KEY);
272
- const [isFilterOpen, setIsFilterOpen] = useState(false);
273
  const [loading, setLoading] = useState(false);
274
  const [error, setError] = useState<string | null>(null);
275
  const [result, setResult] = useState<OSMClipResult | null>(null);
276
  const [savedRouteIds, setSavedRouteIds] = useState<Set<string>>(new Set());
277
 
278
- const labels = useMemo(() => [...AVAILABLE_LABELS], []);
279
-
280
- const openRouteDetail = (
281
- route: GeneratedRoute,
282
- routeNumber: number,
283
- runKey: string,
284
- placeText: string,
285
- baseParams: URLSearchParams,
286
- intent: "run" | "simulate",
287
- routeLabel: string
288
- ) => {
289
- if (!route.route_coords || route.route_coords.length < 2) {
290
- return;
291
- }
292
-
293
- const detailId = createGeneratedRouteDetailId(route, placeText, routeNumber);
294
- const record: GeneratedRouteDetailRecord = {
295
- id: detailId,
296
- routeNumber,
297
- runKey,
298
- place: placeText,
299
- routeLabel,
300
- modelTopLabel: getModelTopRouteLabel(route),
301
- title: `#${routeNumber} ${formatLabel(routeLabel)} 코스`,
302
- distanceText: `${route.length_km.toFixed(2)}km`,
303
- runParams: baseParams.toString(),
304
- route,
305
- createdAt: new Date().toISOString(),
306
- };
307
-
308
- upsertGeneratedRouteDetail(record);
309
- router.push(`/route-results/${detailId}?intent=${intent}`);
310
- };
311
 
312
  const fetchRoutes = async () => {
313
- const parsedRouteCount = parseNumericInput(routeCountInput);
314
- if (parsedRouteCount === null || parsedRouteCount < 5 || parsedRouteCount > 100) {
315
- setError("경로 수는 5~100 사이 숫자로 입력해 주세요.");
 
 
316
  return;
317
  }
318
-
319
- const parsedTargetDistance = parseNumericInput(targetDistanceInput);
320
- if (
321
- parsedTargetDistance === null ||
322
- parsedTargetDistance < 1 ||
323
- parsedTargetDistance > 30
324
- ) {
325
- setError("거리(km)는 1~30 사이 숫자로 입력해 주세요.");
326
  return;
327
  }
328
 
@@ -330,25 +163,24 @@ function RouteResultsPageContent() {
330
  setError(null);
331
 
332
  const loadingId = startNavigationLoading({
333
- title: "경로 생성 중입니다",
334
- steps: ROUTE_GENERATE_LOADING_STEPS,
335
  visual: "mascot-progress",
336
  expectedDurationMs: 90000,
337
  });
338
-
339
  updateNavigationLoading({
340
  id: loadingId,
341
  progress: 12,
342
  stepIndex: 0,
343
- step: ROUTE_GENERATE_LOADING_STEPS[0],
344
  });
345
 
346
  try {
347
  updateNavigationLoading({
348
  id: loadingId,
349
- progress: 42,
350
  stepIndex: 1,
351
- step: ROUTE_GENERATE_LOADING_STEPS[1],
352
  });
353
 
354
  const response = await fetch(`${apiBase}/api/v1/ai/routes/generate`, {
@@ -360,30 +192,31 @@ function RouteResultsPageContent() {
360
  latitude,
361
  longitude,
362
  radiusKm,
363
- routeCount: parsedRouteCount,
364
- targetDistanceKm: parsedTargetDistance,
365
- labels,
366
  selectedLabel,
367
  selectedTopK,
368
  }),
369
  });
370
 
 
 
 
 
 
 
 
371
  const data = await response.json();
372
  if (!response.ok) {
373
- throw new Error(data?.detail || data?.error || "OSM-CLIP 요청 실패");
374
  }
375
 
 
376
  setResult(data as OSMClipResult);
377
-
378
- updateNavigationLoading({
379
- id: loadingId,
380
- progress: 100,
381
- stepIndex: 2,
382
- step: ROUTE_GENERATE_LOADING_STEPS[2],
383
- });
384
- } catch (e) {
385
  setResult(null);
386
- setError(e instanceof Error ? e.message : String(e));
387
  } finally {
388
  endNavigationLoading(loadingId);
389
  setLoading(false);
@@ -392,57 +225,32 @@ function RouteResultsPageContent() {
392
 
393
  useEffect(() => {
394
  if (typeof window === "undefined") {
395
- fetchRoutes();
396
  return;
397
  }
398
 
399
  const cached = window.sessionStorage.getItem(cacheKey);
400
  if (!cached) {
401
- fetchRoutes();
402
  return;
403
  }
404
 
405
  try {
406
- const parsed = JSON.parse(cached) as OSMClipResult;
407
- setResult(parsed);
408
  } catch {
409
  window.sessionStorage.removeItem(cacheKey);
410
- fetchRoutes();
411
  }
412
  // eslint-disable-next-line react-hooks/exhaustive-deps
413
  }, [cacheKey]);
414
 
415
  useEffect(() => {
416
- if (!result || typeof window === "undefined") {
417
- return;
418
- }
419
  window.sessionStorage.setItem(cacheKey, JSON.stringify(result));
420
  }, [cacheKey, result]);
421
 
422
- const filterOptions = useMemo(() => {
423
- const modelTopLabels = result?.routes.map((route) => getModelTopRouteLabel(route)) ?? [];
424
- const sourceLabels = [...labels, ...(result?.labels ?? []), selectedLabel, ...modelTopLabels];
425
- const labelSet = new Set<string>();
426
- for (const label of sourceLabels) {
427
- const normalized = normalizeLabelKey(label);
428
- if (normalized) {
429
- labelSet.add(normalized);
430
- }
431
- }
432
- labelSet.add(selectedLabel);
433
- return [ALL_FILTER_KEY, ...Array.from(labelSet)];
434
- }, [labels, result?.labels, result?.routes, selectedLabel]);
435
-
436
  useEffect(() => {
437
- if (!filterOptions.includes(activeFilter)) {
438
- setActiveFilter(ALL_FILTER_KEY);
439
- }
440
- }, [activeFilter, filterOptions]);
441
-
442
- useEffect(() => {
443
- if (typeof window === "undefined") {
444
- return;
445
- }
446
 
447
  const syncSavedRouteIds = () => {
448
  const saved = readSavedRoutesFromStorage();
@@ -451,61 +259,67 @@ function RouteResultsPageContent() {
451
 
452
  syncSavedRouteIds();
453
  window.addEventListener("storage", syncSavedRouteIds);
454
- return () => {
455
- window.removeEventListener("storage", syncSavedRouteIds);
456
- };
457
  }, []);
458
 
459
- const filteredRoutes = useMemo(() => {
460
- if (!result) return [];
461
- if (activeFilter === ALL_FILTER_KEY) return result.routes;
462
- return result.routes.filter(
463
- (route) => normalizeLabelKey(getModelTopRouteLabel(route)) === activeFilter
464
- );
465
- }, [result, activeFilter]);
466
-
467
- const activeFilterLabel =
468
- activeFilter === ALL_FILTER_KEY ? "전체" : formatLabel(activeFilter);
469
-
470
- const bestClipOnly = useMemo(() => {
471
- if (!result) return null;
472
- const best = result.best_route;
473
- const score =
474
- typeof best.selected_label_clip === "number"
475
- ? best.selected_label_clip
476
- : typeof best.top1_clip === "number"
477
- ? best.top1_clip
478
- : null;
479
- const label = best.selected_label ?? best.top1;
480
- if (score === null) return null;
481
- return { label, score };
482
- }, [result]);
483
-
484
- const handleToggleSavedRoute = (route: GeneratedRoute, routeNumber: number, placeText: string) => {
485
- if (!route.route_coords || route.route_coords.length < 2) {
486
- return;
487
- }
488
 
489
- const routeId = createSavedRouteId(route, placeText);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  if (savedRouteIds.has(routeId)) {
491
  const next = removeSavedRoute(routeId);
492
  setSavedRouteIds(new Set(next.map((item) => item.id)));
493
  return;
494
  }
495
 
496
- const clipOnlyScore =
497
- typeof route.selected_label_clip === "number"
498
- ? route.selected_label_clip
499
- : typeof route.top1_clip === "number"
500
- ? route.top1_clip
501
- : null;
502
  const record: SavedRouteRecord = {
503
  id: routeId,
504
- title: `#${routeNumber} ${formatLabel(route.top1)} 코스`,
505
- place: placeText,
506
  distanceKm: route.length_km,
507
- label: route.selected_label ?? route.top1,
508
- clipScore: clipOnlyScore,
509
  center: route.center ?? route.route_coords[0] ?? null,
510
  routeCoords: route.route_coords,
511
  createdAt: new Date().toISOString(),
@@ -514,407 +328,144 @@ function RouteResultsPageContent() {
514
  setSavedRouteIds(new Set(next.map((item) => item.id)));
515
  };
516
 
 
 
 
517
  return (
518
- <main className="min-h-screen bg-white">
519
  <TopBar />
520
 
521
- <section className="mx-auto w-full max-w-6xl px-6 py-8 sm:px-10">
522
- <div className="rounded-2xl border border-black/10 p-4 sm:p-6">
523
- <div className="grid gap-3 sm:grid-cols-[1fr_120px_120px_auto] sm:items-end">
524
- <div className="flex flex-col gap-2">
525
- <label className="text-sm font-medium">지역(Place)</label>
526
- <input
527
- value={place}
528
- onChange={(e) => setPlace(e.target.value)}
529
- disabled={source === "current"}
530
- className="h-10 rounded-lg border border-black/15 px-3 text-sm"
531
- />
532
- </div>
533
-
534
- <div className="flex flex-col gap-2">
535
- <label className="text-sm font-medium">경로 수</label>
536
- <input
537
- type="number"
538
- min={5}
539
- max={100}
540
- value={routeCountInput}
541
- onChange={(e) => setRouteCountInput(e.target.value)}
542
- className="h-10 rounded-lg border border-black/15 px-3 text-sm"
543
- />
544
- </div>
545
-
546
- <div className="flex flex-col gap-2">
547
- <label className="text-sm font-medium">거리(km)</label>
548
- <input
549
- type="number"
550
- min={1}
551
- max={30}
552
- step={0.5}
553
- value={targetDistanceInput}
554
- onChange={(e) => setTargetDistanceInput(e.target.value)}
555
- className="h-10 rounded-lg border border-black/15 px-3 text-sm"
556
- />
557
- </div>
558
-
559
- <button
560
- type="button"
561
- onClick={fetchRoutes}
562
- disabled={loading}
563
- className="h-10 rounded-full bg-black px-5 text-sm font-semibold text-white disabled:opacity-60"
564
- >
565
- {loading ? (
566
- <span className="inline-flex items-center gap-2">
567
- <img
568
- src="/loading/login-running-dog.svg"
569
- alt=""
570
- aria-hidden="true"
571
- className="h-4 w-4 rounded-full object-cover"
572
- />
573
- 생성 중...
574
- </span>
575
- ) : (
576
- "다시 생성"
577
- )}
578
  </button>
579
-
580
- {source === "current" && (
581
- <>
582
- <div className="flex flex-col gap-2">
583
- <label className="text-sm font-medium">위도</label>
584
- <input
585
- type="number"
586
- value={latitude ?? ""}
587
- onChange={(e) => setLatitude(Number(e.target.value))}
588
- className="h-10 rounded-lg border border-black/15 px-3 text-sm"
589
- />
590
- </div>
591
-
592
- <div className="flex flex-col gap-2">
593
- <label className="text-sm font-medium">경도</label>
594
- <input
595
- type="number"
596
- value={longitude ?? ""}
597
- onChange={(e) => setLongitude(Number(e.target.value))}
598
- className="h-10 rounded-lg border border-black/15 px-3 text-sm"
599
- />
600
- </div>
601
-
602
- <div className="flex flex-col gap-2">
603
- <label className="text-sm font-medium">반경(km)</label>
604
- <input
605
- type="number"
606
- min={0.5}
607
- max={10}
608
- step={0.5}
609
- value={radiusKm}
610
- onChange={(e) => setRadiusKm(Number(e.target.value || 5))}
611
- className="h-10 rounded-lg border border-black/15 px-3 text-sm"
612
- />
613
- </div>
614
- </>
615
- )}
616
  </div>
617
 
618
- {error && <p className="mt-4 text-sm text-red-600">오류: {error}</p>}
619
- </div>
620
-
621
- {result && (
622
- <div className="relative z-10 mt-5 space-y-4 overflow-visible">
623
- <div className="rounded-2xl border border-black/10 bg-[#fafafa] p-4 sm:p-5">
624
- <p className="text-2xl font-bold text-black">
625
- {result.place.split(",")[0]} 주변 {(result.radius_km ?? radiusKm).toFixed(1)}km
626
- </p>
627
-
628
- <p className="mt-1 text-sm text-[#666]">
629
- 생성 {result.generated_count}/{result.requested_count}
630
- {result.candidate_count ? ` (후보 ${result.candidate_count})` : ""} | 목표 거리{" "}
631
- {result.target_distance_km}km
632
- {bestClipOnly
633
- ? ` | CLIP 단독 ${formatLabel(bestClipOnly.label)}: ${bestClipOnly.score.toFixed(6)}`
634
- : ""}
635
- </p>
636
-
637
- <p className="mt-1 text-xs text-[#666]">
638
- 선택 모양: {formatLabel(selectedLabel)} | 표시 개수:{" "}
639
- {result.selected_top_k ?? selectedTopK}
640
- </p>
641
-
642
- <details className="mt-2">
643
- <summary className="cursor-pointer text-xs text-[#666]">
644
- 분류 상세(개발용)
645
- </summary>
646
- <p className="mt-1 text-xs text-[#666]">
647
- vector_source: {result.clip_source ?? "unknown"} | weights: clip{" "}
648
- {result.weights?.clip ?? "-"}, shape {result.weights?.shape ?? "-"}, gatv2{" "}
649
- {result.weights?.gatv2 ?? "-"}
650
- </p>
651
- <p className="mt-1 text-xs text-[#666]">
652
- clip_components: image{" "}
653
- {result.clip_components?.image_proto_weight ?? "-"}, text{" "}
654
- {result.clip_components?.text_proto_weight ?? "-"} (
655
- {result.clip_components?.text_proto_source ?? "none"}) | representative top-k{" "}
656
- {result.clip_components?.representative_top_k ?? "-"}
657
- </p>
658
- </details>
659
- </div>
660
 
661
- <div className="border-b border-black/10 pb-3 overflow-visible">
662
- <div className="relative w-full max-w-xs overflow-visible">
663
- <button
664
- type="button"
665
- onClick={() => setIsFilterOpen((prev) => !prev)}
666
- className="flex h-11 w-full items-center justify-between rounded-xl border border-black/10 bg-white px-4 text-sm font-medium text-black shadow-sm transition hover:bg-[#fafafa]"
667
- >
668
- <span>{activeFilterLabel}</span>
669
- <span
670
- className={`text-[#666] transition-transform duration-200 ${
671
- isFilterOpen ? "rotate-180" : ""
672
- }`}
673
- >
674
- ▼
675
- </span>
676
- </button>
677
-
678
- {isFilterOpen && (
679
- <div className="absolute left-0 top-[calc(100%+8px)] z-[1000] w-full overflow-hidden rounded-xl border border-black/10 bg-white shadow-lg">
680
- {filterOptions.map((label) => {
681
- const isActive = activeFilter === label;
682
- const displayLabel =
683
- label === ALL_FILTER_KEY ? "전체" : formatLabel(label);
684
-
685
- return (
686
- <button
687
- key={label}
688
- type="button"
689
- onClick={() => {
690
- setActiveFilter(label);
691
- setIsFilterOpen(false);
692
- }}
693
- className={`flex h-11 w-full items-center justify-between px-4 text-sm transition ${
694
- isActive
695
- ? "bg-[#f5f5f5] font-medium text-black"
696
- : "bg-white text-[#444] hover:bg-[#f8f8f8]"
697
- }`}
698
- >
699
- <span>{displayLabel}</span>
700
- {isActive && <span className="text-[#666]">●</span>}
701
- </button>
702
- );
703
- })}
704
- </div>
705
- )}
706
  </div>
707
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
708
 
709
- <div className="grid gap-4 sm:grid-cols-2">
710
- {filteredRoutes.map((route, idx) => {
711
- const routeLabel = getPrimaryRouteLabel(route);
712
- const modelTopLabel = getModelTopRouteLabel(route);
713
- const routeNumber = result.routes.indexOf(route) + 1;
714
- const runKey = `route_${result.place}_${routeNumber}`.replace(
715
- /[^a-zA-Z0-9_]/g,
716
- "_"
717
- );
718
- const params = new URLSearchParams({
719
- animal: routeLabel,
720
- distance: `${route.length_km.toFixed(2)}km`,
721
- place: result.place,
722
- image: route.image_path,
723
- runKey,
724
- });
725
- const hasRouteCoords = !!(
726
- route.route_coords && route.route_coords.length > 1
727
- );
728
- const savedRouteId = createSavedRouteId(route, result.place);
729
- const isBookmarked = savedRouteIds.has(savedRouteId);
730
-
731
- return (
732
- <div
733
- key={`${route.image_path}-${idx}`}
734
- className="overflow-hidden rounded-2xl border border-black/10 bg-white shadow-sm"
735
- >
736
- <div className="relative z-0 h-56 w-full overflow-hidden bg-[#f5f5f5]">
737
- {hasRouteCoords ? (
738
- <LeafletMap
739
- center={route.center ?? route.route_coords![0]}
740
- route={route.route_coords}
741
- className="h-full w-full"
742
- zoom={14}
743
- />
744
- ) : (
745
- <div className="flex h-full w-full items-center justify-center px-4 text-center text-xs text-[#666]">
746
- 지도 좌표가 없어 윤곽만 생성되었습니다. 다시 생성해 주세요.
747
- </div>
748
- )}
749
- </div>
750
-
751
- <div className="hidden">
752
- <p className="mb-1 text-[11px] text-[#666]">
753
- 기반 QuickDraw (
754
- {formatLabel(route.quickdraw_preview_label ?? routeLabel)})
755
- {typeof route.quickdraw_preview_similarity === "number"
756
- ? ` · 유사도 ${route.quickdraw_preview_similarity.toFixed(6)}`
757
- : ""}
758
- </p>
759
-
760
- {route.quickdraw_preview_data_url ||
761
- route.quickdraw_preview_image_path ? (
762
- <div className="flex items-center gap-2">
763
- <img
764
- src={
765
- route.quickdraw_preview_data_url ??
766
- route.quickdraw_preview_image_path ??
767
- ""
768
- }
769
- alt="quickdraw preview"
770
- className="h-16 w-16 rounded border border-black/10 bg-white object-contain p-1"
771
- />
772
- </div>
773
- ) : (
774
- <p className="text-[11px] text-[#999]">
775
- 기반 이미지를 찾지 못했습니다.
776
- </p>
777
- )}
778
  </div>
779
 
780
- <div className="hidden">
781
- <p className="text-[11px] text-[#666]">
782
- AI 판독 기준: {formatLabel(routeLabel)}
783
- {modelTopLabel !== routeLabel
784
- ? ` · 모델 분류 ${formatLabel(modelTopLabel)}`
785
- : ""}
786
- {typeof route.selected_label_gatv2 === "number" &&
787
- route.selected_label_gatv2 > 0
788
- ? ` · GATv2 ${route.selected_label_gatv2.toFixed(3)}`
789
- : ""}
790
- </p>
 
 
791
  </div>
792
 
793
- <div className="p-3">
794
- {(() => {
795
- const clipOnlyScore =
796
- typeof route.selected_label_clip === "number"
797
- ? route.selected_label_clip
798
- : typeof route.top1_clip === "number"
799
- ? route.top1_clip
800
- : null;
801
- const clipOnlyLabel =
802
- typeof route.selected_label_clip === "number" &&
803
- route.selected_label
804
- ? route.selected_label
805
- : routeLabel;
806
- const aiShapeScore = getAiShapeScore(route);
807
- const gatv2Score =
808
- typeof route.selected_label_gatv2 === "number"
809
- ? route.selected_label_gatv2
810
- : typeof route.top1_gatv2 === "number"
811
- ? route.top1_gatv2
812
- : null;
813
-
814
- return (
815
- <>
816
- <div className="flex items-start justify-between gap-3">
817
- <p className="text-[28px] font-semibold leading-none text-black">
818
- #{routeNumber} {formatLabel(routeLabel)} 코스
819
- </p>
820
- <button
821
- type="button"
822
- disabled={!hasRouteCoords}
823
- onClick={() => {
824
- handleToggleSavedRoute(route, routeNumber, result.place);
825
- }}
826
- aria-label={
827
- isBookmarked
828
- ? "저장된 루트에서 제거"
829
- : "저장된 루트에 추가"
830
- }
831
- title={
832
- isBookmarked
833
- ? "저장된 루트에서 제거"
834
- : "저장된 루트에 추가"
835
- }
836
- className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-black/10 bg-white disabled:cursor-not-allowed disabled:opacity-50"
837
- >
838
- <BookmarkIcon filled={isBookmarked} />
839
- </button>
840
- </div>
841
- <p className="mt-2 text-sm text-[#666]">
842
- 거리 {route.length_km.toFixed(2)}km
843
- </p>
844
- <p className="mt-1 text-sm text-[#666]">
845
- CLIP 단독: {formatLabel(clipOnlyLabel)}{" "}
846
- {clipOnlyScore !== null ? clipOnlyScore.toFixed(6) : "-"}
847
- </p>
848
- <p className="mt-1 text-sm font-medium text-black">
849
- AI 형태 점수: {aiShapeScore !== null ? `${aiShapeScore}점 / 100` : "-"}
850
- {gatv2Score !== null && gatv2Score > 0
851
- ? ` | GATv2 ${gatv2Score.toFixed(3)}`
852
- : ""}
853
- </p>
854
- <div className="mt-2 flex flex-wrap gap-1">
855
- <span className="rounded-full bg-black/5 px-2 py-0.5 text-[11px] text-[#444]">
856
- {formatLabel(routeLabel)}
857
- </span>
858
- </div>
859
- </>
860
- );
861
- })()}
862
-
863
- <div className="mt-3 grid grid-cols-2 gap-2">
864
- <button
865
- type="button"
866
- disabled={!hasRouteCoords}
867
- onClick={() => {
868
- openRouteDetail(
869
- route,
870
- routeNumber,
871
- runKey,
872
- result.place,
873
- params,
874
- "run",
875
- routeLabel
876
- );
877
- }}
878
- className="inline-flex h-9 w-full items-center justify-center rounded-full bg-black text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
879
- >
880
- 코스 상세
881
- </button>
882
-
883
- <button
884
- type="button"
885
- disabled={!hasRouteCoords}
886
- onClick={() => {
887
- openRouteDetail(
888
- route,
889
- routeNumber,
890
- runKey,
891
- result.place,
892
- params,
893
- "simulate",
894
- routeLabel
895
- );
896
- }}
897
- className="inline-flex h-9 w-full items-center justify-center rounded-full border border-[#c11f1f] bg-[#fff4f4] text-sm font-semibold text-[#a01919] disabled:cursor-not-allowed disabled:opacity-50"
898
- >
899
- 시뮬레이션 상세
900
- </button>
901
- </div>
902
  </div>
903
  </div>
904
- );
905
- })}
906
- </div>
907
-
908
- {filteredRoutes.length === 0 && (
909
- <div className="rounded-xl border border-dashed border-black/20 p-6 text-center text-sm text-[#666]">
910
- {activeFilter === ALL_FILTER_KEY
911
- ? "표시할 경로가 없습니다. 다시 생성해 주세요."
912
- : `선택한 라벨(${formatLabel(activeFilter)})에 해당하는 경로가 없습니다. 다시 생성해 주세요.`}
913
- </div>
914
- )}
915
- </div>
916
- )}
917
- </section>
918
  </main>
919
  );
920
  }
 
4
  import { useRouter, useSearchParams } from "next/navigation";
5
  import TopBar from "../_components/TopBar";
6
  import LeafletMap from "../_components/LeafletMap";
7
+ import { PageContainer, PageHeader, SectionCard, EmptyState } from "../_components/RunShapeUI";
8
+ import {
9
+ endNavigationLoading,
10
+ startNavigationLoading,
11
+ updateNavigationLoading,
12
+ } from "../_components/navigationLoadingBus";
13
  import {
14
  readSavedRoutesFromStorage,
15
  removeSavedRoute,
16
+ type SavedRouteRecord,
17
  upsertSavedRoute,
18
  } from "../_utils/savedRoutes";
19
  import {
20
  createGeneratedRouteDetailId,
21
+ formatGeneratedRouteLabel,
22
+ getGeneratedRouteAiShapeScore,
23
+ getModelTopGeneratedRouteLabel,
24
+ getPrimaryGeneratedRouteLabel,
25
  upsertGeneratedRouteDetail,
26
+ type GeneratedRoute,
27
  type GeneratedRouteDetailRecord,
28
  } from "../_utils/generatedRouteDetails";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  type OSMClipResult = {
31
  place: string;
 
35
  generated_count: number;
36
  target_distance_km: number;
37
  radius_km?: number;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  labels: string[];
39
  selected_label?: string | null;
40
  selected_top_k?: number | null;
 
43
  };
44
 
45
  const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
46
+ const AVAILABLE_LABELS = ["cup", "face", "cloud", "ice_cream", "t_shirt", "fish", "apple", "candle"];
47
+ const ROUTE_GENERATION_STEPS = [
48
+ "선택한 지역을 확인하고 있어요.",
49
+ "지도 위에 달릴 수 있는 경로를 만들고 있어요.",
50
+ "모양이 잘 보이는 경로를 정리하고 있어요.",
 
 
 
 
 
 
 
 
 
 
 
51
  ];
52
 
53
+ function parseNumberParam(value: string | null): number {
54
+ if (!value || value.trim() === "") return Number.NaN;
 
 
55
  return Number(value);
56
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
+ function normalizeLabelKey(raw: string): string {
59
+ return raw.trim().toLowerCase().replace(/[\s-]+/g, "_");
60
+ }
 
 
 
 
 
 
 
 
 
 
61
 
62
+ function hashText(input: string): string {
63
  let hash = 5381;
64
  for (let index = 0; index < input.length; index += 1) {
65
  hash = (hash * 33) ^ input.charCodeAt(index);
66
  }
67
  return (hash >>> 0).toString(36);
68
+ }
69
 
70
+ function createSavedRouteId(route: GeneratedRoute, placeText: string): string {
71
  const coords = (route.route_coords ?? [])
72
  .map(([lat, lng]) => `${lat.toFixed(5)},${lng.toFixed(5)}`)
73
  .join("|");
74
+ return `saved_${hashText(`${placeText}|${route.top1}|${route.length_km.toFixed(3)}|${coords}`)}`;
75
+ }
76
+
77
+ function formatDistance(distanceKm: number): string {
78
+ if (!Number.isFinite(distanceKm) || distanceKm <= 0) return "-";
79
+ return `${distanceKm.toFixed(distanceKm >= 10 ? 1 : 2)}km`;
80
+ }
81
+
82
+ function formatEstimatedTime(distanceKm: number): string {
83
+ if (!Number.isFinite(distanceKm) || distanceKm <= 0) return "-";
84
+ const minutes = Math.max(1, Math.round((distanceKm / 6.5) * 60));
85
+ if (minutes < 60) return `약 ${minutes}분`;
86
+ const hours = Math.floor(minutes / 60);
87
+ const rest = minutes % 60;
88
+ return rest > 0 ? `약 ${hours}시간 ${rest}분` : `약 ${hours}시간`;
89
+ }
90
+
91
+ function getCompletionLabel(score: number | null): string {
92
+ if (score === null) return "형태 확인 필요";
93
+ if (score >= 78) return "형태 완성도 높음";
94
+ if (score >= 58) return "모양이 비교적 선명한 경로";
95
+ return "일부 구간에서 모양이 달라질 수 있음";
96
+ }
97
+
98
+ function getRouteTitle(routeNumber: number, routeLabel: string): string {
99
+ return `${formatGeneratedRouteLabel(routeLabel)} 코스 ${routeNumber}`;
100
+ }
101
 
102
  function BookmarkIcon({ filled }: { filled: boolean }) {
103
  return (
 
105
  width="18"
106
  height="18"
107
  viewBox="0 0 24 24"
 
108
  fill={filled ? "currentColor" : "none"}
109
  stroke="currentColor"
110
  strokeWidth="1.8"
 
120
  function RouteResultsPageContent() {
121
  const searchParams = useSearchParams();
122
  const router = useRouter();
 
 
 
 
123
 
124
+ const cacheKey = useMemo(() => `route-results:${searchParams.toString()}`, [searchParams]);
125
  const initialPlace = searchParams.get("place") || "Gangnam-gu, Seoul, South Korea";
126
  const initialSource = (searchParams.get("source") as "place" | "current" | null) ?? "place";
127
  const initialLatitude = parseNumberParam(searchParams.get("latitude"));
 
130
  const initialCount = parseNumberParam(searchParams.get("routeCount"));
131
  const initialDistance = parseNumberParam(searchParams.get("targetDistanceKm"));
132
  const initialSelectedTopK = parseNumberParam(searchParams.get("selectedTopK"));
133
+ const selectedLabel = normalizeLabelKey(searchParams.get("label") || "cloud");
 
 
 
134
 
 
135
  const [place, setPlace] = useState(initialPlace);
136
+ const [routeCountInput, setRouteCountInput] = useState(Number.isFinite(initialCount) ? String(initialCount) : "8");
137
+ const [targetDistanceInput, setTargetDistanceInput] = useState(Number.isFinite(initialDistance) ? String(initialDistance) : "5");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  const [loading, setLoading] = useState(false);
139
  const [error, setError] = useState<string | null>(null);
140
  const [result, setResult] = useState<OSMClipResult | null>(null);
141
  const [savedRouteIds, setSavedRouteIds] = useState<Set<string>>(new Set());
142
 
143
+ const source = initialSource;
144
+ const latitude = Number.isFinite(initialLatitude) ? initialLatitude : null;
145
+ const longitude = Number.isFinite(initialLongitude) ? initialLongitude : null;
146
+ const radiusKm = Number.isFinite(initialRadiusKm) ? initialRadiusKm : 5;
147
+ const selectedTopK = Number.isFinite(initialSelectedTopK) ? initialSelectedTopK : 3;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
  const fetchRoutes = async () => {
150
+ const routeCount = Number(routeCountInput);
151
+ const targetDistanceKm = Number(targetDistanceInput);
152
+
153
+ if (!Number.isFinite(routeCount) || routeCount < 5 || routeCount > 100) {
154
+ setError("경로 수는 5~100 사이로 입력해주세요.");
155
  return;
156
  }
157
+ if (!Number.isFinite(targetDistanceKm) || targetDistanceKm < 1 || targetDistanceKm > 30) {
158
+ setError("목표 거리는 1~30km 사이로 입력해주세요.");
 
 
 
 
 
 
159
  return;
160
  }
161
 
 
163
  setError(null);
164
 
165
  const loadingId = startNavigationLoading({
166
+ title: "추천 경로를 찾고 있어요",
167
+ steps: ROUTE_GENERATION_STEPS,
168
  visual: "mascot-progress",
169
  expectedDurationMs: 90000,
170
  });
 
171
  updateNavigationLoading({
172
  id: loadingId,
173
  progress: 12,
174
  stepIndex: 0,
175
+ step: ROUTE_GENERATION_STEPS[0],
176
  });
177
 
178
  try {
179
  updateNavigationLoading({
180
  id: loadingId,
181
+ progress: 36,
182
  stepIndex: 1,
183
+ step: ROUTE_GENERATION_STEPS[1],
184
  });
185
 
186
  const response = await fetch(`${apiBase}/api/v1/ai/routes/generate`, {
 
192
  latitude,
193
  longitude,
194
  radiusKm,
195
+ routeCount,
196
+ targetDistanceKm,
197
+ labels: AVAILABLE_LABELS,
198
  selectedLabel,
199
  selectedTopK,
200
  }),
201
  });
202
 
203
+ updateNavigationLoading({
204
+ id: loadingId,
205
+ progress: 82,
206
+ stepIndex: 2,
207
+ step: ROUTE_GENERATION_STEPS[2],
208
+ });
209
+
210
  const data = await response.json();
211
  if (!response.ok) {
212
+ throw new Error(data?.detail || data?.error || "경로를 찾지 못했습니다.");
213
  }
214
 
215
+ updateNavigationLoading({ id: loadingId, progress: 100 });
216
  setResult(data as OSMClipResult);
217
+ } catch (caught) {
 
 
 
 
 
 
 
218
  setResult(null);
219
+ setError(caught instanceof Error ? caught.message : String(caught));
220
  } finally {
221
  endNavigationLoading(loadingId);
222
  setLoading(false);
 
225
 
226
  useEffect(() => {
227
  if (typeof window === "undefined") {
228
+ void fetchRoutes();
229
  return;
230
  }
231
 
232
  const cached = window.sessionStorage.getItem(cacheKey);
233
  if (!cached) {
234
+ void fetchRoutes();
235
  return;
236
  }
237
 
238
  try {
239
+ setResult(JSON.parse(cached) as OSMClipResult);
 
240
  } catch {
241
  window.sessionStorage.removeItem(cacheKey);
242
+ void fetchRoutes();
243
  }
244
  // eslint-disable-next-line react-hooks/exhaustive-deps
245
  }, [cacheKey]);
246
 
247
  useEffect(() => {
248
+ if (!result || typeof window === "undefined") return;
 
 
249
  window.sessionStorage.setItem(cacheKey, JSON.stringify(result));
250
  }, [cacheKey, result]);
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  useEffect(() => {
253
+ if (typeof window === "undefined") return;
 
 
 
 
 
 
 
 
254
 
255
  const syncSavedRouteIds = () => {
256
  const saved = readSavedRoutesFromStorage();
 
259
 
260
  syncSavedRouteIds();
261
  window.addEventListener("storage", syncSavedRouteIds);
262
+ return () => window.removeEventListener("storage", syncSavedRouteIds);
 
 
263
  }, []);
264
 
265
+ const openRouteDetail = (
266
+ route: GeneratedRoute,
267
+ routeNumber: number,
268
+ intent: "run" | "simulate",
269
+ ) => {
270
+ if (!route.route_coords || route.route_coords.length < 2 || !result) return;
271
+
272
+ const routeLabel = getPrimaryGeneratedRouteLabel(route);
273
+ const runKey = `route_${result.place}_${routeNumber}`.replace(/[^a-zA-Z0-9_]/g, "_");
274
+ const params = new URLSearchParams({
275
+ animal: routeLabel,
276
+ distance: `${route.length_km.toFixed(2)}km`,
277
+ place: result.place,
278
+ image: route.image_path,
279
+ runKey,
280
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
282
+ const detailId = createGeneratedRouteDetailId(route, result.place, routeNumber);
283
+ const record: GeneratedRouteDetailRecord = {
284
+ id: detailId,
285
+ routeNumber,
286
+ runKey,
287
+ place: result.place,
288
+ routeLabel,
289
+ modelTopLabel: getModelTopGeneratedRouteLabel(route),
290
+ title: getRouteTitle(routeNumber, routeLabel),
291
+ distanceText: formatDistance(route.length_km),
292
+ runParams: params.toString(),
293
+ route,
294
+ createdAt: new Date().toISOString(),
295
+ };
296
+
297
+ upsertGeneratedRouteDetail(record);
298
+ const detailParams = new URLSearchParams({
299
+ intent,
300
+ back: searchParams.toString(),
301
+ });
302
+ router.push(`/route-results/${detailId}?${detailParams.toString()}`);
303
+ };
304
+
305
+ const handleToggleSavedRoute = (route: GeneratedRoute, routeNumber: number) => {
306
+ if (!route.route_coords || route.route_coords.length < 2 || !result) return;
307
+
308
+ const routeId = createSavedRouteId(route, result.place);
309
  if (savedRouteIds.has(routeId)) {
310
  const next = removeSavedRoute(routeId);
311
  setSavedRouteIds(new Set(next.map((item) => item.id)));
312
  return;
313
  }
314
 
315
+ const routeLabel = getPrimaryGeneratedRouteLabel(route);
 
 
 
 
 
316
  const record: SavedRouteRecord = {
317
  id: routeId,
318
+ title: getRouteTitle(routeNumber, routeLabel),
319
+ place: result.place,
320
  distanceKm: route.length_km,
321
+ label: routeLabel,
322
+ clipScore: null,
323
  center: route.center ?? route.route_coords[0] ?? null,
324
  routeCoords: route.route_coords,
325
  createdAt: new Date().toISOString(),
 
328
  setSavedRouteIds(new Set(next.map((item) => item.id)));
329
  };
330
 
331
+ const routes = result?.routes ?? [];
332
+ const placeTitle = result?.place.split(",")[0] ?? place.split(",")[0];
333
+
334
  return (
335
+ <main className="rts-page">
336
  <TopBar />
337
 
338
+ <PageContainer className="flex flex-col gap-6">
339
+ <PageHeader
340
+ title="추천 경로"
341
+ subtitle={`${placeTitle} 주변에서 ${formatGeneratedRouteLabel(selectedLabel)} 모양에 가까운 경로를 찾고 있어요.`}
342
+ backHref="/lobby"
343
+ actions={
344
+ <button type="button" onClick={() => void fetchRoutes()} disabled={loading} className="rts-secondary-button h-10">
345
+ {loading ? "찾는 중" : "다시 찾기"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  </button>
347
+ }
348
+ />
349
+
350
+ <SectionCard>
351
+ <div className="grid gap-3 sm:grid-cols-[1fr_120px_120px] sm:items-end">
352
+ <label className="grid gap-2">
353
+ <span className="rts-label">지역</span>
354
+ <input value={place} onChange={(event) => setPlace(event.target.value)} className="rts-input" />
355
+ </label>
356
+ <label className="grid gap-2">
357
+ <span className="rts-label">경로 수</span>
358
+ <input type="number" min={5} max={100} value={routeCountInput} onChange={(event) => setRouteCountInput(event.target.value)} className="rts-input" />
359
+ </label>
360
+ <label className="grid gap-2">
361
+ <span className="rts-label">목표 거리</span>
362
+ <input type="number" min={1} max={30} step={0.5} value={targetDistanceInput} onChange={(event) => setTargetDistanceInput(event.target.value)} className="rts-input" />
363
+ </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  </div>
365
 
366
+ {error ? <p className="mt-3 text-sm text-red-600">{error}</p> : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
 
368
+ {result ? (
369
+ <div className="rts-result-summary-grid mt-4">
370
+ <div>
371
+ <strong>{routes.length}개</strong>
372
+ <span>추천 경로</span>
373
+ </div>
374
+ <div>
375
+ <strong>{formatDistance(result.target_distance_km)}</strong>
376
+ <span>목표 거리</span>
377
+ </div>
378
+ <div>
379
+ <strong>{formatGeneratedRouteLabel(selectedLabel)}</strong>
380
+ <span>선택 모양</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  </div>
382
  </div>
383
+ ) : null}
384
+ </SectionCard>
385
+
386
+ {loading && !result ? (
387
+ <EmptyState>지도 위에 표시할 경로를 찾는 중입니다.</EmptyState>
388
+ ) : null}
389
+
390
+ {result ? (
391
+ <section className="grid gap-4 sm:grid-cols-2">
392
+ {routes.map((route, index) => {
393
+ const routeNumber = index + 1;
394
+ const routeLabel = getPrimaryGeneratedRouteLabel(route);
395
+ const hasRouteCoords = Boolean(route.route_coords && route.route_coords.length > 1);
396
+ const score = getGeneratedRouteAiShapeScore(route);
397
+ const savedRouteId = createSavedRouteId(route, result.place);
398
+ const isBookmarked = savedRouteIds.has(savedRouteId);
399
+
400
+ return (
401
+ <article key={`${route.image_path}-${index}`} className="rts-card rts-card-clip rts-route-card-enter">
402
+ <div className="rts-route-map-stage h-64 w-full bg-[#f5f5f5]">
403
+ <div className="rts-route-rank">추천 {routeNumber}</div>
404
+ {hasRouteCoords ? (
405
+ <LeafletMap
406
+ center={route.center ?? route.route_coords![0]}
407
+ route={route.route_coords}
408
+ className="h-full w-full"
409
+ zoom={14}
410
+ />
411
+ ) : (
412
+ <div className="flex h-full w-full items-center justify-center px-5 text-center text-sm text-[#666]">
413
+ 지도에 표시할 좌표가 없습니다. 다시 찾아주세요.
414
+ </div>
415
+ )}
416
+ </div>
417
 
418
+ <div className="grid gap-4 p-5">
419
+ <div className="flex items-start justify-between gap-3">
420
+ <div>
421
+ <h2 className="text-xl font-bold text-black sm:text-2xl">
422
+ {getRouteTitle(routeNumber, routeLabel)}
423
+ </h2>
424
+ <p className="mt-1 text-sm text-[#666]">{formatGeneratedRouteLabel(routeLabel)} 모양</p>
425
+ </div>
426
+ <button
427
+ type="button"
428
+ disabled={!hasRouteCoords}
429
+ onClick={() => handleToggleSavedRoute(route, routeNumber)}
430
+ aria-label={isBookmarked ? "저장 해제" : "경로 저장"}
431
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-[#e5e5e5] bg-white text-[#555] disabled:opacity-50"
432
+ >
433
+ <BookmarkIcon filled={isBookmarked} />
434
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  </div>
436
 
437
+ <div className="rts-data-grid">
438
+ <div className="rts-data-item">
439
+ <span className="rts-data-value">{formatDistance(route.length_km)}</span>
440
+ <p className="rts-data-label">거리</p>
441
+ </div>
442
+ <div className="rts-data-item">
443
+ <span className="rts-data-value">{formatEstimatedTime(route.length_km)}</span>
444
+ <p className="rts-data-label">예상 시간</p>
445
+ </div>
446
+ <div className="rts-data-item">
447
+ <span className="rts-data-value">{getCompletionLabel(score)}</span>
448
+ <p className="rts-data-label">형태 완성도</p>
449
+ </div>
450
  </div>
451
 
452
+ <div className="grid gap-2 sm:grid-cols-2">
453
+ <button type="button" disabled={!hasRouteCoords} onClick={() => openRouteDetail(route, routeNumber, "run")} className="rts-primary-button h-11 disabled:opacity-50">
454
+ 상세 보기
455
+ </button>
456
+ <button type="button" disabled={!hasRouteCoords} onClick={() => openRouteDetail(route, routeNumber, "run")} className="rts-secondary-button h-11 disabled:opacity-50">
457
+ 달리기 시작
458
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  </div>
460
  </div>
461
+ </article>
462
+ );
463
+ })}
464
+
465
+ {routes.length === 0 ? <EmptyState className="sm:col-span-2">표시할 경로가 없습니다. 조건을 바꿔 다시 찾아주세요.</EmptyState> : null}
466
+ </section>
467
+ ) : null}
468
+ </PageContainer>
 
 
 
 
 
 
469
  </main>
470
  );
471
  }
main/app/route-search/page.tsx CHANGED
@@ -3,14 +3,10 @@
3
  import { Suspense, useEffect, useMemo, useState } from "react";
4
  import { useRouter, useSearchParams } from "next/navigation";
5
  import TopBar from "../_components/TopBar";
 
6
  import { FLOW_KEYS, isUiFlowPermitValid, issueUiFlowPermit } from "../_utils/uiFlowGuard";
7
 
8
- const formatLabel = (raw: string): string =>
9
- raw
10
- .replace(/_/g, " ")
11
- .split(" ")
12
- .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
13
- .join(" ");
14
 
15
  type DistrictOption = {
16
  label: string;
@@ -20,13 +16,21 @@ type DistrictOption = {
20
  type RegionOption = {
21
  key: string;
22
  label: string;
 
23
  districts: DistrictOption[];
24
  };
25
 
 
 
 
 
 
 
26
  const REGION_OPTIONS: RegionOption[] = [
27
  {
28
  key: "seoul",
29
  label: "서울특별시",
 
30
  districts: [
31
  { label: "강남구", query: "강남구, 서울특별시, 대한민국" },
32
  { label: "마포구", query: "마포구, 서울특별시, 대한민국" },
@@ -41,6 +45,7 @@ const REGION_OPTIONS: RegionOption[] = [
41
  {
42
  key: "busan",
43
  label: "부산광역시",
 
44
  districts: [
45
  { label: "부산진구", query: "부산진구, 부산광역시, 대한민국" },
46
  { label: "해운대구", query: "해운대구, 부산광역시, 대한민국" },
@@ -54,6 +59,7 @@ const REGION_OPTIONS: RegionOption[] = [
54
  {
55
  key: "daegu",
56
  label: "대구광역시",
 
57
  districts: [
58
  { label: "수성구", query: "수성구, 대구광역시, 대한민국" },
59
  { label: "중구", query: "중구, 대구광역시, 대한민국" },
@@ -66,6 +72,7 @@ const REGION_OPTIONS: RegionOption[] = [
66
  {
67
  key: "incheon",
68
  label: "인천광역시",
 
69
  districts: [
70
  { label: "연수구", query: "연수구, 인천광역시, 대한민국" },
71
  { label: "남동구", query: "남동구, 인천광역시, 대한민국" },
@@ -78,6 +85,7 @@ const REGION_OPTIONS: RegionOption[] = [
78
  {
79
  key: "gwangju",
80
  label: "광주광역시",
 
81
  districts: [
82
  { label: "동구", query: "동구, 광주광역시, 대한민국" },
83
  { label: "서구", query: "서구, 광주광역시, 대한민국" },
@@ -89,6 +97,7 @@ const REGION_OPTIONS: RegionOption[] = [
89
  {
90
  key: "daejeon",
91
  label: "대전광역시",
 
92
  districts: [
93
  { label: "유성구", query: "유성구, 대전광역시, 대한민국" },
94
  { label: "서구", query: "서구, 대전광역시, 대한민국" },
@@ -100,6 +109,7 @@ const REGION_OPTIONS: RegionOption[] = [
100
  {
101
  key: "ulsan",
102
  label: "울산광역시",
 
103
  districts: [
104
  { label: "남구", query: "남구, 울산광역시, 대한민국" },
105
  { label: "중구", query: "중구, 울산광역시, 대한민국" },
@@ -111,6 +121,7 @@ const REGION_OPTIONS: RegionOption[] = [
111
  {
112
  key: "sejong",
113
  label: "세종특별자치시",
 
114
  districts: [
115
  { label: "나성동", query: "나성동, 세종특별자치시, 대한민국" },
116
  { label: "어진동", query: "어진동, 세종특별자치시, 대한민국" },
@@ -121,6 +132,7 @@ const REGION_OPTIONS: RegionOption[] = [
121
  {
122
  key: "gyeonggi",
123
  label: "경기도",
 
124
  districts: [
125
  { label: "수원시 영통구", query: "수원시 영통구, 경기도, 대한민국" },
126
  { label: "성남시 분당구", query: "성남시 분당구, 경기도, 대한민국" },
@@ -134,6 +146,7 @@ const REGION_OPTIONS: RegionOption[] = [
134
  {
135
  key: "gangwon",
136
  label: "강원특별자치도",
 
137
  districts: [
138
  { label: "춘천시", query: "춘천시, 강원특별자치도, 대한민국" },
139
  { label: "원주시", query: "원주시, 강원특별자치도, 대한민국" },
@@ -145,6 +158,7 @@ const REGION_OPTIONS: RegionOption[] = [
145
  {
146
  key: "chungbuk",
147
  label: "충청북도",
 
148
  districts: [
149
  { label: "청주시 상당구", query: "청주시 상당구, 충청북도, 대한민국" },
150
  { label: "충주시", query: "충주시, 충청북도, 대한민국" },
@@ -156,6 +170,7 @@ const REGION_OPTIONS: RegionOption[] = [
156
  {
157
  key: "chungnam",
158
  label: "충청남도",
 
159
  districts: [
160
  { label: "천안시 서북구", query: "천안시 서북구, 충청남도, 대한민국" },
161
  { label: "아산시", query: "아산시, 충청남도, 대한민국" },
@@ -167,6 +182,7 @@ const REGION_OPTIONS: RegionOption[] = [
167
  {
168
  key: "jeonbuk",
169
  label: "전북특별자치도",
 
170
  districts: [
171
  { label: "전주시 완산구", query: "전주시 완산구, 전북특별자��도, 대한민국" },
172
  { label: "군산시", query: "군산시, 전북특별자치도, 대한민국" },
@@ -178,6 +194,7 @@ const REGION_OPTIONS: RegionOption[] = [
178
  {
179
  key: "jeonnam",
180
  label: "전라남도",
 
181
  districts: [
182
  { label: "목포시", query: "목포시, 전라남도, 대한민국" },
183
  { label: "여수시", query: "여수시, 전라남도, 대한민국" },
@@ -189,6 +206,7 @@ const REGION_OPTIONS: RegionOption[] = [
189
  {
190
  key: "gyeongbuk",
191
  label: "경상북도",
 
192
  districts: [
193
  { label: "포항시 남구", query: "포항시 남구, 경상북도, 대한민국" },
194
  { label: "구미시", query: "구미시, 경상북도, 대한민국" },
@@ -200,6 +218,7 @@ const REGION_OPTIONS: RegionOption[] = [
200
  {
201
  key: "gyeongnam",
202
  label: "경상남도",
 
203
  districts: [
204
  { label: "창원시 성산구", query: "창원시 성산구, 경상남도, 대한민국" },
205
  { label: "김해시", query: "김해시, 경상남도, 대한민국" },
@@ -211,6 +230,7 @@ const REGION_OPTIONS: RegionOption[] = [
211
  {
212
  key: "jeju",
213
  label: "제주특별자치도",
 
214
  districts: [
215
  { label: "제주시", query: "제주시, 제주특별자치도, 대한민국" },
216
  { label: "서귀포시", query: "서귀포시, 제주특별자치도, 대한민국" },
@@ -218,6 +238,120 @@ const REGION_OPTIONS: RegionOption[] = [
218
  },
219
  ];
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  const normalizeLocationText = (value: string): string =>
222
  value.toLowerCase().replace(/\s+/g, "").replace(/[.,\-()]/g, "");
223
 
@@ -271,6 +405,13 @@ const findRegionDistrictByPlace = (
271
  return null;
272
  };
273
 
 
 
 
 
 
 
 
274
  function RouteSearchPageContent() {
275
  const router = useRouter();
276
  const searchParams = useSearchParams();
@@ -284,8 +425,8 @@ function RouteSearchPageContent() {
284
  const [latitude, setLatitude] = useState<number | null>(null);
285
  const [longitude, setLongitude] = useState<number | null>(null);
286
  const [radiusKm, setRadiusKm] = useState(2.5);
287
- const [routeCountInput, setRouteCountInput] = useState("5");
288
- const [targetDistanceInput, setTargetDistanceInput] = useState("6");
289
  const [selectedLabel, setSelectedLabel] = useState("cloud");
290
  const [geoError, setGeoError] = useState<string | null>(null);
291
  const [formError, setFormError] = useState<string | null>(null);
@@ -303,10 +444,8 @@ function RouteSearchPageContent() {
303
  const routeCountPresets = [5, 8, 16];
304
  const distancePresets = [3, 5, 10];
305
  const radiusPresets = [2.5, 5, 10];
306
- const labelOptions = useMemo(
307
- () => ["cup", "face", "cloud", "ice_cream", "t_shirt", "fish", "apple", "candle"],
308
- []
309
- );
310
 
311
  const selectedDistrictLabel = useMemo(() => {
312
  const allDistricts = REGION_OPTIONS.flatMap((region) => region.districts);
@@ -314,6 +453,18 @@ function RouteSearchPageContent() {
314
  return found?.label ?? place.split(",")[0];
315
  }, [place, selectedDistrict]);
316
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  useEffect(() => {
318
  const isValid = isUiFlowPermitValid(FLOW_KEYS.routeSearch, flowToken);
319
  if (!isValid) {
@@ -380,7 +531,7 @@ function RouteSearchPageContent() {
380
  const routeCount = Number(routeCountInput);
381
  const targetDistanceKm = Number(targetDistanceInput);
382
  if (!Number.isFinite(routeCount) || routeCount < 5 || routeCount > 100) {
383
- setFormError("생성 경로 수는 5~100 사이 숫자로 입력해 주세요.");
384
  return;
385
  }
386
  if (
@@ -445,247 +596,245 @@ function RouteSearchPageContent() {
445
  }
446
 
447
  return (
448
- <main className="min-h-screen bg-[#f7f9fc]">
 
 
 
449
  <TopBar />
450
 
451
- <section className="mx-auto w-full max-w-6xl px-6 py-8 sm:px-10">
452
- <div className="grid gap-4 sm:gap-5">
453
- <div className="rounded-2xl border border-black/10 bg-white p-5 sm:p-6">
454
- <h2 className="text-lg font-semibold text-black">OSM 도로망 경로 생성</h2>
455
- <p className="mt-1 text-sm text-[#666]">
456
- 위치와 목표를 설정하면 다수 경로를 생성하고 형태 유사도를 분석합니다.
457
- </p>
458
  </div>
459
-
460
- <div className="rounded-2xl border border-black/10 bg-white p-5 sm:p-6">
461
- <h3 className="text-base font-semibold text-black">위치 설정</h3>
462
- <div className="mt-3 grid gap-3 sm:grid-cols-2">
463
- <div className="flex flex-col gap-2">
464
- <label className="text-sm font-medium text-black">시/도 선택</label>
465
- <div className="relative">
466
- <select
467
- value={selectedRegion}
468
- onChange={(e) => {
469
- setSelectedRegion(e.target.value);
470
- setSource("place");
471
- setLatitude(null);
472
- setLongitude(null);
473
- }}
474
- className="h-11 w-full appearance-none rounded-lg border border-black/15 bg-white px-3 pr-10 text-sm"
475
- >
476
- {REGION_OPTIONS.map((region) => (
477
- <option key={region.key} value={region.key}>
478
- {region.label}
479
- </option>
480
- ))}
481
- </select>
482
- <span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 leading-none text-lg text-[#6b7280]">
483
- ⌄
484
- </span>
485
- </div>
486
- </div>
487
-
488
- <div className="flex flex-col gap-2">
489
- <label className="text-sm font-medium text-black">구/군 선택</label>
490
- <div className="relative">
491
- <select
492
- value={selectedDistrict}
493
- onChange={(e) => {
494
- const nextDistrict = e.target.value;
495
- setSelectedDistrict(nextDistrict);
496
- setPlace(nextDistrict);
497
- setSource("place");
498
- setLatitude(null);
499
- setLongitude(null);
500
- }}
501
- className="h-11 w-full appearance-none rounded-lg border border-black/15 bg-white px-3 pr-10 text-sm"
502
- >
503
- {districtOptions.map((district) => (
504
- <option key={district.query} value={district.query}>
505
- {district.label}
506
- </option>
507
- ))}
508
- </select>
509
- <span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 leading-none text-lg text-[#6b7280]">
510
- ⌄
511
- </span>
512
- </div>
513
  </div>
514
  </div>
 
 
515
 
516
- <div className="mt-3">
 
 
 
517
  <button
518
  type="button"
519
  onClick={handleUseCurrentLocation}
520
- className={`h-9 rounded-full px-4 text-sm transition-colors ${
521
  source === "current"
522
  ? "bg-black text-white"
523
- : "border border-black/20 text-black hover:bg-black/5"
524
  }`}
525
  >
526
- 현재 위치 사용
527
  </button>
528
- <p className="mt-2 text-xs text-[#666]">
529
- 현재 위치를 사용하면 선택 박스가 자동으로 가장 가까운 시/도·구군으로 동기화됩니다.
530
- </p>
531
  </div>
532
 
533
- {source === "current" && (
534
- <div className="mt-4 grid gap-3 sm:grid-cols-2">
535
- <div className="rounded-lg bg-[#f4f6fa] px-3 py-3 text-xs text-[#555]">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  {latitude !== null && longitude !== null
537
- ? `현재 좌표: ${latitude.toFixed(6)}, ${longitude.toFixed(6)}`
538
  : "현재 위치를 먼저 받아주세요."}
539
  </div>
540
- <div className="flex flex-col gap-2">
541
- <label className="text-sm font-medium text-black">검색 반경 (km)</label>
542
- <input
543
- type="number"
544
- min={0.5}
545
- max={10}
546
- step={0.5}
547
- value={radiusKm}
548
- onChange={(e) => setRadiusKm(Number(e.target.value || 5))}
549
- className="h-11 rounded-lg border border-black/15 px-3 text-sm"
550
- />
551
- <div className="flex flex-wrap gap-2">
552
- {radiusPresets.map((radius) => (
553
- <button
554
- key={radius}
555
- type="button"
556
- onClick={() => setRadiusKm(radius)}
557
- className={`h-7 rounded-full px-3 text-xs ${
558
- radiusKm === radius
559
- ? "bg-black text-white"
560
- : "border border-black/15 text-black hover:bg-black/5"
561
- }`}
562
- >
563
- {radius}km
564
- </button>
565
- ))}
566
- </div>
567
- </div>
568
- </div>
569
- )}
570
- </div>
571
-
572
- <div className="rounded-2xl border border-black/10 bg-white p-5 sm:p-6">
573
- <h3 className="text-base font-semibold text-black">경로 옵션</h3>
574
- <div className="mt-4 grid gap-3 sm:grid-cols-2">
575
- <div className="flex flex-col gap-2">
576
- <label className="text-sm font-medium text-black">생성 경로 수 (5~100)</label>
577
- <input
578
- type="text"
579
- inputMode="numeric"
580
- value={routeCountInput}
581
- onChange={(e) => setRouteCountInput(e.target.value)}
582
- className="h-11 rounded-lg border border-black/15 px-3 text-sm"
583
- />
584
- <div className="flex flex-wrap gap-2">
585
- {routeCountPresets.map((count) => (
586
- <button
587
- key={count}
588
- type="button"
589
- onClick={() => setRouteCountInput(String(count))}
590
- className={`h-7 rounded-full px-3 text-xs ${
591
- routeCountInput === String(count)
592
- ? "bg-black text-white"
593
- : "border border-black/15 text-black hover:bg-black/5"
594
- }`}
595
- >
596
- {count}개
597
- </button>
598
  ))}
599
- </div>
600
- </div>
601
- <div className="flex flex-col gap-2">
602
- <label className="text-sm font-medium text-black">목표 거리 (1~30km)</label>
 
603
  <input
604
  type="text"
605
  inputMode="decimal"
606
  value={targetDistanceInput}
607
  onChange={(e) => setTargetDistanceInput(e.target.value)}
608
- className="h-11 rounded-lg border border-black/15 px-3 text-sm"
609
  />
610
- <div className="flex flex-wrap gap-2">
611
- {distancePresets.map((distance) => (
612
- <button
613
- key={distance}
614
- type="button"
615
- onClick={() => setTargetDistanceInput(String(distance))}
616
- className={`h-7 rounded-full px-3 text-xs ${
617
- targetDistanceInput === String(distance)
618
- ? "bg-black text-white"
619
- : "border border-black/15 text-black hover:bg-black/5"
620
- }`}
621
- >
622
- {distance}km
623
- </button>
624
- ))}
625
- </div>
626
- </div>
627
- </div>
628
- <div className="mt-4 flex flex-col gap-2">
629
- <label className="text-sm font-medium text-black">찾을 모양 (1개 선택)</label>
630
- <div className="flex flex-wrap gap-2">
631
- {labelOptions.map((label) => (
632
  <button
633
- key={label}
634
  type="button"
635
- onClick={() => setSelectedLabel(label)}
636
- className={`h-8 rounded-full px-3 text-xs transition-colors ${
637
- selectedLabel === label
638
  ? "bg-black text-white"
639
- : "border border-black/15 text-black hover:bg-black/5"
640
  }`}
641
  >
642
- {formatLabel(label)}
643
  </button>
644
  ))}
645
  </div>
646
- </div>
647
- </div>
648
 
649
- <div className="rounded-2xl border border-black/10 bg-white p-4 sm:p-5">
650
- <p className="text-xs font-medium text-[#666]">선택 요약</p>
651
- <p className="mt-1 text-sm text-black">
652
- {source === "current" ? "현재 위치 기반" : selectedDistrictLabel} · 경로{" "}
653
- {routeCountInput || "-"}개 · 목표 {targetDistanceInput || "-"}km · 반경 {radiusKm}km · 모양{" "}
654
- {formatLabel(selectedLabel)}
655
- </p>
656
- {geoError && <p className="mt-2 text-sm text-red-600">{geoError}</p>}
657
- {formError && <p className="mt-2 text-sm text-red-600">{formError}</p>}
658
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
 
660
- <div className="sticky bottom-4">
661
- <button
662
- type="button"
663
- onClick={handleRouteSearch}
664
- disabled={isNavigating}
665
- className="h-12 w-full rounded-full bg-black text-base font-semibold text-white shadow-lg disabled:opacity-60"
666
- >
667
- {isNavigating ? (
668
- <span className="inline-flex items-center justify-center gap-2">
669
- <img
670
- src="/loading/login-running-dog.svg"
671
- alt=""
672
- aria-hidden="true"
673
- className="h-5 w-5 rounded-full object-cover"
674
- />
675
- 생성 중...
676
- </span>
677
- ) : (
678
- "경로 생성하고 동물 찾기"
679
- )}
680
- </button>
681
- </div>
682
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
683
  </section>
 
 
 
 
 
 
 
 
 
 
 
684
  </main>
685
  );
686
  }
687
 
688
-
689
  export default function Page() {
690
  return (
691
  <Suspense fallback={null}>
 
3
  import { Suspense, useEffect, useMemo, useState } from "react";
4
  import { useRouter, useSearchParams } from "next/navigation";
5
  import TopBar from "../_components/TopBar";
6
+ import LeafletMap from "../_components/LeafletMap";
7
  import { FLOW_KEYS, isUiFlowPermitValid, issueUiFlowPermit } from "../_utils/uiFlowGuard";
8
 
9
+ type Point = [number, number];
 
 
 
 
 
10
 
11
  type DistrictOption = {
12
  label: string;
 
16
  type RegionOption = {
17
  key: string;
18
  label: string;
19
+ center: Point;
20
  districts: DistrictOption[];
21
  };
22
 
23
+ type ShapeOption = {
24
+ key: string;
25
+ label: string;
26
+ offsets: Point[];
27
+ };
28
+
29
  const REGION_OPTIONS: RegionOption[] = [
30
  {
31
  key: "seoul",
32
  label: "서울특별시",
33
+ center: [37.5665, 126.978],
34
  districts: [
35
  { label: "강남구", query: "강남구, 서울특별시, 대한민국" },
36
  { label: "마포구", query: "마포구, 서울특별시, 대한민국" },
 
45
  {
46
  key: "busan",
47
  label: "부산광역시",
48
+ center: [35.1796, 129.0756],
49
  districts: [
50
  { label: "부산진구", query: "부산진구, 부산광역시, 대한민국" },
51
  { label: "해운대구", query: "해운대구, 부산광역시, 대한민국" },
 
59
  {
60
  key: "daegu",
61
  label: "대구광역시",
62
+ center: [35.8714, 128.6014],
63
  districts: [
64
  { label: "수성구", query: "수성구, 대구광역시, 대한민국" },
65
  { label: "중구", query: "중구, 대구광역시, 대한민국" },
 
72
  {
73
  key: "incheon",
74
  label: "인천광역시",
75
+ center: [37.4563, 126.7052],
76
  districts: [
77
  { label: "연수구", query: "연수구, 인천광역시, 대한민국" },
78
  { label: "남동구", query: "남동구, 인천광역시, 대한민국" },
 
85
  {
86
  key: "gwangju",
87
  label: "광주광역시",
88
+ center: [35.1595, 126.8526],
89
  districts: [
90
  { label: "동구", query: "동구, 광주광역시, 대한민국" },
91
  { label: "서구", query: "서구, 광주광역시, 대한민국" },
 
97
  {
98
  key: "daejeon",
99
  label: "대전광역시",
100
+ center: [36.3504, 127.3845],
101
  districts: [
102
  { label: "유성구", query: "유성구, 대전광역시, 대한민국" },
103
  { label: "서구", query: "서구, 대전광역시, 대한민국" },
 
109
  {
110
  key: "ulsan",
111
  label: "울산광역시",
112
+ center: [35.5384, 129.3114],
113
  districts: [
114
  { label: "남구", query: "남구, 울산광역시, 대한민국" },
115
  { label: "중구", query: "중구, 울산광역시, 대한민국" },
 
121
  {
122
  key: "sejong",
123
  label: "세종특별자치시",
124
+ center: [36.4801, 127.289],
125
  districts: [
126
  { label: "나성동", query: "나성동, 세종특별자치시, 대한민국" },
127
  { label: "어진동", query: "어진동, 세종특별자치시, 대한민국" },
 
132
  {
133
  key: "gyeonggi",
134
  label: "경기도",
135
+ center: [37.4138, 127.5183],
136
  districts: [
137
  { label: "수원시 영통구", query: "수원시 영통구, 경기도, 대한민국" },
138
  { label: "성남시 분당구", query: "성남시 분당구, 경기도, 대한민국" },
 
146
  {
147
  key: "gangwon",
148
  label: "강원특별자치도",
149
+ center: [37.8228, 128.1555],
150
  districts: [
151
  { label: "춘천시", query: "춘천시, 강원특별자치도, 대한민국" },
152
  { label: "원주시", query: "원주시, 강원특별자치도, 대한민국" },
 
158
  {
159
  key: "chungbuk",
160
  label: "충청북도",
161
+ center: [36.8, 127.7],
162
  districts: [
163
  { label: "청주시 상당구", query: "청주시 상당구, 충청북도, 대한민국" },
164
  { label: "충주시", query: "충주시, 충청북도, 대한민국" },
 
170
  {
171
  key: "chungnam",
172
  label: "충청남도",
173
+ center: [36.5184, 126.8],
174
  districts: [
175
  { label: "천안시 서북구", query: "천안시 서북구, 충청남도, 대한민국" },
176
  { label: "아산시", query: "아산시, 충청남도, 대한민국" },
 
182
  {
183
  key: "jeonbuk",
184
  label: "전북특별자치도",
185
+ center: [35.7175, 127.153],
186
  districts: [
187
  { label: "전주시 완산구", query: "전주시 완산구, 전북특별자��도, 대한민국" },
188
  { label: "군산시", query: "군산시, 전북특별자치도, 대한민국" },
 
194
  {
195
  key: "jeonnam",
196
  label: "전라남도",
197
+ center: [34.8161, 126.463],
198
  districts: [
199
  { label: "목포시", query: "목포시, 전라남도, 대한민국" },
200
  { label: "여수시", query: "여수시, 전라남도, 대한민국" },
 
206
  {
207
  key: "gyeongbuk",
208
  label: "경상북도",
209
+ center: [36.4919, 128.8889],
210
  districts: [
211
  { label: "포항시 남구", query: "포항시 남구, 경상북도, 대한민국" },
212
  { label: "구미시", query: "구미시, 경상북도, 대한민국" },
 
218
  {
219
  key: "gyeongnam",
220
  label: "경상남도",
221
+ center: [35.4606, 128.2132],
222
  districts: [
223
  { label: "창원시 성산구", query: "창원시 성산구, 경상남도, 대한민국" },
224
  { label: "김해시", query: "김해시, 경상남도, 대한민국" },
 
230
  {
231
  key: "jeju",
232
  label: "제주특별자치도",
233
+ center: [33.4996, 126.5312],
234
  districts: [
235
  { label: "제주시", query: "제주시, 제주특별자치도, 대한민국" },
236
  { label: "서귀포시", query: "서귀포시, 제주특별자치도, 대한민국" },
 
238
  },
239
  ];
240
 
241
+ const SHAPE_OPTIONS: ShapeOption[] = [
242
+ {
243
+ key: "cloud",
244
+ label: "구름",
245
+ offsets: [
246
+ [0.0016, -0.0028],
247
+ [0.0035, -0.0012],
248
+ [0.0032, 0.0017],
249
+ [0.0013, 0.0031],
250
+ [-0.0016, 0.0028],
251
+ [-0.0033, 0.0007],
252
+ [-0.0025, -0.002],
253
+ [0.0016, -0.0028],
254
+ ],
255
+ },
256
+ {
257
+ key: "cup",
258
+ label: "컵",
259
+ offsets: [
260
+ [0.003, -0.002],
261
+ [-0.0027, -0.002],
262
+ [-0.0027, 0.0016],
263
+ [0.0028, 0.0016],
264
+ [0.003, -0.002],
265
+ [0.0002, 0.0016],
266
+ [0.0002, 0.004],
267
+ ],
268
+ },
269
+ {
270
+ key: "face",
271
+ label: "얼굴",
272
+ offsets: [
273
+ [0.0032, 0],
274
+ [0.0021, 0.0025],
275
+ [0, 0.0034],
276
+ [-0.0021, 0.0025],
277
+ [-0.0032, 0],
278
+ [-0.0021, -0.0025],
279
+ [0, -0.0034],
280
+ [0.0021, -0.0025],
281
+ [0.0032, 0],
282
+ ],
283
+ },
284
+ {
285
+ key: "ice_cream",
286
+ label: "아이스크림",
287
+ offsets: [
288
+ [0.0032, 0],
289
+ [0.0012, 0.0026],
290
+ [-0.0031, 0],
291
+ [0.0012, -0.0026],
292
+ [0.0032, 0],
293
+ ],
294
+ },
295
+ {
296
+ key: "t_shirt",
297
+ label: "티셔츠",
298
+ offsets: [
299
+ [0.0032, -0.0022],
300
+ [0.0012, -0.0034],
301
+ [0, -0.0015],
302
+ [-0.0012, -0.0034],
303
+ [-0.0032, -0.0022],
304
+ [-0.0024, 0.0028],
305
+ [0.0024, 0.0028],
306
+ [0.0032, -0.0022],
307
+ ],
308
+ },
309
+ {
310
+ key: "fish",
311
+ label: "물고기",
312
+ offsets: [
313
+ [0, -0.0033],
314
+ [0.0028, -0.0002],
315
+ [0, 0.0033],
316
+ [-0.0028, -0.0002],
317
+ [0, -0.0033],
318
+ [-0.0028, -0.0002],
319
+ [-0.0041, -0.0019],
320
+ [-0.0041, 0.0019],
321
+ [-0.0028, -0.0002],
322
+ ],
323
+ },
324
+ {
325
+ key: "apple",
326
+ label: "사과",
327
+ offsets: [
328
+ [0.0025, -0.0025],
329
+ [0.0035, 0.001],
330
+ [0.0012, 0.0035],
331
+ [-0.0016, 0.0031],
332
+ [-0.0033, 0.0009],
333
+ [-0.0024, -0.0024],
334
+ [0.0006, -0.0034],
335
+ [0.0025, -0.0025],
336
+ [0.0033, -0.0034],
337
+ ],
338
+ },
339
+ {
340
+ key: "candle",
341
+ label: "양초",
342
+ offsets: [
343
+ [0.0034, 0],
344
+ [0.0015, -0.0018],
345
+ [0, -0.0035],
346
+ [-0.0015, -0.0018],
347
+ [-0.0034, 0],
348
+ [-0.0024, 0.0024],
349
+ [0.0024, 0.0024],
350
+ [0.0034, 0],
351
+ ],
352
+ },
353
+ ];
354
+
355
  const normalizeLocationText = (value: string): string =>
356
  value.toLowerCase().replace(/\s+/g, "").replace(/[.,\-()]/g, "");
357
 
 
405
  return null;
406
  };
407
 
408
+ function buildPreviewRoute(center: Point, shape: ShapeOption): Point[] {
409
+ return shape.offsets.map(([latOffset, lngOffset]) => [
410
+ center[0] + latOffset,
411
+ center[1] + lngOffset,
412
+ ]);
413
+ }
414
+
415
  function RouteSearchPageContent() {
416
  const router = useRouter();
417
  const searchParams = useSearchParams();
 
425
  const [latitude, setLatitude] = useState<number | null>(null);
426
  const [longitude, setLongitude] = useState<number | null>(null);
427
  const [radiusKm, setRadiusKm] = useState(2.5);
428
+ const [routeCountInput, setRouteCountInput] = useState("8");
429
+ const [targetDistanceInput, setTargetDistanceInput] = useState("5");
430
  const [selectedLabel, setSelectedLabel] = useState("cloud");
431
  const [geoError, setGeoError] = useState<string | null>(null);
432
  const [formError, setFormError] = useState<string | null>(null);
 
444
  const routeCountPresets = [5, 8, 16];
445
  const distancePresets = [3, 5, 10];
446
  const radiusPresets = [2.5, 5, 10];
447
+ const selectedShape =
448
+ SHAPE_OPTIONS.find((shape) => shape.key === selectedLabel) ?? SHAPE_OPTIONS[0];
 
 
449
 
450
  const selectedDistrictLabel = useMemo(() => {
451
  const allDistricts = REGION_OPTIONS.flatMap((region) => region.districts);
 
453
  return found?.label ?? place.split(",")[0];
454
  }, [place, selectedDistrict]);
455
 
456
+ const mapCenter: Point =
457
+ source === "current" && latitude !== null && longitude !== null
458
+ ? [latitude, longitude]
459
+ : activeRegion.center;
460
+ const previewRoute = useMemo(
461
+ () => buildPreviewRoute(mapCenter, selectedShape),
462
+ [mapCenter, selectedShape]
463
+ );
464
+ const summaryPlace = source === "current" ? "현재 위치" : selectedDistrictLabel;
465
+ const selectedSummary = `${summaryPlace} · ${selectedShape.label} · ${targetDistanceInput || "-"}km`;
466
+ const routePlanText = `${summaryPlace}에서 ${selectedShape.label} 모양에 가까운 ${targetDistanceInput || "-"}km 경로를 ${routeCountInput || "-"}개 추천합니다.`;
467
+
468
  useEffect(() => {
469
  const isValid = isUiFlowPermitValid(FLOW_KEYS.routeSearch, flowToken);
470
  if (!isValid) {
 
531
  const routeCount = Number(routeCountInput);
532
  const targetDistanceKm = Number(targetDistanceInput);
533
  if (!Number.isFinite(routeCount) || routeCount < 5 || routeCount > 100) {
534
+ setFormError("추천 경로 수는 5~100 사이 숫자로 입력해 주세요.");
535
  return;
536
  }
537
  if (
 
596
  }
597
 
598
  return (
599
+ <main
600
+ className="min-h-screen bg-[#fbfbf8] pb-28 text-[#111111]"
601
+ style={{ fontFamily: "Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" }}
602
+ >
603
  <TopBar />
604
 
605
+ <div className="fixed inset-x-0 top-[72px] z-[2200] border-b border-black/10 bg-[#fbfbf8]/95 px-4 py-2 backdrop-blur">
606
+ <div className="mx-auto max-w-[1120px] rounded-[22px] border border-[#1E40AF]/15 bg-[#f3f7ff] px-4 py-3 text-left shadow-[0_10px_24px_rgba(30,64,175,0.10)] sm:flex sm:items-center sm:justify-between sm:gap-5">
607
+ <div className="flex min-w-0 items-center justify-between gap-3 sm:w-[320px] sm:shrink-0">
608
+ <p className="shrink-0 text-[11px] font-bold text-[#1E40AF]">생성 예정</p>
609
+ <p className="min-w-0 truncate text-right text-[11px] font-bold text-[#516177]">{selectedSummary}</p>
 
 
610
  </div>
611
+ <p className="mt-1 text-sm font-bold leading-5 text-[#111] sm:mt-0">{routePlanText}</p>
612
+ </div>
613
+ </div>
614
+
615
+ <section className="mx-auto grid w-full max-w-[1120px] gap-5 px-4 pb-8 pt-[168px] sm:px-8 lg:grid-cols-[minmax(0,1fr)_390px] lg:items-start lg:pb-12">
616
+ <div className="contents lg:grid lg:gap-5">
617
+ <section className="order-3 overflow-hidden rounded-[28px] border border-black/10 bg-white shadow-[0_18px_45px_rgba(0,0,0,0.06)] lg:order-none">
618
+ <div className="relative h-[330px] bg-[#e9edf0] sm:h-[420px]">
619
+ <LeafletMap
620
+ center={mapCenter}
621
+ route={previewRoute}
622
+ className="h-full w-full"
623
+ zoom={14}
624
+ />
625
+ <div className="pointer-events-none absolute left-3 top-3 z-[500] rounded-full bg-black/72 px-3 py-2 text-xs font-bold text-white">
626
+ 경로 미리보기
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
  </div>
628
  </div>
629
+ </section>
630
+ </div>
631
 
632
+ <aside className="order-2 grid gap-4 lg:sticky lg:top-[168px] lg:order-none">
633
+ <section className="rounded-[28px] border border-black/10 bg-white p-5 shadow-[0_18px_45px_rgba(0,0,0,0.06)]">
634
+ <div className="flex items-center justify-between gap-3">
635
+ <h2 className="text-xl font-bold">어디서 달릴까요?</h2>
636
  <button
637
  type="button"
638
  onClick={handleUseCurrentLocation}
639
+ className={`h-9 shrink-0 rounded-full px-4 text-xs font-bold transition-colors ${
640
  source === "current"
641
  ? "bg-black text-white"
642
+ : "border border-black/10 bg-[#f7f7f7] text-black"
643
  }`}
644
  >
645
+ 현재 위치
646
  </button>
 
 
 
647
  </div>
648
 
649
+ <div className="mt-4 grid gap-3">
650
+ <label className="grid gap-2">
651
+ <span className="text-sm font-bold">시·도</span>
652
+ <select
653
+ value={selectedRegion}
654
+ onChange={(e) => {
655
+ setSelectedRegion(e.target.value);
656
+ const nextRegion =
657
+ REGION_OPTIONS.find((region) => region.key === e.target.value) ??
658
+ REGION_OPTIONS[0];
659
+ const nextDistrict = nextRegion.districts[0]?.query;
660
+ if (nextDistrict) {
661
+ setSelectedDistrict(nextDistrict);
662
+ setPlace(nextDistrict);
663
+ }
664
+ setSource("place");
665
+ setLatitude(null);
666
+ setLongitude(null);
667
+ }}
668
+ className="h-12 w-full rounded-2xl border border-black/10 bg-[#f7f7f7] px-4 text-sm font-semibold"
669
+ >
670
+ {REGION_OPTIONS.map((region) => (
671
+ <option key={region.key} value={region.key}>
672
+ {region.label}
673
+ </option>
674
+ ))}
675
+ </select>
676
+ </label>
677
+
678
+ <label className="grid gap-2">
679
+ <span className="text-sm font-bold">지역</span>
680
+ <select
681
+ value={selectedDistrict}
682
+ onChange={(e) => {
683
+ const nextDistrict = e.target.value;
684
+ setSelectedDistrict(nextDistrict);
685
+ setPlace(nextDistrict);
686
+ setSource("place");
687
+ setLatitude(null);
688
+ setLongitude(null);
689
+ }}
690
+ className="h-12 w-full rounded-2xl border border-black/10 bg-[#f7f7f7] px-4 text-sm font-semibold"
691
+ >
692
+ {districtOptions.map((district) => (
693
+ <option key={district.query} value={district.query}>
694
+ {district.label}
695
+ </option>
696
+ ))}
697
+ </select>
698
+ </label>
699
+
700
+ {source === "current" ? (
701
+ <div className="rounded-2xl bg-[#f7f7f7] px-4 py-3 text-xs leading-5 text-[#555]">
702
  {latitude !== null && longitude !== null
703
+ ? `현재 좌표 ${latitude.toFixed(5)}, ${longitude.toFixed(5)}`
704
  : "현재 위치를 먼저 받아주세요."}
705
  </div>
706
+ ) : null}
707
+ {geoError ? <p className="text-sm text-red-600">{geoError}</p> : null}
708
+ </div>
709
+ </section>
710
+
711
+ <section className="rounded-[28px] border border-black/10 bg-white p-5 shadow-[0_18px_45px_rgba(0,0,0,0.06)]">
712
+ <h2 className="text-xl font-bold">모양과 거리</h2>
713
+
714
+ <div className="mt-4 grid gap-4">
715
+ <label className="grid gap-2">
716
+ <span className="text-sm font-bold">모양</span>
717
+ <select
718
+ value={selectedLabel}
719
+ onChange={(e) => setSelectedLabel(e.target.value)}
720
+ className="h-12 w-full rounded-2xl border border-black/10 bg-[#f7f7f7] px-4 text-sm font-semibold"
721
+ >
722
+ {SHAPE_OPTIONS.map((shape) => (
723
+ <option key={shape.key} value={shape.key}>
724
+ {shape.label}
725
+ </option>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
726
  ))}
727
+ </select>
728
+ </label>
729
+
730
+ <label className="grid gap-2">
731
+ <span className="text-sm font-bold">목표 거리</span>
732
  <input
733
  type="text"
734
  inputMode="decimal"
735
  value={targetDistanceInput}
736
  onChange={(e) => setTargetDistanceInput(e.target.value)}
737
+ className="h-12 rounded-2xl border border-black/10 bg-[#f7f7f7] px-4 text-sm font-semibold"
738
  />
739
+ </label>
740
+ <div className="grid grid-cols-3 gap-2">
741
+ {distancePresets.map((distance) => (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  <button
743
+ key={distance}
744
  type="button"
745
+ onClick={() => setTargetDistanceInput(String(distance))}
746
+ className={`h-10 rounded-full text-sm font-bold ${
747
+ targetDistanceInput === String(distance)
748
  ? "bg-black text-white"
749
+ : "border border-black/10 bg-white text-black"
750
  }`}
751
  >
752
+ {distance}km
753
  </button>
754
  ))}
755
  </div>
 
 
756
 
757
+ <details className="rounded-2xl bg-[#f7f7f7] p-4">
758
+ <summary className="cursor-pointer text-sm font-bold">세부 설정</summary>
759
+ <div className="mt-4 grid gap-4">
760
+ <label className="grid gap-2">
761
+ <span className="text-sm font-bold">추천 경로 수</span>
762
+ <input
763
+ type="text"
764
+ inputMode="numeric"
765
+ value={routeCountInput}
766
+ onChange={(e) => setRouteCountInput(e.target.value)}
767
+ className="h-11 rounded-2xl border border-black/10 bg-white px-4 text-sm font-semibold"
768
+ />
769
+ </label>
770
+ <div className="grid grid-cols-3 gap-2">
771
+ {routeCountPresets.map((count) => (
772
+ <button
773
+ key={count}
774
+ type="button"
775
+ onClick={() => setRouteCountInput(String(count))}
776
+ className={`h-9 rounded-full text-xs font-bold ${
777
+ routeCountInput === String(count)
778
+ ? "bg-black text-white"
779
+ : "border border-black/10 bg-white text-black"
780
+ }`}
781
+ >
782
+ {count}개
783
+ </button>
784
+ ))}
785
+ </div>
786
 
787
+ <label className="grid gap-2">
788
+ <span className="text-sm font-bold">검색 반경</span>
789
+ <input
790
+ type="number"
791
+ min={0.5}
792
+ max={10}
793
+ step={0.5}
794
+ value={radiusKm}
795
+ onChange={(e) => setRadiusKm(Number(e.target.value || 5))}
796
+ className="h-11 rounded-2xl border border-black/10 bg-white px-4 text-sm font-semibold"
797
+ />
798
+ </label>
799
+ <div className="grid grid-cols-3 gap-2">
800
+ {radiusPresets.map((radius) => (
801
+ <button
802
+ key={radius}
803
+ type="button"
804
+ onClick={() => setRadiusKm(radius)}
805
+ className={`h-9 rounded-full text-xs font-bold ${
806
+ radiusKm === radius
807
+ ? "bg-black text-white"
808
+ : "border border-black/10 bg-white text-black"
809
+ }`}
810
+ >
811
+ {radius}km
812
+ </button>
813
+ ))}
814
+ </div>
815
+ </div>
816
+ </details>
817
+
818
+ {formError ? <p className="text-sm text-red-600">{formError}</p> : null}
819
+ </div>
820
+ </section>
821
+ </aside>
822
  </section>
823
+
824
+ <div className="fixed inset-x-0 bottom-0 z-[900] border-t border-black/10 bg-white/95 px-4 py-3 shadow-[0_-12px_30px_rgba(0,0,0,0.08)] backdrop-blur">
825
+ <button
826
+ type="button"
827
+ onClick={handleRouteSearch}
828
+ disabled={isNavigating}
829
+ className="mx-auto flex h-12 w-full max-w-[390px] items-center justify-center rounded-full bg-[#1E40AF] text-base font-bold text-white shadow-[0_10px_24px_rgba(30,64,175,0.28)] disabled:opacity-60"
830
+ >
831
+ {isNavigating ? "경로 생성 중..." : "경로 생성하기"}
832
+ </button>
833
+ </div>
834
  </main>
835
  );
836
  }
837
 
 
838
  export default function Page() {
839
  return (
840
  <Suspense fallback={null}>
main/app/route/[id]/page.tsx CHANGED
@@ -163,11 +163,11 @@ export default function Page() {
163
 
164
  if (!route) {
165
  return (
166
- <main className="min-h-screen bg-[#f7f8fc]">
167
  <TopBar userName="러너" title="Run The Shape" largeBrand />
168
 
169
- <section className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 py-8 sm:px-10">
170
- <section className="rounded-2xl bg-white p-8 shadow-sm">
171
  <h1 className="text-2xl font-bold text-[#222]">루트를 찾을 수 없습니다.</h1>
172
  <p className="mt-2 text-sm text-[#666]">
173
  요청한 루트 정보가 없거나 삭제된 루트입니다.
@@ -177,7 +177,7 @@ export default function Page() {
177
  <button
178
  type="button"
179
  onClick={() => router.back()}
180
- className="inline-flex h-[44px] items-center justify-center rounded-xl bg-black px-5 text-sm font-semibold text-white transition hover:opacity-90"
181
  >
182
  이전 페이지로 돌아가기
183
  </button>
@@ -208,11 +208,11 @@ export default function Page() {
208
  (route as CommunityPost & { difficulty?: string }).difficulty ?? "정보 없음";
209
 
210
  return (
211
- <main className="min-h-screen bg-[#f7f8fc]">
212
  <TopBar userName="러너" title="Run The Shape" largeBrand />
213
 
214
- <section className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 py-8 sm:px-10">
215
- <section className="rounded-2xl bg-white p-8 shadow-sm">
216
  <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
217
  <div>
218
  <p className="text-sm text-[#666]">{placeLabel}</p>
@@ -252,7 +252,7 @@ export default function Page() {
252
  type="button"
253
  onClick={() => void handleUpdate()}
254
  disabled={isSubmittingAction}
255
- className="inline-flex h-[40px] items-center justify-center rounded-xl bg-black px-4 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-60"
256
  >
257
  저장
258
  </button>
@@ -282,7 +282,7 @@ export default function Page() {
282
  </div>
283
  </section>
284
 
285
- <section className="overflow-hidden rounded-2xl bg-white shadow-sm">
286
  <div className="h-[320px] w-full bg-[#eef1f4]">
287
  <LeafletMap
288
  center={route.map?.center ?? DEFAULT_MAP_CENTER}
@@ -293,7 +293,7 @@ export default function Page() {
293
  </div>
294
  </section>
295
 
296
- <section className="rounded-2xl bg-white p-8 shadow-sm">
297
  <h2 className="mb-4 text-xl font-bold text-[#222]">러닝 기록</h2>
298
 
299
  <div className="grid grid-cols-2 gap-3 text-sm md:grid-cols-3">
@@ -344,7 +344,7 @@ export default function Page() {
344
  </div>
345
  </section>
346
 
347
- <section className="rounded-2xl bg-white p-8 shadow-sm">
348
  <h2 className="text-xl font-bold text-[#222]">루트 설명</h2>
349
  {isEditing ? (
350
  <textarea
@@ -390,14 +390,14 @@ export default function Page() {
390
 
391
  <button
392
  type="button"
393
- className="inline-flex h-[44px] items-center justify-center rounded-xl bg-black px-5 text-sm font-semibold text-white transition hover:opacity-90"
394
  >
395
  이 루트로 달리기
396
  </button>
397
 
398
  <button
399
  type="button"
400
- className="inline-flex h-[44px] items-center justify-center rounded-xl bg-white px-5 text-sm font-semibold text-[#222] shadow-sm transition hover:opacity-90"
401
  >
402
  좋아요 {likes}
403
  </button>
 
163
 
164
  if (!route) {
165
  return (
166
+ <main className="rts-page">
167
  <TopBar userName="러너" title="Run The Shape" largeBrand />
168
 
169
+ <section className="rts-shell">
170
+ <section className="rts-card p-8">
171
  <h1 className="text-2xl font-bold text-[#222]">루트를 찾을 수 없습니다.</h1>
172
  <p className="mt-2 text-sm text-[#666]">
173
  요청한 루트 정보가 없거나 삭제된 루트입니다.
 
177
  <button
178
  type="button"
179
  onClick={() => router.back()}
180
+ className="rts-primary-button h-[44px] px-5"
181
  >
182
  이전 페이지로 돌아가기
183
  </button>
 
208
  (route as CommunityPost & { difficulty?: string }).difficulty ?? "정보 없음";
209
 
210
  return (
211
+ <main className="rts-page">
212
  <TopBar userName="러너" title="Run The Shape" largeBrand />
213
 
214
+ <section className="rts-shell flex flex-col gap-6">
215
+ <section className="rts-card p-8">
216
  <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
217
  <div>
218
  <p className="text-sm text-[#666]">{placeLabel}</p>
 
252
  type="button"
253
  onClick={() => void handleUpdate()}
254
  disabled={isSubmittingAction}
255
+ className="rts-primary-button h-[40px] px-4 disabled:opacity-60"
256
  >
257
  저장
258
  </button>
 
282
  </div>
283
  </section>
284
 
285
+ <section className="rts-card rts-card-clip">
286
  <div className="h-[320px] w-full bg-[#eef1f4]">
287
  <LeafletMap
288
  center={route.map?.center ?? DEFAULT_MAP_CENTER}
 
293
  </div>
294
  </section>
295
 
296
+ <section className="rts-card p-8">
297
  <h2 className="mb-4 text-xl font-bold text-[#222]">러닝 기록</h2>
298
 
299
  <div className="grid grid-cols-2 gap-3 text-sm md:grid-cols-3">
 
344
  </div>
345
  </section>
346
 
347
+ <section className="rts-card p-8">
348
  <h2 className="text-xl font-bold text-[#222]">루트 설명</h2>
349
  {isEditing ? (
350
  <textarea
 
390
 
391
  <button
392
  type="button"
393
+ className="rts-primary-button h-[44px] px-5"
394
  >
395
  이 루트로 달리기
396
  </button>
397
 
398
  <button
399
  type="button"
400
+ className="rts-secondary-button h-[44px] px-5"
401
  >
402
  좋아요 {likes}
403
  </button>
main/app/saved-routes/page.tsx CHANGED
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
4
  import { useRouter } from "next/navigation";
5
  import TopBar from "../_components/TopBar";
6
  import LeafletMap from "../_components/LeafletMap";
 
7
  import {
8
  readSavedRoutesFromStorage,
9
  removeSavedRoute,
@@ -59,8 +60,8 @@ function RouteCard({
59
  const hasRouteCoords = route.routeCoords.length > 1;
60
 
61
  return (
62
- <article className="overflow-hidden rounded-2xl border border-black/10 bg-white shadow-sm">
63
- <div className="h-36 w-full overflow-hidden bg-[#f2f4f8]">
64
  {hasRouteCoords ? (
65
  <LeafletMap
66
  center={route.center ?? route.routeCoords[0]}
@@ -74,15 +75,15 @@ function RouteCard({
74
  </div>
75
  )}
76
  </div>
77
- <div className="flex flex-col gap-2 px-4 py-3">
78
  <div className="flex items-start justify-between gap-3">
79
- <h4 className="font-semibold text-black text-base leading-tight">{route.title}</h4>
80
  <button
81
  type="button"
82
  onClick={() => onRemove(route.id)}
83
  aria-label="저장된 루트에서 제거"
84
  title="저장된 루트에서 제거"
85
- className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-black/10 bg-white"
86
  >
87
  <BookmarkIcon filled />
88
  </button>
@@ -90,12 +91,12 @@ function RouteCard({
90
  <div className="text-sm text-[#666]">거리 {route.distanceKm.toFixed(2)}km</div>
91
  <div className="text-sm text-[#666]">{route.place}</div>
92
  <div className="text-xs text-[#777]">저장 시각 {formatSavedAt(route.createdAt)}</div>
93
- <div className="mt-1 flex flex-wrap items-center gap-2">
94
  <span className="rounded-full bg-black/5 px-2 py-0.5 text-[11px] text-[#444]">
95
  {formatLabel(route.label)}
96
  </span>
97
  <span className="text-[11px] text-[#666]">
98
- CLIP 단독 {route.clipScore !== null ? route.clipScore.toFixed(6) : "-"}
99
  </span>
100
  </div>
101
  </div>
@@ -140,22 +141,22 @@ export default function SavedRoutesPage() {
140
  }, [routeCards, searchTerm]);
141
 
142
  return (
143
- <main className="min-h-screen bg-white">
144
  <TopBar userName="러너" />
145
 
146
- <section className="px-6 py-8">
147
- <header className="flex items-center gap-4 mb-6">
148
- <button onClick={() => router.push("/lobby")} className="text-2xl">‹</button>
149
- <h1 className="text-2xl font-semibold">저장된 루트</h1>
150
  </header>
151
 
152
- <div className="mb-4">
153
  <input
154
  type="text"
155
  placeholder="루트 검색"
156
  value={searchTerm}
157
  onChange={e => setSearchTerm(e.target.value)}
158
- className="w-full rounded-xl border border-black/10 px-4 py-2 focus:outline-none"
159
  />
160
  </div>
161
 
@@ -164,14 +165,14 @@ export default function SavedRoutesPage() {
164
  <RouteCard key={card.id} route={card} onRemove={handleRemove} />
165
  ))}
166
  {filteredCards.length === 0 && (
167
- <p className="text-center text-gray-500 col-span-2">
168
  {routeCards.length === 0
169
  ? "저장된 루트가 없습니다. 코스 결과 화면에서 북마크를 눌러 저장해 주세요."
170
  : "검색 결과가 없습니다."}
171
  </p>
172
  )}
173
  </div>
174
- </section>
175
  </main>
176
  );
177
  }
 
4
  import { useRouter } from "next/navigation";
5
  import TopBar from "../_components/TopBar";
6
  import LeafletMap from "../_components/LeafletMap";
7
+ import { PageContainer } from "../_components/RunShapeUI";
8
  import {
9
  readSavedRoutesFromStorage,
10
  removeSavedRoute,
 
60
  const hasRouteCoords = route.routeCoords.length > 1;
61
 
62
  return (
63
+ <article className="rts-card rts-card-clip">
64
+ <div className="h-40 w-full overflow-hidden bg-[#f2f4f8]">
65
  {hasRouteCoords ? (
66
  <LeafletMap
67
  center={route.center ?? route.routeCoords[0]}
 
75
  </div>
76
  )}
77
  </div>
78
+ <div className="flex flex-col gap-3 px-5 py-4">
79
  <div className="flex items-start justify-between gap-3">
80
+ <h4 className="text-base font-semibold leading-tight text-black">{route.title}</h4>
81
  <button
82
  type="button"
83
  onClick={() => onRemove(route.id)}
84
  aria-label="저장된 루트에서 제거"
85
  title="저장된 루트에서 제거"
86
+ className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-[#e5e5e5] bg-white"
87
  >
88
  <BookmarkIcon filled />
89
  </button>
 
91
  <div className="text-sm text-[#666]">거리 {route.distanceKm.toFixed(2)}km</div>
92
  <div className="text-sm text-[#666]">{route.place}</div>
93
  <div className="text-xs text-[#777]">저장 시각 {formatSavedAt(route.createdAt)}</div>
94
+ <div className="flex flex-wrap items-center justify-between gap-2 border-t border-[#e5e5e5] pt-3">
95
  <span className="rounded-full bg-black/5 px-2 py-0.5 text-[11px] text-[#444]">
96
  {formatLabel(route.label)}
97
  </span>
98
  <span className="text-[11px] text-[#666]">
99
+ 형태 점수 {route.clipScore !== null ? Math.round(route.clipScore * 100) : "-"}
100
  </span>
101
  </div>
102
  </div>
 
141
  }, [routeCards, searchTerm]);
142
 
143
  return (
144
+ <main className="rts-page">
145
  <TopBar userName="러너" />
146
 
147
+ <PageContainer>
148
+ <header className="rts-page-header">
149
+ <button onClick={() => router.push("/lobby")} className="rts-back-button">‹</button>
150
+ <h1>저장된 루트</h1>
151
  </header>
152
 
153
+ <div className="my-5">
154
  <input
155
  type="text"
156
  placeholder="루트 검색"
157
  value={searchTerm}
158
  onChange={e => setSearchTerm(e.target.value)}
159
+ className="rts-input"
160
  />
161
  </div>
162
 
 
165
  <RouteCard key={card.id} route={card} onRemove={handleRemove} />
166
  ))}
167
  {filteredCards.length === 0 && (
168
+ <p className="rts-empty-state col-span-2">
169
  {routeCards.length === 0
170
  ? "저장된 루트가 없습니다. 코스 결과 화면에서 북마크를 눌러 저장해 주세요."
171
  : "검색 결과가 없습니다."}
172
  </p>
173
  )}
174
  </div>
175
+ </PageContainer>
176
  </main>
177
  );
178
  }
main/app/stats/complete-number/page.tsx CHANGED
@@ -329,7 +329,6 @@ export default function CompleteCalendarPage() {
329
  </button>
330
 
331
  <div className="titleBox">
332
- <p className="eyebrow">RUNNING STATS</p>
333
  <h1>완주 캘린더</h1>
334
  <p className="subText">완주한 날짜에 루트 모양이 자동으로 기록됩니다.</p>
335
  </div>
@@ -419,7 +418,6 @@ export default function CompleteCalendarPage() {
419
  <article className="selectedPanel">
420
  <div className="panelHeader">
421
  <div>
422
- <p className="panelEyebrow">SELECTED DAY</p>
423
  <h2>{formatKoreanDate(viewYear, viewMonth, selectedDay)}</h2>
424
  <p className="routeName">
425
  {selectedRouteRecord?.routeTitle ?? "완주한 루트 기록 없음"}
@@ -532,12 +530,6 @@ export default function CompleteCalendarPage() {
532
  min-height: 100vh;
533
  background: #ffffff;
534
  color: #111111;
535
- font-family:
536
- Arial,
537
- -apple-system,
538
- BlinkMacSystemFont,
539
- "Segoe UI",
540
- sans-serif;
541
  overflow-x: hidden;
542
  }
543
 
@@ -586,20 +578,10 @@ export default function CompleteCalendarPage() {
586
  min-width: 0;
587
  }
588
 
589
- .eyebrow,
590
- .panelEyebrow {
591
- margin: 0 0 4px;
592
- font-size: 11px;
593
- font-weight: 700;
594
- letter-spacing: 0.1em;
595
- color: #8a8a8a;
596
- }
597
-
598
  .topHeader h1 {
599
  margin: 0;
600
  font-size: 24px;
601
  font-weight: 700;
602
- letter-spacing: -0.03em;
603
  }
604
 
605
  .subText {
@@ -641,7 +623,6 @@ export default function CompleteCalendarPage() {
641
  margin: 0;
642
  font-size: 28px;
643
  font-weight: 800;
644
- letter-spacing: -0.04em;
645
  }
646
 
647
  .monthTitle p {
@@ -816,7 +797,6 @@ export default function CompleteCalendarPage() {
816
  margin: 0;
817
  font-size: 22px;
818
  font-weight: 800;
819
- letter-spacing: -0.04em;
820
  }
821
 
822
  .routeName {
@@ -970,7 +950,6 @@ export default function CompleteCalendarPage() {
970
  line-height: 1.5;
971
  resize: vertical;
972
  outline: none;
973
- font-family: inherit;
974
  }
975
 
976
  textarea:focus {
@@ -1078,10 +1057,6 @@ export default function CompleteCalendarPage() {
1078
  padding: 0 2px 14px;
1079
  }
1080
 
1081
- .eyebrow {
1082
- display: none;
1083
- }
1084
-
1085
  .topHeader h1 {
1086
  font-size: 22px;
1087
  }
@@ -1210,4 +1185,4 @@ export default function CompleteCalendarPage() {
1210
  `}</style>
1211
  </main>
1212
  );
1213
- }
 
329
  </button>
330
 
331
  <div className="titleBox">
 
332
  <h1>완주 캘린더</h1>
333
  <p className="subText">완주한 날짜에 루트 모양이 자동으로 기록됩니다.</p>
334
  </div>
 
418
  <article className="selectedPanel">
419
  <div className="panelHeader">
420
  <div>
 
421
  <h2>{formatKoreanDate(viewYear, viewMonth, selectedDay)}</h2>
422
  <p className="routeName">
423
  {selectedRouteRecord?.routeTitle ?? "완주한 루트 기록 없음"}
 
530
  min-height: 100vh;
531
  background: #ffffff;
532
  color: #111111;
 
 
 
 
 
 
533
  overflow-x: hidden;
534
  }
535
 
 
578
  min-width: 0;
579
  }
580
 
 
 
 
 
 
 
 
 
 
581
  .topHeader h1 {
582
  margin: 0;
583
  font-size: 24px;
584
  font-weight: 700;
 
585
  }
586
 
587
  .subText {
 
623
  margin: 0;
624
  font-size: 28px;
625
  font-weight: 800;
 
626
  }
627
 
628
  .monthTitle p {
 
797
  margin: 0;
798
  font-size: 22px;
799
  font-weight: 800;
 
800
  }
801
 
802
  .routeName {
 
950
  line-height: 1.5;
951
  resize: vertical;
952
  outline: none;
 
953
  }
954
 
955
  textarea:focus {
 
1057
  padding: 0 2px 14px;
1058
  }
1059
 
 
 
 
 
1060
  .topHeader h1 {
1061
  font-size: 22px;
1062
  }
 
1185
  `}</style>
1186
  </main>
1187
  );
1188
+ }
main/app/stats/page.tsx CHANGED
@@ -329,7 +329,6 @@ export default function StatsPage() {
329
  </button>
330
 
331
  <div>
332
- <p className="eyebrow">RUNNING STATS</p>
333
  <h1>활동</h1>
334
  </div>
335
  </header>
@@ -424,7 +423,6 @@ export default function StatsPage() {
424
  <aside className="sideColumn">
425
  <section className="detailSection">
426
  <div className="sectionHeader">
427
- <p className="sectionEyebrow">RECENT</p>
428
  <h2>전체 활동 통계</h2>
429
  </div>
430
 
@@ -477,12 +475,6 @@ export default function StatsPage() {
477
  min-height: 100vh;
478
  background: #f6f6f6;
479
  color: #111111;
480
- font-family:
481
- Arial,
482
- -apple-system,
483
- BlinkMacSystemFont,
484
- "Segoe UI",
485
- sans-serif;
486
  }
487
 
488
  .shell {
@@ -519,19 +511,10 @@ export default function StatsPage() {
519
  flex-shrink: 0;
520
  }
521
 
522
- .eyebrow {
523
- margin: 0 0 4px;
524
- font-size: 11px;
525
- font-weight: 700;
526
- letter-spacing: 0.1em;
527
- color: #8a8a8a;
528
- }
529
-
530
  .header h1 {
531
  margin: 0;
532
  font-size: 28px;
533
  font-weight: 700;
534
- letter-spacing: -0.04em;
535
  }
536
 
537
  .dashboardGrid {
@@ -561,7 +544,6 @@ export default function StatsPage() {
561
  font-size: 22px;
562
  font-weight: 500;
563
  color: #111111;
564
- letter-spacing: -0.03em;
565
  }
566
 
567
  .mainDistance {
@@ -579,8 +561,7 @@ export default function StatsPage() {
579
  .distanceNumber {
580
  font-size: clamp(64px, 10vw, 118px);
581
  line-height: 0.9;
582
- font-weight: 900;
583
- letter-spacing: -0.08em;
584
  }
585
 
586
  .distanceUnit {
@@ -615,7 +596,6 @@ export default function StatsPage() {
615
  margin-bottom: 8px;
616
  font-size: clamp(20px, 3vw, 34px);
617
  font-weight: 800;
618
- letter-spacing: -0.04em;
619
  white-space: nowrap;
620
  overflow: hidden;
621
  text-overflow: ellipsis;
@@ -718,19 +698,10 @@ export default function StatsPage() {
718
  margin-bottom: 22px;
719
  }
720
 
721
- .sectionEyebrow {
722
- margin: 0 0 4px;
723
- font-size: 11px;
724
- font-weight: 700;
725
- letter-spacing: 0.1em;
726
- color: #9b9b9b;
727
- }
728
-
729
  .detailSection h2 {
730
  margin: 0;
731
  font-size: 26px;
732
  font-weight: 700;
733
- letter-spacing: -0.04em;
734
  }
735
 
736
  .detailCard {
@@ -771,7 +742,6 @@ export default function StatsPage() {
771
  font-size: clamp(30px, 4vw, 48px);
772
  line-height: 1;
773
  font-weight: 800;
774
- letter-spacing: -0.06em;
775
  }
776
 
777
  .errorBox {
@@ -814,8 +784,7 @@ export default function StatsPage() {
814
  border-bottom: 1px solid #d9d9d9;
815
  }
816
 
817
- .backButton,
818
- .eyebrow {
819
  display: none;
820
  }
821
 
@@ -907,10 +876,6 @@ export default function StatsPage() {
907
  font-size: 16px;
908
  }
909
 
910
- .sectionEyebrow {
911
- display: none;
912
- }
913
-
914
  .detailSection h2 {
915
  font-size: 28px;
916
  }
@@ -966,4 +931,4 @@ export default function StatsPage() {
966
  `}</style>
967
  </main>
968
  );
969
- }
 
329
  </button>
330
 
331
  <div>
 
332
  <h1>활동</h1>
333
  </div>
334
  </header>
 
423
  <aside className="sideColumn">
424
  <section className="detailSection">
425
  <div className="sectionHeader">
 
426
  <h2>전체 활동 통계</h2>
427
  </div>
428
 
 
475
  min-height: 100vh;
476
  background: #f6f6f6;
477
  color: #111111;
 
 
 
 
 
 
478
  }
479
 
480
  .shell {
 
511
  flex-shrink: 0;
512
  }
513
 
 
 
 
 
 
 
 
 
514
  .header h1 {
515
  margin: 0;
516
  font-size: 28px;
517
  font-weight: 700;
 
518
  }
519
 
520
  .dashboardGrid {
 
544
  font-size: 22px;
545
  font-weight: 500;
546
  color: #111111;
 
547
  }
548
 
549
  .mainDistance {
 
561
  .distanceNumber {
562
  font-size: clamp(64px, 10vw, 118px);
563
  line-height: 0.9;
564
+ font-weight: 800;
 
565
  }
566
 
567
  .distanceUnit {
 
596
  margin-bottom: 8px;
597
  font-size: clamp(20px, 3vw, 34px);
598
  font-weight: 800;
 
599
  white-space: nowrap;
600
  overflow: hidden;
601
  text-overflow: ellipsis;
 
698
  margin-bottom: 22px;
699
  }
700
 
 
 
 
 
 
 
 
 
701
  .detailSection h2 {
702
  margin: 0;
703
  font-size: 26px;
704
  font-weight: 700;
 
705
  }
706
 
707
  .detailCard {
 
742
  font-size: clamp(30px, 4vw, 48px);
743
  line-height: 1;
744
  font-weight: 800;
 
745
  }
746
 
747
  .errorBox {
 
784
  border-bottom: 1px solid #d9d9d9;
785
  }
786
 
787
+ .backButton {
 
788
  display: none;
789
  }
790
 
 
876
  font-size: 16px;
877
  }
878
 
 
 
 
 
879
  .detailSection h2 {
880
  font-size: 28px;
881
  }
 
931
  `}</style>
932
  </main>
933
  );
934
+ }
main/app/stats/total-length/page.tsx CHANGED
@@ -400,7 +400,6 @@ export default function TotalLengthPage() {
400
  </button>
401
 
402
  <div>
403
- <p className="eyebrow">RUNNING STATS</p>
404
  <h1>이동 거리</h1>
405
  </div>
406
  </header>
@@ -586,7 +585,6 @@ export default function TotalLengthPage() {
586
  <aside className="sideColumn">
587
  <section className="recentSection">
588
  <div className="sectionHeader">
589
- <p className="sectionEyebrow">RECENT</p>
590
  <h2>최근 활동</h2>
591
  </div>
592
 
@@ -629,12 +627,6 @@ export default function TotalLengthPage() {
629
  min-height: 100vh;
630
  background: #ffffff;
631
  color: #111111;
632
- font-family:
633
- Arial,
634
- -apple-system,
635
- BlinkMacSystemFont,
636
- "Segoe UI",
637
- sans-serif;
638
  overflow-x: hidden;
639
  }
640
 
@@ -679,19 +671,10 @@ export default function TotalLengthPage() {
679
  flex-shrink: 0;
680
  }
681
 
682
- .eyebrow {
683
- margin: 0 0 4px;
684
- font-size: 11px;
685
- font-weight: 700;
686
- letter-spacing: 0.1em;
687
- color: #8a8a8a;
688
- }
689
-
690
  .topHeader h1 {
691
  margin: 0;
692
  font-size: 24px;
693
  font-weight: 600;
694
- letter-spacing: -0.03em;
695
  }
696
 
697
  .dashboardGrid {
@@ -826,9 +809,7 @@ export default function TotalLengthPage() {
826
  margin: 0;
827
  font-size: clamp(34px, 5vw, 56px);
828
  line-height: 1;
829
- font-weight: 900;
830
- font-style: italic;
831
- letter-spacing: -0.06em;
832
  }
833
 
834
  .unitToggle {
@@ -1004,19 +985,10 @@ export default function TotalLengthPage() {
1004
  margin-bottom: 22px;
1005
  }
1006
 
1007
- .sectionEyebrow {
1008
- margin: 0 0 4px;
1009
- font-size: 11px;
1010
- font-weight: 700;
1011
- letter-spacing: 0.1em;
1012
- color: #9b9b9b;
1013
- }
1014
-
1015
  .recentSection h2 {
1016
  margin: 0;
1017
  font-size: 22px;
1018
  font-weight: 600;
1019
- letter-spacing: -0.03em;
1020
  }
1021
 
1022
  .activityCard,
@@ -1047,10 +1019,8 @@ export default function TotalLengthPage() {
1047
  .activityDistance {
1048
  margin: 0 0 18px;
1049
  font-size: 20px;
1050
- font-weight: 900;
1051
- font-style: italic;
1052
  color: #111111;
1053
- letter-spacing: -0.04em;
1054
  }
1055
 
1056
  .activityImage,
@@ -1117,8 +1087,6 @@ export default function TotalLengthPage() {
1117
  margin-top: 28px;
1118
  }
1119
 
1120
- .eyebrow,
1121
- .sectionEyebrow,
1122
  .summaryLabel {
1123
  display: none;
1124
  }
@@ -1231,4 +1199,4 @@ export default function TotalLengthPage() {
1231
  `}</style>
1232
  </main>
1233
  );
1234
- }
 
400
  </button>
401
 
402
  <div>
 
403
  <h1>이동 거리</h1>
404
  </div>
405
  </header>
 
585
  <aside className="sideColumn">
586
  <section className="recentSection">
587
  <div className="sectionHeader">
 
588
  <h2>최근 활동</h2>
589
  </div>
590
 
 
627
  min-height: 100vh;
628
  background: #ffffff;
629
  color: #111111;
 
 
 
 
 
 
630
  overflow-x: hidden;
631
  }
632
 
 
671
  flex-shrink: 0;
672
  }
673
 
 
 
 
 
 
 
 
 
674
  .topHeader h1 {
675
  margin: 0;
676
  font-size: 24px;
677
  font-weight: 600;
 
678
  }
679
 
680
  .dashboardGrid {
 
809
  margin: 0;
810
  font-size: clamp(34px, 5vw, 56px);
811
  line-height: 1;
812
+ font-weight: 800;
 
 
813
  }
814
 
815
  .unitToggle {
 
985
  margin-bottom: 22px;
986
  }
987
 
 
 
 
 
 
 
 
 
988
  .recentSection h2 {
989
  margin: 0;
990
  font-size: 22px;
991
  font-weight: 600;
 
992
  }
993
 
994
  .activityCard,
 
1019
  .activityDistance {
1020
  margin: 0 0 18px;
1021
  font-size: 20px;
1022
+ font-weight: 800;
 
1023
  color: #111111;
 
1024
  }
1025
 
1026
  .activityImage,
 
1087
  margin-top: 28px;
1088
  }
1089
 
 
 
1090
  .summaryLabel {
1091
  display: none;
1092
  }
 
1199
  `}</style>
1200
  </main>
1201
  );
1202
+ }
main/app/stats/total-time/page.tsx CHANGED
@@ -407,7 +407,6 @@ export default function TotalTimePage() {
407
  </button>
408
 
409
  <div>
410
- <p className="eyebrow">RUNNING STATS</p>
411
  <h1>총 시간</h1>
412
  </div>
413
  </header>
@@ -601,7 +600,6 @@ export default function TotalTimePage() {
601
  <aside className="sideColumn">
602
  <section className="recentSection">
603
  <div className="sectionHeader">
604
- <p className="sectionEyebrow">RECENT</p>
605
  <h2>최근 활동</h2>
606
  </div>
607
 
@@ -644,12 +642,6 @@ export default function TotalTimePage() {
644
  min-height: 100vh;
645
  background: #ffffff;
646
  color: #111111;
647
- font-family:
648
- Arial,
649
- -apple-system,
650
- BlinkMacSystemFont,
651
- "Segoe UI",
652
- sans-serif;
653
  overflow-x: hidden;
654
  }
655
 
@@ -694,19 +686,10 @@ export default function TotalTimePage() {
694
  flex-shrink: 0;
695
  }
696
 
697
- .eyebrow {
698
- margin: 0 0 4px;
699
- font-size: 11px;
700
- font-weight: 700;
701
- letter-spacing: 0.1em;
702
- color: #8a8a8a;
703
- }
704
-
705
  .topHeader h1 {
706
  margin: 0;
707
  font-size: 24px;
708
  font-weight: 600;
709
- letter-spacing: -0.03em;
710
  }
711
 
712
  .dashboardGrid {
@@ -841,9 +824,7 @@ export default function TotalTimePage() {
841
  margin: 0;
842
  font-size: clamp(34px, 5vw, 56px);
843
  line-height: 1;
844
- font-weight: 900;
845
- font-style: italic;
846
- letter-spacing: -0.06em;
847
  }
848
 
849
  .unitToggle {
@@ -1019,19 +1000,10 @@ export default function TotalTimePage() {
1019
  margin-bottom: 22px;
1020
  }
1021
 
1022
- .sectionEyebrow {
1023
- margin: 0 0 4px;
1024
- font-size: 11px;
1025
- font-weight: 700;
1026
- letter-spacing: 0.1em;
1027
- color: #9b9b9b;
1028
- }
1029
-
1030
  .recentSection h2 {
1031
  margin: 0;
1032
  font-size: 22px;
1033
  font-weight: 600;
1034
- letter-spacing: -0.03em;
1035
  }
1036
 
1037
  .activityCard,
@@ -1062,10 +1034,8 @@ export default function TotalTimePage() {
1062
  .activityDuration {
1063
  margin: 0 0 18px;
1064
  font-size: 20px;
1065
- font-weight: 900;
1066
- font-style: italic;
1067
  color: #111111;
1068
- letter-spacing: -0.04em;
1069
  }
1070
 
1071
  .activityImage,
@@ -1132,8 +1102,6 @@ export default function TotalTimePage() {
1132
  margin-top: 28px;
1133
  }
1134
 
1135
- .eyebrow,
1136
- .sectionEyebrow,
1137
  .summaryLabel {
1138
  display: none;
1139
  }
@@ -1246,4 +1214,4 @@ export default function TotalTimePage() {
1246
  `}</style>
1247
  </main>
1248
  );
1249
- }
 
407
  </button>
408
 
409
  <div>
 
410
  <h1>총 시간</h1>
411
  </div>
412
  </header>
 
600
  <aside className="sideColumn">
601
  <section className="recentSection">
602
  <div className="sectionHeader">
 
603
  <h2>최근 활동</h2>
604
  </div>
605
 
 
642
  min-height: 100vh;
643
  background: #ffffff;
644
  color: #111111;
 
 
 
 
 
 
645
  overflow-x: hidden;
646
  }
647
 
 
686
  flex-shrink: 0;
687
  }
688
 
 
 
 
 
 
 
 
 
689
  .topHeader h1 {
690
  margin: 0;
691
  font-size: 24px;
692
  font-weight: 600;
 
693
  }
694
 
695
  .dashboardGrid {
 
824
  margin: 0;
825
  font-size: clamp(34px, 5vw, 56px);
826
  line-height: 1;
827
+ font-weight: 800;
 
 
828
  }
829
 
830
  .unitToggle {
 
1000
  margin-bottom: 22px;
1001
  }
1002
 
 
 
 
 
 
 
 
 
1003
  .recentSection h2 {
1004
  margin: 0;
1005
  font-size: 22px;
1006
  font-weight: 600;
 
1007
  }
1008
 
1009
  .activityCard,
 
1034
  .activityDuration {
1035
  margin: 0 0 18px;
1036
  font-size: 20px;
1037
+ font-weight: 800;
 
1038
  color: #111111;
 
1039
  }
1040
 
1041
  .activityImage,
 
1102
  margin-top: 28px;
1103
  }
1104
 
 
 
1105
  .summaryLabel {
1106
  display: none;
1107
  }
 
1214
  `}</style>
1215
  </main>
1216
  );
1217
+ }
main/app/trend-routes/page.tsx CHANGED
@@ -173,7 +173,7 @@ function TopRouteCard({
173
 
174
  return (
175
  <article
176
- className={`rounded-2xl border border-black/10 bg-white shadow-sm ${
177
  large ? "p-8" : "p-6"
178
  }`}
179
  >
@@ -232,7 +232,7 @@ function TopRouteCard({
232
  <div className="mt-6">
233
  <Link
234
  href={`/route/${route.id}`}
235
- className="inline-flex h-[44px] items-center justify-center rounded-xl bg-[#bfdcff] px-5 text-sm font-semibold text-black shadow-sm transition-shadow hover:shadow-md"
236
  >
237
  해당 루트 보러가기
238
  </Link>
@@ -292,17 +292,17 @@ export default function Page() {
292
  }, [posts, sortType]);
293
 
294
  return (
295
- <main className="min-h-screen bg-[#f6f6f6]">
296
  <TopBar userName="러너" title="Run The Shape" largeBrand />
297
 
298
- <section className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-6 py-8 sm:px-10">
299
  {loadError ? (
300
- <div className="rounded-xl border border-[#bfdcff] bg-[#eef7ff] px-4 py-3 text-sm text-[#2459a8]">
301
  {loadError}
302
  </div>
303
  ) : null}
304
 
305
- <section className="rounded-2xl border border-black/10 bg-white p-8 shadow-sm">
306
  <div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
307
  <div>
308
  <p className="text-xs font-bold tracking-[0.14em] text-[#8a8a8a]">
@@ -344,7 +344,7 @@ export default function Page() {
344
  </section>
345
 
346
  {topRoutes.length === 0 ? (
347
- <section className="rounded-2xl border border-black/10 bg-white p-8 text-center shadow-sm">
348
  <h2 className="text-2xl font-bold text-black">인기 루트가 아직 없습니다.</h2>
349
 
350
  <p className="mt-2 text-sm text-[#666]">
@@ -354,7 +354,7 @@ export default function Page() {
354
  <div className="mt-6">
355
  <Link
356
  href="/recent-shared-routes"
357
- className="inline-flex h-[44px] items-center justify-center rounded-xl bg-[#bfdcff] px-5 text-sm font-semibold text-black shadow-sm transition-shadow hover:shadow-md"
358
  >
359
  최근 공유 루트 보러가기
360
  </Link>
 
173
 
174
  return (
175
  <article
176
+ className={`rts-card ${
177
  large ? "p-8" : "p-6"
178
  }`}
179
  >
 
232
  <div className="mt-6">
233
  <Link
234
  href={`/route/${route.id}`}
235
+ className="rts-primary-button h-[44px] px-5"
236
  >
237
  해당 루트 보러가기
238
  </Link>
 
292
  }, [posts, sortType]);
293
 
294
  return (
295
+ <main className="rts-page rts-page-soft">
296
  <TopBar userName="러너" title="Run The Shape" largeBrand />
297
 
298
+ <section className="rts-shell flex flex-col gap-6">
299
  {loadError ? (
300
+ <div className="rounded-[18px] border border-[#bfdcff] bg-[#eef7ff] px-4 py-3 text-sm text-[#2459a8]">
301
  {loadError}
302
  </div>
303
  ) : null}
304
 
305
+ <section className="rts-card p-8">
306
  <div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
307
  <div>
308
  <p className="text-xs font-bold tracking-[0.14em] text-[#8a8a8a]">
 
344
  </section>
345
 
346
  {topRoutes.length === 0 ? (
347
+ <section className="rts-empty-state">
348
  <h2 className="text-2xl font-bold text-black">인기 루트가 아직 없습니다.</h2>
349
 
350
  <p className="mt-2 text-sm text-[#666]">
 
354
  <div className="mt-6">
355
  <Link
356
  href="/recent-shared-routes"
357
+ className="rts-primary-button h-[44px] px-5"
358
  >
359
  최근 공유 루트 보러가기
360
  </Link>