Enhance user profile and world travel pages with improved UI and functionality
- Updated user profile page to include achievement calculations and enhanced styling for user information and statistics. - Added icons for better visual representation of user stats and achievements. - Improved layout for displaying adventures and collections with conditional rendering for empty states. - Refactored world travel page to include search and filter functionality for cities, with a sidebar for progress and stats. - Implemented completion percentage and progress bars for visited cities. - Enhanced map integration with markers for visited and not visited cities, including toggle options for map labels.
This commit is contained in:
@@ -1,123 +1,337 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import MapWithPins from '$lib/assets/MapWithPins.webp';
|
||||
import type { Background } from '$lib/types.js';
|
||||
|
||||
// Icons
|
||||
import MapIcon from '~icons/mdi/map-outline';
|
||||
import CameraIcon from '~icons/mdi/camera-outline';
|
||||
import CalendarIcon from '~icons/mdi/calendar-outline';
|
||||
import TrophyIcon from '~icons/mdi/trophy-outline';
|
||||
import ChevronRight from '~icons/mdi/chevron-right';
|
||||
import PlayIcon from '~icons/mdi/play';
|
||||
import CheckIcon from '~icons/mdi/check-circle';
|
||||
import StarIcon from '~icons/mdi/star';
|
||||
import GlobeIcon from '~icons/mdi/earth';
|
||||
import LightningIcon from '~icons/mdi/lightning-bolt';
|
||||
|
||||
export let data;
|
||||
|
||||
let background: Background = data.props?.background ?? { url: '' };
|
||||
let isVisible = false;
|
||||
|
||||
onMount(() => {
|
||||
setTimeout(() => (isVisible = true), 100);
|
||||
});
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: MapIcon,
|
||||
title: $t('home.feature_1'),
|
||||
description: $t('home.feature_1_desc'),
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20'
|
||||
},
|
||||
{
|
||||
icon: CameraIcon,
|
||||
title: $t('home.feature_2'),
|
||||
description: $t('home.feature_2_desc'),
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-50 dark:bg-green-900/20'
|
||||
},
|
||||
{
|
||||
icon: TrophyIcon,
|
||||
title: $t('home.feature_3'),
|
||||
description: $t('home.feature_3_desc'),
|
||||
color: 'text-yellow-500',
|
||||
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20'
|
||||
}
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ label: 'Countries Tracked', value: '195+', icon: GlobeIcon },
|
||||
{ label: 'Adventures Logged', value: '10K+', icon: CalendarIcon },
|
||||
{ label: 'Active Travelers', value: '5K+', icon: StarIcon }
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="flex items-center justify-center w-full py-20 bg-gray-50 dark:bg-gray-800">
|
||||
<div class="container mx-auto px-4 flex flex-col-reverse md:flex-row items-center gap-8">
|
||||
<!-- Text Content -->
|
||||
<div class="w-full md:w-1/2 space-y-6">
|
||||
{#if data.user}
|
||||
{#if data.user.first_name && data.user.first_name !== null}
|
||||
<h1
|
||||
class="text-5xl md:text-6xl font-extrabold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"
|
||||
>
|
||||
{data.user.first_name.charAt(0).toUpperCase() + data.user.first_name.slice(1)}, {$t(
|
||||
'home.hero_1'
|
||||
)}
|
||||
</h1>
|
||||
{:else}
|
||||
<h1
|
||||
class="text-5xl md:text-6xl font-extrabold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"
|
||||
>
|
||||
{$t('home.hero_1')}
|
||||
</h1>
|
||||
{/if}
|
||||
{:else}
|
||||
<h1
|
||||
class="text-5xl md:text-6xl font-extrabold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"
|
||||
>
|
||||
{$t('home.hero_1')}
|
||||
</h1>
|
||||
{/if}
|
||||
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-xl">
|
||||
{$t('home.hero_2')}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
{#if data.user}
|
||||
<button on:click={() => goto('/adventures')} class="btn btn-primary">
|
||||
{$t('home.go_to')}
|
||||
</button>
|
||||
{:else}
|
||||
<button on:click={() => goto('/login')} class="btn btn-primary">
|
||||
{$t('auth.login')}
|
||||
</button>
|
||||
<button on:click={() => goto('/signup')} class="btn btn-secondary">
|
||||
{$t('auth.signup')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-h-screen bg-gradient-to-br from-base-200 via-base-100 to-base-200">
|
||||
<!-- Hero Section -->
|
||||
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||
<!-- Background Pattern -->
|
||||
<div class="absolute inset-0 opacity-5">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 to-secondary/20"></div>
|
||||
<svg class="absolute inset-0 w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="currentColor" stroke-width="0.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100" height="100" fill="url(#grid)" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Image -->
|
||||
<div class="w-full md:w-1/2">
|
||||
<img
|
||||
src={background.url}
|
||||
alt={background.location}
|
||||
class="rounded-lg shadow-lg object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-16 bg-white dark:bg-gray-900">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="text-center mb-12">
|
||||
<div class="inline-block text-neutral-content bg-neutral px-4 py-2 rounded-full">
|
||||
{$t('home.key_features')}
|
||||
</div>
|
||||
<h2
|
||||
class="mt-4 text-3xl md:text-4xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"
|
||||
>
|
||||
{$t('home.desc_1')}
|
||||
</h2>
|
||||
<p class="mt-4 text-gray-600 dark:text-gray-300 max-w-2xl mx-auto text-lg">
|
||||
{$t('home.desc_2')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
|
||||
<!-- Image for Features -->
|
||||
<div class="order-1 md:order-2">
|
||||
<img
|
||||
src={MapWithPins}
|
||||
alt="World map with pins"
|
||||
class="rounded-lg shadow-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
<!-- Feature List -->
|
||||
<div class="order-2 md:order-1">
|
||||
<ul class="space-y-6">
|
||||
<li class="space-y-2">
|
||||
<h3 class="text-xl font-semibold dark:text-gray-300">{$t('home.feature_1')}</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{$t('home.feature_1_desc')}
|
||||
</p>
|
||||
</li>
|
||||
<li class="space-y-2">
|
||||
<h3 class="text-xl font-semibold dark:text-gray-300">{$t('home.feature_2')}</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{$t('home.feature_2_desc')}
|
||||
</p>
|
||||
</li>
|
||||
<li class="space-y-2">
|
||||
<h3 class="text-xl font-semibold dark:text-gray-300">{$t('home.feature_3')}</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{$t('home.feature_3_desc')}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="container mx-auto px-6 py-20 relative z-10">
|
||||
<div class="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<!-- Left Content -->
|
||||
<div class="space-y-8 {isVisible ? 'animate-fade-in-up' : 'opacity-0'}">
|
||||
{#if data.user}
|
||||
{#if data.user.first_name && data.user.first_name !== null}
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary rounded-full border border-primary/20"
|
||||
>
|
||||
<LightningIcon class="w-4 h-4" />
|
||||
<span class="text-sm font-medium">Welcome back!</span>
|
||||
</div>
|
||||
<h1 class="text-5xl lg:text-7xl font-black leading-tight">
|
||||
<span
|
||||
class="bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent"
|
||||
>
|
||||
{data.user.first_name.charAt(0).toUpperCase() + data.user.first_name.slice(1)},
|
||||
</span>
|
||||
<br />
|
||||
<span class="text-base-content/90">
|
||||
{$t('home.hero_1')}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary rounded-full border border-primary/20"
|
||||
>
|
||||
<LightningIcon class="w-4 h-4" />
|
||||
<span class="text-sm font-medium">Ready to explore?</span>
|
||||
</div>
|
||||
<h1 class="text-5xl lg:text-7xl font-black leading-tight">
|
||||
<span
|
||||
class="bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent"
|
||||
>
|
||||
{$t('home.hero_1')}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary rounded-full border border-primary/20"
|
||||
>
|
||||
<LightningIcon class="w-4 h-4" />
|
||||
<span class="text-sm font-medium">Start your journey</span>
|
||||
</div>
|
||||
<h1 class="text-5xl lg:text-7xl font-black leading-tight">
|
||||
<span
|
||||
class="bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent"
|
||||
>
|
||||
{$t('home.hero_1')}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-xl lg:text-2xl text-base-content/70 leading-relaxed font-light max-w-2xl">
|
||||
{$t('home.hero_2')}
|
||||
</p>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
{#if data.user}
|
||||
<button
|
||||
on:click={() => goto('/adventures')}
|
||||
class="btn btn-primary btn-lg gap-3 shadow-lg hover:shadow-xl transition-all duration-300 group"
|
||||
>
|
||||
<PlayIcon class="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||
{$t('home.go_to')}
|
||||
<ChevronRight class="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
on:click={() => goto('/login')}
|
||||
class="btn btn-primary btn-lg gap-3 shadow-lg hover:shadow-xl transition-all duration-300 group"
|
||||
>
|
||||
{$t('auth.login')}
|
||||
<ChevronRight class="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<button
|
||||
on:click={() => goto('/signup')}
|
||||
class="btn btn-outline btn-lg gap-3 hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
{$t('auth.signup')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-3 gap-6 pt-8 border-t border-base-300">
|
||||
{#each stats as stat}
|
||||
<div class="text-center">
|
||||
<div class="flex justify-center mb-2">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<svelte:component this={stat.icon} class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-base-content">{stat.value}</div>
|
||||
<div class="text-sm text-base-content/60">{stat.label}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Content - Hero Image -->
|
||||
<div class="relative {isVisible ? 'animate-fade-in-right' : 'opacity-0'}">
|
||||
<div class="relative">
|
||||
<!-- Decorative Elements -->
|
||||
<div class="absolute -top-4 -left-4 w-24 h-24 bg-primary/10 rounded-2xl rotate-6"></div>
|
||||
<div
|
||||
class="absolute -bottom-4 -right-4 w-32 h-32 bg-secondary/10 rounded-2xl -rotate-6"
|
||||
></div>
|
||||
|
||||
<!-- Main Image -->
|
||||
<div class="relative bg-base-100 p-4 rounded-3xl shadow-2xl">
|
||||
<img
|
||||
src={background.url}
|
||||
alt={background.location}
|
||||
class="rounded-2xl object-cover w-full h-[500px] shadow-lg"
|
||||
/>
|
||||
|
||||
<!-- Floating Badge -->
|
||||
<div
|
||||
class="absolute top-8 left-8 bg-base-100/90 backdrop-blur-sm px-4 py-2 rounded-full shadow-lg border"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span class="text-sm font-medium"
|
||||
>{background.location || 'Adventure Awaits'}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scroll Indicator -->
|
||||
<div class="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
|
||||
<div class="w-6 h-10 border-2 border-base-content/30 rounded-full flex justify-center">
|
||||
<div class="w-1 h-3 bg-base-content/30 rounded-full mt-2 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-24 bg-base-100">
|
||||
<div class="container mx-auto px-6">
|
||||
<!-- Section Header -->
|
||||
<div class="text-center mb-16 space-y-4">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-neutral/10 text-neutral rounded-full border border-neutral/20"
|
||||
>
|
||||
<StarIcon class="w-4 h-4" />
|
||||
<span class="text-sm font-medium">{$t('home.key_features')}</span>
|
||||
</div>
|
||||
<h2 class="text-4xl lg:text-5xl font-bold">
|
||||
<span class="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
{$t('home.desc_1')}
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-xl text-base-content/70 max-w-3xl mx-auto leading-relaxed">
|
||||
{$t('home.desc_2')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-16 items-center">
|
||||
<!-- Features List -->
|
||||
<div class="space-y-8">
|
||||
{#each features as feature, index}
|
||||
<div
|
||||
class="group hover:bg-base-200/50 p-6 rounded-2xl transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 p-3 {feature.bgColor} rounded-xl">
|
||||
<svelte:component this={feature.icon} class="w-6 h-6 {feature.color}" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h3
|
||||
class="text-xl font-bold text-base-content group-hover:text-primary transition-colors"
|
||||
>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p class="text-base-content/70 leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Feature Image -->
|
||||
<div class="relative">
|
||||
<div class="relative bg-gradient-to-br from-primary/5 to-secondary/5 p-8 rounded-3xl">
|
||||
<img
|
||||
src={MapWithPins}
|
||||
alt="World map with pins"
|
||||
class="rounded-2xl shadow-2xl object-cover w-full"
|
||||
/>
|
||||
|
||||
<!-- Floating Elements -->
|
||||
<div class="absolute top-4 right-4 bg-base-100 p-3 rounded-xl shadow-lg animate-float">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckIcon class="w-4 h-4 text-success" />
|
||||
<span class="text-sm font-medium">25 Countries</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-4 left-4 bg-base-100 p-3 rounded-xl shadow-lg animate-float-delayed"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<TrophyIcon class="w-4 h-4 text-warning" />
|
||||
<span class="text-sm font-medium">Explorer Level</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Call to Action Section -->
|
||||
{#if !data.user}
|
||||
<section class="py-24 bg-gradient-to-r from-primary to-secondary">
|
||||
<div class="container mx-auto px-6 text-center">
|
||||
<div class="max-w-3xl mx-auto space-y-8">
|
||||
<h2 class="text-4xl lg:text-5xl font-bold text-white">Ready to Start Your Adventure?</h2>
|
||||
<p class="text-xl text-white/90 leading-relaxed">
|
||||
Join thousands of travelers already using AdventureLog to document their journeys and
|
||||
discover new destinations.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center pt-4">
|
||||
<button
|
||||
on:click={() => goto('/signup')}
|
||||
class="btn btn-lg bg-white text-primary hover:bg-white/90 gap-3 shadow-lg group"
|
||||
>
|
||||
Get Started Free
|
||||
<ChevronRight class="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<button
|
||||
on:click={() => goto('/login')}
|
||||
class="btn btn-lg btn-outline text-white border-white hover:bg-white hover:text-primary gap-3"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<svelte:head>
|
||||
<title>Home | AdventureLog</title>
|
||||
@@ -126,3 +340,63 @@
|
||||
content="AdventureLog is a platform to log your adventures and plan your travel."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<style>
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-delayed {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.8s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-in-right {
|
||||
animation: fade-in-right 0.8s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-delayed {
|
||||
animation: float-delayed 3s ease-in-out infinite 1.5s;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
|
||||
<div class="drawer-content">
|
||||
<!-- Header Section -->
|
||||
<div class="sticky top-0 z-40 bg-base-100/80 backdrop-blur-lg border-b border-base-300">
|
||||
<div class="sticky top-0 z-30 bg-base-100/80 backdrop-blur-lg border-b border-base-300">
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -292,7 +292,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="drawer-side z-50">
|
||||
<div class="drawer-side z-30">
|
||||
<label for="my-drawer" class="drawer-overlay"></label>
|
||||
<div class="w-80 min-h-full bg-base-100 shadow-2xl">
|
||||
<div class="p-6">
|
||||
@@ -481,7 +481,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Floating Action Button -->
|
||||
<div class="fixed bottom-6 right-6 z-50">
|
||||
<div class="fixed bottom-6 right-6 z-40">
|
||||
<div class="dropdown dropdown-top dropdown-end">
|
||||
<div
|
||||
tabindex="0"
|
||||
@@ -492,7 +492,7 @@
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content z-[1] menu p-4 shadow-2xl bg-base-100 rounded-2xl w-64 border border-base-300"
|
||||
class="dropdown-content z-[40] menu p-4 shadow-2xl bg-base-100 rounded-2xl w-64 border border-base-300"
|
||||
>
|
||||
<div class="text-center mb-4">
|
||||
<h3 class="font-bold text-lg">{$t('adventures.create_new')}</h3>
|
||||
|
||||
@@ -5,124 +5,270 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
// Initial animation for page load
|
||||
onMount(() => {
|
||||
// Stat animations with quicker duration
|
||||
gsap.from('.stat', {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
duration: 0.6, // Quicker animation duration
|
||||
stagger: 0.1, // Faster staggering between elements
|
||||
ease: 'power2.out' // Slightly sharper easing for quicker feel
|
||||
});
|
||||
|
||||
gsap.from('.stat-title', {
|
||||
opacity: 0,
|
||||
x: -50, // Smaller movement for quicker animation
|
||||
duration: 0.6, // Quicker animation duration
|
||||
stagger: 0.1, // Faster staggering
|
||||
ease: 'power2.out' // Slightly sharper easing for quicker feel
|
||||
});
|
||||
|
||||
// Stat values with faster reveal and snappier effect
|
||||
gsap.from('.stat-value', {
|
||||
opacity: 0,
|
||||
scale: 0.8, // Slightly less scaling for a snappier effect
|
||||
duration: 1, // Shorter duration
|
||||
stagger: 0.2, // Faster staggering
|
||||
ease: 'elastic.out(0.75, 0.5)', // Slightly snappier bounce
|
||||
delay: 0 // Faster delay for quicker sequencing
|
||||
});
|
||||
|
||||
// Adventure card animations with quicker reveal
|
||||
gsap.from('.adventure-card', {
|
||||
opacity: 0,
|
||||
y: 50, // Less movement for snappier feel
|
||||
duration: 0.8, // Quicker duration
|
||||
stagger: 0.1, // Faster staggering
|
||||
ease: 'power2.out',
|
||||
delay: 0 // Shorter delay for quicker appearance
|
||||
});
|
||||
});
|
||||
export let data: PageData;
|
||||
|
||||
// Icons
|
||||
import FlagCheckeredVariantIcon from '~icons/mdi/flag-checkered-variant';
|
||||
import Airplane from '~icons/mdi/airplane';
|
||||
import CityVariantOutline from '~icons/mdi/city-variant-outline';
|
||||
import MapMarkerStarOutline from '~icons/mdi/map-marker-star-outline';
|
||||
import TrendingUp from '~icons/mdi/trending-up';
|
||||
import CalendarClock from '~icons/mdi/calendar-clock';
|
||||
import Plus from '~icons/mdi/plus';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const user = data.user;
|
||||
const recentAdventures = data.props.adventures;
|
||||
const stats = data.props.stats;
|
||||
|
||||
// Calculate completion percentage
|
||||
$: completionPercentage =
|
||||
stats.visited_country_count > 0 ? Math.round((stats.visited_country_count / 195) * 100) : 0; // Assuming ~195 countries worldwide
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-4">
|
||||
<!-- Welcome Message -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-4xl font-extrabold">
|
||||
{$t('dashboard.welcome_back')}, {user?.first_name ? `${user.first_name}` : user?.username}!
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats shadow mb-8 w-full bg-neutral">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<Airplane class="w-10 h-10 inline-block" />
|
||||
</div>
|
||||
<div class="stat-title text-neutral-content">{$t('dashboard.total_adventures')}</div>
|
||||
<div class="stat-value text-secondary">{stats.adventure_count}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<FlagCheckeredVariantIcon class="w-10 h-10 inline-block" />
|
||||
</div>
|
||||
<div class="stat-title text-neutral-content">{$t('dashboard.countries_visited')}</div>
|
||||
<div class="stat-value text-primary">{stats.visited_country_count}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-success">
|
||||
<MapMarkerStarOutline class="w-10 h-10 inline-block" />
|
||||
</div>
|
||||
<div class="stat-title text-neutral-content">{$t('dashboard.total_visited_regions')}</div>
|
||||
<div class="stat-value text-success">{stats.visited_region_count}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-info">
|
||||
<CityVariantOutline class="w-10 h-10 inline-block" />
|
||||
</div>
|
||||
<div class="stat-title text-neutral-content">{$t('dashboard.total_visited_cities')}</div>
|
||||
<div class="stat-value text-info">{stats.visited_city_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Adventures -->
|
||||
{#if recentAdventures.length > 0}
|
||||
<h2 class="text-3xl font-semibold mb-4">{$t('dashboard.recent_adventures')}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
{#each recentAdventures as adventure}
|
||||
<div class="adventure-card">
|
||||
<AdventureCard {adventure} user={data.user} readOnly />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Inspiration if there are no recent adventures -->
|
||||
{#if recentAdventures.length === 0}
|
||||
<div
|
||||
class="inspiration flex flex-col items-center justify-center bg-neutral shadow p-8 mb-8 rounded-lg text-neutral-content"
|
||||
>
|
||||
<h2 class="text-3xl font-semibold mb-4">{$t('dashboard.no_recent_adventures')}</h2>
|
||||
<p class="text-lg text-center">
|
||||
{$t('dashboard.add_some')}
|
||||
</p>
|
||||
<a href="/adventures" class="btn btn-primary mt-4">{$t('map.add_adventure')}</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard | AdventureLog</title>
|
||||
<meta name="description" content="Home dashboard for AdventureLog." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-gradient-to-br from-base-200 via-base-100 to-base-200">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<!-- Welcome Section -->
|
||||
<div class="welcome-section mb-12">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-16 h-16">
|
||||
<span class="text-xl font-bold">
|
||||
{user?.first_name?.charAt(0) || user?.username?.charAt(0) || 'A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
class="text-4xl lg:text-5xl font-bold bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent"
|
||||
>
|
||||
{$t('dashboard.welcome_back')}, {user?.first_name
|
||||
? `${user.first_name}`
|
||||
: user?.username}!
|
||||
</h1>
|
||||
<p class="text-lg text-base-content/60 mt-2">
|
||||
{#if stats.adventure_count > 0}
|
||||
You've been on <span class="font-semibold text-primary"
|
||||
>{stats.adventure_count}</span
|
||||
> adventures so far
|
||||
{:else}
|
||||
Ready to start your adventure journey?
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Action -->
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<a
|
||||
href="/adventures"
|
||||
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_adventure')}
|
||||
</a>
|
||||
<a href="/worldtravel" class="btn btn-outline btn-lg gap-2">
|
||||
<FlagCheckeredVariantIcon class="w-5 h-5" />
|
||||
Explore World
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
<!-- Countries Visited -->
|
||||
<div
|
||||
class="stat-card card bg-gradient-to-br from-primary/10 to-primary/5 shadow-xl border border-primary/20 hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="stat-title text-primary/70 font-medium">
|
||||
{$t('dashboard.countries_visited')}
|
||||
</div>
|
||||
<div class="stat-value text-3xl font-bold text-primary">
|
||||
{stats.visited_country_count}
|
||||
</div>
|
||||
<div class="stat-desc text-primary/60 mt-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium">{completionPercentage}% of world</span>
|
||||
</div>
|
||||
<progress
|
||||
class="progress progress-primary w-full mt-1"
|
||||
value={stats.visited_country_count}
|
||||
max="195"
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-primary/20 rounded-2xl">
|
||||
<FlagCheckeredVariantIcon class="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regions Visited -->
|
||||
<div
|
||||
class="stat-card card bg-gradient-to-br from-success/10 to-success/5 shadow-xl border border-success/20 hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="stat-title text-success/70 font-medium">
|
||||
{$t('dashboard.total_visited_regions')}
|
||||
</div>
|
||||
<div class="stat-value text-3xl font-bold text-success">
|
||||
{stats.visited_region_count}
|
||||
</div>
|
||||
<div class="stat-desc text-success/60 mt-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<MapMarkerStarOutline class="w-4 h-4" />
|
||||
Unique regions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-success/20 rounded-2xl">
|
||||
<MapMarkerStarOutline class="w-8 h-8 text-success" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cities Visited -->
|
||||
<div
|
||||
class="stat-card card bg-gradient-to-br from-info/10 to-info/5 shadow-xl border border-info/20 hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="stat-title text-info/70 font-medium">
|
||||
{$t('dashboard.total_visited_cities')}
|
||||
</div>
|
||||
<div class="stat-value text-3xl font-bold text-info">{stats.visited_city_count}</div>
|
||||
<div class="stat-desc text-info/60 mt-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<CityVariantOutline class="w-4 h-4" />
|
||||
Urban adventures
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-info/20 rounded-2xl">
|
||||
<CityVariantOutline class="w-8 h-8 text-info" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Adventures Section -->
|
||||
{#if recentAdventures.length > 0}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-xl">
|
||||
<CalendarClock class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold">{$t('dashboard.recent_adventures')}</h2>
|
||||
<p class="text-base-content/60">Your latest travel experiences</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/adventures" class="btn btn-ghost gap-2">
|
||||
View All
|
||||
<span class="badge badge-primary">{stats.adventure_count}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{#each recentAdventures as adventure}
|
||||
<div class="adventure-card">
|
||||
<AdventureCard {adventure} user={data.user} readOnly />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty State / Inspiration -->
|
||||
{#if recentAdventures.length === 0}
|
||||
<div
|
||||
class="empty-state card bg-gradient-to-br from-base-100 to-base-200 shadow-2xl border border-base-300"
|
||||
>
|
||||
<div class="card-body p-12 text-center">
|
||||
<div class="flex justify-center mb-6">
|
||||
<div class="p-6 bg-primary/10 rounded-3xl">
|
||||
<Airplane class="w-16 h-16 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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')}
|
||||
</h2>
|
||||
<p class="text-lg text-base-content/60 mb-8 max-w-md mx-auto leading-relaxed">
|
||||
{$t('dashboard.add_some')} Start documenting your travels and build your personal adventure
|
||||
map!
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="/adventures"
|
||||
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_adventure')}
|
||||
</a>
|
||||
<a href="/worldtravel" class="btn btn-outline btn-lg gap-2">
|
||||
<FlagCheckeredVariantIcon class="w-5 h-5" />
|
||||
Explore World Map
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Inspiration Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-12 max-w-4xl mx-auto">
|
||||
<div class="card bg-base-100/50 shadow-lg">
|
||||
<div class="card-body p-6 text-center">
|
||||
<div class="p-3 bg-primary/10 rounded-xl w-fit mx-auto mb-4">
|
||||
<Airplane class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-primary">Plan Adventures</h3>
|
||||
<p class="text-sm text-base-content/60 mt-2">
|
||||
Create and organize your travel plans
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100/50 shadow-lg">
|
||||
<div class="card-body p-6 text-center">
|
||||
<div class="p-3 bg-secondary/10 rounded-xl w-fit mx-auto mb-4">
|
||||
<FlagCheckeredVariantIcon class="w-6 h-6 text-secondary" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-secondary">Track Countries</h3>
|
||||
<p class="text-sm text-base-content/60 mt-2">Mark visited countries and regions</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100/50 shadow-lg">
|
||||
<div class="card-body p-6 text-center">
|
||||
<div class="p-3 bg-success/10 rounded-xl w-fit mx-auto mb-4">
|
||||
<MapMarkerStarOutline class="w-6 h-6 text-success" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-success">Share Memories</h3>
|
||||
<p class="text-sm text-base-content/60 mt-2">Document and share your experiences</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,24 @@
|
||||
import CardCarousel from '$lib/components/CardCarousel.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getBasemapUrl } from '$lib';
|
||||
|
||||
// Icons
|
||||
import MapIcon from '~icons/mdi/map';
|
||||
import Filter from '~icons/mdi/filter-variant';
|
||||
import Plus from '~icons/mdi/plus';
|
||||
import Clear from '~icons/mdi/close';
|
||||
import Eye from '~icons/mdi/eye';
|
||||
import EyeOff from '~icons/mdi/eye-off';
|
||||
import Pin from '~icons/mdi/map-marker';
|
||||
import Calendar from '~icons/mdi/calendar';
|
||||
import Category from '~icons/mdi/shape';
|
||||
import Location from '~icons/mdi/crosshairs-gps';
|
||||
|
||||
export let data;
|
||||
|
||||
let createModalOpen: boolean = false;
|
||||
let showGeo: boolean = false;
|
||||
let sidebarOpen = false;
|
||||
|
||||
export let initialLatLng: { lat: number; lng: number } | null = null;
|
||||
|
||||
@@ -18,6 +32,27 @@
|
||||
|
||||
let filteredAdventures = adventures;
|
||||
|
||||
let showVisited: boolean = true;
|
||||
let showPlanned: boolean = true;
|
||||
|
||||
let newMarker: { lngLat: any } | null = null;
|
||||
let newLongitude: number | null = null;
|
||||
let newLatitude: number | null = null;
|
||||
|
||||
let openPopupId: string | null = null;
|
||||
let isPopupOpen = false;
|
||||
|
||||
// Statistics
|
||||
$: totalAdventures = adventures.length;
|
||||
$: visitedAdventures = adventures.filter((adventure) => adventure.is_visited).length;
|
||||
$: plannedAdventures = adventures.filter((adventure) => !adventure.is_visited).length;
|
||||
$: totalRegions = visitedRegions.length;
|
||||
|
||||
// Get unique categories for filtering
|
||||
$: categories = [
|
||||
...new Set(adventures.map((adventure) => adventure.category?.display_name).filter(Boolean))
|
||||
];
|
||||
|
||||
// Updates the filtered adventures based on the checkboxes
|
||||
$: {
|
||||
filteredAdventures = adventures.filter(
|
||||
@@ -25,7 +60,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Reset the longitude and latitude when the newMarker is set to null so new adventures are not created at the wrong location
|
||||
// Reset the longitude and latitude when the newMarker is set to null
|
||||
$: {
|
||||
if (!newMarker) {
|
||||
newLongitude = null;
|
||||
@@ -33,18 +68,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
console.log(data);
|
||||
|
||||
let showVisited: boolean = true;
|
||||
let showPlanned: boolean = true;
|
||||
|
||||
let newMarker: { lngLat: any } | null = null;
|
||||
|
||||
let newLongitude: number | null = null;
|
||||
let newLatitude: number | null = null;
|
||||
|
||||
let openPopupId: string | null = null; // Store the ID of the currently open popup
|
||||
|
||||
function addMarker(e: { detail: { lngLat: { lng: any; lat: any } } }) {
|
||||
newMarker = null;
|
||||
newMarker = { lngLat: e.detail.lngLat };
|
||||
@@ -63,48 +86,372 @@
|
||||
createModalOpen = false;
|
||||
}
|
||||
|
||||
let isPopupOpen = false;
|
||||
|
||||
function togglePopup() {
|
||||
isPopupOpen = !isPopupOpen;
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
}
|
||||
|
||||
function clearMarker() {
|
||||
newMarker = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1 class="text-center font-bold text-4xl">{$t('map.adventure_map')}</h1>
|
||||
<svelte:head>
|
||||
<title>Adventure Map</title>
|
||||
<meta name="description" content="View your travels on a map." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="m-2 flex flex-col items-center justify-center">
|
||||
<div class="gap-4 border-solid border-2 rounded-lg p-2 mb-4 border-neutral max-w-4xl">
|
||||
<p class="font-semibold text-center text-xl mb-2">{$t('map.map_options')}</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text mr-1">{$t('adventures.visited')}</span>
|
||||
<input type="checkbox" bind:checked={showVisited} class="checkbox checkbox-primary" />
|
||||
</label>
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text mr-1">{$t('adventures.planned')}</span>
|
||||
<input type="checkbox" bind:checked={showPlanned} class="checkbox checkbox-primary" />
|
||||
</label>
|
||||
<label for="show-geo">{$t('map.show_visited_regions')}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="show-geo"
|
||||
name="show-geo"
|
||||
class="checkbox"
|
||||
on:click={() => (showGeo = !showGeo)}
|
||||
/>
|
||||
<div class="divider divider-horizontal"></div>
|
||||
{#if newMarker}
|
||||
<button type="button" class="btn btn-primary mb-2" on:click={newAdventure}
|
||||
>{$t('map.add_adventure_at_marker')}</button
|
||||
>
|
||||
<button type="button" class="btn btn-neutral mb-2" on:click={() => (newMarker = null)}
|
||||
>{$t('map.clear_marker')}</button
|
||||
>
|
||||
{:else}
|
||||
<button type="button" class="btn btn-primary mb-2" on:click={() => (createModalOpen = true)}
|
||||
>{$t('map.add_adventure')}</button
|
||||
>
|
||||
{/if}
|
||||
<div class="min-h-screen bg-gradient-to-br from-base-200 via-base-100 to-base-200">
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input id="map-drawer" type="checkbox" class="drawer-toggle" bind:checked={sidebarOpen} />
|
||||
|
||||
<div class="drawer-content">
|
||||
<!-- Header Section -->
|
||||
<div class="sticky top-0 z-40 bg-base-100/80 backdrop-blur-lg border-b border-base-300">
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn btn-ghost btn-square lg:hidden" on:click={toggleSidebar}>
|
||||
<Filter class="w-5 h-5" />
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-xl">
|
||||
<MapIcon class="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"
|
||||
>
|
||||
{$t('map.adventure_map')}
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{filteredAdventures.length} of {totalAdventures} adventures shown
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<div class="stats stats-horizontal bg-base-100 shadow-lg">
|
||||
<div class="stat py-2 px-4">
|
||||
<div class="stat-title text-xs">Visited</div>
|
||||
<div class="stat-value text-lg text-success">{visitedAdventures}</div>
|
||||
</div>
|
||||
<div class="stat py-2 px-4">
|
||||
<div class="stat-title text-xs">Planned</div>
|
||||
<div class="stat-value text-lg text-info">{plannedAdventures}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
{#if newMarker}
|
||||
<button type="button" class="btn btn-primary btn-sm gap-2" on:click={newAdventure}>
|
||||
<Plus class="w-4 h-4" />
|
||||
{$t('map.add_adventure_at_marker')}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm gap-2" on:click={clearMarker}>
|
||||
<Clear class="w-4 h-4" />
|
||||
{$t('map.clear_marker')}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm gap-2"
|
||||
on:click={() => (createModalOpen = true)}
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
{$t('map.add_adventure')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Section -->
|
||||
<div class="container mx-auto px-6 py-4 flex-1">
|
||||
<div class="card bg-base-100 shadow-xl h-full">
|
||||
<div class="card-body p-4 h-full">
|
||||
<MapLibre
|
||||
style={getBasemapUrl()}
|
||||
class="w-full h-full min-h-[70vh] rounded-lg"
|
||||
standardControls
|
||||
>
|
||||
{#each filteredAdventures as adventure}
|
||||
{#if adventure.latitude && adventure.longitude}
|
||||
<Marker
|
||||
lngLat={[adventure.longitude, adventure.latitude]}
|
||||
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200 shadow-lg cursor-pointer transition-transform hover:scale-110 {adventure.is_visited
|
||||
? 'bg-red-300 hover:bg-red-400'
|
||||
: 'bg-blue-300 hover:bg-blue-400'} text-black focus:outline-6 focus:outline-black"
|
||||
on:click={togglePopup}
|
||||
>
|
||||
<span class="text-xl">
|
||||
{adventure.category?.icon || '📍'}
|
||||
</span>
|
||||
{#if isPopupOpen}
|
||||
<Popup
|
||||
openOn="click"
|
||||
offset={[0, -10]}
|
||||
on:close={() => (isPopupOpen = false)}
|
||||
>
|
||||
<div class="min-w-64 max-w-sm">
|
||||
{#if adventure.images && adventure.images.length > 0}
|
||||
<div class="mb-3">
|
||||
<CardCarousel adventures={[adventure]} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-2">
|
||||
<div class="text-lg text-black font-bold">{adventure.name}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="badge {adventure.is_visited
|
||||
? 'badge-success'
|
||||
: 'badge-info'} badge-sm"
|
||||
>
|
||||
{adventure.is_visited
|
||||
? $t('adventures.visited')
|
||||
: $t('adventures.planned')}
|
||||
</span>
|
||||
{#if adventure.category}
|
||||
<span class="badge badge-outline badge-sm">
|
||||
{adventure.category.display_name}
|
||||
{adventure.category.icon}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if adventure.visits && adventure.visits.length > 0}
|
||||
<div class="text-black text-sm space-y-1">
|
||||
{#each adventure.visits as visit}
|
||||
<div class="flex items-center gap-1">
|
||||
<Calendar class="w-3 h-3" />
|
||||
<span>
|
||||
{visit.start_date
|
||||
? new Date(visit.start_date).toLocaleDateString(undefined, {
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
: ''}
|
||||
{visit.end_date &&
|
||||
visit.end_date !== '' &&
|
||||
visit.end_date !== visit.start_date
|
||||
? ' - ' +
|
||||
new Date(visit.end_date).toLocaleDateString(undefined, {
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2 pt-2">
|
||||
{#if adventure.longitude && adventure.latitude}
|
||||
<a
|
||||
class="btn btn-outline btn-sm gap-2"
|
||||
href={`https://maps.apple.com/?q=${adventure.latitude},${adventure.longitude}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Location class="w-4 h-4" />
|
||||
{$t('adventures.open_in_maps')}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-primary btn-sm gap-2"
|
||||
on:click={() => goto(`/adventures/${adventure.id}`)}
|
||||
>
|
||||
<Eye class="w-4 h-4" />
|
||||
{$t('map.view_details')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
{/if}
|
||||
</Marker>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<MapEvents on:click={addMarker} />
|
||||
{#if newMarker}
|
||||
<DefaultMarker lngLat={newMarker.lngLat} />
|
||||
{/if}
|
||||
|
||||
{#each visitedRegions as region}
|
||||
{#if showGeo}
|
||||
<Marker
|
||||
lngLat={[region.longitude, region.latitude]}
|
||||
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200 bg-green-300 hover:bg-green-400 text-black shadow-lg cursor-pointer transition-transform hover:scale-110"
|
||||
>
|
||||
<Location class="w-5 h-5 text-green-700" />
|
||||
<Popup openOn="click" offset={[0, -10]}>
|
||||
<div class="space-y-2">
|
||||
<div class="text-lg text-black font-bold">{region.name}</div>
|
||||
<div class="badge badge-success badge-sm">{region.region}</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
{/if}
|
||||
{/each}
|
||||
</MapLibre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="drawer-side z-50">
|
||||
<label for="map-drawer" class="drawer-overlay"></label>
|
||||
<div class="w-80 min-h-full bg-base-100 shadow-2xl">
|
||||
<div class="p-6">
|
||||
<!-- Sidebar Header -->
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<Filter class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold">Map Controls</h2>
|
||||
</div>
|
||||
|
||||
<!-- Adventure Statistics -->
|
||||
<div class="card bg-base-200/50 p-4 mb-6">
|
||||
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<MapIcon class="w-5 h-5" />
|
||||
Adventure Stats
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="stat p-0">
|
||||
<div class="stat-title text-sm">Total Adventures</div>
|
||||
<div class="stat-value text-2xl">{totalAdventures}</div>
|
||||
<div class="stat-desc">Across all locations</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="stat p-0">
|
||||
<div class="stat-title text-xs">Visited</div>
|
||||
<div class="stat-value text-lg text-success">{visitedAdventures}</div>
|
||||
</div>
|
||||
<div class="stat p-0">
|
||||
<div class="stat-title text-xs">Planned</div>
|
||||
<div class="stat-value text-lg text-info">{plannedAdventures}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat p-0">
|
||||
<div class="stat-title text-xs">Regions</div>
|
||||
<div class="stat-value text-lg text-accent">{totalRegions}</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>Completion</span>
|
||||
<span>{Math.round((visitedAdventures / totalAdventures) * 100)}%</span>
|
||||
</div>
|
||||
<progress
|
||||
class="progress progress-primary w-full"
|
||||
value={visitedAdventures}
|
||||
max={totalAdventures}
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display Options -->
|
||||
<div class="card bg-base-200/50 p-4 mb-6">
|
||||
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<Eye class="w-5 h-5" />
|
||||
Display Options
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={showVisited}
|
||||
class="checkbox checkbox-success checkbox-sm"
|
||||
/>
|
||||
<span class="label-text flex items-center gap-2">
|
||||
<Eye class="w-4 h-4" />
|
||||
{$t('adventures.visited')} ({visitedAdventures})
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={showPlanned}
|
||||
class="checkbox checkbox-info checkbox-sm"
|
||||
/>
|
||||
<span class="label-text flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4" />
|
||||
{$t('adventures.planned')} ({plannedAdventures})
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={showGeo}
|
||||
class="checkbox checkbox-accent checkbox-sm"
|
||||
/>
|
||||
<span class="label-text flex items-center gap-2">
|
||||
<Location class="w-4 h-4" />
|
||||
{$t('map.show_visited_regions')} ({totalRegions})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Adventure Section -->
|
||||
<div class="card bg-base-200/50 p-4">
|
||||
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<Plus class="w-5 h-5" />
|
||||
Add Adventure
|
||||
</h3>
|
||||
|
||||
{#if newMarker}
|
||||
<div class="space-y-3">
|
||||
<div class="alert alert-info">
|
||||
<Pin class="w-4 h-4" />
|
||||
<span class="text-sm">Marker placed on map</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary w-full gap-2" on:click={newAdventure}>
|
||||
<Plus class="w-4 h-4" />
|
||||
{$t('map.add_adventure_at_marker')}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost w-full gap-2" on:click={clearMarker}>
|
||||
<Clear class="w-4 h-4" />
|
||||
{$t('map.clear_marker')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-base-content/60">
|
||||
Click on the map to place a marker, or add an adventure without location.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full gap-2"
|
||||
on:click={() => (createModalOpen = true)}
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
{$t('map.add_adventure')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,108 +464,6 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<MapLibre
|
||||
style={getBasemapUrl()}
|
||||
class="mx-auto aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"
|
||||
standardControls
|
||||
>
|
||||
{#each filteredAdventures as adventure}
|
||||
{#if adventure.latitude && adventure.longitude}
|
||||
<Marker
|
||||
lngLat={[adventure.longitude, adventure.latitude]}
|
||||
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200 {adventure.is_visited
|
||||
? 'bg-red-300'
|
||||
: 'bg-blue-300'} text-black focus:outline-6 focus:outline-black"
|
||||
on:click={togglePopup}
|
||||
>
|
||||
<span class="text-xl">
|
||||
{adventure.category?.icon}
|
||||
</span>
|
||||
{#if isPopupOpen}
|
||||
<Popup openOn="click" offset={[0, -10]} on:close={() => (isPopupOpen = false)}>
|
||||
{#if adventure.images && adventure.images.length > 0}
|
||||
<CardCarousel adventures={[adventure]} />
|
||||
{/if}
|
||||
<div class="text-lg text-black font-bold">{adventure.name}</div>
|
||||
<p class="font-semibold text-black text-md">
|
||||
{adventure.is_visited ? $t('adventures.visited') : $t('adventures.planned')}
|
||||
</p>
|
||||
<p class="font-semibold text-black text-md">
|
||||
{adventure.category?.display_name + ' ' + adventure.category?.icon}
|
||||
</p>
|
||||
{#if adventure.visits && adventure.visits.length > 0}
|
||||
<p class="text-black text-sm">
|
||||
{#each adventure.visits as visit}
|
||||
{visit.start_date
|
||||
? new Date(visit.start_date).toLocaleDateString(undefined, {
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
: ''}
|
||||
{visit.end_date && visit.end_date !== '' && visit.end_date !== visit.start_date
|
||||
? ' - ' +
|
||||
new Date(visit.end_date).toLocaleDateString(undefined, {
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
: ''}
|
||||
<br />
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex flex-col">
|
||||
{#if adventure.longitude && adventure.latitude}
|
||||
<a
|
||||
class="btn btn-neutral btn-wide btn-sm mt-4"
|
||||
href={`https://maps.apple.com/?q=${adventure.latitude},${adventure.longitude}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{$t('adventures.open_in_maps')}</a
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-neutral btn-wide btn-sm mt-2"
|
||||
on:click={() => goto(`/adventures/${adventure.id}`)}
|
||||
>{$t('map.view_details')}</button
|
||||
>
|
||||
</div>
|
||||
</Popup>
|
||||
{/if}
|
||||
</Marker>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<MapEvents on:click={addMarker} />
|
||||
{#if newMarker}
|
||||
<DefaultMarker lngLat={newMarker.lngLat} />
|
||||
{/if}
|
||||
|
||||
{#each visitedRegions as region}
|
||||
{#if showGeo}
|
||||
<Marker
|
||||
lngLat={[region.longitude, region.latitude]}
|
||||
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200 bg-green-300 text-black shadow-md"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke="green" stroke-width="2" fill="green" />
|
||||
</svg>
|
||||
<Popup openOn="click" offset={[0, -10]}>
|
||||
<div class="text-lg text-black font-bold">{region.name}</div>
|
||||
<p class="font-semibold text-black text-md">{region.region}</p>
|
||||
</Popup>
|
||||
</Marker>
|
||||
{/if}
|
||||
{/each}
|
||||
</MapLibre>
|
||||
|
||||
<svelte:head>
|
||||
<title>Adventure Map</title>
|
||||
<meta name="description" content="View your travels on a map." />
|
||||
</svelte:head>
|
||||
|
||||
<style>
|
||||
:global(.map) {
|
||||
height: 500px;
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
import CollectionCard from '$lib/components/CollectionCard.svelte';
|
||||
import type { Adventure, Collection, User } from '$lib/types.js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
// Icons
|
||||
import Calendar from '~icons/mdi/calendar';
|
||||
import MapMarker from '~icons/mdi/map-marker';
|
||||
import Airplane from '~icons/mdi/airplane';
|
||||
import FlagCheckered from '~icons/mdi/flag-checkered-variant';
|
||||
import CityVariant from '~icons/mdi/city-variant-outline';
|
||||
import MapMarkerStar from '~icons/mdi/map-marker-star-outline';
|
||||
import CollectionIcon from '~icons/mdi/folder-multiple';
|
||||
import TrendingUp from '~icons/mdi/trending-up';
|
||||
import Share from '~icons/mdi/share-variant';
|
||||
import Award from '~icons/mdi/award';
|
||||
|
||||
let stats: {
|
||||
visited_country_count: number;
|
||||
@@ -20,165 +34,377 @@
|
||||
const adventures: Adventure[] = data.adventures;
|
||||
const collections: Collection[] = data.collections;
|
||||
stats = data.stats || null;
|
||||
|
||||
// Calculate achievements
|
||||
$: worldExplorationPercentage = stats
|
||||
? Math.round((stats.visited_country_count / stats.total_countries) * 100)
|
||||
: 0;
|
||||
$: regionExplorationPercentage = stats
|
||||
? Math.round((stats.visited_region_count / stats.total_regions) * 100)
|
||||
: 0;
|
||||
$: cityExplorationPercentage = stats
|
||||
? Math.round((stats.visited_city_count / stats.total_cities) * 100)
|
||||
: 0;
|
||||
|
||||
// Achievement levels
|
||||
$: achievementLevel =
|
||||
(stats?.adventure_count ?? 0) >= 50
|
||||
? 'Explorer Master'
|
||||
: (stats?.adventure_count ?? 0) >= 25
|
||||
? 'Seasoned Traveler'
|
||||
: (stats?.adventure_count ?? 0) >= 10
|
||||
? 'Adventure Seeker'
|
||||
: (stats?.adventure_count ?? 0) >= 5
|
||||
? 'Journey Starter'
|
||||
: 'Travel Enthusiast';
|
||||
|
||||
$: achievementColor =
|
||||
(stats?.adventure_count ?? 0) >= 50
|
||||
? 'text-warning'
|
||||
: (stats?.adventure_count ?? 0) >= 25
|
||||
? 'text-success'
|
||||
: (stats?.adventure_count ?? 0) >= 10
|
||||
? 'text-info'
|
||||
: (stats?.adventure_count ?? 0) >= 5
|
||||
? 'text-secondary'
|
||||
: 'text-primary';
|
||||
</script>
|
||||
|
||||
<section class="min-h-screen bg-base-100 py-8 px-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<!-- Profile Picture -->
|
||||
{#if user.profile_pic}
|
||||
<div class="avatar">
|
||||
<div
|
||||
class="w-24 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2 shadow-md"
|
||||
>
|
||||
<img src={user.profile_pic} alt="Profile" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- show first last initial -->
|
||||
<div class="avatar">
|
||||
<div
|
||||
class="w-24 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2 shadow-md"
|
||||
>
|
||||
{#if user.first_name && user.last_name}
|
||||
<img
|
||||
src={`https://eu.ui-avatars.com/api/?name=${user.first_name}+${user.last_name}&size=250`}
|
||||
alt="Profile"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={`https://eu.ui-avatars.com/api/?name=${user.username}&size=250`}
|
||||
alt="Profile"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- User Name -->
|
||||
{#if user && user.first_name && user.last_name}
|
||||
<h1 class="text-4xl font-bold text-primary mt-4">
|
||||
{user.first_name}
|
||||
{user.last_name}
|
||||
</h1>
|
||||
{/if}
|
||||
<p class="text-lg text-base-content mt-2">{user.username}</p>
|
||||
|
||||
<!-- Member Since -->
|
||||
{#if user && user.date_joined}
|
||||
<div class="mt-4 flex items-center text-center text-base-content">
|
||||
<p class="text-lg font-medium">{$t('profile.member_since')}</p>
|
||||
<div class="flex items-center ml-2">
|
||||
<iconify-icon icon="mdi:calendar" class="text-2xl text-primary"></iconify-icon>
|
||||
<p class="ml-2 text-lg">
|
||||
{new Date(user.date_joined).toLocaleDateString(undefined, { timeZone: 'UTC' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
{#if stats}
|
||||
<div class="divider my-8"></div>
|
||||
|
||||
<h2 class="text-2xl font-bold text-center mb-6 text-primary">
|
||||
{$t('profile.user_stats')}
|
||||
</h2>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow bg-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('navbar.adventures')}</div>
|
||||
<div class="stat-value text-center">{stats.adventure_count}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('navbar.collections')}</div>
|
||||
<div class="stat-value text-center">{stats.trips_count}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_countries')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{stats.visited_country_count}
|
||||
</div>
|
||||
<div class="stat-desc text-center">
|
||||
{Math.round((stats.visited_country_count / stats.total_countries) * 100)}% {$t(
|
||||
'adventures.of'
|
||||
)}
|
||||
{stats.total_countries}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_regions')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{stats.visited_region_count}
|
||||
</div>
|
||||
<div class="stat-desc text-center">
|
||||
{Math.round((stats.visited_region_count / stats.total_regions) * 100)}% {$t(
|
||||
'adventures.of'
|
||||
)}
|
||||
{stats.total_regions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_cities')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{stats.visited_city_count}
|
||||
</div>
|
||||
<div class="stat-desc text-center">
|
||||
{Math.round((stats.visited_city_count / stats.total_cities) * 100)}% {$t(
|
||||
'adventures.of'
|
||||
)}
|
||||
{stats.total_cities}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Adventures Section -->
|
||||
<div class="divider my-8"></div>
|
||||
|
||||
<h2 class="text-2xl font-bold text-center mb-6 text-primary">
|
||||
{$t('auth.user_adventures')}
|
||||
</h2>
|
||||
|
||||
{#if adventures && adventures.length === 0}
|
||||
<p class="text-lg text-center text-base-content">
|
||||
{$t('auth.no_public_adventures')}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each adventures as adventure}
|
||||
<AdventureCard {adventure} user={null} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Collections Section -->
|
||||
<div class="divider my-8"></div>
|
||||
|
||||
<h2 class="text-2xl font-bold text-center mb-6 text-primary">
|
||||
{$t('auth.user_collections')}
|
||||
</h2>
|
||||
|
||||
{#if collections && collections.length === 0}
|
||||
<p class="text-lg text-center text-base-content">
|
||||
{$t('auth.no_public_collections')}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each collections as collection}
|
||||
<CollectionCard {collection} type={''} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<svelte:head>
|
||||
<title>{user.first_name || user.username}'s Profile | AdventureLog</title>
|
||||
<meta name="description" content="User Profile" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-gradient-to-br from-base-200 via-base-100 to-base-200">
|
||||
<!-- Hero Profile Section -->
|
||||
<div class="relative overflow-hidden">
|
||||
<!-- Background Pattern -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-primary/5 via-secondary/5 to-accent/5"></div>
|
||||
|
||||
<div class="container mx-auto px-6 py-16 relative">
|
||||
<div class="profile-header flex flex-col items-center text-center">
|
||||
<!-- Profile Picture with Enhanced Styling -->
|
||||
<div class="relative mb-6">
|
||||
{#if user.profile_pic}
|
||||
<div class="avatar">
|
||||
<div
|
||||
class="w-32 h-32 rounded-full ring-4 ring-primary ring-offset-4 ring-offset-base-100 shadow-2xl"
|
||||
>
|
||||
<img src={user.profile_pic} alt="Profile" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="avatar">
|
||||
<div
|
||||
class="w-32 h-32 rounded-full ring-4 ring-primary ring-offset-4 ring-offset-base-100 shadow-2xl bg-gradient-to-br from-primary to-secondary"
|
||||
>
|
||||
{#if user.first_name && user.last_name}
|
||||
<img
|
||||
src={`https://eu.ui-avatars.com/api/?name=${user.first_name}+${user.last_name}&size=250&background=random`}
|
||||
alt="Profile"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={`https://eu.ui-avatars.com/api/?name=${user.username}&size=250&background=random`}
|
||||
alt="Profile"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="space-y-4">
|
||||
{#if user && user.first_name && user.last_name}
|
||||
<h1
|
||||
class="text-5xl font-bold bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent"
|
||||
>
|
||||
{user.first_name}
|
||||
{user.last_name}
|
||||
</h1>
|
||||
{:else}
|
||||
<h1
|
||||
class="text-5xl font-bold bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent"
|
||||
>
|
||||
{user.username}
|
||||
</h1>
|
||||
{/if}
|
||||
|
||||
<p class="text-xl text-base-content/70">@{user.username}</p>
|
||||
|
||||
<!-- Member Since -->
|
||||
{#if user && user.date_joined}
|
||||
<div class="flex items-center justify-center gap-2 text-base-content/60">
|
||||
<Calendar class="w-5 h-5" />
|
||||
<span class="text-lg">
|
||||
{$t('profile.member_since')}
|
||||
{new Date(user.date_joined).toLocaleDateString(undefined, {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'long'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- User rank achievement -->
|
||||
{#if stats && stats.adventure_count > 0}
|
||||
<div class="flex items-center justify-center gap-2 text-base-content/70">
|
||||
<Award class="w-5 h-5" />
|
||||
<span class={`text-lg ${achievementColor}`}>{achievementLevel}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button class="btn btn-primary gap-2">
|
||||
<Share class="w-4 h-4" />
|
||||
Share Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<!-- Enhanced Stats Section -->
|
||||
{#if stats}
|
||||
<div class="content-section mb-16">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-3xl font-bold mb-2">Travel Statistics</h2>
|
||||
<p class="text-base-content/60">Your adventure journey at a glance</p>
|
||||
</div>
|
||||
|
||||
<!-- Primary Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Adventures -->
|
||||
<div
|
||||
class="stat-card card bg-gradient-to-br from-primary/10 to-primary/5 shadow-xl border border-primary/20 hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-primary/70 font-medium text-sm uppercase tracking-wide">
|
||||
{$t('navbar.adventures')}
|
||||
</div>
|
||||
<div class="text-4xl font-bold text-primary">{stats.adventure_count}</div>
|
||||
<div class="text-primary/60 mt-2 flex items-center gap-1">
|
||||
<TrendingUp class="w-4 h-4" />
|
||||
{achievementLevel}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-primary/20 rounded-2xl">
|
||||
<Airplane class="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collections -->
|
||||
<div
|
||||
class="stat-card card bg-gradient-to-br from-secondary/10 to-secondary/5 shadow-xl border border-secondary/20 hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-secondary/70 font-medium text-sm uppercase tracking-wide">
|
||||
{$t('navbar.collections')}
|
||||
</div>
|
||||
<div class="text-4xl font-bold text-secondary">{stats.trips_count}</div>
|
||||
<div class="text-secondary/60 mt-2">Curated trips</div>
|
||||
</div>
|
||||
<div class="p-4 bg-secondary/20 rounded-2xl">
|
||||
<CollectionIcon class="w-8 h-8 text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Countries -->
|
||||
<div
|
||||
class="stat-card card bg-gradient-to-br from-success/10 to-success/5 shadow-xl border border-success/20 hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="text-success/70 font-medium text-sm uppercase tracking-wide">
|
||||
{$t('profile.visited_countries')}
|
||||
</div>
|
||||
<div class="text-4xl font-bold text-success">{stats.visited_country_count}</div>
|
||||
<div class="text-success/60 mt-2">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span>{worldExplorationPercentage}% of world</span>
|
||||
<span class="text-xs"
|
||||
>{stats.visited_country_count}/{stats.total_countries}</span
|
||||
>
|
||||
</div>
|
||||
<progress
|
||||
class="progress progress-success w-full h-2"
|
||||
value={stats.visited_country_count}
|
||||
max={stats.total_countries}
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-success/20 rounded-2xl">
|
||||
<FlagCheckered class="w-8 h-8 text-success" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secondary Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Regions -->
|
||||
<div
|
||||
class="stat-card card bg-gradient-to-br from-info/10 to-info/5 shadow-xl border border-info/20 hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="text-info/70 font-medium text-sm uppercase tracking-wide">
|
||||
{$t('profile.visited_regions')}
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-info">{stats.visited_region_count}</div>
|
||||
<div class="text-info/60 mt-2">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span>{regionExplorationPercentage}% explored</span>
|
||||
<span class="text-xs">{stats.visited_region_count}/{stats.total_regions}</span
|
||||
>
|
||||
</div>
|
||||
<progress
|
||||
class="progress progress-info w-full h-2"
|
||||
value={stats.visited_region_count}
|
||||
max={stats.total_regions}
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-info/20 rounded-2xl">
|
||||
<MapMarkerStar class="w-8 h-8 text-info" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cities -->
|
||||
<div
|
||||
class="stat-card card bg-gradient-to-br from-warning/10 to-warning/5 shadow-xl border border-warning/20 hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="text-warning/70 font-medium text-sm uppercase tracking-wide">
|
||||
{$t('profile.visited_cities')}
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-warning">{stats.visited_city_count}</div>
|
||||
<div class="text-warning/60 mt-2">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span>{cityExplorationPercentage}% discovered</span>
|
||||
<span class="text-xs">{stats.visited_city_count}/{stats.total_cities}</span>
|
||||
</div>
|
||||
<progress
|
||||
class="progress progress-warning w-full h-2"
|
||||
value={stats.visited_city_count}
|
||||
max={stats.total_cities}
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-warning/20 rounded-2xl">
|
||||
<CityVariant class="w-8 h-8 text-warning" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Adventures Section -->
|
||||
<div class="content-section mb-16">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-xl">
|
||||
<Airplane class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold">{$t('auth.user_adventures')}</h2>
|
||||
<p class="text-base-content/60">Public adventure experiences</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if adventures && adventures.length > 0}
|
||||
<div class="badge badge-primary badge-lg">
|
||||
{adventures.length}
|
||||
{adventures.length === 1 ? 'Adventure' : 'Adventures'}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if adventures && adventures.length === 0}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body text-center py-16">
|
||||
<div class="p-6 bg-base-200/50 rounded-2xl w-fit mx-auto mb-6">
|
||||
<Airplane class="w-16 h-16 text-base-content/30" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-base-content/70 mb-2">
|
||||
{$t('auth.no_public_adventures')}
|
||||
</h3>
|
||||
<p class="text-base-content/50">This user hasn't shared any public adventures yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{#each adventures as adventure}
|
||||
<div class="adventure-card">
|
||||
<AdventureCard {adventure} user={null} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Collections Section -->
|
||||
<div class="content-section">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-secondary/10 rounded-xl">
|
||||
<CollectionIcon class="w-6 h-6 text-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold">{$t('auth.user_collections')}</h2>
|
||||
<p class="text-base-content/60">Curated travel collections</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if collections && collections.length > 0}
|
||||
<div class="badge badge-secondary badge-lg">
|
||||
{collections.length}
|
||||
{collections.length === 1 ? 'Collection' : 'Collections'}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if collections && collections.length === 0}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body text-center py-16">
|
||||
<div class="p-6 bg-base-200/50 rounded-2xl w-fit mx-auto mb-6">
|
||||
<CollectionIcon class="w-16 h-16 text-base-content/30" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-base-content/70 mb-2">
|
||||
{$t('auth.no_public_collections')}
|
||||
</h3>
|
||||
<p class="text-base-content/50">This user hasn't shared any public collections yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{#each collections as collection}
|
||||
<div class="collection-card">
|
||||
<CollectionCard {collection} type={''} user={data.user} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,32 +2,82 @@
|
||||
import { getBasemapUrl } from '$lib';
|
||||
import CityCard from '$lib/components/CityCard.svelte';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import type { City } from '$lib/types';
|
||||
import type { City, VisitedCity } from '$lib/types';
|
||||
import type { PageData } from './$types';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { MapLibre, Marker } from 'svelte-maplibre';
|
||||
|
||||
export let data: PageData;
|
||||
console.log(data);
|
||||
// Icons
|
||||
import MapMarker from '~icons/mdi/map-marker';
|
||||
import Search from '~icons/mdi/magnify';
|
||||
import Clear from '~icons/mdi/close';
|
||||
import Filter from '~icons/mdi/filter-variant';
|
||||
import Map from '~icons/mdi/map';
|
||||
import Check from '~icons/mdi/check-circle';
|
||||
import Cancel from '~icons/mdi/cancel';
|
||||
import Trophy from '~icons/mdi/trophy';
|
||||
import Target from '~icons/mdi/target';
|
||||
import CityIcon from '~icons/mdi/city';
|
||||
|
||||
let searchQuery: string = '';
|
||||
export let data: PageData;
|
||||
|
||||
let filteredCities: City[] = [];
|
||||
const allCities: City[] = data.props?.cities || [];
|
||||
let visitedCities = data.props?.visitedCities || [];
|
||||
let searchQuery: string = '';
|
||||
let showGeo: boolean = true;
|
||||
let sidebarOpen = false;
|
||||
let filterOption: string = 'all';
|
||||
|
||||
const allCities: City[] = data.props?.cities || [];
|
||||
let visitedCities: VisitedCity[] = data.props?.visitedCities || [];
|
||||
const region = data.props?.region || null;
|
||||
|
||||
console.log(data);
|
||||
|
||||
// Statistics
|
||||
let numCities: number = allCities.length;
|
||||
let numVisitedCities: number = visitedCities.length;
|
||||
|
||||
$: visitedCount = visitedCities.length;
|
||||
$: notVisitedCount = allCities.length - visitedCount;
|
||||
$: completionPercentage =
|
||||
allCities.length > 0 ? Math.round((visitedCount / allCities.length) * 100) : 0;
|
||||
|
||||
// Filter cities based on search and filter options
|
||||
$: {
|
||||
if (searchQuery === '') {
|
||||
filteredCities = allCities;
|
||||
} else {
|
||||
// otherwise, filter countries by name
|
||||
filteredCities = allCities.filter((country) =>
|
||||
country.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
filteredCities = allCities.filter((city) =>
|
||||
city.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (filterOption === 'visited') {
|
||||
filteredCities = filteredCities.filter((city) =>
|
||||
visitedCities.some((visitedCity) => visitedCity.city === city.id)
|
||||
);
|
||||
} else if (filterOption === 'not-visited') {
|
||||
filteredCities = filteredCities.filter(
|
||||
(city) => !visitedCities.some((visitedCity) => visitedCity.city === city.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function togleVisited(city: City) {
|
||||
// Remove duplicates from visitedCities
|
||||
visitedCities = visitedCities.filter(
|
||||
(visitedCity, index, self) => index === self.findIndex((t) => t.city === visitedCity.city)
|
||||
);
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery = '';
|
||||
filterOption = 'all';
|
||||
}
|
||||
|
||||
function toggleVisited(city: City) {
|
||||
return () => {
|
||||
const visitedCity = visitedCities.find((visitedCity) => visitedCity.city === city.id);
|
||||
if (visitedCity) {
|
||||
@@ -47,128 +97,333 @@
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error('Failed to mark city as visited');
|
||||
addToast('error', `Failed to mark visit to ${city.name}`);
|
||||
addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${city.name}`);
|
||||
return;
|
||||
} else {
|
||||
visitedCities = [...visitedCities, await res.json()];
|
||||
addToast('success', `Visit to ${city.name} marked`);
|
||||
addToast(
|
||||
'success',
|
||||
`${$t('worldtravel.visit_to')} ${city.name} ${$t('worldtravel.marked_visited')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
async function removeVisit(region: City) {
|
||||
let res = await fetch(`/api/visitedcity/${region.id}`, {
|
||||
|
||||
async function removeVisit(city: City) {
|
||||
let res = await fetch(`/api/visitedcity/${city.id}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error('Failed to remove visit');
|
||||
addToast('error', `Failed to remove visit to ${region.name}`);
|
||||
console.error('Failed to remove city visit');
|
||||
addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${city.name}`);
|
||||
return;
|
||||
} else {
|
||||
visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== region.id);
|
||||
addToast('info', `Visit to ${region.name} removed`);
|
||||
visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== city.id);
|
||||
addToast('info', `${$t('worldtravel.visit_to')} ${city.name} ${$t('worldtravel.removed')}`);
|
||||
}
|
||||
}
|
||||
|
||||
let numCities: number = data.props?.cities?.length || 0;
|
||||
let numVisitedCities: number = visitedCities.length;
|
||||
|
||||
$: {
|
||||
numVisitedCities = visitedCities.length;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1 class="text-center font-bold text-4xl">Cities in {data.props?.region.name}</h1>
|
||||
<!-- result count -->
|
||||
<p class="text-center mb-4">
|
||||
{allCities.length}
|
||||
Cities Found
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<div class="stats shadow bg-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-title">City Stats</div>
|
||||
<div class="stat-value">{numVisitedCities}/{numCities} Visited</div>
|
||||
{#if numCities === numVisitedCities}
|
||||
<div class="stat-desc">You've visited all cities in {data.props?.region.name} 🎉!</div>
|
||||
{:else}
|
||||
<div class="stat-desc">Keep exploring!</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if allCities.length > 0}
|
||||
<div class="mt-4 mb-4 flex justify-center">
|
||||
<!-- checkbox to toggle marker -->
|
||||
|
||||
<MapLibre
|
||||
style={getBasemapUrl()}
|
||||
class="aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"
|
||||
standardControls
|
||||
center={allCities[0] && allCities[0].longitude !== null && allCities[0].latitude !== null
|
||||
? [allCities[0].longitude, allCities[0].latitude]
|
||||
: [0, 0]}
|
||||
zoom={8}
|
||||
>
|
||||
{#each filteredCities as city}
|
||||
{#if city.latitude && city.longitude}
|
||||
<Marker
|
||||
lngLat={[city.longitude, city.latitude]}
|
||||
class="grid px-2 py-1 place-items-center rounded-full border border-gray-200 {visitedCities.some(
|
||||
(visitedCity) => visitedCity.city === city.id
|
||||
)
|
||||
? 'bg-green-200'
|
||||
: 'bg-red-200'} text-black focus:outline-6 focus:outline-black"
|
||||
on:click={togleVisited(city)}
|
||||
>
|
||||
<span class="text-xs">
|
||||
{city.name}
|
||||
</span>
|
||||
</Marker>
|
||||
{/if}
|
||||
{/each}
|
||||
</MapLibre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('navbar.search')}
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery.length > 0}
|
||||
<!-- clear button -->
|
||||
<div class="flex items-center justify-center ml-4">
|
||||
<button class="btn btn-neutral" on:click={() => (searchQuery = '')}>
|
||||
{$t('worldtravel.clear_search')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
|
||||
{#each filteredCities as city}
|
||||
<CityCard
|
||||
{city}
|
||||
visited={visitedCities.some((visitedCity) => visitedCity.city === city.id)}
|
||||
on:visit={(e) => {
|
||||
visitedCities = [...visitedCities, e.detail];
|
||||
}}
|
||||
on:remove={() => {
|
||||
visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== city.id);
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if filteredCities.length === 0}
|
||||
<p class="text-center font-bold text-2xl mt-12">{$t('worldtravel.no_cities_found')}</p>
|
||||
{/if}
|
||||
|
||||
<svelte:head>
|
||||
<title>Cities in {data.props?.region.name} | World Travel</title>
|
||||
<meta name="description" content="Explore the world and add countries to your visited list!" />
|
||||
<title>{region ? `Cities in ${region.name}` : 'Cities'}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="View the cities in regions and mark them visited to track your world travel."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-gradient-to-br from-base-200 via-base-100 to-base-200">
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input id="cities-drawer" type="checkbox" class="drawer-toggle" bind:checked={sidebarOpen} />
|
||||
|
||||
<div class="drawer-content">
|
||||
<!-- Header Section -->
|
||||
<div class="sticky top-0 z-40 bg-base-100/80 backdrop-blur-lg border-b border-base-300">
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn btn-ghost btn-square lg:hidden" on:click={toggleSidebar}>
|
||||
<Filter class="w-5 h-5" />
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-xl">
|
||||
<CityIcon class="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"
|
||||
>
|
||||
{$t('worldtravel.cities_in')}
|
||||
{region?.name}
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{filteredCities.length} of {allCities.length} cities
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completion Badge -->
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
{#if completionPercentage === 100}
|
||||
<div class="badge badge-success gap-2 p-3">
|
||||
<Trophy class="w-4 h-4" />
|
||||
Complete!
|
||||
</div>
|
||||
{:else}
|
||||
<div class="badge badge-primary gap-2 p-3">
|
||||
<Target class="w-4 h-4" />
|
||||
{completionPercentage}%
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<Search
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('navbar.search')}
|
||||
class="input input-bordered w-full pl-10 pr-10 bg-base-100/80"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery.length > 0}
|
||||
<button
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-base-content"
|
||||
on:click={() => (searchQuery = '')}
|
||||
>
|
||||
<Clear class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Map Toggle -->
|
||||
<button
|
||||
class="btn btn-outline gap-2 {showGeo ? 'btn-active' : ''}"
|
||||
on:click={() => (showGeo = !showGeo)}
|
||||
>
|
||||
{#if showGeo}
|
||||
<Map class="w-4 h-4" />
|
||||
<span class="hidden sm:inline">Hide Labels</span>
|
||||
{:else}
|
||||
<Map class="w-4 h-4" />
|
||||
<span class="hidden sm:inline">Show Labels</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Chips -->
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-base-content/60">Filter by:</span>
|
||||
<div class="tabs tabs-boxed bg-base-200">
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'all' ? 'tab-active' : ''}"
|
||||
on:click={() => (filterOption = 'all')}
|
||||
>
|
||||
<MapMarker class="w-3 h-3" />
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}"
|
||||
on:click={() => (filterOption = 'visited')}
|
||||
>
|
||||
<Check class="w-3 h-3" />
|
||||
Visited
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'not-visited' ? 'tab-active' : ''}"
|
||||
on:click={() => (filterOption = 'not-visited')}
|
||||
>
|
||||
<Cancel class="w-3 h-3" />
|
||||
Not Visited
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if searchQuery || filterOption !== 'all'}
|
||||
<button class="btn btn-ghost btn-xs gap-1" on:click={clearFilters}>
|
||||
<Clear class="w-3 h-3" />
|
||||
Clear All
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Section -->
|
||||
{#if allCities.length > 0}
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Map class="w-5 h-5 text-primary" />
|
||||
<h2 class="text-lg font-semibold">Interactive Map</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-base-content/60">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-3 h-3 bg-green-200 rounded-full border"></div>
|
||||
<span>Visited</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-3 h-3 bg-red-200 rounded-full border"></div>
|
||||
<span>Not Visited</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MapLibre
|
||||
style={getBasemapUrl()}
|
||||
class="aspect-[16/10] w-full rounded-lg"
|
||||
standardControls
|
||||
center={allCities[0] &&
|
||||
allCities[0].longitude !== null &&
|
||||
allCities[0].latitude !== null
|
||||
? [allCities[0].longitude, allCities[0].latitude]
|
||||
: [0, 0]}
|
||||
zoom={8}
|
||||
>
|
||||
{#each filteredCities as city}
|
||||
{#if city.latitude && city.longitude && showGeo}
|
||||
<Marker
|
||||
lngLat={[city.longitude, city.latitude]}
|
||||
class="grid px-2 py-1 place-items-center rounded-full border border-gray-200 {visitedCities.some(
|
||||
(visitedCity) => visitedCity.city === city.id
|
||||
)
|
||||
? 'bg-green-200'
|
||||
: 'bg-red-200'} text-black focus:outline-6 focus:outline-black"
|
||||
on:click={toggleVisited(city)}
|
||||
>
|
||||
<span class="text-xs">
|
||||
{city.name}
|
||||
</span>
|
||||
</Marker>
|
||||
{/if}
|
||||
{/each}
|
||||
</MapLibre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
{#if filteredCities.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
|
||||
<CityIcon class="w-16 h-16 text-base-content/30" />
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-base-content/70 mb-2">No cities found</h3>
|
||||
<p class="text-base-content/50 text-center max-w-md mb-6">
|
||||
Try adjusting your search terms or filters to find the cities you're looking for.
|
||||
</p>
|
||||
<button class="btn btn-primary gap-2" on:click={clearFilters}>
|
||||
<Clear class="w-4 h-4" />
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Cities Grid -->
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6"
|
||||
>
|
||||
{#each filteredCities as city}
|
||||
<CityCard
|
||||
{city}
|
||||
visited={visitedCities.some((visitedCity) => visitedCity.city === city.id)}
|
||||
on:visit={(e) => {
|
||||
visitedCities = [...visitedCities, e.detail];
|
||||
numVisitedCities++;
|
||||
}}
|
||||
on:remove={() => {
|
||||
visitedCities = visitedCities.filter(
|
||||
(visitedCity) => visitedCity.city !== city.id
|
||||
);
|
||||
numVisitedCities--;
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="drawer-side z-50">
|
||||
<label for="cities-drawer" class="drawer-overlay"></label>
|
||||
<div class="w-80 min-h-full bg-base-100 shadow-2xl">
|
||||
<div class="p-6">
|
||||
<!-- Sidebar Header -->
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<Filter class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold">Progress & Stats</h2>
|
||||
</div>
|
||||
|
||||
<!-- Region Progress -->
|
||||
<div class="card bg-base-200/50 p-4 mb-6">
|
||||
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<CityIcon class="w-5 h-5" />
|
||||
{region?.name}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="stat p-0">
|
||||
<div class="stat-title text-sm">Total Cities</div>
|
||||
<div class="stat-value text-2xl">{allCities.length}</div>
|
||||
<div class="stat-desc">Available to explore</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="stat p-0">
|
||||
<div class="stat-title text-xs">Visited</div>
|
||||
<div class="stat-value text-lg text-success">{visitedCount}</div>
|
||||
</div>
|
||||
<div class="stat p-0">
|
||||
<div class="stat-title text-xs">Remaining</div>
|
||||
<div class="stat-value text-lg text-error">{notVisitedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>Progress</span>
|
||||
<span>{completionPercentage}%</span>
|
||||
</div>
|
||||
<progress
|
||||
class="progress progress-primary w-full"
|
||||
value={visitedCount}
|
||||
max={allCities.length}
|
||||
></progress>
|
||||
</div>
|
||||
|
||||
{#if completionPercentage === 100}
|
||||
<div class="alert alert-success">
|
||||
<Trophy class="w-4 h-4" />
|
||||
<span class="text-sm">Region completed! 🎉</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="space-y-3">
|
||||
<button class="btn btn-outline w-full gap-2" on:click={() => (showGeo = !showGeo)}>
|
||||
{#if showGeo}
|
||||
<Map class="w-4 h-4" />
|
||||
Hide Map Labels
|
||||
{:else}
|
||||
<Map class="w-4 h-4" />
|
||||
Show Map Labels
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user