Spaces:
Running
Running
Sync from GitHub via hub-sync
Browse files- api/ai-service/public/generated_routes/Gangnam_gu_Seoul_South_Korea_000.png +0 -0
- api/ai-service/public/generated_routes/Gangnam_gu_Seoul_South_Korea_001.png +0 -0
- api/ai-service/public/generated_routes/Gangnam_gu_Seoul_South_Korea_002.png +0 -0
- main/app/_utils/generatedRouteDetails.ts +19 -2
- main/app/community/new/page.tsx +8 -8
- main/app/globals.css +408 -0
- main/app/loading-preview/page.tsx +1 -1
- main/app/lobby/page.tsx +157 -201
- main/app/manual-route/page.tsx +13 -13
- main/app/my-posts/page.tsx +85 -204
- main/app/mypage/page.tsx +111 -46
- main/app/recent-shared-routes/page.tsx +15 -15
- main/app/route-results/[id]/page.tsx +183 -197
- main/app/route-results/page.tsx +265 -714
- main/app/route-search/page.tsx +362 -213
- main/app/route/[id]/page.tsx +13 -13
- main/app/saved-routes/page.tsx +17 -16
- main/app/stats/complete-number/page.tsx +1 -26
- main/app/stats/page.tsx +3 -38
- main/app/stats/total-length/page.tsx +3 -35
- main/app/stats/total-time/page.tsx +3 -35
- main/app/trend-routes/page.tsx +8 -8
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 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 193 |
<TopBar />
|
| 194 |
|
| 195 |
-
<section className="
|
| 196 |
-
<div className="
|
| 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="
|
| 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="
|
| 224 |
/>
|
| 225 |
</label>
|
| 226 |
|
|
@@ -231,11 +231,11 @@ function CommunityNewPageContent() {
|
|
| 231 |
onChange={(event) => setContent(event.target.value)}
|
| 232 |
rows={4}
|
| 233 |
placeholder="오늘 러닝 어땠는지 적어보세요."
|
| 234 |
-
className="
|
| 235 |
/>
|
| 236 |
</label>
|
| 237 |
|
| 238 |
-
<article className="
|
| 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
|
| 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 |
-
"
|
| 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
|
| 120 |
typeof rating === "number"
|
| 121 |
-
? Math.max(0, Math.min(5,
|
| 122 |
-
:
|
|
|
|
| 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 <
|
| 131 |
>
|
| 132 |
★
|
| 133 |
</span>
|
| 134 |
))}
|
| 135 |
</div>
|
| 136 |
<span className="text-xs text-[#666]">
|
| 137 |
-
|
| 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="
|
| 273 |
<TopBar userName={displayName} title="Run The Shape" largeBrand />
|
| 274 |
|
| 275 |
-
<
|
| 276 |
-
<
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
</div>
|
| 287 |
-
<span className="[font-family:'Inter-SemiBold',Helvetica] text-base font-semibold text-black">
|
| 288 |
-
루트를 추천해드려요!
|
| 289 |
-
</span>
|
| 290 |
-
</div>
|
| 291 |
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
</ul>
|
| 302 |
|
|
|
|
| 303 |
<button
|
| 304 |
type="button"
|
| 305 |
-
|
| 306 |
-
|
| 307 |
>
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
</button>
|
|
|
|
| 310 |
<button
|
| 311 |
type="button"
|
| 312 |
-
|
| 313 |
-
|
| 314 |
>
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
</button>
|
| 317 |
-
</
|
| 318 |
-
|
| 319 |
-
<
|
| 320 |
-
<
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 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-
|
| 370 |
-
className="
|
| 371 |
>
|
| 372 |
-
<div className="
|
| 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="
|
| 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 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 578 |
<TopBar />
|
| 579 |
<RunStartNoticeModal
|
| 580 |
open={isRunNoticeOpen}
|
|
@@ -585,8 +585,8 @@ function ManualRoutePageContent() {
|
|
| 585 |
}}
|
| 586 |
/>
|
| 587 |
|
| 588 |
-
<section className="
|
| 589 |
-
<div className="
|
| 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-
|
| 606 |
-
: "border border-
|
| 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-
|
| 617 |
-
: "border border-
|
| 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-
|
| 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-
|
| 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="
|
| 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="
|
| 675 |
>
|
| 676 |
{isLocating ? "확인 중" : "내 위치"}
|
| 677 |
</button>
|
| 678 |
<button
|
| 679 |
type="button"
|
| 680 |
onClick={handleClear}
|
| 681 |
-
className="
|
| 682 |
>
|
| 683 |
지우기
|
| 684 |
</button>
|
|
@@ -691,7 +691,7 @@ function ManualRoutePageContent() {
|
|
| 691 |
isCreatingRoute ||
|
| 692 |
snapStatus === "creating"
|
| 693 |
}
|
| 694 |
-
className="
|
| 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 |
-
|
| 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 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 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
|
| 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="
|
| 397 |
-
<div className="relative z-0 h-
|
| 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
|
| 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 |
-
조회
|
| 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 |
-
|
| 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-
|
| 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("게시글 수정에 실패했습니다.
|
| 597 |
return;
|
| 598 |
}
|
| 599 |
|
| 600 |
-
setPosts((prev) =>
|
| 601 |
-
prev.map((post) =>
|
| 602 |
-
post.id === editingPost.id
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 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("게시글 삭제에 실패했습니다.
|
| 627 |
return;
|
| 628 |
}
|
| 629 |
|
| 630 |
-
setPosts((prev) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
};
|
| 632 |
|
| 633 |
return (
|
| 634 |
-
<main className="
|
| 635 |
<TopBar />
|
| 636 |
|
| 637 |
-
<section className="
|
| 638 |
-
<section className="
|
| 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-
|
| 656 |
{errorMessage}
|
| 657 |
</div>
|
| 658 |
-
)}
|
| 659 |
|
| 660 |
<section className="flex items-center justify-between gap-3">
|
| 661 |
-
<
|
| 662 |
-
|
| 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="
|
| 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
|
| 724 |
<div className="flex items-start justify-between gap-4">
|
| 725 |
-
<
|
| 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="
|
| 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="
|
| 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="
|
| 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: #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
}
|
| 461 |
|
| 462 |
.container {
|
| 463 |
-
max-width:
|
| 464 |
margin: 0 auto;
|
| 465 |
-
padding:
|
| 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 |
-
|
|
|
|
|
|
|
| 475 |
}
|
| 476 |
|
| 477 |
.header h1 {
|
|
@@ -481,24 +502,34 @@ export default function Page() {
|
|
| 481 |
}
|
| 482 |
|
| 483 |
.back {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
font-size: 24px;
|
| 485 |
-
|
| 486 |
-
border: none;
|
| 487 |
cursor: pointer;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
}
|
| 489 |
|
| 490 |
.profileCard {
|
| 491 |
background: #fff;
|
| 492 |
-
border
|
| 493 |
-
|
|
|
|
| 494 |
display: flex;
|
| 495 |
-
gap:
|
| 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:
|
| 586 |
-
|
| 587 |
gap: 16px;
|
| 588 |
}
|
| 589 |
|
| 590 |
.card {
|
| 591 |
background: #fff;
|
| 592 |
-
border
|
|
|
|
| 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: #
|
| 608 |
-
border-radius:
|
| 609 |
padding: 10px 12px;
|
| 610 |
font-size: 15px;
|
| 611 |
}
|
|
@@ -618,31 +665,35 @@ export default function Page() {
|
|
| 618 |
}
|
| 619 |
|
| 620 |
.changePasswordButton {
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
color: #
|
|
|
|
| 625 |
border: none;
|
| 626 |
-
font-
|
|
|
|
| 627 |
cursor: pointer;
|
| 628 |
}
|
| 629 |
|
| 630 |
.changePasswordButton:hover {
|
| 631 |
-
|
| 632 |
}
|
| 633 |
|
| 634 |
.audioFolderButton {
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
color: #
|
| 639 |
-
|
| 640 |
-
|
|
|
|
|
|
|
| 641 |
cursor: pointer;
|
| 642 |
}
|
| 643 |
|
| 644 |
.audioFolderButton:hover {
|
| 645 |
-
background-color: #
|
| 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:
|
| 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="
|
| 95 |
-
<div className="relative z-0 h-
|
| 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="
|
| 168 |
>
|
| 169 |
해당 루트 보러가기
|
| 170 |
</Link>
|
|
@@ -243,11 +243,11 @@ function RecentSharedRoutesPageContent() {
|
|
| 243 |
}, [posts, searchKeyword, sortType]);
|
| 244 |
|
| 245 |
return (
|
| 246 |
-
<main className="
|
| 247 |
<TopBar />
|
| 248 |
|
| 249 |
-
<section className="
|
| 250 |
-
<section className="
|
| 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
|
| 266 |
-
|
| 267 |
</div>
|
| 268 |
</div>
|
| 269 |
</div>
|
| 270 |
</section>
|
| 271 |
|
| 272 |
{posted && (
|
| 273 |
-
<div className="rounded-
|
| 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-
|
| 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-
|
| 298 |
>
|
| 299 |
{sortType === "latest" ? "최신순" : "완주율순"} ▼
|
| 300 |
</button>
|
| 301 |
|
| 302 |
{isSortOpen ? (
|
| 303 |
-
<div className="absolute right-0 top-[52px] z-[9999] w-[140px] overflow-hidden rounded-
|
| 304 |
<button
|
| 305 |
type="button"
|
| 306 |
onClick={() => {
|
|
@@ -343,7 +343,7 @@ function RecentSharedRoutesPageContent() {
|
|
| 343 |
|
| 344 |
<Link
|
| 345 |
href="/community/new"
|
| 346 |
-
className="
|
| 347 |
>
|
| 348 |
새 글 작성
|
| 349 |
</Link>
|
|
@@ -351,7 +351,7 @@ function RecentSharedRoutesPageContent() {
|
|
| 351 |
</div>
|
| 352 |
|
| 353 |
{filteredRoutes.length === 0 ? (
|
| 354 |
-
<div className="
|
| 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
|
| 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 |
-
|
| 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
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
-
function
|
| 49 |
-
label,
|
| 50 |
-
value,
|
| 51 |
-
}: {
|
| 52 |
-
label: string;
|
| 53 |
-
value: string | number;
|
| 54 |
-
}) {
|
| 55 |
return (
|
| 56 |
-
<div className="
|
| 57 |
-
<
|
| 58 |
-
<p className="
|
| 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 |
-
|
|
|
|
| 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
|
| 84 |
-
const
|
| 85 |
-
const
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 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 |
-
|
| 108 |
-
|
| 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 |
-
|
| 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
|
| 155 |
-
|
| 156 |
-
if (openNativeRun) {
|
| 157 |
-
return;
|
| 158 |
-
}
|
| 159 |
|
| 160 |
-
const token =
|
| 161 |
-
|
| 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 |
-
|
| 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="
|
| 237 |
<TopBar />
|
| 238 |
</main>
|
| 239 |
);
|
|
@@ -241,29 +289,19 @@ function GeneratedRouteDetailPageContent() {
|
|
| 241 |
|
| 242 |
if (!record) {
|
| 243 |
return (
|
| 244 |
-
<main className="
|
| 245 |
<TopBar />
|
| 246 |
-
<
|
| 247 |
-
<
|
| 248 |
-
|
| 249 |
-
|
| 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="
|
| 267 |
<TopBar />
|
| 268 |
<RunStartNoticeModal
|
| 269 |
open={isRunNoticeOpen}
|
|
@@ -277,116 +315,64 @@ function GeneratedRouteDetailPageContent() {
|
|
| 277 |
}}
|
| 278 |
/>
|
| 279 |
|
| 280 |
-
<
|
| 281 |
-
<
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 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 |
-
<
|
| 312 |
-
<div className="h-[
|
| 313 |
-
<LeafletMap
|
| 314 |
-
center={center}
|
| 315 |
-
route={routeCoords}
|
| 316 |
-
className="h-full w-full"
|
| 317 |
-
zoom={14}
|
| 318 |
-
/>
|
| 319 |
</div>
|
| 320 |
-
</
|
| 321 |
|
| 322 |
-
<section className="grid gap-
|
| 323 |
-
<
|
| 324 |
-
<
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
<
|
| 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 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
<
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
</
|
| 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 |
-
{
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
<
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 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 |
-
</
|
| 378 |
-
</section>
|
| 379 |
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
className="
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
</section>
|
| 389 |
-
</
|
| 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 |
-
|
| 100 |
-
"
|
| 101 |
-
"
|
| 102 |
-
"
|
| 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 |
-
|
| 117 |
-
if (value
|
| 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 |
-
|
| 181 |
-
|
| 182 |
-
|
| 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 |
-
|
| 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 |
-
|
| 203 |
const coords = (route.route_coords ?? [])
|
| 204 |
.map(([lat, lng]) => `${lat.toFixed(5)},${lng.toFixed(5)}`)
|
| 205 |
.join("|");
|
| 206 |
-
return `saved_${hashText(
|
| 207 |
-
|
| 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 =
|
| 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 [
|
| 254 |
-
|
| 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
|
| 279 |
-
|
| 280 |
-
const
|
| 281 |
-
|
| 282 |
-
|
| 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
|
| 314 |
-
|
| 315 |
-
|
|
|
|
|
|
|
| 316 |
return;
|
| 317 |
}
|
| 318 |
-
|
| 319 |
-
|
| 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:
|
| 335 |
visual: "mascot-progress",
|
| 336 |
expectedDurationMs: 90000,
|
| 337 |
});
|
| 338 |
-
|
| 339 |
updateNavigationLoading({
|
| 340 |
id: loadingId,
|
| 341 |
progress: 12,
|
| 342 |
stepIndex: 0,
|
| 343 |
-
step:
|
| 344 |
});
|
| 345 |
|
| 346 |
try {
|
| 347 |
updateNavigationLoading({
|
| 348 |
id: loadingId,
|
| 349 |
-
progress:
|
| 350 |
stepIndex: 1,
|
| 351 |
-
step:
|
| 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
|
| 364 |
-
targetDistanceKm
|
| 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 || "
|
| 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(
|
| 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 |
-
|
| 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 (
|
| 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
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
);
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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:
|
| 505 |
-
place:
|
| 506 |
distanceKm: route.length_km,
|
| 507 |
-
label:
|
| 508 |
-
clipScore:
|
| 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="
|
| 519 |
<TopBar />
|
| 520 |
|
| 521 |
-
<
|
| 522 |
-
<
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 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 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 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
|
| 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 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 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 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 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="
|
| 781 |
-
<
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
|
|
|
|
|
|
| 791 |
</div>
|
| 792 |
|
| 793 |
-
<div className="
|
| 794 |
-
{(
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 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 |
-
|
| 907 |
-
|
| 908 |
-
{
|
| 909 |
-
|
| 910 |
-
|
| 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 |
-
|
| 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("
|
| 288 |
-
const [targetDistanceInput, setTargetDistanceInput] = useState("
|
| 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
|
| 307 |
-
() =>
|
| 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("
|
| 384 |
return;
|
| 385 |
}
|
| 386 |
if (
|
|
@@ -445,247 +596,245 @@ function RouteSearchPageContent() {
|
|
| 445 |
}
|
| 446 |
|
| 447 |
return (
|
| 448 |
-
<main
|
|
|
|
|
|
|
|
|
|
| 449 |
<TopBar />
|
| 450 |
|
| 451 |
-
<
|
| 452 |
-
<div className="
|
| 453 |
-
<div className="
|
| 454 |
-
<
|
| 455 |
-
<p className="
|
| 456 |
-
위치와 목표를 설정하면 다수 경로를 생성하고 형태 유사도를 분석합니다.
|
| 457 |
-
</p>
|
| 458 |
</div>
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 517 |
<button
|
| 518 |
type="button"
|
| 519 |
onClick={handleUseCurrentLocation}
|
| 520 |
-
className={`h-9 rounded-full px-4 text-
|
| 521 |
source === "current"
|
| 522 |
? "bg-black text-white"
|
| 523 |
-
: "border border-black/
|
| 524 |
}`}
|
| 525 |
>
|
| 526 |
-
현재 위치
|
| 527 |
</button>
|
| 528 |
-
<p className="mt-2 text-xs text-[#666]">
|
| 529 |
-
현재 위치를 사용하면 선택 박스가 자동으로 가장 가까운 시/도·구군으로 동기화됩니다.
|
| 530 |
-
</p>
|
| 531 |
</div>
|
| 532 |
|
| 533 |
-
|
| 534 |
-
<
|
| 535 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
{latitude !== null && longitude !== null
|
| 537 |
-
? `현재 좌표
|
| 538 |
: "현재 위치를 먼저 받아주세요."}
|
| 539 |
</div>
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 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 |
-
</
|
| 600 |
-
</
|
| 601 |
-
|
| 602 |
-
|
|
|
|
| 603 |
<input
|
| 604 |
type="text"
|
| 605 |
inputMode="decimal"
|
| 606 |
value={targetDistanceInput}
|
| 607 |
onChange={(e) => setTargetDistanceInput(e.target.value)}
|
| 608 |
-
className="h-
|
| 609 |
/>
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 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={
|
| 634 |
type="button"
|
| 635 |
-
onClick={() =>
|
| 636 |
-
className={`h-
|
| 637 |
-
|
| 638 |
? "bg-black text-white"
|
| 639 |
-
: "border border-black/
|
| 640 |
}`}
|
| 641 |
>
|
| 642 |
-
{
|
| 643 |
</button>
|
| 644 |
))}
|
| 645 |
</div>
|
| 646 |
-
</div>
|
| 647 |
-
</div>
|
| 648 |
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 659 |
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 167 |
<TopBar userName="러너" title="Run The Shape" largeBrand />
|
| 168 |
|
| 169 |
-
<section className="
|
| 170 |
-
<section className="
|
| 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="
|
| 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="
|
| 212 |
<TopBar userName="러너" title="Run The Shape" largeBrand />
|
| 213 |
|
| 214 |
-
<section className="
|
| 215 |
-
<section className="
|
| 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="
|
| 256 |
>
|
| 257 |
저장
|
| 258 |
</button>
|
|
@@ -282,7 +282,7 @@ export default function Page() {
|
|
| 282 |
</div>
|
| 283 |
</section>
|
| 284 |
|
| 285 |
-
<section className="
|
| 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="
|
| 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="
|
| 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="
|
| 394 |
>
|
| 395 |
이 루트로 달리기
|
| 396 |
</button>
|
| 397 |
|
| 398 |
<button
|
| 399 |
type="button"
|
| 400 |
-
className="
|
| 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="
|
| 63 |
-
<div className="h-
|
| 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-
|
| 78 |
<div className="flex items-start justify-between gap-3">
|
| 79 |
-
<h4 className="
|
| 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-
|
| 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="
|
| 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 |
-
|
| 99 |
</span>
|
| 100 |
</div>
|
| 101 |
</div>
|
|
@@ -140,22 +141,22 @@ export default function SavedRoutesPage() {
|
|
| 140 |
}, [routeCards, searchTerm]);
|
| 141 |
|
| 142 |
return (
|
| 143 |
-
<main className="
|
| 144 |
<TopBar userName="러너" />
|
| 145 |
|
| 146 |
-
<
|
| 147 |
-
<header className="
|
| 148 |
-
<button onClick={() => router.push("/lobby")} className="
|
| 149 |
-
<h1
|
| 150 |
</header>
|
| 151 |
|
| 152 |
-
<div className="
|
| 153 |
<input
|
| 154 |
type="text"
|
| 155 |
placeholder="루트 검색"
|
| 156 |
value={searchTerm}
|
| 157 |
onChange={e => setSearchTerm(e.target.value)}
|
| 158 |
-
className="
|
| 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="
|
| 168 |
{routeCards.length === 0
|
| 169 |
? "저장된 루트가 없습니다. 코스 결과 화면에서 북마크를 눌러 저장해 주세요."
|
| 170 |
: "검색 결과가 없습니다."}
|
| 171 |
</p>
|
| 172 |
)}
|
| 173 |
</div>
|
| 174 |
-
</
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 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={`
|
| 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="
|
| 236 |
>
|
| 237 |
해당 루트 보러가기
|
| 238 |
</Link>
|
|
@@ -292,17 +292,17 @@ export default function Page() {
|
|
| 292 |
}, [posts, sortType]);
|
| 293 |
|
| 294 |
return (
|
| 295 |
-
<main className="
|
| 296 |
<TopBar userName="러너" title="Run The Shape" largeBrand />
|
| 297 |
|
| 298 |
-
<section className="
|
| 299 |
{loadError ? (
|
| 300 |
-
<div className="rounded-
|
| 301 |
{loadError}
|
| 302 |
</div>
|
| 303 |
) : null}
|
| 304 |
|
| 305 |
-
<section className="
|
| 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="
|
| 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="
|
| 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>
|