rest of the changes to the new workout struct
This commit is contained in:
@@ -38,9 +38,9 @@ RUN mkdir -p /var/run/nginx && \
|
||||
|
||||
USER nginx
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 3001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://127.0.0.1:3000/ || exit 1
|
||||
CMD wget --quiet --tries=1 --spider http://127.0.0.1:3001/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -138,7 +138,7 @@ if [ "$RUN_CONTAINER" = true ]; then
|
||||
|
||||
if podman run -d \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-p 3000:3000 \
|
||||
-p 3001:3000 \
|
||||
"$FULL_IMAGE"; then
|
||||
echo -e "${GREEN}✓ Container started: $CONTAINER_NAME${NC}"
|
||||
sleep 2
|
||||
@@ -148,7 +148,7 @@ if [ "$RUN_CONTAINER" = true ]; then
|
||||
podman logs "$CONTAINER_NAME" 2>/dev/null || echo "No logs yet"
|
||||
echo ""
|
||||
|
||||
echo -e "${GREEN}Frontend available at: http://localhost:3000${NC}"
|
||||
echo -e "${GREEN}Frontend available at: http://localhost:3001${NC}"
|
||||
echo -e "${YELLOW}API configured at: $API_URL${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}To view logs:${NC}"
|
||||
@@ -162,7 +162,7 @@ if [ "$RUN_CONTAINER" = true ]; then
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}To run the container:${NC}"
|
||||
echo " podman run -d --name $CONTAINER_NAME -p 3000:3000 $FULL_IMAGE"
|
||||
echo " podman run -d --name $CONTAINER_NAME -p 3001:3000 $FULL_IMAGE"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Or use this script with --run:${NC}"
|
||||
echo " $0 -t $IMAGE_TAG --run -a '$API_URL'"
|
||||
|
||||
@@ -15,34 +15,32 @@
|
||||
<!-- Desktop nav -->
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<RouterLink to="/dashboard" class="nav-link-item" @click="closeMobileMenu">
|
||||
<RouterLink to="/dashboard" class="nav-link-item">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M3 9L12 2L21 9V20C21 20.5304 20.7893 21.0391 20.4142 21.4142C20.0391 21.7893 19.5304 22 19 22H5C4.46957 22 3.96086 21.7893 3.58579 21.4142C3.21071 21.0391 3 20.5304 3 20V9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>Dashboard</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/profile" class="nav-link-item" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="7" r="4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>Profile</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/equipment" class="nav-link-item" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/><path d="M12 1V3M12 21V23M4.22 4.22L5.64 5.64M18.36 18.36L19.78 19.78M1 12H3M21 12H23M4.22 19.78L5.64 18.36M18.36 5.64L19.78 4.22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<span>Equipment</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/zones" class="nav-link-item" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M22 12H18L15 21L9 3L6 12H2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>Zones</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/calendar" class="nav-link-item" @click="closeMobileMenu">
|
||||
<RouterLink to="/calendar" class="nav-link-item">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2"/><line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<span>Calendar</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/workouts" class="nav-link-item">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><polygon points="5,3 19,12 5,21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
|
||||
<span>Workouts</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/templates" class="nav-link-item">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<span>Templates</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/stats" class="nav-link-item">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><rect x="18" y="3" width="4" height="18" rx="1" stroke="currentColor" stroke-width="2"/><rect x="10" y="8" width="4" height="13" rx="1" stroke="currentColor" stroke-width="2"/><rect x="2" y="13" width="4" height="8" rx="1" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<span>Stats</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Theme toggle -->
|
||||
<button @click="themeStore.toggleTheme()" class="flex items-center justify-center w-8 h-8 rounded-md text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer" title="Toggle theme">
|
||||
<!-- Sun icon (shown in dark mode) -->
|
||||
<svg v-if="themeStore.theme === 'dark'" class="w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="5" stroke="currentColor" stroke-width="2"/><path d="M12 1V3M12 21V23M4.22 4.22L5.64 5.64M18.36 18.36L19.78 19.78M1 12H3M21 12H23M4.22 19.78L5.64 18.36M18.36 5.64L19.78 4.22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<!-- Moon icon (shown in light mode) -->
|
||||
<svg v-else class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -58,12 +56,36 @@
|
||||
</div>
|
||||
<RouterLink to="/profile" class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100 no-underline transition-colors" @click="closeMenus">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="7" r="4" stroke="currentColor" stroke-width="2"/></svg>
|
||||
Edit Profile
|
||||
Profile
|
||||
</RouterLink>
|
||||
<RouterLink to="/equipment" class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100 no-underline transition-colors" @click="closeMenus">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/><path d="M12 1V3M12 21V23M4.22 4.22L5.64 5.64M18.36 18.36L19.78 19.78M1 12H3M21 12H23M4.22 19.78L5.64 18.36M18.36 5.64L19.78 4.22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
Equipment
|
||||
</RouterLink>
|
||||
<RouterLink to="/zones" class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100 no-underline transition-colors" @click="closeMenus">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M22 12H18L15 21L9 3L6 12H2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Training Zones
|
||||
</RouterLink>
|
||||
<div class="border-t border-zinc-100 dark:border-zinc-800 mt-1 pt-1">
|
||||
<RouterLink to="/teams" class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100 no-underline transition-colors" @click="closeMenus">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M17 21V19C17 17.9391 16.5786 16.9217 15.8284 16.1716C15.0783 15.4214 14.0609 15 13 15H5C3.93913 15 2.92172 15.4214 2.17157 16.1716C1.42143 16.9217 1 17.9391 1 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="2"/><path d="M23 21V19C22.9993 18.1137 22.7044 17.2528 22.1614 16.5523C21.6184 15.8519 20.8581 15.3516 20 15.13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M16 3.13C16.8604 3.35031 17.623 3.85071 18.1676 4.55232C18.7122 5.25392 19.0078 6.11683 19.0078 7.005C19.0078 7.89318 18.7122 8.75608 18.1676 9.45769C17.623 10.1593 16.8604 10.6597 16 10.88" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
Teams
|
||||
</RouterLink>
|
||||
<RouterLink to="/coaching" class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100 no-underline transition-colors" @click="closeMenus">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M12 14L21 9L12 4L3 9L12 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M21 9V15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M6 11.5V16.5C6 16.5 8 19 12 19C16 19 18 16.5 18 16.5V11.5" stroke="currentColor" stroke-width="2"/></svg>
|
||||
Coaching
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="border-t border-zinc-100 dark:border-zinc-800 mt-1 pt-1">
|
||||
<RouterLink to="/settings/connections" class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100 no-underline transition-colors" @click="closeMenus">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M10 13C10.4295 13.5741 10.9774 14.0492 11.6066 14.3929C12.2357 14.7367 12.9315 14.9411 13.6467 14.9923C14.3618 15.0435 15.0796 14.9404 15.7513 14.6898C16.4231 14.4392 17.0331 14.047 17.54 13.54L20.54 10.54C21.4508 9.59699 21.9548 8.33397 21.9434 7.02299C21.932 5.71201 21.4061 4.45794 20.479 3.5309C19.552 2.60386 18.2979 2.07802 16.987 2.0666C15.676 2.05518 14.413 2.55921 13.47 3.47L11.75 5.18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 11C13.5705 10.4259 13.0226 9.9508 12.3934 9.60707C11.7642 9.26334 11.0684 9.05886 10.3533 9.00769C9.63816 8.95651 8.92037 9.05964 8.24861 9.31023C7.57685 9.56082 6.96684 9.953 6.46 10.46L3.46 13.46C2.54921 14.403 2.04518 15.666 2.0566 16.977C2.06802 18.288 2.59386 19.5421 3.5209 20.4691C4.44794 21.3961 5.70201 21.922 7.01299 21.9334C8.32397 21.9448 9.58699 21.4408 10.53 20.53L12.24 18.82" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Connections
|
||||
</RouterLink>
|
||||
<RouterLink to="/settings/sessions" class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100 no-underline transition-colors" @click="closeMenus">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><rect x="2" y="6" width="20" height="12" rx="2" stroke="currentColor" stroke-width="2"/><path d="M6 10H6.01M10 10H10.01M14 10H14.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
Sessions
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="border-t border-zinc-100 dark:border-zinc-800 mt-1 pt-1">
|
||||
<button @click="handleLogout" class="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer bg-transparent border-none text-left font-[inherit]">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><polyline points="16,17 21,12 16,7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
@@ -76,12 +98,35 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile nav -->
|
||||
<div v-show="mobileMenuOpen" class="md:hidden fixed top-12 left-0 right-0 bg-white dark:bg-surface-950 border-b border-zinc-200 dark:border-zinc-800 p-3 z-40">
|
||||
<div v-show="mobileMenuOpen" class="md:hidden fixed top-12 left-0 right-0 bottom-0 bg-white dark:bg-surface-950 border-b border-zinc-200 dark:border-zinc-800 p-3 z-40 overflow-y-auto">
|
||||
<!-- Training -->
|
||||
<p class="px-3 pt-1 pb-1.5 text-[10px] font-bold uppercase tracking-wider text-zinc-400">Training</p>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<RouterLink to="/dashboard" class="mobile-nav-link" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M3 9L12 2L21 9V20C21 20.5304 20.7893 21.0391 20.4142 21.4142C20.0391 21.7893 19.5304 22 19 22H5C4.46957 22 3.96086 21.7893 3.58579 21.4142C3.21071 21.0391 3 20.5304 3 20V9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Dashboard
|
||||
</RouterLink>
|
||||
<RouterLink to="/calendar" class="mobile-nav-link" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2"/><line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"/></svg>
|
||||
Calendar
|
||||
</RouterLink>
|
||||
<RouterLink to="/workouts" class="mobile-nav-link" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><polygon points="5,3 19,12 5,21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
|
||||
Workouts
|
||||
</RouterLink>
|
||||
<RouterLink to="/templates" class="mobile-nav-link" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/></svg>
|
||||
Templates
|
||||
</RouterLink>
|
||||
<RouterLink to="/stats" class="mobile-nav-link" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><rect x="18" y="3" width="4" height="18" rx="1" stroke="currentColor" stroke-width="2"/><rect x="10" y="8" width="4" height="13" rx="1" stroke="currentColor" stroke-width="2"/><rect x="2" y="13" width="4" height="8" rx="1" stroke="currentColor" stroke-width="2"/></svg>
|
||||
Stats
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Profile & Setup -->
|
||||
<p class="px-3 pt-3 pb-1.5 text-[10px] font-bold uppercase tracking-wider text-zinc-400 border-t border-zinc-200 dark:border-zinc-800 mt-2">Profile & Setup</p>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<RouterLink to="/profile" class="mobile-nav-link" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="7" r="4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Profile
|
||||
@@ -92,14 +137,28 @@
|
||||
</RouterLink>
|
||||
<RouterLink to="/zones" class="mobile-nav-link" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M22 12H18L15 21L9 3L6 12H2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Zones
|
||||
Training Zones
|
||||
</RouterLink>
|
||||
<RouterLink to="/calendar" class="mobile-nav-link" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2"/><line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"/></svg>
|
||||
Calendar
|
||||
<RouterLink to="/settings/connections" class="mobile-nav-link" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M10 13C10.4295 13.5741 10.9774 14.0492 11.6066 14.3929C12.2357 14.7367 12.9315 14.9411 13.6467 14.9923C14.3618 15.0435 15.0796 14.9404 15.7513 14.6898C16.4231 14.4392 17.0331 14.047 17.54 13.54L20.54 10.54C21.4508 9.59699 21.9548 8.33397 21.9434 7.02299C21.932 5.71201 21.4061 4.45794 20.479 3.5309C19.552 2.60386 18.2979 2.07802 16.987 2.0666C15.676 2.05518 14.413 2.55921 13.47 3.47L11.75 5.18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 11C13.5705 10.4259 13.0226 9.9508 12.3934 9.60707C11.7642 9.26334 11.0684 9.05886 10.3533 9.00769C9.63816 8.95651 8.92037 9.05964 8.24861 9.31023C7.57685 9.56082 6.96684 9.953 6.46 10.46L3.46 13.46C2.54921 14.403 2.04518 15.666 2.0566 16.977C2.06802 18.288 2.59386 19.5421 3.5209 20.4691C4.44794 21.3961 5.70201 21.922 7.01299 21.9334C8.32397 21.9448 9.58699 21.4408 10.53 20.53L12.24 18.82" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Connections
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="border-t border-zinc-200 dark:border-zinc-800 mt-2 pt-2 flex items-center justify-between">
|
||||
|
||||
<!-- Community -->
|
||||
<p class="px-3 pt-3 pb-1.5 text-[10px] font-bold uppercase tracking-wider text-zinc-400 border-t border-zinc-200 dark:border-zinc-800 mt-2">Community</p>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<RouterLink to="/teams" class="mobile-nav-link" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M17 21V19C17 17.9391 16.5786 16.9217 15.8284 16.1716C15.0783 15.4214 14.0609 15 13 15H5C3.93913 15 2.92172 15.4214 2.17157 16.1716C1.42143 16.9217 1 17.9391 1 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="2"/><path d="M23 21V19C22.9993 18.1137 22.7044 17.2528 22.1614 16.5523C21.6184 15.8519 20.8581 15.3516 20 15.13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M16 3.13C16.8604 3.35031 17.623 3.85071 18.1676 4.55232C18.7122 5.25392 19.0078 6.11683 19.0078 7.005C19.0078 7.89318 18.7122 8.75608 18.1676 9.45769C17.623 10.1593 16.8604 10.6597 16 10.88" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
Teams
|
||||
</RouterLink>
|
||||
<RouterLink to="/coaching" class="mobile-nav-link" @click="closeMobileMenu">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M12 14L21 9L12 4L3 9L12 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M21 9V15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M6 11.5V16.5C6 16.5 8 19 12 19C16 19 18 16.5 18 16.5V11.5" stroke="currentColor" stroke-width="2"/></svg>
|
||||
Coaching
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-zinc-200 dark:border-zinc-800 mt-3 pt-2 flex items-center justify-between">
|
||||
<button @click="themeStore.toggleTheme()" class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-600 dark:text-zinc-400 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer bg-transparent border-none font-[inherit]">
|
||||
<svg v-if="themeStore.theme === 'dark'" class="w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="5" stroke="currentColor" stroke-width="2"/><path d="M12 1V3M12 21V23M4.22 4.22L5.64 5.64M18.36 18.36L19.78 19.78M1 12H3M21 12H23M4.22 19.78L5.64 18.36M18.36 5.64L19.78 4.22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<svg v-else class="w-4 h-4" viewBox="0 0 24 24" fill="none"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<!-- Workout Info -->
|
||||
<div class="card p-3">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
@@ -55,6 +55,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workout Structure Visualization -->
|
||||
<div v-if="workoutStructure" class="space-y-2">
|
||||
<h3 class="text-sm font-bold text-zinc-900 dark:text-zinc-100 border-b-2 border-brand-500 pb-2">Workout Structure</h3>
|
||||
<IntervalDisplay :structure="workoutStructure" :show-legend="true" />
|
||||
</div>
|
||||
|
||||
<!-- Auto-generated Workout Summary -->
|
||||
<div v-if="workoutDescription" class="space-y-2">
|
||||
<h3 class="text-sm font-bold text-zinc-900 dark:text-zinc-100 border-b-2 border-brand-500 pb-2">Workout Summary</h3>
|
||||
<div class="card p-3 text-sm text-zinc-600 dark:text-zinc-300 whitespace-pre-line leading-relaxed">{{ workoutDescription }}</div>
|
||||
</div>
|
||||
|
||||
<!-- File Metadata -->
|
||||
<div v-if="workout.workout_data && hasFileMetadata" class="space-y-2">
|
||||
<h3 class="text-sm font-bold text-zinc-900 dark:text-zinc-100 border-b-2 border-brand-500 pb-2">File Information</h3>
|
||||
@@ -113,22 +125,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone Distribution -->
|
||||
<div v-if="hasSegments" class="space-y-2">
|
||||
<h3 class="text-sm font-bold text-zinc-900 dark:text-zinc-100 border-b-2 border-brand-500 pb-2">Zone Distribution</h3>
|
||||
<ZoneDistribution :segments="workout.workout_data.segments" />
|
||||
</div>
|
||||
|
||||
<!-- Completed Metrics -->
|
||||
<div v-if="workout.status === 'completed'" class="space-y-2">
|
||||
<h3 class="text-sm font-bold text-zinc-900 dark:text-zinc-100 border-b-2 border-brand-500 pb-2">Completed Metrics</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<div v-if="workout.distance" class="card p-3 text-center">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-wide text-zinc-400 dark:text-zinc-500 mb-1">Distance</span>
|
||||
<span class="block text-lg font-extrabold text-brand-500">{{ workout.distance }} km</span>
|
||||
<span class="block text-lg font-extrabold text-brand-500">{{ Number(workout.distance).toFixed(1) }} km</span>
|
||||
</div>
|
||||
<div v-if="workout.avg_power" class="card p-3 text-center">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-wide text-zinc-400 dark:text-zinc-500 mb-1">Avg Power</span>
|
||||
<span class="block text-lg font-extrabold text-brand-500">{{ workout.avg_power }}W</span>
|
||||
</div>
|
||||
<div v-if="workout.max_power" class="card p-3 text-center">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-wide text-zinc-400 dark:text-zinc-500 mb-1">Max Power</span>
|
||||
<span class="block text-lg font-extrabold text-zinc-900 dark:text-zinc-100">{{ workout.max_power }}W</span>
|
||||
</div>
|
||||
<div v-if="workout.avg_hr" class="card p-3 text-center">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-wide text-zinc-400 dark:text-zinc-500 mb-1">Avg HR</span>
|
||||
<span class="block text-lg font-extrabold text-brand-500">{{ workout.avg_hr }} bpm</span>
|
||||
</div>
|
||||
<div v-if="workout.max_hr" class="card p-3 text-center">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-wide text-zinc-400 dark:text-zinc-500 mb-1">Max HR</span>
|
||||
<span class="block text-lg font-extrabold text-zinc-900 dark:text-zinc-100">{{ workout.max_hr }} bpm</span>
|
||||
</div>
|
||||
<div v-if="workout.elev_gain" class="card p-3 text-center">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-wide text-zinc-400 dark:text-zinc-500 mb-1">Elevation</span>
|
||||
<span class="block text-lg font-extrabold text-zinc-900 dark:text-zinc-100">{{ Math.round(workout.elev_gain) }}m</span>
|
||||
</div>
|
||||
<div v-if="workout.calories_burned" class="card p-3 text-center">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-wide text-zinc-400 dark:text-zinc-500 mb-1">Calories</span>
|
||||
<span class="block text-lg font-extrabold text-brand-500">{{ workout.calories_burned }}</span>
|
||||
@@ -163,15 +193,42 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex flex-wrap gap-2 p-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 rounded-b-xl">
|
||||
<select v-model="selectedStatus" @change="updateStatus" class="input-field flex-1 min-w-[140px]">
|
||||
<option value="planned">Planned</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="skipped">Skipped</option>
|
||||
</select>
|
||||
<button v-if="!isEditMode" @click="enableEditMode" class="btn-secondary">Edit</button>
|
||||
<button @click="confirmDelete" class="btn-danger">Delete</button>
|
||||
<button @click="closeModal" class="btn-secondary">Close</button>
|
||||
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 rounded-b-xl space-y-2">
|
||||
<!-- Export & Push Row -->
|
||||
<div v-if="hasSegments" class="flex flex-wrap gap-2">
|
||||
<button @click="exportFIT" class="btn-secondary text-xs gap-1" :disabled="exporting">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><path d="M21 15V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M7 10L12 15L17 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 15V3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
Export FIT
|
||||
</button>
|
||||
<button @click="exportZWO" class="btn-secondary text-xs gap-1" :disabled="exporting">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><path d="M21 15V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M7 10L12 15L17 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 15V3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
Export ZWO
|
||||
</button>
|
||||
<button v-if="garminConnected" @click="pushToGarmin" class="btn-secondary text-xs gap-1" :disabled="pushing">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><path d="M22 2L11 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 2L15 22L11 13L2 9L22 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
{{ pushing ? 'Pushing...' : 'Push to Garmin' }}
|
||||
</button>
|
||||
<button v-if="wahooConnected" @click="pushToWahoo" class="btn-secondary text-xs gap-1" :disabled="pushing">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><path d="M22 2L11 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 2L15 22L11 13L2 9L22 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
{{ pushing ? 'Pushing...' : 'Push to Wahoo' }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Action Row -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<select v-model="selectedStatus" @change="updateStatus" class="input-field flex-1 min-w-[140px]">
|
||||
<option value="planned">Planned</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="skipped">Skipped</option>
|
||||
</select>
|
||||
<button v-if="!isEditMode" @click="enableEditMode" class="btn-secondary">Edit</button>
|
||||
<button @click="confirmDelete" class="btn-danger">Delete</button>
|
||||
<button @click="closeModal" class="btn-secondary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Push/Export Toast -->
|
||||
<div v-if="actionMessage" class="fixed bottom-4 right-4 z-[70] flex items-center gap-2 px-3 py-2 text-white text-sm font-medium rounded-lg shadow-lg cursor-pointer" :class="actionError ? 'bg-red-600' : 'bg-green-600'" @click="actionMessage = ''">
|
||||
{{ actionMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,8 +254,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, ref, watch, computed, reactive } from 'vue'
|
||||
import api from '@/services/api'
|
||||
import { defineProps, defineEmits, ref, watch, computed, reactive, onMounted } from 'vue'
|
||||
import api, { calendarApi, integrationsApi } from '@/services/api'
|
||||
import IntervalDisplay from '@/components/workout/IntervalDisplay.vue'
|
||||
import ZoneDistribution from '@/components/workout/ZoneDistribution.vue'
|
||||
import { segmentsToStructure, generateWorkoutDescription } from '@/utils/workoutHelpers'
|
||||
|
||||
const props = defineProps({
|
||||
workout: {
|
||||
@@ -212,12 +272,112 @@ const selectedStatus = ref(props.workout.status)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const isEditMode = ref(false)
|
||||
const equipmentName = ref('')
|
||||
const exporting = ref(false)
|
||||
const pushing = ref(false)
|
||||
const actionMessage = ref('')
|
||||
const actionError = ref(false)
|
||||
const garminConnected = ref(false)
|
||||
const wahooConnected = ref(false)
|
||||
|
||||
const editForm = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const hasSegments = computed(() => {
|
||||
return props.workout.workout_data?.segments?.length > 0
|
||||
})
|
||||
|
||||
const workoutStructure = computed(() => {
|
||||
return segmentsToStructure(props.workout.workout_data?.segments)
|
||||
})
|
||||
|
||||
const workoutDescription = computed(() => {
|
||||
return generateWorkoutDescription(props.workout.workout_data?.segments)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [garminRes, wahooRes] = await Promise.allSettled([
|
||||
integrationsApi.garminStatus(),
|
||||
integrationsApi.wahooStatus()
|
||||
])
|
||||
if (garminRes.status === 'fulfilled' && garminRes.value?.connected) garminConnected.value = true
|
||||
if (wahooRes.status === 'fulfilled' && wahooRes.value?.connected) wahooConnected.value = true
|
||||
} catch { /* ignore connection check errors */ }
|
||||
})
|
||||
|
||||
function showAction(msg, isError = false) {
|
||||
actionMessage.value = msg
|
||||
actionError.value = isError
|
||||
setTimeout(() => { actionMessage.value = '' }, 3000)
|
||||
}
|
||||
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function exportFIT() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await calendarApi.exportFIT(props.workout.id)
|
||||
downloadBlob(blob, `${props.workout.title || 'workout'}.fit`)
|
||||
showAction('FIT file downloaded!')
|
||||
} catch (err) {
|
||||
console.error('Export FIT failed:', err)
|
||||
showAction('Failed to export FIT file', true)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function exportZWO() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await calendarApi.exportZWO(props.workout.id)
|
||||
downloadBlob(blob, `${props.workout.title || 'workout'}.zwo`)
|
||||
showAction('ZWO file downloaded!')
|
||||
} catch (err) {
|
||||
console.error('Export ZWO failed:', err)
|
||||
showAction('Failed to export ZWO file', true)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pushToGarmin() {
|
||||
pushing.value = true
|
||||
try {
|
||||
await integrationsApi.pushToGarmin(props.workout.id)
|
||||
showAction('Workout pushed to Garmin!')
|
||||
} catch (err) {
|
||||
console.error('Push to Garmin failed:', err)
|
||||
showAction('Failed to push to Garmin', true)
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pushToWahoo() {
|
||||
pushing.value = true
|
||||
try {
|
||||
await integrationsApi.pushToWahoo(props.workout.id)
|
||||
showAction('Workout pushed to Wahoo!')
|
||||
} catch (err) {
|
||||
console.error('Push to Wahoo failed:', err)
|
||||
showAction('Failed to push to Wahoo', true)
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEquipmentName() {
|
||||
if (!props.workout.equipment_id) return
|
||||
try {
|
||||
@@ -266,7 +426,7 @@ function cancelEdit() {
|
||||
|
||||
async function saveWorkout() {
|
||||
try {
|
||||
await api.put(`/protected/workouts?id=${props.workout.id}`, {
|
||||
await api.put(`/api/protected/workouts?id=${props.workout.id}`, {
|
||||
title: editForm.title,
|
||||
description: editForm.description,
|
||||
})
|
||||
@@ -279,7 +439,7 @@ async function saveWorkout() {
|
||||
|
||||
async function updateStatus() {
|
||||
try {
|
||||
await api.put(`/protected/workouts?id=${props.workout.id}`, {
|
||||
await api.put(`/api/protected/workouts?id=${props.workout.id}`, {
|
||||
status: selectedStatus.value,
|
||||
})
|
||||
emit('updated')
|
||||
@@ -295,7 +455,7 @@ function confirmDelete() {
|
||||
|
||||
async function deleteWorkout() {
|
||||
try {
|
||||
await api.delete(`/protected/workouts?id=${props.workout.id}`)
|
||||
await api.delete(`/api/protected/workouts?id=${props.workout.id}`)
|
||||
showDeleteConfirm.value = false
|
||||
emit('deleted')
|
||||
emit('close')
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
<template>
|
||||
<div class="step-equipment">
|
||||
<div class="step-intro">
|
||||
<h3>Your Equipment</h3>
|
||||
<p>Add your bikes and trainers to track mileage and maintenance.</p>
|
||||
<div class="max-w-[600px] mx-auto">
|
||||
<!-- Intro -->
|
||||
<div class="text-center mb-6">
|
||||
<h3 class="mb-2 text-xl font-semibold text-zinc-900 dark:text-zinc-100">Your Equipment</h3>
|
||||
<p class="text-zinc-500 dark:text-zinc-400">Add your bikes and trainers to track mileage and maintenance.</p>
|
||||
</div>
|
||||
|
||||
<!-- Equipment List -->
|
||||
<div v-if="equipment.length > 0" class="equipment-list">
|
||||
<div v-for="item in equipment" :key="item.id" class="equipment-card">
|
||||
<div class="equipment-icon">
|
||||
<svg v-if="item.type === 'bike'" viewBox="0 0 24 24" fill="none">
|
||||
<div v-if="equipment.length > 0" class="flex flex-col gap-2 mb-6">
|
||||
<div v-for="item in equipment" :key="item.id" class="flex items-center gap-4 px-4 py-3 card">
|
||||
<div class="w-10 h-10 rounded-lg bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
|
||||
<svg v-if="item.type === 'bike'" class="w-5 h-5 text-zinc-500 dark:text-zinc-400" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="5.5" cy="17.5" r="3.5" stroke="currentColor" stroke-width="2"/>
|
||||
<circle cx="18.5" cy="17.5" r="3.5" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M15 6L18 17.5M5.5 17.5L9 6H15L12 12H5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none">
|
||||
<svg v-else class="w-5 h-5 text-zinc-500 dark:text-zinc-400" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="8" width="20" height="8" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="6" y1="8" x2="6" y2="16" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="18" y1="8" x2="18" y2="16" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="equipment-info">
|
||||
<span class="equipment-name">{{ item.name }}</span>
|
||||
<span class="equipment-type">{{ formatType(item.type) }}</span>
|
||||
<div class="flex-1">
|
||||
<span class="block font-medium text-zinc-900 dark:text-zinc-100">{{ item.name }}</span>
|
||||
<span class="block text-sm text-zinc-500 dark:text-zinc-400">{{ formatType(item.type) }}</span>
|
||||
</div>
|
||||
<button @click="removeEquipment(item)" class="btn-remove" title="Remove">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<button @click="removeEquipment(item)" class="w-8 h-8 border-0 bg-transparent cursor-pointer flex items-center justify-center rounded-lg text-zinc-500 dark:text-zinc-400 hover:bg-red-500/10 hover:text-red-500 transition-colors" title="Remove">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none">
|
||||
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
@@ -34,14 +35,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Add Equipment Form -->
|
||||
<div class="add-equipment-section">
|
||||
<h4 v-if="equipment.length > 0">Add Another</h4>
|
||||
<div class="card p-6 mb-4">
|
||||
<h4 v-if="equipment.length > 0" class="mb-4 text-base font-semibold text-zinc-900 dark:text-zinc-100">Add Another</h4>
|
||||
|
||||
<form @submit.prevent="addEquipment" class="form-modern">
|
||||
<div class="form-row">
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Equipment Type</label>
|
||||
<select v-model="newEquipment.type" class="form-input-modern">
|
||||
<form @submit.prevent="addEquipment">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-1 gap-4">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Equipment Type</label>
|
||||
<select v-model="newEquipment.type" class="input-field">
|
||||
<option value="bike">Bike</option>
|
||||
<option value="trainer">Smart Trainer</option>
|
||||
<option value="power_meter">Power Meter</option>
|
||||
@@ -49,42 +50,42 @@
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Name *</label>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Name *</label>
|
||||
<input
|
||||
v-model="newEquipment.name"
|
||||
type="text"
|
||||
class="form-input-modern"
|
||||
class="input-field"
|
||||
:placeholder="getPlaceholder(newEquipment.type)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" v-if="newEquipment.type === 'bike'">
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Brand</label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-1 gap-4" v-if="newEquipment.type === 'bike'">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Brand</label>
|
||||
<input
|
||||
v-model="newEquipment.brand"
|
||||
type="text"
|
||||
class="form-input-modern"
|
||||
class="input-field"
|
||||
placeholder="e.g., Canyon, Trek, Specialized"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Model</label>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Model</label>
|
||||
<input
|
||||
v-model="newEquipment.model"
|
||||
type="text"
|
||||
class="form-input-modern"
|
||||
class="input-field"
|
||||
placeholder="e.g., Ultimate CF SLX"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-modern btn-modern-secondary" :disabled="!newEquipment.name || adding">
|
||||
<span v-if="adding" class="btn-spinner"></span>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" class="btn-icon">
|
||||
<button type="submit" class="btn-secondary" :disabled="!newEquipment.name || adding">
|
||||
<span v-if="adding" class="inline-block w-4 h-4 border-2 border-current border-r-transparent rounded-full animate-spin mr-1"></span>
|
||||
<svg v-else class="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="none">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
@@ -94,8 +95,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Skip Note -->
|
||||
<div class="skip-note">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<div class="flex items-center gap-2 p-3 bg-brand-500/5 rounded-lg text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<svg class="w-[18px] h-[18px] text-brand-500 shrink-0" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
@@ -163,7 +164,7 @@ async function addEquipment() {
|
||||
model: newEquipment.model || null
|
||||
}
|
||||
|
||||
const { data } = await api.post('/protected/equipment', payload)
|
||||
const { data } = await api.post('/api/protected/equipment', payload)
|
||||
|
||||
equipment.value.push({
|
||||
id: data.equipment?.id || Date.now(),
|
||||
@@ -196,7 +197,7 @@ async function addEquipment() {
|
||||
|
||||
async function removeEquipment(item) {
|
||||
try {
|
||||
await api.delete(`/protected/equipment?id=${item.id}`)
|
||||
await api.delete(`/api/protected/equipment?id=${item.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete equipment:', err)
|
||||
}
|
||||
@@ -207,195 +208,3 @@ watch(equipment, (newVal) => {
|
||||
emit('update:modelValue', { equipment: [...newVal] })
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-equipment {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.step-intro {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.step-intro h3 {
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.step-intro p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.equipment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.equipment-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.equipment-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.equipment-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.equipment-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.equipment-name {
|
||||
display: block;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.equipment-type {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
background: rgba(230, 57, 70, 0.1);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.btn-remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.add-equipment-section {
|
||||
padding: var(--spacing-xl);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.add-equipment-section h4 {
|
||||
margin: 0 0 var(--spacing-lg);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group-modern {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-label-modern {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-input-modern {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.form-input-modern:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.skip-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background: rgba(0, 102, 204, 0.05);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.skip-note svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,95 +1,99 @@
|
||||
<template>
|
||||
<div class="step-metrics">
|
||||
<div class="step-intro">
|
||||
<h3>Training Metrics</h3>
|
||||
<p>Set up your power and heart rate zones for personalized training.</p>
|
||||
<div class="max-w-[700px] mx-auto">
|
||||
<!-- Intro -->
|
||||
<div class="text-center mb-6">
|
||||
<h3 class="mb-2 text-xl font-semibold text-zinc-900 dark:text-zinc-100">Training Metrics</h3>
|
||||
<p class="text-zinc-500 dark:text-zinc-400">Set up your power and heart rate zones for personalized training.</p>
|
||||
</div>
|
||||
|
||||
<form class="form-modern" @submit.prevent>
|
||||
<form @submit.prevent>
|
||||
<!-- FTP Section -->
|
||||
<div class="metric-section">
|
||||
<h4 class="metric-section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<div class="card p-6 mb-6">
|
||||
<h4 class="flex items-center gap-2 mb-4 text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
<svg class="w-5 h-5 text-brand-500" viewBox="0 0 24 24" fill="none">
|
||||
<polygon points="13,2 3,14 12,14 11,22 21,10 12,10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Functional Threshold Power (FTP)
|
||||
</h4>
|
||||
|
||||
<div class="ftp-options">
|
||||
<!-- FTP Method Options -->
|
||||
<div class="grid grid-cols-3 sm:grid-cols-1 gap-3 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="option-card"
|
||||
:class="{ active: ftpMethod === 'known' }"
|
||||
class="flex flex-col items-center gap-2 p-4 bg-zinc-50 dark:bg-zinc-800 border-2 rounded-lg cursor-pointer transition-all"
|
||||
:class="ftpMethod === 'known' ? 'border-brand-500 bg-brand-500/5' : 'border-zinc-200 dark:border-zinc-700 hover:border-brand-500'"
|
||||
@click="ftpMethod = 'known'"
|
||||
>
|
||||
<span class="option-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<span class="w-10 h-10 rounded-full flex items-center justify-center" :class="ftpMethod === 'known' ? 'bg-brand-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 dark:text-zinc-400'">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="currentColor" stroke-width="2"/>
|
||||
<polyline points="22,4 12,14.01 9,11.01" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="option-label">I know my FTP</span>
|
||||
<span class="text-sm font-medium text-zinc-900 dark:text-zinc-100 text-center">I know my FTP</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="option-card"
|
||||
:class="{ active: ftpMethod === 'estimate' }"
|
||||
class="flex flex-col items-center gap-2 p-4 bg-zinc-50 dark:bg-zinc-800 border-2 rounded-lg cursor-pointer transition-all"
|
||||
:class="ftpMethod === 'estimate' ? 'border-brand-500 bg-brand-500/5' : 'border-zinc-200 dark:border-zinc-700 hover:border-brand-500'"
|
||||
@click="ftpMethod = 'estimate'"
|
||||
>
|
||||
<span class="option-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<span class="w-10 h-10 rounded-full flex items-center justify-center" :class="ftpMethod === 'estimate' ? 'bg-brand-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 dark:text-zinc-400'">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M9.09 9C9.3 8.4 9.7 7.9 10.2 7.6C10.7 7.3 11.3 7.2 11.9 7.3C12.5 7.4 13 7.7 13.4 8.2C13.8 8.7 14 9.3 14 9.9C14 11 12 11.5 12 13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="option-label">Estimate my FTP</span>
|
||||
<span class="text-sm font-medium text-zinc-900 dark:text-zinc-100 text-center">Estimate my FTP</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="option-card"
|
||||
:class="{ active: ftpMethod === 'skip' }"
|
||||
class="flex flex-col items-center gap-2 p-4 bg-zinc-50 dark:bg-zinc-800 border-2 rounded-lg cursor-pointer transition-all"
|
||||
:class="ftpMethod === 'skip' ? 'border-brand-500 bg-brand-500/5' : 'border-zinc-200 dark:border-zinc-700 hover:border-brand-500'"
|
||||
@click="ftpMethod = 'skip'"
|
||||
>
|
||||
<span class="option-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<span class="w-10 h-10 rounded-full flex items-center justify-center" :class="ftpMethod === 'skip' ? 'bg-brand-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500 dark:text-zinc-400'">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="option-label">Skip for now</span>
|
||||
<span class="text-sm font-medium text-zinc-900 dark:text-zinc-100 text-center">Skip for now</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ftpMethod === 'known'" class="form-group-modern">
|
||||
<label class="form-label-modern">Your FTP (watts)</label>
|
||||
<!-- Known FTP Input -->
|
||||
<div v-if="ftpMethod === 'known'" class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Your FTP (watts)</label>
|
||||
<input
|
||||
v-model.number="localData.ftp"
|
||||
type="number"
|
||||
class="form-input-modern"
|
||||
class="input-field"
|
||||
placeholder="Enter your FTP"
|
||||
min="50"
|
||||
max="500"
|
||||
/>
|
||||
<span class="form-helper-text">Enter your most recent FTP test result</span>
|
||||
<span class="block mt-1 text-xs text-zinc-500 dark:text-zinc-400">Enter your most recent FTP test result</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ftpMethod === 'estimate'" class="estimate-section">
|
||||
<div class="form-row">
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Your Weight (kg)</label>
|
||||
<!-- Estimate FTP -->
|
||||
<div v-if="ftpMethod === 'estimate'" class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-1 gap-4">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Your Weight (kg)</label>
|
||||
<input
|
||||
v-model.number="estimateWeight"
|
||||
type="number"
|
||||
class="form-input-modern"
|
||||
class="input-field"
|
||||
placeholder="70"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Fitness Level</label>
|
||||
<select v-model="fitnessLevel" class="form-input-modern">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Fitness Level</label>
|
||||
<select v-model="fitnessLevel" class="input-field">
|
||||
<option value="beginner">Beginner (new to cycling)</option>
|
||||
<option value="recreational">Recreational (ride occasionally)</option>
|
||||
<option value="intermediate">Intermediate (ride regularly)</option>
|
||||
@@ -98,52 +102,52 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" @click="estimateFtp" class="btn-modern btn-modern-secondary" :disabled="estimating">
|
||||
<span v-if="estimating" class="btn-spinner"></span>
|
||||
<button type="button" @click="estimateFtp" class="btn-secondary" :disabled="estimating">
|
||||
<span v-if="estimating" class="inline-block w-4 h-4 border-2 border-current border-r-transparent rounded-full animate-spin mr-1"></span>
|
||||
Calculate Estimate
|
||||
</button>
|
||||
<div v-if="localData.ftp && ftpMethod === 'estimate'" class="estimate-result">
|
||||
<span class="estimate-label">Estimated FTP:</span>
|
||||
<span class="estimate-value">{{ localData.ftp }}w</span>
|
||||
<div v-if="localData.ftp && ftpMethod === 'estimate'" class="flex items-center gap-2 mt-3 p-3 bg-emerald-500/10 rounded-lg">
|
||||
<span class="text-sm text-zinc-500 dark:text-zinc-400">Estimated FTP:</span>
|
||||
<span class="text-lg font-bold text-emerald-500">{{ localData.ftp }}w</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heart Rate Section -->
|
||||
<div class="metric-section">
|
||||
<h4 class="metric-section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<div class="card p-6 mb-6">
|
||||
<h4 class="flex items-center gap-2 mb-4 text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
<svg class="w-5 h-5 text-brand-500" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20.84 4.61C20.3292 4.09924 19.7228 3.69397 19.0554 3.41708C18.3879 3.14019 17.6725 2.99755 16.95 2.99755C16.2275 2.99755 15.5121 3.14019 14.8446 3.41708C14.1772 3.69397 13.5708 4.09924 13.06 4.61L12 5.67L10.94 4.61C9.9083 3.57831 8.50903 2.99804 7.05 2.99804C5.59096 2.99804 4.1917 3.57831 3.16 4.61C2.1283 5.6417 1.54804 7.04097 1.54804 8.5C1.54804 9.95903 2.1283 11.3583 3.16 12.39L4.22 13.45L12 21.23L19.78 13.45L20.84 12.39C21.3508 11.8792 21.756 11.2728 22.0329 10.6054C22.3098 9.93789 22.4525 9.22249 22.4525 8.5C22.4525 7.77751 22.3098 7.0621 22.0329 6.39464C21.756 5.72718 21.3508 5.12075 20.84 4.61Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Heart Rate Zones
|
||||
</h4>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Max Heart Rate (bpm)</label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-1 gap-4">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Max Heart Rate (bpm)</label>
|
||||
<input
|
||||
v-model.number="localData.max_hr"
|
||||
type="number"
|
||||
class="form-input-modern"
|
||||
class="input-field"
|
||||
placeholder="190"
|
||||
min="120"
|
||||
max="220"
|
||||
/>
|
||||
<button type="button" @click="estimateMaxHr" class="estimate-link" :disabled="!age">
|
||||
<button type="button" @click="estimateMaxHr" class="inline-block mt-1 text-xs text-brand-500 bg-transparent border-0 p-0 cursor-pointer underline hover:text-brand-700 disabled:text-zinc-400 disabled:cursor-not-allowed disabled:no-underline" :disabled="!age">
|
||||
Estimate from age{{ age ? ` (${age})` : '' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Resting Heart Rate (bpm)</label>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Resting Heart Rate (bpm)</label>
|
||||
<input
|
||||
v-model.number="localData.resting_hr"
|
||||
type="number"
|
||||
class="form-input-modern"
|
||||
class="input-field"
|
||||
placeholder="60"
|
||||
min="30"
|
||||
max="100"
|
||||
/>
|
||||
<span class="form-helper-text">Measure first thing in the morning</span>
|
||||
<span class="block mt-1 text-xs text-zinc-500 dark:text-zinc-400">Measure first thing in the morning</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,7 +199,7 @@ const age = computed(() => {
|
||||
async function estimateFtp() {
|
||||
estimating.value = true
|
||||
try {
|
||||
const { data } = await api.post('/protected/onboarding/estimate-ftp', {
|
||||
const { data } = await api.post('/api/protected/onboarding/estimate-ftp', {
|
||||
weight: estimateWeight.value,
|
||||
fitness_level: fitnessLevel.value
|
||||
})
|
||||
@@ -221,7 +225,7 @@ async function estimateMaxHr() {
|
||||
if (!age.value) return
|
||||
|
||||
try {
|
||||
const { data } = await api.post('/protected/onboarding/estimate-max-hr', {
|
||||
const { data } = await api.post('/api/protected/onboarding/estimate-max-hr', {
|
||||
age: age.value
|
||||
})
|
||||
localData.max_hr = data.estimated_max_hr
|
||||
@@ -243,228 +247,3 @@ watch(() => props.profileData.weight, (newWeight) => {
|
||||
if (newWeight) estimateWeight.value = newWeight
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-metrics {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.step-intro {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.step-intro h3 {
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.step-intro p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.metric-section {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.metric-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin: 0 0 var(--spacing-lg);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.metric-section-title svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.ftp-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.option-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.option-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.option-card.active {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(0, 102, 204, 0.05);
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.option-card.active .option-icon {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.option-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.estimate-section {
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group-modern {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group-modern:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-label-modern {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-input-modern {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.form-input-modern:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||
}
|
||||
|
||||
.form-helper-text {
|
||||
display: block;
|
||||
margin-top: var(--spacing-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.estimate-link {
|
||||
display: inline-block;
|
||||
margin-top: var(--spacing-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.estimate-link:hover {
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.estimate-link:disabled {
|
||||
color: var(--color-text-secondary);
|
||||
cursor: not-allowed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.estimate-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: rgba(0, 200, 83, 0.1);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.estimate-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.estimate-value {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.ftp-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,48 +1,55 @@
|
||||
<template>
|
||||
<div class="step-preferences">
|
||||
<div class="step-intro">
|
||||
<h3>Training Preferences</h3>
|
||||
<p>Help us create training plans tailored to your goals and schedule.</p>
|
||||
<div class="max-w-[700px] mx-auto">
|
||||
<!-- Intro -->
|
||||
<div class="text-center mb-6">
|
||||
<h3 class="mb-2 text-xl font-semibold text-zinc-900 dark:text-zinc-100">Training Preferences</h3>
|
||||
<p class="text-zinc-500 dark:text-zinc-400">Help us create training plans tailored to your goals and schedule.</p>
|
||||
</div>
|
||||
|
||||
<form class="form-modern" @submit.prevent>
|
||||
<form @submit.prevent>
|
||||
<!-- Training Goal -->
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">What's your primary training goal?</label>
|
||||
<div class="goal-options">
|
||||
<div class="mb-6">
|
||||
<label class="block mb-3 text-sm font-semibold text-zinc-900 dark:text-zinc-100">What's your primary training goal?</label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-1 gap-3">
|
||||
<button
|
||||
v-for="goal in goals"
|
||||
:key="goal.value"
|
||||
type="button"
|
||||
class="goal-card"
|
||||
:class="{ active: localData.training_goal === goal.value }"
|
||||
class="flex flex-col items-start p-4 border-2 rounded-lg cursor-pointer transition-all text-left"
|
||||
:class="localData.training_goal === goal.value
|
||||
? 'border-brand-500 bg-brand-500/5'
|
||||
: 'border-zinc-200 dark:border-zinc-700 bg-white dark:bg-surface-900 hover:border-brand-500'"
|
||||
@click="localData.training_goal = goal.value"
|
||||
>
|
||||
<span class="goal-icon" v-html="goal.icon"></span>
|
||||
<span class="goal-label">{{ goal.label }}</span>
|
||||
<span class="goal-description">{{ goal.description }}</span>
|
||||
<span
|
||||
class="w-8 h-8 mb-2 [&>svg]:w-full [&>svg]:h-full"
|
||||
:class="localData.training_goal === goal.value ? 'text-brand-500' : 'text-zinc-500 dark:text-zinc-400'"
|
||||
v-html="goal.icon"
|
||||
></span>
|
||||
<span class="font-semibold text-zinc-900 dark:text-zinc-100 mb-1">{{ goal.label }}</span>
|
||||
<span class="text-sm text-zinc-500 dark:text-zinc-400">{{ goal.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly Hours -->
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">How many hours per week can you train?</label>
|
||||
<div class="hours-slider">
|
||||
<div class="mb-6">
|
||||
<label class="block mb-3 text-sm font-semibold text-zinc-900 dark:text-zinc-100">How many hours per week can you train?</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<input
|
||||
v-model.number="localData.weekly_hours"
|
||||
type="range"
|
||||
min="3"
|
||||
max="20"
|
||||
step="1"
|
||||
class="range-input"
|
||||
class="flex-1 h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 outline-none appearance-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-6 [&::-webkit-slider-thumb]:h-6 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-brand-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-md"
|
||||
/>
|
||||
<div class="hours-display">
|
||||
<span class="hours-value">{{ localData.weekly_hours }}</span>
|
||||
<span class="hours-label">hours/week</span>
|
||||
<div class="flex flex-col items-center min-w-[80px]">
|
||||
<span class="text-2xl font-bold text-brand-500">{{ localData.weekly_hours }}</span>
|
||||
<span class="text-xs text-zinc-500 dark:text-zinc-400">hours/week</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hours-markers">
|
||||
<div class="flex justify-between mt-1 pr-24 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<span>3h</span>
|
||||
<span>8h</span>
|
||||
<span>12h</span>
|
||||
@@ -52,15 +59,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Preferred Days -->
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Which days do you prefer to train?</label>
|
||||
<div class="days-selector">
|
||||
<div class="mb-6">
|
||||
<label class="block mb-3 text-sm font-semibold text-zinc-900 dark:text-zinc-100">Which days do you prefer to train?</label>
|
||||
<div class="flex gap-2 sm:flex-wrap">
|
||||
<button
|
||||
v-for="day in days"
|
||||
:key="day.value"
|
||||
type="button"
|
||||
class="day-button"
|
||||
:class="{ active: localData.preferred_days.includes(day.value) }"
|
||||
class="flex-1 sm:flex-none sm:basis-[calc(25%-0.5rem)] py-2 px-3 border-2 rounded-lg text-sm font-medium cursor-pointer transition-all"
|
||||
:class="localData.preferred_days.includes(day.value)
|
||||
? 'bg-brand-500 border-brand-500 text-white'
|
||||
: 'bg-white dark:bg-surface-900 border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:border-brand-500'"
|
||||
@click="toggleDay(day.value)"
|
||||
>
|
||||
{{ day.label }}
|
||||
@@ -69,9 +78,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Preferred Time -->
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">When do you typically train?</label>
|
||||
<select v-model="localData.preferred_time" class="form-input-modern">
|
||||
<div class="mb-6">
|
||||
<label class="block mb-3 text-sm font-semibold text-zinc-900 dark:text-zinc-100">When do you typically train?</label>
|
||||
<select v-model="localData.preferred_time" class="input-field">
|
||||
<option value="morning">Morning (6am - 10am)</option>
|
||||
<option value="midday">Midday (10am - 2pm)</option>
|
||||
<option value="afternoon">Afternoon (2pm - 6pm)</option>
|
||||
@@ -81,29 +90,33 @@
|
||||
</div>
|
||||
|
||||
<!-- Indoor/Outdoor -->
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Training Environment</label>
|
||||
<div class="environment-options">
|
||||
<div class="mb-6">
|
||||
<label class="block mb-3 text-sm font-semibold text-zinc-900 dark:text-zinc-100">Training Environment</label>
|
||||
<div class="grid grid-cols-3 sm:grid-cols-1 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="env-option"
|
||||
:class="{ active: localData.indoor_outdoor === 'indoor' }"
|
||||
class="flex flex-col items-center gap-2 p-4 border-2 rounded-lg cursor-pointer transition-all"
|
||||
:class="localData.indoor_outdoor === 'indoor'
|
||||
? 'border-brand-500 bg-brand-500/5'
|
||||
: 'border-zinc-200 dark:border-zinc-700 bg-white dark:bg-surface-900 hover:border-brand-500'"
|
||||
@click="localData.indoor_outdoor = 'indoor'"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<svg class="w-7 h-7" :class="localData.indoor_outdoor === 'indoor' ? 'text-brand-500' : 'text-zinc-500 dark:text-zinc-400'" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="8" width="20" height="8" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="6" y1="8" x2="6" y2="16" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="18" y1="8" x2="18" y2="16" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<span>Mostly Indoor</span>
|
||||
<span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Mostly Indoor</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="env-option"
|
||||
:class="{ active: localData.indoor_outdoor === 'outdoor' }"
|
||||
class="flex flex-col items-center gap-2 p-4 border-2 rounded-lg cursor-pointer transition-all"
|
||||
:class="localData.indoor_outdoor === 'outdoor'
|
||||
? 'border-brand-500 bg-brand-500/5'
|
||||
: 'border-zinc-200 dark:border-zinc-700 bg-white dark:bg-surface-900 hover:border-brand-500'"
|
||||
@click="localData.indoor_outdoor = 'outdoor'"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<svg class="w-7 h-7" :class="localData.indoor_outdoor === 'outdoor' ? 'text-brand-500' : 'text-zinc-500 dark:text-zinc-400'" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="12" y1="2" x2="12" y2="4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="12" y1="20" x2="12" y2="22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
@@ -112,36 +125,55 @@
|
||||
<line x1="2" y1="12" x2="4" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="20" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Mostly Outdoor</span>
|
||||
<span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Mostly Outdoor</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="env-option"
|
||||
:class="{ active: localData.indoor_outdoor === 'mixed' }"
|
||||
class="flex flex-col items-center gap-2 p-4 border-2 rounded-lg cursor-pointer transition-all"
|
||||
:class="localData.indoor_outdoor === 'mixed'
|
||||
? 'border-brand-500 bg-brand-500/5'
|
||||
: 'border-zinc-200 dark:border-zinc-700 bg-white dark:bg-surface-900 hover:border-brand-500'"
|
||||
@click="localData.indoor_outdoor = 'mixed'"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<svg class="w-7 h-7" :class="localData.indoor_outdoor === 'mixed' ? 'text-brand-500' : 'text-zinc-500 dark:text-zinc-400'" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>Mixed</span>
|
||||
<span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Mixed</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Training Focus -->
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">What would you like to focus on?</label>
|
||||
<div class="focus-options">
|
||||
<label class="focus-checkbox" v-for="focus in focusOptions" :key="focus.value">
|
||||
<div class="mb-6">
|
||||
<label class="block mb-3 text-sm font-semibold text-zinc-900 dark:text-zinc-100">What would you like to focus on?</label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-1 gap-2">
|
||||
<label
|
||||
class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer transition-all hover:border-brand-500"
|
||||
:class="localData.training_focus.includes(focus.value)
|
||||
? 'border-brand-500'
|
||||
: 'border-zinc-200 dark:border-zinc-700 bg-white dark:bg-surface-900'"
|
||||
v-for="focus in focusOptions"
|
||||
:key="focus.value"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="focus.value"
|
||||
v-model="localData.training_focus"
|
||||
class="hidden"
|
||||
/>
|
||||
<span class="checkbox-custom"></span>
|
||||
<span class="focus-label">{{ focus.label }}</span>
|
||||
<span
|
||||
class="w-5 h-5 border-2 rounded flex items-center justify-center shrink-0 transition-all"
|
||||
:class="localData.training_focus.includes(focus.value)
|
||||
? 'bg-brand-500 border-brand-500'
|
||||
: 'border-zinc-300 dark:border-zinc-600'"
|
||||
>
|
||||
<svg v-if="localData.training_focus.includes(focus.value)" class="w-3 h-3 text-white" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-sm text-zinc-900 dark:text-zinc-100">{{ focus.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,320 +269,3 @@ watch(() => props.modelValue, (newVal) => {
|
||||
})
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-preferences {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.step-intro {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.step-intro h3 {
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.step-intro p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-group-modern {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.form-label-modern {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-input-modern {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.form-input-modern:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||
}
|
||||
|
||||
/* Goal Options */
|
||||
.goal-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.goal-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.goal-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.goal-card.active {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(0, 102, 204, 0.05);
|
||||
}
|
||||
|
||||
.goal-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.goal-card.active .goal-icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.goal-icon :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.goal-label {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.goal-description {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Hours Slider */
|
||||
.hours-slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.range-input {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-border);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.range-input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.hours-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.hours-value {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.hours-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.hours-markers {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: var(--spacing-xs);
|
||||
padding-right: 96px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Days Selector */
|
||||
.days-selector {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.day-button {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.day-button:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.day-button.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Environment Options */
|
||||
.environment-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.env-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.env-option:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.env-option.active {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(0, 102, 204, 0.05);
|
||||
}
|
||||
|
||||
.env-option svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.env-option.active svg {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.env-option span {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Focus Options */
|
||||
.focus-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.focus-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.focus-checkbox:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.focus-checkbox input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkbox-custom {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.focus-checkbox input:checked + .checkbox-custom {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.focus-checkbox input:checked + .checkbox-custom::after {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.focus-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.goal-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.environment-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.focus-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.days-selector {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.day-button {
|
||||
flex: 0 0 calc(25% - var(--spacing-sm));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
<template>
|
||||
<div class="step-profile">
|
||||
<div class="step-intro">
|
||||
<h3>Let's get to know you</h3>
|
||||
<p>This information helps us personalize your training experience.</p>
|
||||
<div class="max-w-[600px] mx-auto">
|
||||
<!-- Intro -->
|
||||
<div class="text-center mb-6">
|
||||
<h3 class="mb-2 text-xl font-semibold text-zinc-900 dark:text-zinc-100">Let's get to know you</h3>
|
||||
<p class="text-zinc-500 dark:text-zinc-400">This information helps us personalize your training experience.</p>
|
||||
</div>
|
||||
|
||||
<form class="form-modern" @submit.prevent>
|
||||
<div class="form-row">
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">First Name *</label>
|
||||
<form @submit.prevent>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-1 gap-4">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">First Name *</label>
|
||||
<input
|
||||
v-model="localData.first_name"
|
||||
type="text"
|
||||
class="form-input-modern"
|
||||
class="input-field"
|
||||
placeholder="Enter your first name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Last Name *</label>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Last Name *</label>
|
||||
<input
|
||||
v-model="localData.last_name"
|
||||
type="text"
|
||||
class="form-input-modern"
|
||||
class="input-field"
|
||||
placeholder="Enter your last name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Date of Birth</label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-1 gap-4">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Date of Birth</label>
|
||||
<input
|
||||
v-model="localData.date_of_birth"
|
||||
type="date"
|
||||
class="form-input-modern"
|
||||
class="input-field"
|
||||
/>
|
||||
<span class="form-helper-text">Used to calculate age-based training zones</span>
|
||||
<span class="block mt-1 text-xs text-zinc-500 dark:text-zinc-400">Used to calculate age-based training zones</span>
|
||||
</div>
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Gender</label>
|
||||
<select v-model="localData.gender" class="form-input-modern">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Gender</label>
|
||||
<select v-model="localData.gender" class="input-field">
|
||||
<option value="">Prefer not to say</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
@@ -50,31 +51,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Height (cm)</label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-1 gap-4">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Height (cm)</label>
|
||||
<input
|
||||
v-model.number="localData.height"
|
||||
type="number"
|
||||
class="form-input-modern"
|
||||
class="input-field"
|
||||
placeholder="175"
|
||||
min="100"
|
||||
max="250"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Weight (kg) *</label>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">Weight (kg) *</label>
|
||||
<input
|
||||
v-model.number="localData.weight"
|
||||
type="number"
|
||||
class="form-input-modern"
|
||||
class="input-field"
|
||||
placeholder="70"
|
||||
min="30"
|
||||
max="200"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
<span class="form-helper-text">Used to calculate power-to-weight ratio</span>
|
||||
<span class="block mt-1 text-xs text-zinc-500 dark:text-zinc-400">Used to calculate power-to-weight ratio</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -110,75 +111,3 @@ watch(() => props.modelValue, (newVal) => {
|
||||
Object.assign(localData, newVal)
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-profile {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.step-intro {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.step-intro h3 {
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.step-intro p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group-modern {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-label-modern {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-input-modern {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.form-input-modern:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||
}
|
||||
|
||||
.form-helper-text {
|
||||
display: block;
|
||||
margin-top: var(--spacing-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="modal-overlay" @click.self="handleCancel">
|
||||
<div class="modal-confirm" :class="variantClass">
|
||||
<div class="confirm-icon" :class="variantClass">
|
||||
<svg v-if="variant === 'danger'" viewBox="0 0 24 24" fill="none">
|
||||
<div class="modal-panel max-w-[400px] w-full text-center p-6">
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
:class="{
|
||||
'bg-red-500/10 text-red-500': variant === 'danger',
|
||||
'bg-amber-500/10 text-amber-500': variant === 'warning',
|
||||
'bg-brand-500/10 text-brand-500': variant === 'info'
|
||||
}"
|
||||
>
|
||||
<svg v-if="variant === 'danger'" class="w-8 h-8" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 9V13M12 17H12.01M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<svg v-else-if="variant === 'warning'" viewBox="0 0 24 24" fill="none">
|
||||
<svg v-else-if="variant === 'warning'" class="w-8 h-8" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 9V13M12 17H12.01M10.29 3.86L1.82 18C1.64 18.3 1.55 18.64 1.55 19C1.55 19.36 1.64 19.7 1.82 20C2 20.3 2.26 20.56 2.56 20.73C2.86 20.91 3.2 21 3.56 21H20.44C20.8 21 21.14 20.91 21.44 20.73C21.74 20.56 22 20.3 22.18 20C22.36 19.7 22.45 19.36 22.45 19C22.45 18.64 22.36 18.3 22.18 18L13.71 3.86C13.53 3.56 13.27 3.31 12.97 3.14C12.67 2.97 12.34 2.88 12 2.88C11.66 2.88 11.33 2.97 11.03 3.14C10.73 3.31 10.47 3.56 10.29 3.86Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none">
|
||||
<svg v-else class="w-8 h-8" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 8V12M12 16H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="confirm-title">{{ title }}</h3>
|
||||
<p class="confirm-message">{{ message }}</p>
|
||||
<div class="confirm-actions">
|
||||
|
||||
<h3 class="mb-2 text-lg font-semibold text-zinc-900 dark:text-zinc-100">{{ title }}</h3>
|
||||
<p class="mb-6 text-sm text-zinc-500 dark:text-zinc-400 leading-relaxed">{{ message }}</p>
|
||||
|
||||
<div class="flex gap-3 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-modern btn-modern-secondary"
|
||||
class="btn-secondary flex-1 max-w-[150px]"
|
||||
@click="handleCancel"
|
||||
:disabled="loading"
|
||||
>
|
||||
@@ -27,12 +37,12 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-modern"
|
||||
class="flex-1 max-w-[150px]"
|
||||
:class="confirmButtonClass"
|
||||
@click="handleConfirm"
|
||||
:disabled="loading"
|
||||
>
|
||||
<span v-if="loading" class="btn-spinner"></span>
|
||||
<span v-if="loading" class="inline-block w-4 h-4 border-2 border-current border-r-transparent rounded-full animate-spin mr-1"></span>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -78,13 +88,11 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel'])
|
||||
|
||||
const variantClass = computed(() => `variant-${props.variant}`)
|
||||
|
||||
const confirmButtonClass = computed(() => {
|
||||
switch (props.variant) {
|
||||
case 'danger': return 'btn-modern-danger'
|
||||
case 'warning': return 'btn-modern-warning'
|
||||
default: return 'btn-modern-primary'
|
||||
case 'danger': return 'btn-danger'
|
||||
case 'warning': return 'inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-amber-500 rounded-lg hover:bg-amber-600 transition-colors'
|
||||
default: return 'btn-primary'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -98,120 +106,3 @@ function handleCancel() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal, 1000);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.modal-confirm {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-2xl);
|
||||
animation: modalSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto var(--spacing-lg);
|
||||
}
|
||||
|
||||
.confirm-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.confirm-icon.variant-danger {
|
||||
background: rgba(230, 57, 70, 0.1);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.confirm-icon.variant-warning {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.confirm-icon.variant-info {
|
||||
background: rgba(0, 102, 204, 0.1);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
margin: 0 0 var(--spacing-xl);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.confirm-actions .btn-modern {
|
||||
flex: 1;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Warning button variant (not in base styles) */
|
||||
.btn-modern-warning {
|
||||
background: var(--color-warning);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-modern-warning:hover {
|
||||
background: #e68900;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,54 @@
|
||||
<template>
|
||||
<div class="stepper-wizard">
|
||||
<div class="stepper-progress">
|
||||
<div class="w-full">
|
||||
<!-- Stepper Progress -->
|
||||
<div class="flex items-start justify-center mb-8 px-4 md:overflow-x-auto md:justify-start md:pb-4 md:gap-1">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="stepper-step"
|
||||
:class="{ clickable: allowNavigation && index < currentStep }"
|
||||
class="flex flex-col items-center relative flex-1 max-w-[200px] md:min-w-[80px] md:flex-none sm:min-w-[60px]"
|
||||
:class="{ 'cursor-pointer': allowNavigation && index < currentStep }"
|
||||
@click="handleStepClick(index)"
|
||||
>
|
||||
<div class="step-indicator" :class="getStepClass(index)">
|
||||
<svg v-if="index < currentStep" viewBox="0 0 24 24" fill="none" class="check-icon">
|
||||
<!-- Step Indicator -->
|
||||
<div
|
||||
class="w-12 h-12 md:w-10 md:h-10 rounded-full flex items-center justify-center text-lg md:text-base font-semibold transition-all relative z-[1]"
|
||||
:class="{
|
||||
'bg-zinc-100 dark:bg-zinc-800 border-2 border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400': getStepClass(index) === 'pending',
|
||||
'bg-gradient-to-br from-brand-500 to-brand-600 border-2 border-transparent text-white shadow-[0_4px_12px_rgba(99,102,241,0.3)]': getStepClass(index) === 'active',
|
||||
'bg-emerald-500 border-2 border-transparent text-white': getStepClass(index) === 'completed'
|
||||
}"
|
||||
>
|
||||
<svg v-if="index < currentStep" class="w-6 h-6" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</div>
|
||||
<span class="step-label" :class="{ active: index === currentStep }">{{ step.label }}</span>
|
||||
<div v-if="index < steps.length - 1" class="step-connector" :class="getConnectorClass(index)"></div>
|
||||
|
||||
<!-- Step Label -->
|
||||
<span
|
||||
class="mt-2 text-sm md:text-xs sm:hidden text-center font-medium transition-colors"
|
||||
:class="{
|
||||
'text-brand-500 font-semibold': index === currentStep,
|
||||
'text-zinc-500 dark:text-zinc-400': index !== currentStep
|
||||
}"
|
||||
>
|
||||
{{ step.label }}
|
||||
</span>
|
||||
|
||||
<!-- Connector Line -->
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="absolute top-6 md:top-5 left-[calc(50%+28px)] md:left-[calc(50%+24px)] w-[calc(100%-56px)] md:w-[calc(100%-48px)] h-0.5 transition-colors"
|
||||
:class="{
|
||||
'bg-emerald-500': getConnectorClass(index) === 'completed',
|
||||
'bg-zinc-200 dark:bg-zinc-700': getConnectorClass(index) === 'pending'
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
|
||||
<!-- Step Content -->
|
||||
<div class="w-full">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,147 +92,3 @@ function handleStepClick(index) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stepper-wizard {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stepper-progress {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.stepper-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.stepper-step.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stepper-step.clickable:hover .step-indicator.completed {
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
transition: all var(--transition-base);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.step-indicator.pending {
|
||||
background: var(--color-surface-secondary);
|
||||
border: 2px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.step-indicator.active {
|
||||
background: var(--gradient-primary);
|
||||
border: 2px solid transparent;
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 102, 204, 0.3);
|
||||
}
|
||||
|
||||
.step-indicator.completed {
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid transparent;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
.step-label.active {
|
||||
color: var(--color-primary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: calc(50% + 28px);
|
||||
width: calc(100% - 56px);
|
||||
height: 2px;
|
||||
background: var(--color-border);
|
||||
transition: background var(--transition-base);
|
||||
}
|
||||
|
||||
.step-connector.completed {
|
||||
background: var(--color-secondary);
|
||||
}
|
||||
|
||||
.step-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.stepper-progress {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--spacing-md);
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stepper-step {
|
||||
min-width: 80px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
top: 20px;
|
||||
left: calc(50% + 24px);
|
||||
width: calc(100% - 48px);
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.step-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stepper-step {
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="favorite-btn"
|
||||
:class="{ favorited: isFavorited }"
|
||||
class="w-8 h-8 border-0 bg-transparent cursor-pointer flex items-center justify-center rounded-lg transition-all hover:bg-red-500/10 hover:text-red-500"
|
||||
:class="isFavorited ? 'text-red-500' : 'text-zinc-500 dark:text-zinc-400'"
|
||||
@click="$emit('toggle')"
|
||||
:title="isFavorited ? 'Remove from favorites' : 'Add to favorites'"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M20.84 4.61C20.3292 4.099 19.7228 3.69364 19.0554 3.41708C18.3879 3.14052 17.6725 2.99817 16.95 2.99817C16.2275 2.99817 15.5121 3.14052 14.8446 3.41708C14.1772 3.69364 13.5708 4.099 13.06 4.61L12 5.67L10.94 4.61C9.9083 3.57831 8.50903 2.99871 7.05 2.99871C5.59096 2.99871 4.19169 3.57831 3.16 4.61C2.1283 5.64169 1.54871 7.04097 1.54871 8.5C1.54871 9.95903 2.1283 11.3583 3.16 12.39L4.22 13.45L12 21.23L19.78 13.45L20.84 12.39C21.351 11.8792 21.7563 11.2728 22.0329 10.6054C22.3095 9.93789 22.4518 9.22248 22.4518 8.5C22.4518 7.77752 22.3095 7.0621 22.0329 6.39464C21.7563 5.72718 21.351 5.12075 20.84 4.61Z"
|
||||
:fill="isFavorited ? 'currentColor' : 'none'"
|
||||
@@ -31,33 +31,3 @@ defineProps({
|
||||
|
||||
defineEmits(['toggle'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favorite-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.favorite-btn:hover {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.favorite-btn.favorited {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.favorite-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,149 +1,150 @@
|
||||
<template>
|
||||
<div class="interval-builder">
|
||||
<div class="builder-header">
|
||||
<h3>Workout Structure</h3>
|
||||
<div class="total-duration">
|
||||
<div class="card p-4">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="m-0 text-lg font-semibold text-zinc-900 dark:text-zinc-100">Workout Structure</h3>
|
||||
<div class="px-3 py-1 bg-brand-500 text-white rounded-lg text-sm font-semibold">
|
||||
Total: {{ formatTotalDuration(calculatedTotalSeconds) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workout Graph Preview -->
|
||||
<div class="workout-graph">
|
||||
<div class="mb-6">
|
||||
<IntervalDisplay :structure="localStructure" :show-labels="true" />
|
||||
</div>
|
||||
|
||||
<!-- Warmup Section -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h4>
|
||||
<span class="section-badge warmup">Warmup</span>
|
||||
<div class="mb-4 p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h4 class="m-0 flex items-center gap-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-semibold uppercase text-white bg-[#3498db]">Warmup</span>
|
||||
{{ formatSectionDuration(localStructure.warmup) }}
|
||||
</h4>
|
||||
<button type="button" class="btn-add" @click="addInterval('warmup')">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<button type="button" class="flex items-center gap-1 px-2 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-xs cursor-pointer transition-colors bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 hover:border-brand-500 hover:text-brand-500" @click="addInterval('warmup')">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="intervals-list" v-if="localStructure.warmup.length > 0">
|
||||
<div class="flex flex-col gap-2" v-if="localStructure.warmup.length > 0">
|
||||
<div
|
||||
v-for="(interval, index) in localStructure.warmup"
|
||||
:key="`warmup-${index}`"
|
||||
class="interval-row"
|
||||
class="flex items-start gap-3 md:flex-col p-3 bg-white dark:bg-surface-900 border border-zinc-200 dark:border-zinc-800 rounded-lg"
|
||||
>
|
||||
<IntervalRowFields
|
||||
:interval="interval"
|
||||
@update="updateInterval('warmup', index, $event)"
|
||||
/>
|
||||
<div class="interval-actions">
|
||||
<button type="button" class="btn-action" @click="moveInterval('warmup', index, -1)" :disabled="index === 0" title="Move up">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M18 15L12 9L6 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<div class="flex flex-col md:flex-row md:w-full md:justify-end gap-1 shrink-0">
|
||||
<button type="button" class="w-7 h-7 border border-zinc-200 dark:border-zinc-700 rounded bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 cursor-pointer flex items-center justify-center transition-colors hover:border-brand-500 hover:text-brand-500 disabled:opacity-30 disabled:cursor-not-allowed" @click="moveInterval('warmup', index, -1)" :disabled="index === 0" title="Move up">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><path d="M18 15L12 9L6 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn-action" @click="moveInterval('warmup', index, 1)" :disabled="index === localStructure.warmup.length - 1" title="Move down">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<button type="button" class="w-7 h-7 border border-zinc-200 dark:border-zinc-700 rounded bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 cursor-pointer flex items-center justify-center transition-colors hover:border-brand-500 hover:text-brand-500 disabled:opacity-30 disabled:cursor-not-allowed" @click="moveInterval('warmup', index, 1)" :disabled="index === localStructure.warmup.length - 1" title="Move down">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn-action btn-danger" @click="removeInterval('warmup', index)" title="Remove">
|
||||
<svg viewBox="0 0 24 24" fill="none"><line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<button type="button" class="w-7 h-7 border border-zinc-200 dark:border-zinc-700 rounded bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 cursor-pointer flex items-center justify-center transition-colors hover:border-red-500 hover:text-red-500 disabled:opacity-30 disabled:cursor-not-allowed" @click="removeInterval('warmup', index)" title="Remove">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-section">Click "Add" to add a warmup interval</div>
|
||||
<div v-else class="p-3 text-center text-sm text-zinc-500 dark:text-zinc-400 bg-white dark:bg-surface-900 rounded border border-dashed border-zinc-200 dark:border-zinc-700">Click "Add" to add a warmup interval</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Section -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h4>
|
||||
<span class="section-badge main">Main Set</span>
|
||||
<div class="mb-4 p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h4 class="m-0 flex items-center gap-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-semibold uppercase text-white bg-[#e74c3c]">Main Set</span>
|
||||
{{ formatSectionDuration(localStructure.main) }}
|
||||
</h4>
|
||||
<button type="button" class="btn-add" @click="addInterval('main')">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<button type="button" class="flex items-center gap-1 px-2 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-xs cursor-pointer transition-colors bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 hover:border-brand-500 hover:text-brand-500" @click="addInterval('main')">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="intervals-list" v-if="localStructure.main.length > 0">
|
||||
<div class="flex flex-col gap-2" v-if="localStructure.main.length > 0">
|
||||
<div
|
||||
v-for="(interval, index) in localStructure.main"
|
||||
:key="`main-${index}`"
|
||||
class="interval-row"
|
||||
class="flex items-start gap-3 md:flex-col p-3 bg-white dark:bg-surface-900 border border-zinc-200 dark:border-zinc-800 rounded-lg"
|
||||
>
|
||||
<IntervalRowFields
|
||||
:interval="interval"
|
||||
:show-repeats="true"
|
||||
@update="updateInterval('main', index, $event)"
|
||||
/>
|
||||
<div class="interval-actions">
|
||||
<button type="button" class="btn-action" @click="moveInterval('main', index, -1)" :disabled="index === 0" title="Move up">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M18 15L12 9L6 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<div class="flex flex-col md:flex-row md:w-full md:justify-end gap-1 shrink-0">
|
||||
<button type="button" class="w-7 h-7 border border-zinc-200 dark:border-zinc-700 rounded bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 cursor-pointer flex items-center justify-center transition-colors hover:border-brand-500 hover:text-brand-500 disabled:opacity-30 disabled:cursor-not-allowed" @click="moveInterval('main', index, -1)" :disabled="index === 0" title="Move up">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><path d="M18 15L12 9L6 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn-action" @click="moveInterval('main', index, 1)" :disabled="index === localStructure.main.length - 1" title="Move down">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<button type="button" class="w-7 h-7 border border-zinc-200 dark:border-zinc-700 rounded bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 cursor-pointer flex items-center justify-center transition-colors hover:border-brand-500 hover:text-brand-500 disabled:opacity-30 disabled:cursor-not-allowed" @click="moveInterval('main', index, 1)" :disabled="index === localStructure.main.length - 1" title="Move down">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn-action btn-danger" @click="removeInterval('main', index)" title="Remove">
|
||||
<svg viewBox="0 0 24 24" fill="none"><line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<button type="button" class="w-7 h-7 border border-zinc-200 dark:border-zinc-700 rounded bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 cursor-pointer flex items-center justify-center transition-colors hover:border-red-500 hover:text-red-500 disabled:opacity-30 disabled:cursor-not-allowed" @click="removeInterval('main', index)" title="Remove">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-section">Click "Add" to add main set intervals</div>
|
||||
<div v-else class="p-3 text-center text-sm text-zinc-500 dark:text-zinc-400 bg-white dark:bg-surface-900 rounded border border-dashed border-zinc-200 dark:border-zinc-700">Click "Add" to add main set intervals</div>
|
||||
</div>
|
||||
|
||||
<!-- Cooldown Section -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h4>
|
||||
<span class="section-badge cooldown">Cooldown</span>
|
||||
<div class="mb-4 p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h4 class="m-0 flex items-center gap-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-semibold uppercase text-white bg-[#9b59b6]">Cooldown</span>
|
||||
{{ formatSectionDuration(localStructure.cooldown) }}
|
||||
</h4>
|
||||
<button type="button" class="btn-add" @click="addInterval('cooldown')">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<button type="button" class="flex items-center gap-1 px-2 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-xs cursor-pointer transition-colors bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 hover:border-brand-500 hover:text-brand-500" @click="addInterval('cooldown')">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="intervals-list" v-if="localStructure.cooldown.length > 0">
|
||||
<div class="flex flex-col gap-2" v-if="localStructure.cooldown.length > 0">
|
||||
<div
|
||||
v-for="(interval, index) in localStructure.cooldown"
|
||||
:key="`cooldown-${index}`"
|
||||
class="interval-row"
|
||||
class="flex items-start gap-3 md:flex-col p-3 bg-white dark:bg-surface-900 border border-zinc-200 dark:border-zinc-800 rounded-lg"
|
||||
>
|
||||
<IntervalRowFields
|
||||
:interval="interval"
|
||||
@update="updateInterval('cooldown', index, $event)"
|
||||
/>
|
||||
<div class="interval-actions">
|
||||
<button type="button" class="btn-action" @click="moveInterval('cooldown', index, -1)" :disabled="index === 0" title="Move up">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M18 15L12 9L6 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<div class="flex flex-col md:flex-row md:w-full md:justify-end gap-1 shrink-0">
|
||||
<button type="button" class="w-7 h-7 border border-zinc-200 dark:border-zinc-700 rounded bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 cursor-pointer flex items-center justify-center transition-colors hover:border-brand-500 hover:text-brand-500 disabled:opacity-30 disabled:cursor-not-allowed" @click="moveInterval('cooldown', index, -1)" :disabled="index === 0" title="Move up">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><path d="M18 15L12 9L6 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn-action" @click="moveInterval('cooldown', index, 1)" :disabled="index === localStructure.cooldown.length - 1" title="Move down">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<button type="button" class="w-7 h-7 border border-zinc-200 dark:border-zinc-700 rounded bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 cursor-pointer flex items-center justify-center transition-colors hover:border-brand-500 hover:text-brand-500 disabled:opacity-30 disabled:cursor-not-allowed" @click="moveInterval('cooldown', index, 1)" :disabled="index === localStructure.cooldown.length - 1" title="Move down">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn-action btn-danger" @click="removeInterval('cooldown', index)" title="Remove">
|
||||
<svg viewBox="0 0 24 24" fill="none"><line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<button type="button" class="w-7 h-7 border border-zinc-200 dark:border-zinc-700 rounded bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 cursor-pointer flex items-center justify-center transition-colors hover:border-red-500 hover:text-red-500 disabled:opacity-30 disabled:cursor-not-allowed" @click="removeInterval('cooldown', index)" title="Remove">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"><line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-section">Click "Add" to add a cooldown interval</div>
|
||||
<div v-else class="p-3 text-center text-sm text-zinc-500 dark:text-zinc-400 bg-white dark:bg-surface-900 rounded border border-dashed border-zinc-200 dark:border-zinc-700">Click "Add" to add a cooldown interval</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Add Presets -->
|
||||
<div class="quick-presets">
|
||||
<span class="presets-label">Quick add:</span>
|
||||
<button type="button" class="btn-preset" @click="addPreset('warmup-easy')">10min Warmup</button>
|
||||
<button type="button" class="btn-preset" @click="addPreset('sweetspot')">Sweet Spot 2x20</button>
|
||||
<button type="button" class="btn-preset" @click="addPreset('vo2max')">VO2Max 5x3</button>
|
||||
<button type="button" class="btn-preset" @click="addPreset('threshold')">Threshold 2x20</button>
|
||||
<button type="button" class="btn-preset" @click="addPreset('cooldown')">5min Cooldown</button>
|
||||
<div class="flex items-center gap-2 flex-wrap pt-4 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<span class="text-sm text-zinc-500 dark:text-zinc-400">Quick add:</span>
|
||||
<button type="button" class="px-2 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-xs cursor-pointer transition-colors bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 hover:border-brand-500 hover:text-brand-500" @click="addPreset('warmup-easy')">10min Warmup</button>
|
||||
<button type="button" class="px-2 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-xs cursor-pointer transition-colors bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 hover:border-brand-500 hover:text-brand-500" @click="addPreset('sweetspot')">Sweet Spot 2x20</button>
|
||||
<button type="button" class="px-2 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-xs cursor-pointer transition-colors bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 hover:border-brand-500 hover:text-brand-500" @click="addPreset('vo2max')">VO2Max 5x3</button>
|
||||
<button type="button" class="px-2 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-xs cursor-pointer transition-colors bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 hover:border-brand-500 hover:text-brand-500" @click="addPreset('threshold')">Threshold 2x20</button>
|
||||
<button type="button" class="px-2 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-xs cursor-pointer transition-colors bg-white dark:bg-surface-900 text-zinc-500 dark:text-zinc-400 hover:border-brand-500 hover:text-brand-500" @click="addPreset('cooldown')">5min Cooldown</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -307,209 +308,3 @@ watch(() => props.modelValue, (newVal) => {
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.interval-builder {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.builder-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.builder-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.total-duration {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.workout-graph {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.section-header h4 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section-badge {
|
||||
padding: 2px var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.section-badge.warmup { background: #3498db; }
|
||||
.section-badge.main { background: #e74c3c; }
|
||||
.section-badge.cooldown { background: #9b59b6; }
|
||||
|
||||
.btn-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-add svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.intervals-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.interval-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.interval-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-action:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-action:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-action.btn-danger:hover:not(:disabled) {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.btn-action svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.empty-section {
|
||||
padding: var(--spacing-md);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px dashed var(--color-border);
|
||||
}
|
||||
|
||||
.quick-presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.presets-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-preset {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-preset:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.interval-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.interval-actions {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,59 +1,75 @@
|
||||
<template>
|
||||
<div class="interval-display">
|
||||
<div class="interval-chart" v-if="flattenedIntervals.length > 0">
|
||||
<div class="w-full">
|
||||
<!-- Chart -->
|
||||
<div class="flex items-end h-[120px] bg-zinc-50 dark:bg-zinc-800/50 rounded-lg p-2 gap-0.5" v-if="flattenedIntervals.length > 0">
|
||||
<div
|
||||
v-for="(interval, index) in flattenedIntervals"
|
||||
:key="index"
|
||||
class="interval-bar"
|
||||
:class="`interval-${interval.section}`"
|
||||
class="min-w-1 rounded-t transition-all relative flex items-end justify-center hover:opacity-80"
|
||||
:class="{
|
||||
'bg-gradient-to-t from-[#3498db] to-[#5dade2]': interval.section === 'warmup',
|
||||
'bg-gradient-to-t from-[#e74c3c] to-[#ec7063]': interval.section === 'main',
|
||||
'bg-gradient-to-t from-[#2ecc71] to-[#58d68d]': interval.section === 'rest',
|
||||
'bg-gradient-to-t from-[#9b59b6] to-[#af7ac5]': interval.section === 'cooldown'
|
||||
}"
|
||||
:style="{
|
||||
width: `${getIntervalWidth(interval)}%`,
|
||||
height: `${getIntervalHeight(interval)}%`
|
||||
}"
|
||||
:title="getIntervalTooltip(interval)"
|
||||
>
|
||||
<span class="interval-label" v-if="showLabels && getIntervalWidth(interval) > 8">
|
||||
<span class="absolute bottom-1 text-xs text-white whitespace-nowrap drop-shadow-sm" v-if="showLabels && getIntervalWidth(interval) > 8">
|
||||
{{ formatDuration(interval.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-intervals">
|
||||
<div v-else class="h-[120px] flex items-center justify-center bg-zinc-50 dark:bg-zinc-800/50 rounded-lg text-sm text-zinc-500 dark:text-zinc-400">
|
||||
No intervals defined
|
||||
</div>
|
||||
|
||||
<div v-if="showLegend" class="interval-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-color interval-warmup"></span>
|
||||
<!-- Legend -->
|
||||
<div v-if="showLegend" class="flex gap-4 mt-3 justify-center">
|
||||
<div class="flex items-center gap-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span class="w-3 h-3 rounded-sm bg-[#3498db]"></span>
|
||||
<span>Warmup</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color interval-main"></span>
|
||||
<div class="flex items-center gap-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span class="w-3 h-3 rounded-sm bg-[#e74c3c]"></span>
|
||||
<span>Main</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color interval-rest"></span>
|
||||
<div class="flex items-center gap-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span class="w-3 h-3 rounded-sm bg-[#2ecc71]"></span>
|
||||
<span>Rest</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color interval-cooldown"></span>
|
||||
<div class="flex items-center gap-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span class="w-3 h-3 rounded-sm bg-[#9b59b6]"></span>
|
||||
<span>Cooldown</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showDetails && flattenedIntervals.length > 0" class="interval-details">
|
||||
<div v-for="(section, sectionName) in groupedIntervals" :key="sectionName" class="interval-section">
|
||||
<h4 class="section-title">{{ formatSectionName(sectionName) }}</h4>
|
||||
<div v-for="(interval, index) in section" :key="index" class="interval-detail-row">
|
||||
<span class="interval-index">{{ index + 1 }}</span>
|
||||
<span class="interval-type-badge" :class="`interval-${sectionName}`">
|
||||
<!-- Details -->
|
||||
<div v-if="showDetails && flattenedIntervals.length > 0" class="mt-4 flex flex-col gap-4">
|
||||
<div v-for="(section, sectionName) in groupedIntervals" :key="sectionName" class="flex flex-col gap-2">
|
||||
<h4 class="m-0 text-sm font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{{ formatSectionName(sectionName) }}</h4>
|
||||
<div v-for="(interval, index) in section" :key="index" class="flex items-center gap-3 py-2 px-3 bg-white dark:bg-surface-900 border border-zinc-200 dark:border-zinc-800 rounded-lg text-sm">
|
||||
<span class="w-6 h-6 flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 rounded-full font-medium text-zinc-500 dark:text-zinc-400">{{ index + 1 }}</span>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-medium text-white"
|
||||
:class="{
|
||||
'bg-[#3498db]': sectionName === 'warmup',
|
||||
'bg-[#e74c3c]': sectionName === 'main',
|
||||
'bg-[#2ecc71]': sectionName === 'rest',
|
||||
'bg-[#9b59b6]': sectionName === 'cooldown'
|
||||
}"
|
||||
>
|
||||
{{ interval.name || formatSectionName(sectionName) }}
|
||||
</span>
|
||||
<span class="interval-duration">{{ formatDuration(interval.duration) }}</span>
|
||||
<span class="interval-power">{{ formatPower(interval) }}</span>
|
||||
<span class="interval-cadence" v-if="interval.cadence">
|
||||
<span class="font-medium text-zinc-900 dark:text-zinc-100 min-w-[50px]">{{ formatDuration(interval.duration) }}</span>
|
||||
<span class="text-zinc-500 dark:text-zinc-400 min-w-[100px]">{{ formatPower(interval) }}</span>
|
||||
<span class="text-zinc-500 dark:text-zinc-400" v-if="interval.cadence">
|
||||
{{ interval.cadence }} rpm
|
||||
</span>
|
||||
<span class="interval-repeat" v-if="interval.repeat && interval.repeat > 1">
|
||||
<span class="px-1.5 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded text-xs font-medium text-zinc-500 dark:text-zinc-400" v-if="interval.repeat && interval.repeat > 1">
|
||||
x{{ interval.repeat }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -197,199 +213,3 @@ function formatPower(interval) {
|
||||
return `${low}-${high}% FTP`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.interval-display {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.interval-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 120px;
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-sm);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.interval-bar {
|
||||
min-width: 4px;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
transition: all var(--transition-base);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.interval-bar:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.interval-warmup {
|
||||
background: linear-gradient(to top, #3498db, #5dade2);
|
||||
}
|
||||
|
||||
.interval-main {
|
||||
background: linear-gradient(to top, #e74c3c, #ec7063);
|
||||
}
|
||||
|
||||
.interval-rest {
|
||||
background: linear-gradient(to top, #2ecc71, #58d68d);
|
||||
}
|
||||
|
||||
.interval-cooldown {
|
||||
background: linear-gradient(to top, #9b59b6, #af7ac5);
|
||||
}
|
||||
|
||||
.interval-label {
|
||||
position: absolute;
|
||||
bottom: var(--spacing-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.no-intervals {
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.interval-legend {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
margin-top: var(--spacing-md);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.legend-color.interval-warmup {
|
||||
background: #3498db;
|
||||
}
|
||||
|
||||
.legend-color.interval-main {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
.legend-color.interval-rest {
|
||||
background: #2ecc71;
|
||||
}
|
||||
|
||||
.legend-color.interval-cooldown {
|
||||
background: #9b59b6;
|
||||
}
|
||||
|
||||
.interval-details {
|
||||
margin-top: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.interval-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.interval-detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.interval-index {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: 50%;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.interval-type-badge {
|
||||
padding: 2px var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.interval-type-badge.interval-warmup {
|
||||
background: #3498db;
|
||||
}
|
||||
|
||||
.interval-type-badge.interval-main {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
.interval-type-badge.interval-rest {
|
||||
background: #2ecc71;
|
||||
}
|
||||
|
||||
.interval-type-badge.interval-cooldown {
|
||||
background: #9b59b6;
|
||||
}
|
||||
|
||||
.interval-duration {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.interval-power {
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.interval-cadence {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.interval-repeat {
|
||||
padding: 2px var(--spacing-xs);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div class="interval-row-fields">
|
||||
<div class="field-group duration-field">
|
||||
<label>Duration</label>
|
||||
<div class="duration-input">
|
||||
<div class="flex flex-wrap gap-2 items-end">
|
||||
<!-- Duration -->
|
||||
<div class="flex flex-col gap-1 min-w-[100px]">
|
||||
<label class="text-xs text-zinc-500 dark:text-zinc-400 font-medium">Duration</label>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<input
|
||||
type="number"
|
||||
:value="minutes"
|
||||
@@ -10,8 +11,9 @@
|
||||
min="0"
|
||||
max="180"
|
||||
placeholder="0"
|
||||
class="w-[45px] text-center px-1.5 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-sm bg-white dark:bg-surface-900 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-brand-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<span class="separator">:</span>
|
||||
<span class="font-bold text-zinc-500 dark:text-zinc-400">:</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="seconds"
|
||||
@@ -19,12 +21,14 @@
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="00"
|
||||
class="w-[45px] text-center px-1.5 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-sm bg-white dark:bg-surface-900 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-brand-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group power-field">
|
||||
<label>Power Low (%)</label>
|
||||
<!-- Power Low -->
|
||||
<div class="flex flex-col gap-1 min-w-[80px]">
|
||||
<label class="text-xs text-zinc-500 dark:text-zinc-400 font-medium">Power Low (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="powerLowPercent"
|
||||
@@ -33,11 +37,13 @@
|
||||
max="200"
|
||||
step="5"
|
||||
placeholder="50"
|
||||
class="w-[70px] px-1.5 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-sm bg-white dark:bg-surface-900 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-brand-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group power-field">
|
||||
<label>Power High (%)</label>
|
||||
<!-- Power High -->
|
||||
<div class="flex flex-col gap-1 min-w-[80px]">
|
||||
<label class="text-xs text-zinc-500 dark:text-zinc-400 font-medium">Power High (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="powerHighPercent"
|
||||
@@ -46,21 +52,25 @@
|
||||
max="200"
|
||||
step="5"
|
||||
placeholder="100"
|
||||
class="w-[70px] px-1.5 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-sm bg-white dark:bg-surface-900 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-brand-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group name-field">
|
||||
<label>Name</label>
|
||||
<!-- Name -->
|
||||
<div class="flex flex-col gap-1 flex-1 min-w-[120px]">
|
||||
<label class="text-xs text-zinc-500 dark:text-zinc-400 font-medium">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
:value="interval.name || ''"
|
||||
@input="updateField('name', $event.target.value)"
|
||||
placeholder="Optional"
|
||||
class="w-full px-1.5 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-sm bg-white dark:bg-surface-900 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group cadence-field">
|
||||
<label>Cadence</label>
|
||||
<!-- Cadence -->
|
||||
<div class="flex flex-col gap-1 min-w-[70px]">
|
||||
<label class="text-xs text-zinc-500 dark:text-zinc-400 font-medium">Cadence</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="interval.cadence || ''"
|
||||
@@ -68,24 +78,28 @@
|
||||
min="40"
|
||||
max="150"
|
||||
placeholder="RPM"
|
||||
class="w-[60px] px-1.5 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-sm bg-white dark:bg-surface-900 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-brand-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="showRepeats">
|
||||
<div class="field-group repeat-field">
|
||||
<label>Repeat</label>
|
||||
<!-- Repeat -->
|
||||
<div class="flex flex-col gap-1 min-w-[60px]">
|
||||
<label class="text-xs text-zinc-500 dark:text-zinc-400 font-medium">Repeat</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="interval.repeat || 1"
|
||||
@input="updateField('repeat', parseInt($event.target.value) || 1)"
|
||||
min="1"
|
||||
max="20"
|
||||
class="w-[50px] px-1.5 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-sm bg-white dark:bg-surface-900 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-brand-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group rest-field" v-if="(interval.repeat || 1) > 1">
|
||||
<label>Rest Between</label>
|
||||
<div class="duration-input">
|
||||
<!-- Rest Between -->
|
||||
<div class="flex flex-col gap-1 min-w-[100px]" v-if="(interval.repeat || 1) > 1">
|
||||
<label class="text-xs text-zinc-500 dark:text-zinc-400 font-medium">Rest Between</label>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<input
|
||||
type="number"
|
||||
:value="restMinutes"
|
||||
@@ -93,8 +107,9 @@
|
||||
min="0"
|
||||
max="30"
|
||||
placeholder="0"
|
||||
class="w-[45px] text-center px-1.5 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-sm bg-white dark:bg-surface-900 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-brand-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<span class="separator">:</span>
|
||||
<span class="font-bold text-zinc-500 dark:text-zinc-400">:</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="restSeconds"
|
||||
@@ -102,6 +117,7 @@
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="00"
|
||||
class="w-[45px] text-center px-1.5 py-1 border border-zinc-200 dark:border-zinc-700 rounded text-sm bg-white dark:bg-surface-900 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-brand-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,105 +208,3 @@ function emitUpdate(changes) {
|
||||
emit('update', { ...props.interval, ...changes })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.interval-row-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.field-group label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.field-group input {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.field-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.field-group input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.field-group input[type="number"]::-webkit-outer-spin-button,
|
||||
.field-group input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.duration-field {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.duration-input input {
|
||||
width: 45px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.duration-input .separator {
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.power-field {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.power-field input {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.name-field {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.name-field input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cadence-field {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.cadence-field input {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.repeat-field {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.repeat-field input {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.rest-field {
|
||||
min-width: 100px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
<template>
|
||||
<div class="star-rating" :class="[`size-${size}`, { readonly }]">
|
||||
<div
|
||||
class="inline-flex gap-0.5"
|
||||
:class="{ 'pointer-events-none': readonly }"
|
||||
>
|
||||
<button
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
type="button"
|
||||
class="star-btn"
|
||||
:class="{ filled: star <= displayRating, hovered: star <= hoverRating }"
|
||||
class="bg-transparent border-0 p-0 transition-all"
|
||||
:class="[
|
||||
readonly ? 'cursor-default' : 'cursor-pointer hover:scale-110',
|
||||
(star <= displayRating || star <= hoverRating) ? 'text-amber-400' : 'text-zinc-300 dark:text-zinc-600'
|
||||
]"
|
||||
@click="!readonly && selectRating(star)"
|
||||
@mouseenter="!readonly && (hoverRating = star)"
|
||||
@mouseleave="hoverRating = 0"
|
||||
:disabled="readonly"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<svg
|
||||
:class="{
|
||||
'w-3.5 h-3.5': size === 'small',
|
||||
'w-5 h-5': size === 'medium',
|
||||
'w-7 h-7': size === 'large'
|
||||
}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"
|
||||
:fill="star <= displayRating || star <= hoverRating ? 'currentColor' : 'none'"
|
||||
@@ -55,51 +69,3 @@ function selectRating(star) {
|
||||
emit('rate', star)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.star-rating {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.star-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-border);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.star-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.star-btn.filled,
|
||||
.star-btn.hovered {
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.star-btn:not(:disabled):hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.readonly .star-btn {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.size-small .star-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.size-medium .star-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.size-large .star-btn svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
<template>
|
||||
<div class="workout-card" @click="$emit('click', workout)">
|
||||
<div class="workout-card-header">
|
||||
<div class="workout-badges">
|
||||
<div class="workout-type" :class="`type-${workout.type}`">
|
||||
<div class="card p-4 cursor-pointer transition-all hover:border-brand-500 hover:shadow-md hover:-translate-y-0.5 relative" @click="$emit('click', workout)">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
<div
|
||||
class="inline-block px-2 py-1 rounded text-xs font-semibold uppercase tracking-wide"
|
||||
:class="{
|
||||
'bg-emerald-500/15 text-emerald-600': workout.type === 'endurance',
|
||||
'bg-blue-500/15 text-blue-600': workout.type === 'tempo',
|
||||
'bg-yellow-500/15 text-yellow-600': workout.type === 'threshold',
|
||||
'bg-red-500/15 text-red-600': workout.type === 'vo2max' || workout.type === 'race',
|
||||
'bg-purple-500/15 text-purple-600': workout.type === 'sprint',
|
||||
'bg-zinc-400/15 text-zinc-500': workout.type === 'recovery',
|
||||
'bg-orange-500/15 text-orange-600': workout.type === 'climbing',
|
||||
'bg-teal-500/15 text-teal-600': workout.type === 'interval',
|
||||
'bg-zinc-600/15 text-zinc-600 dark:text-zinc-400': workout.type === 'freeride'
|
||||
}"
|
||||
>
|
||||
{{ formatType(workout.type) }}
|
||||
</div>
|
||||
<div v-if="workout.difficulty" class="workout-difficulty" :class="`difficulty-${workout.difficulty}`">
|
||||
<div
|
||||
v-if="workout.difficulty"
|
||||
class="inline-block px-2 py-1 rounded text-xs font-semibold uppercase tracking-wide"
|
||||
:class="{
|
||||
'bg-emerald-500/10 text-emerald-600': workout.difficulty === 'beginner',
|
||||
'bg-yellow-500/10 text-yellow-600': workout.difficulty === 'intermediate',
|
||||
'bg-orange-500/10 text-orange-600': workout.difficulty === 'advanced',
|
||||
'bg-red-500/10 text-red-600': workout.difficulty === 'expert'
|
||||
}"
|
||||
>
|
||||
{{ formatDifficulty(workout.difficulty) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,36 +40,38 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 class="workout-name">{{ workout.name }}</h3>
|
||||
<p class="workout-description">{{ truncateDescription(workout.description) }}</p>
|
||||
<h3 class="m-0 mb-1 text-lg font-semibold text-zinc-900 dark:text-zinc-100">{{ workout.name }}</h3>
|
||||
<p class="m-0 mb-3 text-sm text-zinc-500 dark:text-zinc-400 leading-relaxed min-h-[42px]">{{ truncateDescription(workout.description) }}</p>
|
||||
|
||||
<div class="workout-stats">
|
||||
<div class="stat">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<!-- Stats -->
|
||||
<div class="flex gap-4 mb-3 pb-3 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<div class="flex items-center gap-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>{{ workout.duration_minutes }} min</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<div class="flex items-center gap-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>IF {{ formatIF(workout.intensity_factor) }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<div class="flex items-center gap-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M22 12H18L15 21L9 3L6 12H2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>TSS {{ workout.tss || '—' }}</span>
|
||||
<span>TSS {{ workout.tss || '--' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workout-footer">
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center gap-2">
|
||||
<StarRating :rating="workout.average_rating || 0" :readonly="true" size="small" />
|
||||
<span class="rating-count" v-if="workout.rating_count">({{ workout.rating_count }})</span>
|
||||
<div class="use-count" v-if="workout.use_count">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<span class="text-xs text-zinc-500 dark:text-zinc-400" v-if="workout.rating_count">({{ workout.rating_count }})</span>
|
||||
<div class="flex items-center gap-1 ml-auto text-xs text-zinc-500 dark:text-zinc-400" v-if="workout.use_count">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M17 21V19C17 17.9391 16.5786 16.9217 15.8284 16.1716C15.0783 15.4214 14.0609 15 13 15H5C3.93913 15 2.92172 15.4214 2.17157 16.1716C1.42143 16.9217 1 17.9391 1 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
@@ -54,7 +79,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="workout.is_system" class="system-badge">System</div>
|
||||
<div v-if="workout.is_system" class="absolute top-3 right-3 px-1.5 py-0.5 bg-brand-500 text-white text-xs font-medium rounded">System</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -109,7 +134,7 @@ function formatDifficulty(difficulty) {
|
||||
}
|
||||
|
||||
function formatIF(value) {
|
||||
if (!value) return '—'
|
||||
if (!value) return '--'
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
@@ -118,193 +143,3 @@ function truncateDescription(text) {
|
||||
return text.length > 100 ? text.substring(0, 100) + '...' : text
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workout-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workout-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.workout-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.workout-badges {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workout-type,
|
||||
.workout-difficulty {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.type-endurance {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.type-tempo {
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
color: #2980b9;
|
||||
}
|
||||
|
||||
.type-threshold {
|
||||
background: rgba(241, 196, 15, 0.15);
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.type-vo2max {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.type-sprint {
|
||||
background: rgba(155, 89, 182, 0.15);
|
||||
color: #8e44ad;
|
||||
}
|
||||
|
||||
.type-recovery {
|
||||
background: rgba(149, 165, 166, 0.15);
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.type-climbing {
|
||||
background: rgba(230, 126, 34, 0.15);
|
||||
color: #d35400;
|
||||
}
|
||||
|
||||
.type-interval {
|
||||
background: rgba(26, 188, 156, 0.15);
|
||||
color: #16a085;
|
||||
}
|
||||
|
||||
.type-freeride {
|
||||
background: rgba(52, 73, 94, 0.15);
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.type-race {
|
||||
background: rgba(192, 57, 43, 0.15);
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.difficulty-beginner {
|
||||
background: rgba(46, 204, 113, 0.1);
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.difficulty-intermediate {
|
||||
background: rgba(241, 196, 15, 0.1);
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.difficulty-advanced {
|
||||
background: rgba(230, 126, 34, 0.1);
|
||||
color: #d35400;
|
||||
}
|
||||
|
||||
.difficulty-expert {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.workout-name {
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.workout-description {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.workout-stats {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stat svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.workout-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.rating-count {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.use-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.use-count svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.system-badge {
|
||||
position: absolute;
|
||||
top: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
padding: 2px var(--spacing-xs);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.workout-card-header + .system-badge {
|
||||
top: var(--spacing-lg);
|
||||
right: 48px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<div class="workout-filters">
|
||||
<div class="filter-header">
|
||||
<h3>Filters</h3>
|
||||
<button v-if="hasActiveFilters" @click="clearFilters" class="btn-clear">
|
||||
<div class="card p-4">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="m-0 text-lg font-semibold text-zinc-900 dark:text-zinc-100">Filters</h3>
|
||||
<button v-if="hasActiveFilters" @click="clearFilters" class="bg-transparent border-0 text-brand-500 text-sm cursor-pointer p-0 hover:underline">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">Search</label>
|
||||
<div class="search-input-wrapper">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="search-icon">
|
||||
<!-- Search -->
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">Search</label>
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-[18px] h-[18px] text-zinc-500 dark:text-zinc-400" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M21 21L16.65 16.65" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
@@ -19,20 +21,23 @@
|
||||
v-model="localFilters.search"
|
||||
@input="debouncedEmit"
|
||||
placeholder="Search workouts..."
|
||||
class="form-input-modern search-input"
|
||||
class="input-field pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">Type</label>
|
||||
<div class="filter-options">
|
||||
<!-- Type -->
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">Type</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="t in types"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
:class="{ active: localFilters.type === t.value }"
|
||||
class="px-3 py-1 border rounded-full text-sm cursor-pointer transition-all"
|
||||
:class="localFilters.type === t.value
|
||||
? 'bg-brand-500 border-brand-500 text-white'
|
||||
: 'bg-white dark:bg-surface-900 border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:border-brand-500 hover:text-brand-500'"
|
||||
@click="toggleFilter('type', t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
@@ -40,15 +45,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">Category</label>
|
||||
<div class="filter-options">
|
||||
<!-- Category -->
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">Category</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.value"
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
:class="{ active: localFilters.category === cat.value }"
|
||||
class="px-3 py-1 border rounded-full text-sm cursor-pointer transition-all"
|
||||
:class="localFilters.category === cat.value
|
||||
? 'bg-brand-500 border-brand-500 text-white'
|
||||
: 'bg-white dark:bg-surface-900 border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:border-brand-500 hover:text-brand-500'"
|
||||
@click="toggleFilter('category', cat.value)"
|
||||
>
|
||||
{{ cat.label }}
|
||||
@@ -56,15 +64,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">Difficulty</label>
|
||||
<div class="filter-options">
|
||||
<!-- Difficulty -->
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">Difficulty</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="diff in difficulties"
|
||||
:key="diff.value"
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
:class="{ active: localFilters.difficulty === diff.value }"
|
||||
class="px-3 py-1 border rounded-full text-sm cursor-pointer transition-all"
|
||||
:class="localFilters.difficulty === diff.value
|
||||
? 'bg-brand-500 border-brand-500 text-white'
|
||||
: 'bg-white dark:bg-surface-900 border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:border-brand-500 hover:text-brand-500'"
|
||||
@click="toggleFilter('difficulty', diff.value)"
|
||||
>
|
||||
{{ diff.label }}
|
||||
@@ -176,101 +187,3 @@ watch(() => props.filters, (newFilters) => {
|
||||
localFilters.value = { ...localFilters.value, ...newFilters }
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workout-filters {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.filter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.filter-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-clear:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.filter-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: var(--spacing-md);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user