feat: refine itinerary flow and add OSRM connector metrics
This commit is contained in:
@@ -54,7 +54,7 @@ Voyage aims to be simple, beautiful, and open to everyone — inheriting Adventu
|
||||
<img src="./brand/screenshots/map-satellite.png" alt="Location Details" />
|
||||
<p>View a 3D representation of your locations and activities on the map, allowing for a more immersive exploration of your travel history.</p>
|
||||
<img src="./brand/screenshots/dashboard.png" alt="Dashboard" />
|
||||
<p>Displays a summary of your locations, including your world travel stats.</p>
|
||||
<p>Displays a summary of your world travel stats and your most recently updated collections.</p>
|
||||
<img src="./brand/screenshots/itinerary.png" alt="Itinerary" />
|
||||
<p>Plan your adventures with a timeline-style itinerary planner. Each day shows numbered stops, compact transportation connectors between locations, and inline controls for adding places. Drag-and-drop reordering, day-level actions, and multiple views help you build the perfect trip.</p>
|
||||
<img src="./brand/screenshots/countries.png" alt="Countries" />
|
||||
@@ -98,8 +98,9 @@ Voyage aims to be simple, beautiful, and open to everyone — inheriting Adventu
|
||||
- Upload trails and activities to your locations to remember your experiences with detailed maps and stats.
|
||||
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
|
||||
- Itineraries can be created for any number of days and can include multiple destinations.
|
||||
- A timeline-style day view shows ordered stops with numbered markers, compact transportation connector rows (mode, duration, distance), and inline add-place rows per day.
|
||||
- Day-level quick actions include Auto-fill (to populate an empty itinerary from dated records) and an Optimize placeholder for future route optimization.
|
||||
- A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview.
|
||||
- Connector rows between consecutive locations display distance and travel time powered by [OSRM](https://project-osrm.org/) routing (walking if ≤ 20 min, driving otherwise), with automatic haversine fallback when OSRM is unavailable. Self-hosted OSRM instances are supported via the `OSRM_BASE_URL` environment variable. Transportation items appear as separate compact connector rows showing mode, duration, and distance.
|
||||
- Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder.
|
||||
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
|
||||
- Itineraries can be shared with friends and family for collaborative planning.
|
||||
- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together.
|
||||
|
||||
@@ -26,6 +26,7 @@ EMAIL_BACKEND='console'
|
||||
# DEFAULT_FROM_EMAIL='user@example.com'
|
||||
|
||||
# GOOGLE_MAPS_API_KEY='key'
|
||||
# OSRM_BASE_URL='https://router.project-osrm.org' # replace with self-host URL if needed (e.g. http://osrm:5000)
|
||||
|
||||
# ACCOUNT_EMAIL_VERIFICATION='none' # 'none', 'optional', 'mandatory' # You can change this as needed for your environment
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ router.register(r'activities', ActivityViewSet, basename='activities')
|
||||
router.register(r'visits', VisitViewSet, basename='visits')
|
||||
router.register(r'itineraries', ItineraryViewSet, basename='itineraries')
|
||||
router.register(r'itinerary-days', ItineraryDayViewSet, basename='itinerary-days')
|
||||
router.register(r'route-metrics', RouteMetricsViewSet, basename='route-metrics')
|
||||
|
||||
urlpatterns = [
|
||||
# Include the router under the 'api/' prefix
|
||||
|
||||
@@ -19,3 +19,4 @@ from .trail_view import *
|
||||
from .activity_view import *
|
||||
from .visit_view import *
|
||||
from .itinerary_view import *
|
||||
from .route_metrics_view import *
|
||||
|
||||
326
backend/server/adventures/views/route_metrics_view.py
Normal file
326
backend/server/adventures/views/route_metrics_view.py
Normal file
@@ -0,0 +1,326 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RouteMetricsViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
MAX_PAIRS = 50
|
||||
CACHE_TIMEOUT_SECONDS = 60 * 60 * 24
|
||||
WALKING_SPEED_KMH = 5
|
||||
DRIVING_SPEED_KMH = 60
|
||||
WALKING_THRESHOLD_MINUTES = 20
|
||||
OSRM_HEADERS = {"User-Agent": "Voyage Server"}
|
||||
OSRM_DEFAULT_BASE_URL = "https://router.project-osrm.org"
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def query(self, request):
|
||||
pairs = request.data.get("pairs")
|
||||
|
||||
if not isinstance(pairs, list):
|
||||
return Response(
|
||||
{"error": "'pairs' must be a list"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if len(pairs) > self.MAX_PAIRS:
|
||||
return Response(
|
||||
{
|
||||
"error": f"A maximum of {self.MAX_PAIRS} pairs is allowed per request"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
results = []
|
||||
for index, pair in enumerate(pairs):
|
||||
try:
|
||||
from_lat, from_lon, to_lat, to_lon = self._parse_pair(pair)
|
||||
except ValueError as error:
|
||||
logger.warning(
|
||||
"Skipping invalid route-metrics pair at index %s: %s", index, error
|
||||
)
|
||||
results.append(
|
||||
self._invalid_metrics_result(
|
||||
error_code="invalid_pair", message="Invalid route pair payload"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
cache_key = self._build_cache_key(from_lat, from_lon, to_lat, to_lon)
|
||||
cached_result = self._safe_cache_get(cache_key)
|
||||
if cached_result:
|
||||
results.append(cached_result)
|
||||
continue
|
||||
|
||||
try:
|
||||
result = self._calculate_osrm_metrics(
|
||||
from_lat, from_lon, to_lat, to_lon
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"OSRM metrics unavailable for pair index %s; using haversine fallback (%s)",
|
||||
index,
|
||||
error.__class__.__name__,
|
||||
)
|
||||
try:
|
||||
result = self._calculate_haversine_fallback(
|
||||
from_lat, from_lon, to_lat, to_lon
|
||||
)
|
||||
except Exception as fallback_error:
|
||||
logger.warning(
|
||||
"Haversine fallback failed for pair index %s; returning invalid placeholder (%s)",
|
||||
index,
|
||||
fallback_error.__class__.__name__,
|
||||
)
|
||||
result = self._invalid_metrics_result(
|
||||
error_code="compute_failed",
|
||||
message="Route metrics unavailable",
|
||||
)
|
||||
|
||||
self._safe_cache_set(cache_key, result)
|
||||
results.append(result)
|
||||
|
||||
return Response({"results": results})
|
||||
|
||||
def _parse_pair(self, pair):
|
||||
if not isinstance(pair, dict):
|
||||
raise ValueError("Each pair must be an object")
|
||||
|
||||
from_data = pair.get("from")
|
||||
to_data = pair.get("to")
|
||||
if not isinstance(from_data, dict) or not isinstance(to_data, dict):
|
||||
raise ValueError("Each pair must include 'from' and 'to' objects")
|
||||
|
||||
from_lat = self._parse_coordinate(from_data.get("latitude"), "from.latitude")
|
||||
from_lon = self._parse_coordinate(from_data.get("longitude"), "from.longitude")
|
||||
to_lat = self._parse_coordinate(to_data.get("latitude"), "to.latitude")
|
||||
to_lon = self._parse_coordinate(to_data.get("longitude"), "to.longitude")
|
||||
|
||||
if not (-90 <= from_lat <= 90) or not (-90 <= to_lat <= 90):
|
||||
raise ValueError("Latitude must be between -90 and 90")
|
||||
|
||||
if not (-180 <= from_lon <= 180) or not (-180 <= to_lon <= 180):
|
||||
raise ValueError("Longitude must be between -180 and 180")
|
||||
|
||||
return from_lat, from_lon, to_lat, to_lon
|
||||
|
||||
def _parse_coordinate(self, value, field_name):
|
||||
try:
|
||||
coordinate = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"{field_name} must be a number")
|
||||
|
||||
if not math.isfinite(coordinate):
|
||||
raise ValueError(f"{field_name} must be finite")
|
||||
|
||||
return coordinate
|
||||
|
||||
def _calculate_osrm_metrics(self, from_lat, from_lon, to_lat, to_lon):
|
||||
foot_distance_km, foot_duration_minutes = self._query_osrm_route(
|
||||
profile="foot",
|
||||
from_lat=from_lat,
|
||||
from_lon=from_lon,
|
||||
to_lat=to_lat,
|
||||
to_lon=to_lon,
|
||||
)
|
||||
|
||||
if foot_duration_minutes <= self.WALKING_THRESHOLD_MINUTES:
|
||||
return self._build_metrics_result(
|
||||
distance_km=foot_distance_km,
|
||||
duration_minutes=foot_duration_minutes,
|
||||
mode="walking",
|
||||
source="osrm",
|
||||
)
|
||||
|
||||
car_distance_km, car_duration_minutes = self._query_osrm_route(
|
||||
profile="car",
|
||||
from_lat=from_lat,
|
||||
from_lon=from_lon,
|
||||
to_lat=to_lat,
|
||||
to_lon=to_lon,
|
||||
)
|
||||
return self._build_metrics_result(
|
||||
distance_km=car_distance_km,
|
||||
duration_minutes=car_duration_minutes,
|
||||
mode="driving",
|
||||
source="osrm",
|
||||
)
|
||||
|
||||
def _query_osrm_route(self, profile, from_lat, from_lon, to_lat, to_lon):
|
||||
base_url = self._get_validated_osrm_base_url()
|
||||
route_coords = f"{from_lon},{from_lat};{to_lon},{to_lat}"
|
||||
base_parts = urlparse(base_url)
|
||||
base_path = base_parts.path.rstrip("/")
|
||||
route_path = f"{base_path}/route/v1/{profile}/{route_coords}"
|
||||
url = urlunparse(
|
||||
(
|
||||
base_parts.scheme,
|
||||
base_parts.netloc,
|
||||
route_path,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
params={"overview": "false"},
|
||||
headers=self.OSRM_HEADERS,
|
||||
timeout=(2, 5),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
routes = data.get("routes") or []
|
||||
if not routes:
|
||||
raise ValueError("OSRM route response missing routes")
|
||||
|
||||
route = routes[0]
|
||||
distance_meters = route.get("distance")
|
||||
duration_seconds = route.get("duration")
|
||||
|
||||
if not isinstance(distance_meters, (int, float)) or not math.isfinite(
|
||||
distance_meters
|
||||
):
|
||||
raise ValueError("OSRM route distance is invalid")
|
||||
if not isinstance(duration_seconds, (int, float)) or not math.isfinite(
|
||||
duration_seconds
|
||||
):
|
||||
raise ValueError("OSRM route duration is invalid")
|
||||
|
||||
distance_km = max(distance_meters / 1000, 0)
|
||||
duration_minutes = max(duration_seconds / 60, 0)
|
||||
return distance_km, duration_minutes
|
||||
|
||||
def _calculate_haversine_fallback(self, from_lat, from_lon, to_lat, to_lon):
|
||||
distance_km = self._haversine_distance_km(from_lat, from_lon, to_lat, to_lon)
|
||||
walking_minutes = (distance_km / self.WALKING_SPEED_KMH) * 60
|
||||
driving_minutes = (distance_km / self.DRIVING_SPEED_KMH) * 60
|
||||
use_driving = walking_minutes > self.WALKING_THRESHOLD_MINUTES
|
||||
|
||||
return self._build_metrics_result(
|
||||
distance_km=distance_km,
|
||||
duration_minutes=driving_minutes if use_driving else walking_minutes,
|
||||
mode="driving" if use_driving else "walking",
|
||||
source="haversine",
|
||||
)
|
||||
|
||||
def _haversine_distance_km(self, from_lat, from_lon, to_lat, to_lon):
|
||||
earth_radius_km = 6371
|
||||
|
||||
lat_delta = math.radians(to_lat - from_lat)
|
||||
lon_delta = math.radians(to_lon - from_lon)
|
||||
from_lat_radians = math.radians(from_lat)
|
||||
to_lat_radians = math.radians(to_lat)
|
||||
|
||||
a = (
|
||||
math.sin(lat_delta / 2) ** 2
|
||||
+ math.cos(from_lat_radians)
|
||||
* math.cos(to_lat_radians)
|
||||
* math.sin(lon_delta / 2) ** 2
|
||||
)
|
||||
a = min(max(a, 0.0), 1.0)
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
return max(earth_radius_km * c, 0)
|
||||
|
||||
def _get_validated_osrm_base_url(self):
|
||||
configured_base_url = (
|
||||
getattr(settings, "OSRM_BASE_URL", self.OSRM_DEFAULT_BASE_URL)
|
||||
or self.OSRM_DEFAULT_BASE_URL
|
||||
)
|
||||
parsed = urlparse(configured_base_url)
|
||||
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
logger.warning(
|
||||
"Invalid OSRM_BASE_URL configured; falling back to haversine metrics"
|
||||
)
|
||||
raise ValueError("invalid_osrm_base_url")
|
||||
|
||||
normalized_path = parsed.path.rstrip("/")
|
||||
return urlunparse(
|
||||
(
|
||||
parsed.scheme,
|
||||
parsed.netloc,
|
||||
normalized_path,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
def _invalid_metrics_result(self, error_code, message):
|
||||
return {
|
||||
"distance_km": None,
|
||||
"duration_minutes": None,
|
||||
"distance_label": None,
|
||||
"duration_label": None,
|
||||
"mode": None,
|
||||
"source": "invalid",
|
||||
"error": error_code,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
def _build_metrics_result(self, distance_km, duration_minutes, mode, source):
|
||||
safe_distance = max(distance_km, 0)
|
||||
safe_duration = max(duration_minutes, 0)
|
||||
rounded_distance = round(safe_distance, 2)
|
||||
rounded_duration = int(round(safe_duration))
|
||||
|
||||
return {
|
||||
"distance_km": rounded_distance,
|
||||
"duration_minutes": rounded_duration,
|
||||
"distance_label": self._format_distance_label(safe_distance),
|
||||
"duration_label": self._format_duration_label(safe_duration),
|
||||
"mode": mode,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
def _format_distance_label(self, distance_km):
|
||||
if distance_km < 10:
|
||||
return f"{distance_km:.1f} km"
|
||||
return f"{int(round(distance_km))} km"
|
||||
|
||||
def _format_duration_label(self, minutes):
|
||||
safe_minutes = max(0, int(round(minutes)))
|
||||
hours = safe_minutes // 60
|
||||
remaining_minutes = safe_minutes % 60
|
||||
|
||||
if hours == 0:
|
||||
return f"{remaining_minutes}m"
|
||||
if remaining_minutes == 0:
|
||||
return f"{hours}h"
|
||||
return f"{hours}h {remaining_minutes}m"
|
||||
|
||||
def _build_cache_key(self, from_lat, from_lon, to_lat, to_lon):
|
||||
raw = (
|
||||
f"{from_lat:.6f},{from_lon:.6f}:{to_lat:.6f},{to_lon:.6f}"
|
||||
f":walk={self.WALKING_THRESHOLD_MINUTES}"
|
||||
)
|
||||
hashed = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
return f"route_metrics:{hashed}"
|
||||
|
||||
def _safe_cache_get(self, key):
|
||||
try:
|
||||
return cache.get(key)
|
||||
except Exception as error:
|
||||
logger.warning("Route metrics cache get failed: %s", error)
|
||||
return None
|
||||
|
||||
def _safe_cache_set(self, key, value):
|
||||
try:
|
||||
cache.set(key, value, self.CACHE_TIMEOUT_SECONDS)
|
||||
except Exception as error:
|
||||
logger.warning("Route metrics cache set failed: %s", error)
|
||||
@@ -377,3 +377,4 @@ COUNTRY_REGION_JSON_VERSION = 'v3.0'
|
||||
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
|
||||
STRAVA_CLIENT_ID = getenv('STRAVA_CLIENT_ID', '')
|
||||
STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '')
|
||||
OSRM_BASE_URL = getenv('OSRM_BASE_URL', 'https://router.project-osrm.org')
|
||||
@@ -7,3 +7,4 @@ In addition to the primary configuration variables listed above, there are sever
|
||||
| `ACCOUNT_EMAIL_VERIFICATION` | No | Enable email verification for new accounts. Options are `none`, `optional`, or `mandatory` | `none` | Backend |
|
||||
| `FORCE_SOCIALACCOUNT_LOGIN` | No | When set to `True`, only social login is allowed (no password login). The login page will show only social providers or redirect directly to the first provider if only one is configured. | `False` | Backend |
|
||||
| `SOCIALACCOUNT_ALLOW_SIGNUP` | No | When set to `True`, signup will be allowed via social providers even if registration is disabled. | `False` | Backend |
|
||||
| `OSRM_BASE_URL` | No | Base URL of the OSRM routing server used for itinerary connector distance/travel-time metrics. The public OSRM demo server is used by default. Set this to point at your own OSRM instance (e.g. `http://osrm:5000`) for higher rate limits or offline use. When the OSRM server is unreachable, the backend automatically falls back to haversine-based approximations so the itinerary UI always shows metrics. | `https://router.project-osrm.org` | Backend |
|
||||
|
||||
@@ -14,8 +14,9 @@ Voyage is a full-fledged travel companion. With Voyage, you can log your adventu
|
||||
- Upload trails and activities to your locations to remember your experiences with detailed maps and stats.
|
||||
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
|
||||
- Itineraries can be created for any number of days and can include multiple destinations.
|
||||
- A timeline-style day view shows ordered stops with numbered markers, compact transportation connector rows (mode, duration, distance), and inline add-place rows per day.
|
||||
- Day-level quick actions include Auto-fill (to populate an empty itinerary from dated records) and an Optimize placeholder for future route optimization.
|
||||
- A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview.
|
||||
- Connector rows between consecutive locations display distance and travel time powered by [OSRM](https://project-osrm.org/) routing (walking if ≤ 20 min, driving otherwise), with automatic haversine fallback when OSRM is unavailable. Self-hosted OSRM instances are supported via the `OSRM_BASE_URL` environment variable. Transportation items appear as separate compact connector rows showing mode, duration, and distance.
|
||||
- Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder.
|
||||
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
|
||||
- Itineraries can be shared with friends and family for collaborative planning.
|
||||
- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together.
|
||||
|
||||
@@ -22,7 +22,7 @@ The term "Location" is now used instead of "Adventure" - the usage remains the s
|
||||
|
||||
#### Collections
|
||||
|
||||
- **Collection**: a collection is a way to group locations together. Collections are flexible and can be used in many ways. When no start or end date is added to a collection, it acts like a folder to group locations together. When a start and end date is added to a collection, it acts like a trip to group locations together that were visited during that time period. With start and end dates, the collection is transformed into a full itinerary with a timeline-style day view — each day displays numbered stops, compact transportation connector rows, and an inline add-place row. Day-level quick actions include Auto-fill (populates an empty itinerary from dated records) and an Optimize placeholder for future route optimization. The itinerary also includes a map showing the route taken between locations. For example, you could have a collection for a trip to Europe with dates so you can plan where you want to visit, a collection of local hiking trails, or a collection for a list of restaurants you want to try.
|
||||
- **Collection**: a collection is a way to group locations together. Collections are flexible and can be used in many ways. When no start or end date is added to a collection, it acts like a folder to group locations together. When a start and end date is added to a collection, it acts like a trip to group locations together that were visited during that time period. With start and end dates, the collection is transformed into a full itinerary with a timeline-style day view — each day displays numbered stops as compact cards (without image banners), connector rows between consecutive locations showing distance and travel time via OSRM routing (walking if ≤ 20 min, driving otherwise) with automatic haversine fallback when OSRM is unavailable, and a single `+ Add` control for inserting new places. Day-level quick actions include Auto-fill (populates an empty itinerary from dated records) and an Optimize placeholder for future route optimization. The itinerary also includes a map showing the route taken between locations. Your most recently updated collections also appear on the dashboard. For example, you could have a collection for a trip to Europe with dates so you can plan where you want to visit, a collection of local hiking trails, or a collection for a list of restaurants you want to try.
|
||||
- **Transportation**: a transportation is a collection exclusive feature that allows you to add transportation information to your trip. In the itinerary timeline view, transportation items appear as compact connector rows between stops — showing the travel mode, duration, and distance. This can be used to show the route taken between locations and the mode of transportation used. It can also be used to track flight information, such as flight number and departure time.
|
||||
- **Lodging**: a lodging is a collection exclusive feature that allows you to add lodging information to your trip. This can be used to plan where you will stay during your trip and add notes about the lodging location. It can also be used to track reservation information, such as reservation number and check-in time.
|
||||
- **Note**: a note is a collection exclusive feature that allows you to add notes to your trip. This can be used to add additional information about your trip, such as a summary of the trip or a list of things to do. Notes can be assigned to a specific day of the trip to help organize the information.
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
export let collection: Collection | null = null;
|
||||
export let readOnly: boolean = false;
|
||||
export let compact: boolean = false; // For compact grid display in itinerary
|
||||
export let showImage: boolean = true;
|
||||
export let itineraryItem: CollectionItineraryPlanner | null = null;
|
||||
|
||||
let isCollectionModalOpen: boolean = false;
|
||||
@@ -278,86 +279,92 @@
|
||||
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
|
||||
aria-label="location-card"
|
||||
>
|
||||
<!-- Image Section with Overlay -->
|
||||
<div class="relative overflow-hidden rounded-t-2xl">
|
||||
<CardCarousel images={adventure.images} icon={adventure.category?.icon} name={adventure.name} />
|
||||
{#if showImage}
|
||||
<!-- Image Section with Overlay -->
|
||||
<div class="relative overflow-hidden rounded-t-2xl">
|
||||
<CardCarousel
|
||||
images={adventure.images}
|
||||
icon={adventure.category?.icon}
|
||||
name={adventure.name}
|
||||
/>
|
||||
|
||||
<!-- Status Overlay (icon-only) -->
|
||||
<div class="absolute top-2 left-4 flex items-center gap-3">
|
||||
<div
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={adventure.is_visited ? $t('adventures.visited') : $t('adventures.not_visited')}
|
||||
>
|
||||
{#if adventure.is_visited}
|
||||
<div class="badge badge-sm badge-success p-1 rounded-full shadow-sm">
|
||||
<Calendar class="w-4 h-4" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="badge badge-sm badge-warning p-1 rounded-full shadow-sm">
|
||||
<Clock class="w-4 h-4" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy Indicator -->
|
||||
<div class="absolute top-2 right-4">
|
||||
<div
|
||||
class="tooltip tooltip-left"
|
||||
data-tip={adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
>
|
||||
<!-- Status Overlay (icon-only) -->
|
||||
<div class="absolute top-2 left-4 flex items-center gap-3">
|
||||
<div
|
||||
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
|
||||
role="img"
|
||||
aria-label={adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={adventure.is_visited ? $t('adventures.visited') : $t('adventures.not_visited')}
|
||||
>
|
||||
{#if adventure.is_public}
|
||||
<Eye class="w-4 h-4" />
|
||||
{#if adventure.is_visited}
|
||||
<div class="badge badge-sm badge-success p-1 rounded-full shadow-sm">
|
||||
<Calendar class="w-4 h-4" />
|
||||
</div>
|
||||
{:else}
|
||||
<EyeOff class="w-4 h-4" />
|
||||
<div class="badge badge-sm badge-warning p-1 rounded-full shadow-sm">
|
||||
<Clock class="w-4 h-4" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Badge -->
|
||||
{#if adventure.category}
|
||||
<div class="absolute bottom-4 left-4">
|
||||
<a
|
||||
href="/locations?types={adventure.category.name}"
|
||||
class="badge badge-primary shadow-lg font-medium cursor-pointer hover:brightness-110 transition-all"
|
||||
<!-- Privacy Indicator -->
|
||||
<div class="absolute top-2 right-4">
|
||||
<div
|
||||
class="tooltip tooltip-left"
|
||||
data-tip={adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
>
|
||||
{adventure.category.display_name}
|
||||
{adventure.category.icon}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Creator Avatar -->
|
||||
{#if adventure.user && collection}
|
||||
<div class="absolute bottom-4 right-4">
|
||||
<div class="tooltip tooltip-left" data-tip={creatorDisplayName}>
|
||||
<div class="avatar">
|
||||
<div class="w-7 h-7 rounded-full ring-2 ring-white/40 shadow">
|
||||
{#if adventure.user.profile_pic}
|
||||
<img
|
||||
src={adventure.user.profile_pic}
|
||||
alt={creatorDisplayName}
|
||||
class="rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-7 h-7 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-primary-content font-semibold text-xs"
|
||||
>
|
||||
{creatorInitials.toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
|
||||
role="img"
|
||||
aria-label={adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
>
|
||||
{#if adventure.is_public}
|
||||
<Eye class="w-4 h-4" />
|
||||
{:else}
|
||||
<EyeOff class="w-4 h-4" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Category Badge -->
|
||||
{#if adventure.category}
|
||||
<div class="absolute bottom-4 left-4">
|
||||
<a
|
||||
href="/locations?types={adventure.category.name}"
|
||||
class="badge badge-primary shadow-lg font-medium cursor-pointer hover:brightness-110 transition-all"
|
||||
>
|
||||
{adventure.category.display_name}
|
||||
{adventure.category.icon}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Creator Avatar -->
|
||||
{#if adventure.user && collection}
|
||||
<div class="absolute bottom-4 right-4">
|
||||
<div class="tooltip tooltip-left" data-tip={creatorDisplayName}>
|
||||
<div class="avatar">
|
||||
<div class="w-7 h-7 rounded-full ring-2 ring-white/40 shadow">
|
||||
{#if adventure.user.profile_pic}
|
||||
<img
|
||||
src={adventure.user.profile_pic}
|
||||
alt={creatorDisplayName}
|
||||
class="rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-7 h-7 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-primary-content font-semibold text-xs"
|
||||
>
|
||||
{creatorInitials.toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content Section -->
|
||||
<div
|
||||
|
||||
@@ -421,6 +421,306 @@
|
||||
return `${Math.round(distanceKm)} km`;
|
||||
}
|
||||
|
||||
const WALKING_SPEED_KMH = 5;
|
||||
const DRIVING_SPEED_KMH = 60;
|
||||
const WALKING_THRESHOLD_MINUTES = 20;
|
||||
const ROUTE_METRICS_BATCH_SIZE = 50;
|
||||
|
||||
function toRadians(degrees: number): number {
|
||||
return (degrees * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function haversineDistanceKm(from: Location, to: Location): number | null {
|
||||
if (
|
||||
from.latitude === null ||
|
||||
from.longitude === null ||
|
||||
to.latitude === null ||
|
||||
to.longitude === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isFinite(from.latitude) ||
|
||||
!Number.isFinite(from.longitude) ||
|
||||
!Number.isFinite(to.latitude) ||
|
||||
!Number.isFinite(to.longitude)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const earthRadiusKm = 6371;
|
||||
const latDelta = toRadians(to.latitude - from.latitude);
|
||||
const lonDelta = toRadians(to.longitude - from.longitude);
|
||||
const fromLat = toRadians(from.latitude);
|
||||
const toLat = toRadians(to.latitude);
|
||||
|
||||
const a =
|
||||
Math.sin(latDelta / 2) * Math.sin(latDelta / 2) +
|
||||
Math.cos(fromLat) * Math.cos(toLat) * Math.sin(lonDelta / 2) * Math.sin(lonDelta / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
const distanceKm = earthRadiusKm * c;
|
||||
return Number.isFinite(distanceKm) ? distanceKm : null;
|
||||
}
|
||||
|
||||
function formatTravelDuration(minutes: number): string {
|
||||
const totalMinutes = Math.max(0, Math.round(minutes));
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const remainingMinutes = totalMinutes % 60;
|
||||
|
||||
if (hours === 0) return `${remainingMinutes}m`;
|
||||
if (remainingMinutes === 0) return `${hours}h`;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
type LocationConnector = {
|
||||
distanceLabel: string;
|
||||
durationLabel: string;
|
||||
mode: 'walking' | 'driving';
|
||||
};
|
||||
|
||||
type ConnectorPair = {
|
||||
key: string;
|
||||
from: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
to: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
};
|
||||
|
||||
type RouteMetricResult = {
|
||||
distance_label?: string;
|
||||
duration_label?: string;
|
||||
mode?: 'walking' | 'driving';
|
||||
distance_km?: number;
|
||||
duration_minutes?: number;
|
||||
};
|
||||
|
||||
let connectorMetricsMap: Record<string, LocationConnector> = {};
|
||||
let activeConnectorFetchVersion = 0;
|
||||
|
||||
function getLocationConnectorKey(
|
||||
currentItem: ResolvedItineraryItem,
|
||||
nextItem: ResolvedItineraryItem | null
|
||||
): string | null {
|
||||
if (!nextItem) return null;
|
||||
if (!currentItem?.id || !nextItem?.id) return null;
|
||||
return `${currentItem.id}:${nextItem.id}`;
|
||||
}
|
||||
|
||||
function getConnectorPair(
|
||||
currentItem: ResolvedItineraryItem,
|
||||
nextItem: ResolvedItineraryItem | null
|
||||
): ConnectorPair | null {
|
||||
if (!nextItem) return null;
|
||||
|
||||
const currentType = currentItem.item?.type || '';
|
||||
const nextType = nextItem.item?.type || '';
|
||||
if (currentType !== 'location' || nextType !== 'location') return null;
|
||||
|
||||
const currentLocation = currentItem.resolvedObject as Location | null;
|
||||
const nextLocation = nextItem.resolvedObject as Location | null;
|
||||
if (!currentLocation || !nextLocation) return null;
|
||||
|
||||
const fromLatitude = currentLocation.latitude;
|
||||
const fromLongitude = currentLocation.longitude;
|
||||
const toLatitude = nextLocation.latitude;
|
||||
const toLongitude = nextLocation.longitude;
|
||||
|
||||
if (
|
||||
fromLatitude === null ||
|
||||
fromLongitude === null ||
|
||||
toLatitude === null ||
|
||||
toLongitude === null ||
|
||||
!Number.isFinite(fromLatitude) ||
|
||||
!Number.isFinite(fromLongitude) ||
|
||||
!Number.isFinite(toLatitude) ||
|
||||
!Number.isFinite(toLongitude)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = getLocationConnectorKey(currentItem, nextItem);
|
||||
if (!key) return null;
|
||||
|
||||
return {
|
||||
key,
|
||||
from: {
|
||||
latitude: fromLatitude,
|
||||
longitude: fromLongitude
|
||||
},
|
||||
to: {
|
||||
latitude: toLatitude,
|
||||
longitude: toLongitude
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getConnectorPairs(dayGroups: DayGroup[]): ConnectorPair[] {
|
||||
const pairs: ConnectorPair[] = [];
|
||||
|
||||
for (const dayGroup of dayGroups) {
|
||||
for (let index = 0; index < dayGroup.items.length - 1; index += 1) {
|
||||
const pair = getConnectorPair(dayGroup.items[index], dayGroup.items[index + 1]);
|
||||
if (pair) pairs.push(pair);
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function chunkConnectorPairs(pairs: ConnectorPair[], chunkSize: number): ConnectorPair[][] {
|
||||
const chunks: ConnectorPair[][] = [];
|
||||
for (let index = 0; index < pairs.length; index += chunkSize) {
|
||||
chunks.push(pairs.slice(index, index + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function formatDistanceLabel(distanceKm: number): string {
|
||||
if (distanceKm < 10) return `${distanceKm.toFixed(1)} km`;
|
||||
return `${Math.round(distanceKm)} km`;
|
||||
}
|
||||
|
||||
function normalizeRouteMetricResult(result: RouteMetricResult | null): LocationConnector | null {
|
||||
if (!result || (result.mode !== 'walking' && result.mode !== 'driving')) return null;
|
||||
|
||||
let distanceLabel = result.distance_label;
|
||||
if (
|
||||
!distanceLabel &&
|
||||
typeof result.distance_km === 'number' &&
|
||||
Number.isFinite(result.distance_km)
|
||||
) {
|
||||
distanceLabel = formatDistanceLabel(Math.max(0, result.distance_km));
|
||||
}
|
||||
|
||||
let durationLabel = result.duration_label;
|
||||
if (
|
||||
!durationLabel &&
|
||||
typeof result.duration_minutes === 'number' &&
|
||||
Number.isFinite(result.duration_minutes)
|
||||
) {
|
||||
durationLabel = formatTravelDuration(Math.max(0, result.duration_minutes));
|
||||
}
|
||||
|
||||
if (!distanceLabel || !durationLabel) return null;
|
||||
|
||||
return {
|
||||
distanceLabel,
|
||||
durationLabel,
|
||||
mode: result.mode
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRouteMetricChunk(
|
||||
chunk: ConnectorPair[]
|
||||
): Promise<Record<string, LocationConnector>> {
|
||||
const response = await fetch('/api/route-metrics/query/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pairs: chunk.map((pair) => ({
|
||||
from: pair.from,
|
||||
to: pair.to
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Route metrics request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const results = Array.isArray(payload?.results) ? payload.results : [];
|
||||
const normalizedChunk: Record<string, LocationConnector> = {};
|
||||
|
||||
for (let index = 0; index < chunk.length; index += 1) {
|
||||
const connector = normalizeRouteMetricResult(results[index] || null);
|
||||
if (!connector) continue;
|
||||
normalizedChunk[chunk[index].key] = connector;
|
||||
}
|
||||
|
||||
return normalizedChunk;
|
||||
}
|
||||
|
||||
async function loadConnectorMetrics(connectorPairs: ConnectorPair[], fetchVersion: number) {
|
||||
if (connectorPairs.length === 0) {
|
||||
connectorMetricsMap = {};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const chunks = chunkConnectorPairs(connectorPairs, ROUTE_METRICS_BATCH_SIZE);
|
||||
const responses = await Promise.all(chunks.map((chunk) => fetchRouteMetricChunk(chunk)));
|
||||
|
||||
if (fetchVersion !== activeConnectorFetchVersion) return;
|
||||
|
||||
const mergedMap: Record<string, LocationConnector> = {};
|
||||
for (const chunkMap of responses) {
|
||||
Object.assign(mergedMap, chunkMap);
|
||||
}
|
||||
|
||||
connectorMetricsMap = mergedMap;
|
||||
} catch (error) {
|
||||
if (fetchVersion !== activeConnectorFetchVersion) return;
|
||||
console.error('Failed to fetch connector route metrics:', error);
|
||||
connectorMetricsMap = {};
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
const connectorPairs = getConnectorPairs(days);
|
||||
activeConnectorFetchVersion += 1;
|
||||
const fetchVersion = activeConnectorFetchVersion;
|
||||
loadConnectorMetrics(connectorPairs, fetchVersion);
|
||||
}
|
||||
|
||||
function getFallbackLocationConnector(
|
||||
currentItem: ResolvedItineraryItem,
|
||||
nextItem: ResolvedItineraryItem | null
|
||||
): LocationConnector | null {
|
||||
if (!nextItem) return null;
|
||||
|
||||
const currentType = currentItem.item?.type || '';
|
||||
const nextType = nextItem.item?.type || '';
|
||||
if (currentType !== 'location' || nextType !== 'location') return null;
|
||||
|
||||
const currentLocation = currentItem.resolvedObject as Location | null;
|
||||
const nextLocation = nextItem.resolvedObject as Location | null;
|
||||
if (!currentLocation || !nextLocation) return null;
|
||||
|
||||
const distanceKm = haversineDistanceKm(currentLocation, nextLocation);
|
||||
if (distanceKm === null) return null;
|
||||
|
||||
const walkingMinutes = (distanceKm / WALKING_SPEED_KMH) * 60;
|
||||
const drivingMinutes = (distanceKm / DRIVING_SPEED_KMH) * 60;
|
||||
const useDriving = walkingMinutes > WALKING_THRESHOLD_MINUTES;
|
||||
|
||||
return {
|
||||
distanceLabel: formatTransportationDistance(distanceKm) || `${distanceKm.toFixed(1)} km`,
|
||||
durationLabel: formatTravelDuration(useDriving ? drivingMinutes : walkingMinutes),
|
||||
mode: useDriving ? 'driving' : 'walking'
|
||||
};
|
||||
}
|
||||
|
||||
function getLocationConnector(
|
||||
currentItem: ResolvedItineraryItem,
|
||||
nextItem: ResolvedItineraryItem | null
|
||||
): LocationConnector | null {
|
||||
const key = getLocationConnectorKey(currentItem, nextItem);
|
||||
if (key && connectorMetricsMap[key]) {
|
||||
return connectorMetricsMap[key];
|
||||
}
|
||||
|
||||
return getFallbackLocationConnector(currentItem, nextItem);
|
||||
}
|
||||
|
||||
function editTransportationInline(transportation: Transportation) {
|
||||
handleEditTransportation({ detail: transportation } as CustomEvent<Transportation>);
|
||||
}
|
||||
@@ -1833,6 +2133,7 @@
|
||||
{user}
|
||||
{collection}
|
||||
compact={true}
|
||||
showImage={false}
|
||||
/>
|
||||
{:else if objectType === 'transportation'}
|
||||
<TransportationCard
|
||||
@@ -2065,6 +2366,8 @@
|
||||
{@const objectType = item.item?.type || ''}
|
||||
{@const resolvedObj = item.resolvedObject}
|
||||
{@const multiDay = isMultiDay(item)}
|
||||
{@const nextItem = index < day.items.length - 1 ? day.items[index + 1] : null}
|
||||
{@const locationConnector = getLocationConnector(item, nextItem)}
|
||||
{@const isDraggingShadow = item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
|
||||
|
||||
<div
|
||||
@@ -2217,6 +2520,7 @@
|
||||
{user}
|
||||
{collection}
|
||||
compact={true}
|
||||
showImage={false}
|
||||
on:changeDay={(e) =>
|
||||
handleOpenDayPickerForItem(
|
||||
e.detail.type,
|
||||
@@ -2284,6 +2588,21 @@
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if locationConnector}
|
||||
<div
|
||||
class="mt-2 rounded-lg border border-dashed border-base-300 bg-base-100/70 px-3 py-2 text-xs opacity-80"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium">{locationConnector.distanceLabel}</span>
|
||||
<span class="opacity-50">•</span>
|
||||
<span>
|
||||
{locationConnector.mode === 'driving' ? '🚗' : '🚶'}
|
||||
{locationConnector.durationLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -2299,8 +2618,7 @@
|
||||
|
||||
{#if canModify}
|
||||
<div class="mt-4 pt-4 border-t border-base-300 border-dashed">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<p class="text-sm opacity-70">{$t('itinerary.add_place')}</p>
|
||||
<div class="flex items-center justify-end gap-3 flex-wrap">
|
||||
<div class="dropdown dropdown-end z-30">
|
||||
<button
|
||||
type="button"
|
||||
@@ -2611,6 +2929,7 @@
|
||||
{user}
|
||||
{collection}
|
||||
compact={true}
|
||||
showImage={false}
|
||||
/>
|
||||
{:else if type === 'transportation'}
|
||||
<TransportationCard
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"aestheticDark": "Aesthetic Dark",
|
||||
"aqua": "Aqua",
|
||||
"northernLights": "Northern Lights",
|
||||
"dim": "Dim"
|
||||
"dim": "Dim",
|
||||
"catppuccinMocha": "Catppuccin Mocha"
|
||||
},
|
||||
"navigation": "Navigation"
|
||||
},
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"aestheticDark": "Estetic întunecat",
|
||||
"aqua": "Acvatic",
|
||||
"northernLights": "Luminiile Nordului",
|
||||
"dim": "Întunecă"
|
||||
"dim": "Întunecă",
|
||||
"catppuccinMocha": "Catppuccin Mocha"
|
||||
},
|
||||
"navigation": "Navigație"
|
||||
},
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
import type { Location } from '$lib/types';
|
||||
import type { SlimCollection } from '$lib/types';
|
||||
|
||||
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
|
||||
const defaultStats = {
|
||||
visited_country_count: 0,
|
||||
visited_region_count: 0,
|
||||
visited_city_count: 0,
|
||||
location_count: 0,
|
||||
trips_count: 0
|
||||
};
|
||||
|
||||
export const load = (async (event) => {
|
||||
if (!event.locals.user) {
|
||||
return redirect(302, '/login');
|
||||
} else {
|
||||
let adventures: Location[] = [];
|
||||
let collections: SlimCollection[] = [];
|
||||
|
||||
let initialFetch = await event.fetch(`${serverEndpoint}/api/locations/`, {
|
||||
headers: {
|
||||
Cookie: `sessionid=${event.cookies.get('sessionid')}`
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
let initialFetch = await event.fetch(
|
||||
`${serverEndpoint}/api/collections/?order_by=updated_at&order_direction=desc&nested=true`,
|
||||
{
|
||||
headers: {
|
||||
Cookie: `sessionid=${event.cookies.get('sessionid')}`
|
||||
},
|
||||
credentials: 'include'
|
||||
}
|
||||
);
|
||||
|
||||
let stats = null;
|
||||
let stats = { ...defaultStats };
|
||||
|
||||
let res = await event.fetch(
|
||||
`${serverEndpoint}/api/stats/counts/${event.locals.user.username}/`,
|
||||
@@ -31,24 +42,27 @@ export const load = (async (event) => {
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch user stats');
|
||||
} else {
|
||||
stats = await res.json();
|
||||
const statsPayload = await res.json();
|
||||
stats = {
|
||||
...defaultStats,
|
||||
...(statsPayload || {})
|
||||
};
|
||||
}
|
||||
|
||||
if (!initialFetch.ok) {
|
||||
let error_message = await initialFetch.json();
|
||||
console.error(error_message);
|
||||
console.error('Failed to fetch visited adventures');
|
||||
console.error('Failed to fetch recent collections');
|
||||
return redirect(302, '/login');
|
||||
} else {
|
||||
let res = await initialFetch.json();
|
||||
let visited = res.results as Location[];
|
||||
// only get the first 3 adventures or less if there are less than 3
|
||||
adventures = visited.slice(0, 3);
|
||||
let recentCollections = res.results as SlimCollection[];
|
||||
collections = recentCollections.slice(0, 3);
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
adventures,
|
||||
collections,
|
||||
stats
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import LocationCard from '$lib/components/cards/LocationCard.svelte';
|
||||
import CollectionCard from '$lib/components/cards/CollectionCard.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
export let data: PageData;
|
||||
|
||||
const user = data.user;
|
||||
const recentAdventures = data.props.adventures;
|
||||
const recentCollections = (data.props as any).collections ?? [];
|
||||
const stats = data.props.stats;
|
||||
|
||||
// Calculate completion percentage
|
||||
@@ -153,8 +153,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Adventures Section -->
|
||||
{#if recentAdventures.length > 0}
|
||||
<!-- Recent Collections Section -->
|
||||
{#if recentCollections.length > 0}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -162,20 +162,20 @@
|
||||
<CalendarClock class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold">{$t('dashboard.recent_adventures')}</h2>
|
||||
<h2 class="text-3xl font-bold">{$t('navbar.collections')}</h2>
|
||||
<p class="text-base-content/60">{$t('home.latest_travel_experiences')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/locations" class="btn btn-ghost gap-2">
|
||||
<a href="/collections" class="btn btn-ghost gap-2">
|
||||
{$t('dashboard.view_all')}
|
||||
<span class="badge badge-primary">{stats.location_count}</span>
|
||||
<span class="badge badge-primary">{stats.trips_count ?? 0}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{#each recentAdventures as adventure}
|
||||
{#each recentCollections as collection}
|
||||
<div class="adventure-card">
|
||||
<LocationCard {adventure} readOnly user={null} />
|
||||
<CollectionCard {collection} type="viewonly" {user} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -183,7 +183,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Empty State / Inspiration -->
|
||||
{#if recentAdventures.length === 0}
|
||||
{#if recentCollections.length === 0}
|
||||
<div
|
||||
class="empty-state card bg-gradient-to-br from-base-100 to-base-200 shadow-2xl border border-base-300"
|
||||
>
|
||||
@@ -197,19 +197,19 @@
|
||||
<h2
|
||||
class="text-3xl font-bold mb-4 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"
|
||||
>
|
||||
{$t('dashboard.no_recent_adventures')}
|
||||
{$t('collection.no_collections_yet')}
|
||||
</h2>
|
||||
<p class="text-lg text-base-content/60 mb-8 max-w-md mx-auto leading-relaxed">
|
||||
{$t('dashboard.document_some_adventures')}
|
||||
{$t('collection.create_first')}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="/locations"
|
||||
href="/collections"
|
||||
class="btn btn-primary btn-lg gap-2 shadow-lg hover:shadow-xl transition-all duration-300"
|
||||
>
|
||||
<Plus class="w-5 h-5" />
|
||||
{$t('map.add_location')}
|
||||
{$t('collection.new_collection')}
|
||||
</a>
|
||||
<a href="/worldtravel" class="btn btn-outline btn-lg gap-2">
|
||||
<FlagCheckeredVariantIcon class="w-5 h-5" />
|
||||
|
||||
Reference in New Issue
Block a user