1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-24 07:19:36 +02:00

feat: add VisitedCity model and serializer, update admin interface, and enhance city visit tracking functionality

This commit is contained in:
Sean Morley 2025-01-09 12:38:29 -05:00
parent 44810e6343
commit 80cec30fda
15 changed files with 344 additions and 234 deletions

View file

@ -2,7 +2,7 @@ import os
from django.contrib import admin from django.contrib import admin
from django.utils.html import mark_safe from django.utils.html import mark_safe
from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category
from worldtravel.models import Country, Region, VisitedRegion, City from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
from allauth.account.decorators import secure_admin_login from allauth.account.decorators import secure_admin_login
admin.autodiscover() admin.autodiscover()
@ -138,6 +138,7 @@ admin.site.register(ChecklistItem)
admin.site.register(AdventureImage, AdventureImageAdmin) admin.site.register(AdventureImage, AdventureImageAdmin)
admin.site.register(Category, CategoryAdmin) admin.site.register(Category, CategoryAdmin)
admin.site.register(City, CityAdmin) admin.site.register(City, CityAdmin)
admin.site.register(VisitedCity)
admin.site.site_header = 'AdventureLog Admin' admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site' admin.site.site_title = 'AdventureLog Admin Site'

View file

@ -0,0 +1,24 @@
# Generated by Django 5.0.8 on 2025-01-09 17:00
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('worldtravel', '0012_city'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='VisitedCity',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('city', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='worldtravel.city')),
('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -60,4 +60,21 @@ class VisitedRegion(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists(): if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists():
raise ValidationError("Region already visited by user.") raise ValidationError("Region already visited by user.")
super().save(*args, **kwargs) super().save(*args, **kwargs)
class VisitedCity(models.Model):
id = models.AutoField(primary_key=True)
user_id = models.ForeignKey(
User, on_delete=models.CASCADE, default=default_user_id)
city = models.ForeignKey(City, on_delete=models.CASCADE)
def __str__(self):
return f'{self.city.name} ({self.city.region.name}) visited by: {self.user_id.username}'
def save(self, *args, **kwargs):
if VisitedCity.objects.filter(user_id=self.user_id, city=self.city).exists():
raise ValidationError("City already visited by user.")
super().save(*args, **kwargs)
class Meta:
verbose_name_plural = "Visited Cities"

View file

@ -1,5 +1,5 @@
import os import os
from .models import Country, Region, VisitedRegion, City from .models import Country, Region, VisitedRegion, City, VisitedCity
from rest_framework import serializers from rest_framework import serializers
from main.utils import CustomModelSerializer from main.utils import CustomModelSerializer
@ -52,4 +52,14 @@ class VisitedRegionSerializer(CustomModelSerializer):
class Meta: class Meta:
model = VisitedRegion model = VisitedRegion
fields = ['id', 'user_id', 'region', 'longitude', 'latitude', 'name'] fields = ['id', 'user_id', 'region', 'longitude', 'latitude', 'name']
read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']
class VisitedCitySerializer(CustomModelSerializer):
longitude = serializers.DecimalField(source='city.longitude', max_digits=9, decimal_places=6, read_only=True)
latitude = serializers.DecimalField(source='city.latitude', max_digits=9, decimal_places=6, read_only=True)
name = serializers.CharField(source='city.name', read_only=True)
class Meta:
model = VisitedCity
fields = ['id', 'user_id', 'city', 'longitude', 'latitude', 'name']
read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name'] read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']

View file

@ -2,15 +2,17 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region, VisitedCityViewSet, visits_by_region
router = DefaultRouter() router = DefaultRouter()
router.register(r'countries', CountryViewSet, basename='countries') router.register(r'countries', CountryViewSet, basename='countries')
router.register(r'regions', RegionViewSet, basename='regions') router.register(r'regions', RegionViewSet, basename='regions')
router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion') router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion')
router.register(r'visitedcity', VisitedCityViewSet, basename='visitedcity')
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path('<str:country_code>/regions/', regions_by_country, name='regions-by-country'), path('<str:country_code>/regions/', regions_by_country, name='regions-by-country'),
path('<str:country_code>/visits/', visits_by_country, name='visits-by-country'), path('<str:country_code>/visits/', visits_by_country, name='visits-by-country'),
path('regions/<str:region_id>/cities/', cities_by_region, name='cities-by-region'), path('regions/<str:region_id>/cities/', cities_by_region, name='cities-by-region'),
path('regions/<str:region_id>/cities/visits/', visits_by_region, name='visits-by-region'),
] ]

View file

@ -1,6 +1,6 @@
from django.shortcuts import render from django.shortcuts import render
from .models import Country, Region, VisitedRegion, City from .models import Country, Region, VisitedRegion, City, VisitedCity
from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer, VisitedCitySerializer
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@ -41,6 +41,15 @@ def cities_by_region(request, region_id):
serializer = CitySerializer(cities, many=True) serializer = CitySerializer(cities, many=True)
return Response(serializer.data) return Response(serializer.data)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def visits_by_region(request, region_id):
region = get_object_or_404(Region, id=region_id)
visits = VisitedCity.objects.filter(city__region=region, user_id=request.user.id)
serializer = VisitedCitySerializer(visits, many=True)
return Response(serializer.data)
class CountryViewSet(viewsets.ReadOnlyModelViewSet): class CountryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Country.objects.all().order_by('name') queryset = Country.objects.all().order_by('name')
serializer_class = CountrySerializer serializer_class = CountrySerializer
@ -101,4 +110,42 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
self.perform_create(serializer) self.perform_create(serializer)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, **kwargs):
# delete by region id
region = get_object_or_404(Region, id=kwargs['pk'])
visited_region = VisitedRegion.objects.filter(user_id=request.user.id, region=region)
if visited_region.exists():
visited_region.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
else:
return Response({"error": "Visited region not found."}, status=status.HTTP_404_NOT_FOUND)
class VisitedCityViewSet(viewsets.ModelViewSet):
serializer_class = VisitedCitySerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return VisitedCity.objects.filter(user_id=self.request.user.id)
def perform_create(self, serializer):
serializer.save(user_id=self.request.user)
def create(self, request, *args, **kwargs):
request.data['user_id'] = request.user
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, **kwargs):
# delete by city id
city = get_object_or_404(City, id=kwargs['pk'])
visited_city = VisitedCity.objects.filter(user_id=request.user.id, city=city)
if visited_city.exists():
visited_city.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
else:
return Response({"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND)

View file

@ -1,13 +1,42 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { addToast } from '$lib/toasts';
import { continentCodeToString, getFlag } from '$lib'; import type { City } from '$lib/types';
import type { City, Country } from '$lib/types';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
import { t } from 'svelte-i18n';
import MapMarkerStar from '~icons/mdi/map-marker-star';
export let city: City; export let city: City;
export let visited: boolean;
async function markVisited() {
let res = await fetch(`/api/visitedcity/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ city: city.id })
});
if (res.ok) {
visited = true;
let data = await res.json();
addToast('success', `Visit to ${city.name} marked`);
dispatch('visit', data);
} else {
console.error('Failed to mark city as visited');
addToast('error', `Failed to mark visit to ${city.name}`);
}
}
async function removeVisit() {
let res = await fetch(`/api/visitedcity/${city.id}`, {
method: 'DELETE'
});
if (res.ok) {
visited = false;
addToast('info', `Visit to ${city.name} removed`);
dispatch('remove', city);
} else {
console.error('Failed to remove visit');
addToast('error', `Failed to remove visit to ${city.name}`);
}
}
</script> </script>
<div <div
@ -16,17 +45,15 @@
<div class="card-body"> <div class="card-body">
<h2 class="card-title overflow-ellipsis">{city.name}</h2> <h2 class="card-title overflow-ellipsis">{city.name}</h2>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<div class="badge badge-primary">{city.region}</div> <div class="badge badge-primary">{city.id}</div>
<!-- {#if !visited}
{#if country.num_visits > 0 && country.num_visits != country.num_regions} <button class="btn btn-primary" on:click={markVisited}
<div class="badge badge-accent"> >{$t('adventures.mark_visited')}</button
Visited {country.num_visits} Region{country.num_visits > 1 ? 's' : ''} >
</div> {/if}
{:else if country.num_visits > 0 && country.num_visits === country.num_regions} {#if visited}
<div class="badge badge-success">Completed</div> <button class="btn btn-warning" on:click={removeVisit}>{$t('adventures.remove')}</button>
{:else} {/if}
<div class="badge badge-error">Not Visited</div>
{/if} -->
</div> </div>
<div class="card-actions justify-end"> <div class="card-actions justify-end">

View file

@ -9,55 +9,39 @@
export let region: Region; export let region: Region;
export let visited: boolean; export let visited: boolean;
export let visit_id: number | undefined | null;
function goToCity() { function goToCity() {
console.log(region); console.log(region);
goto(`/worldtravel/${region.id.split('-')[0]}/${region.id}`); goto(`/worldtravel/${region.id.split('-')[0]}/${region.id}`);
} }
async function markVisited() { async function markVisited() {
let res = await fetch(`/worldtravel?/markVisited`, { let res = await fetch(`/api/visitedregion/`, {
headers: { 'Content-Type': 'application/json' },
method: 'POST', method: 'POST',
body: JSON.stringify({ regionId: region.id }) body: JSON.stringify({ region: region.id })
}); });
if (res.ok) { if (res.ok) {
// visited = true; visited = true;
const result = await res.json(); let data = await res.json();
const data = JSON.parse(result.data); addToast('success', `Visit to ${region.name} marked`);
if (data[1] !== undefined) { dispatch('visit', data);
console.log('New adventure created with id:', data[3]);
let visit_id = data[3];
let region_id = data[5];
let user_id = data[4];
let newVisit: VisitedRegion = {
id: visit_id,
region: region_id,
user_id: user_id,
longitude: 0,
latitude: 0,
name: ''
};
addToast('success', `Visit to ${region.name} marked`);
dispatch('visit', newVisit);
}
} else { } else {
console.error('Failed to mark region as visited'); console.error('Failed to mark region as visited');
addToast('error', `Failed to mark visit to ${region.name}`); addToast('error', `Failed to mark visit to ${region.name}`);
} }
} }
async function removeVisit() { async function removeVisit() {
let res = await fetch(`/worldtravel?/removeVisited`, { let res = await fetch(`/api/visitedregion/${region.id}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visitId: visit_id }) method: 'DELETE'
}); });
if (res.ok) { if (res.ok) {
visited = false; visited = false;
addToast('info', `Visit to ${region.name} removed`); addToast('info', `Visit to ${region.name} removed`);
dispatch('remove', null); dispatch('remove', region);
} else { } else {
console.error('Failed to remove visit'); console.error('Failed to remove visit');
addToast('error', `Failed to remove visit to ${region.name}`);
} }
} }
</script> </script>

View file

@ -74,7 +74,16 @@ export type City = {
export type VisitedRegion = { export type VisitedRegion = {
id: number; id: number;
region: number; region: string;
user_id: string;
longitude: number;
latitude: number;
name: string;
};
export type VisitedCity = {
id: number;
city: string;
user_id: string; user_id: string;
longitude: number; longitude: number;
latitude: number; latitude: number;

View file

@ -276,7 +276,8 @@
"all_subregions": "All Subregions", "all_subregions": "All Subregions",
"clear_search": "Clear Search", "clear_search": "Clear Search",
"no_countries_found": "No countries found", "no_countries_found": "No countries found",
"view_cities": "View Cities" "view_cities": "View Cities",
"no_cities_found": "No cities found"
}, },
"auth": { "auth": {
"username": "Username", "username": "Username",

View file

@ -12,7 +12,7 @@ export async function GET(event) {
/** @type {import('./$types').RequestHandler} */ /** @type {import('./$types').RequestHandler} */
export async function POST({ url, params, request, fetch, cookies }) { export async function POST({ url, params, request, fetch, cookies }) {
const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; const searchParam = url.search ? `${url.search}` : '';
return handleRequest(url, params, request, fetch, cookies, searchParam, true); return handleRequest(url, params, request, fetch, cookies, searchParam, true);
} }

View file

@ -30,80 +30,3 @@ export const load = (async (event) => {
} }
} }
}) satisfies PageServerLoad; }) satisfies PageServerLoad;
export const actions: Actions = {
markVisited: async (event) => {
const body = await event.request.json();
if (!body || !body.regionId) {
return {
status: 400
};
}
let sessionId = event.cookies.get('sessionid');
if (!event.locals.user || !sessionId) {
return redirect(302, '/login');
}
let csrfToken = await fetchCSRFToken();
const res = await fetch(`${endpoint}/api/visitedregion/`, {
method: 'POST',
headers: {
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ region: body.regionId })
});
if (!res.ok) {
console.error('Failed to mark country as visited');
return { status: 500 };
} else {
return {
status: 200,
data: await res.json()
};
}
},
removeVisited: async (event) => {
const body = await event.request.json();
if (!body || !body.visitId) {
return {
status: 400
};
}
const visitId = body.visitId as number;
let sessionId = event.cookies.get('sessionid');
if (!event.locals.user || !sessionId) {
return redirect(302, '/login');
}
let csrfToken = await fetchCSRFToken();
const res = await fetch(`${endpoint}/api/visitedregion/${visitId}/`, {
method: 'DELETE',
headers: {
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
if (res.status !== 204) {
console.error('Failed to remove country from visited');
return { status: 500 };
} else {
return {
status: 200
};
}
}
};

View file

@ -24,7 +24,7 @@
visitedRegions = visitedRegions.filter( visitedRegions = visitedRegions.filter(
(visitedRegion) => visitedRegion.region !== region.id (visitedRegion) => visitedRegion.region !== region.id
); );
removeVisit(region, visitedRegion.id); removeVisit(region);
} else { } else {
markVisited(region); markVisited(region);
} }
@ -32,48 +32,32 @@
} }
async function markVisited(region: Region) { async function markVisited(region: Region) {
let res = await fetch(`/worldtravel?/markVisited`, { let res = await fetch(`/api/visitedregion/`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ regionId: region.id }) headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ region: region.id })
}); });
if (res.ok) { if (!res.ok) {
// visited = true;
const result = await res.json();
const data = JSON.parse(result.data);
if (data[1] !== undefined) {
console.log('New adventure created with id:', data[3]);
let visit_id = data[3];
let region_id = data[5];
let user_id = data[4];
visitedRegions = [
...visitedRegions,
{
id: visit_id,
region: region_id,
user_id: user_id,
longitude: 0,
latitude: 0,
name: ''
}
];
addToast('success', `Visit to ${region.name} marked`);
}
} else {
console.error('Failed to mark region as visited'); console.error('Failed to mark region as visited');
addToast('error', `Failed to mark visit to ${region.name}`); addToast('error', `Failed to mark visit to ${region.name}`);
return;
} else {
visitedRegions = [...visitedRegions, await res.json()];
addToast('success', `Visit to ${region.name} marked`);
} }
} }
async function removeVisit(region: Region, visitId: number) { async function removeVisit(region: Region) {
let res = await fetch(`/worldtravel?/removeVisited`, { let res = await fetch(`/api/visitedregion/${region.id}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visitId: visitId }) method: 'DELETE'
}); });
if (res.ok) { if (!res.ok) {
addToast('info', `Visit to ${region.name} removed`);
} else {
console.error('Failed to remove visit'); console.error('Failed to remove visit');
addToast('error', `Failed to remove visit to ${region.name}`);
return;
} else {
visitedRegions = visitedRegions.filter((visitedRegion) => visitedRegion.region !== region.id);
addToast('info', `Visit to ${region.name} removed`);
} }
} }
@ -110,8 +94,12 @@
visitedRegions = [...visitedRegions, e.detail]; visitedRegions = [...visitedRegions, e.detail];
numVisitedRegions++; numVisitedRegions++;
}} }}
visit_id={visitedRegions.find((visitedRegion) => visitedRegion.region === region.id)?.id} on:remove={() => {
on:remove={() => numVisitedRegions--} visitedRegions = visitedRegions.filter(
(visitedRegion) => visitedRegion.region !== region.id
);
numVisitedRegions--;
}}
/> />
{/each} {/each}
</div> </div>

View file

@ -1,5 +1,5 @@
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { City, Country, Region, VisitedRegion } from '$lib/types'; import type { City, Country, Region, VisitedCity, VisitedRegion } from '$lib/types';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
@ -10,6 +10,7 @@ export const load = (async (event) => {
let cities: City[] = []; let cities: City[] = [];
let region = {} as Region; let region = {} as Region;
let visitedCities: VisitedCity[] = [];
let sessionId = event.cookies.get('sessionid'); let sessionId = event.cookies.get('sessionid');
@ -30,19 +31,6 @@ export const load = (async (event) => {
cities = (await res.json()) as City[]; cities = (await res.json()) as City[];
} }
// res = await fetch(`${endpoint}/api/${id}/visits/`, {
// method: 'GET',
// headers: {
// Cookie: `sessionid=${sessionId}`
// }
// });
// if (!res.ok) {
// console.error('Failed to fetch visited regions');
// return { status: 500 };
// } else {
// visitedRegions = (await res.json()) as VisitedRegion[];
// }
res = await fetch(`${endpoint}/api/regions/${id}/`, { res = await fetch(`${endpoint}/api/regions/${id}/`, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -56,10 +44,24 @@ export const load = (async (event) => {
region = (await res.json()) as Region; region = (await res.json()) as Region;
} }
res = await fetch(`${endpoint}/api/regions/${region.id}/cities/visits/`, {
method: 'GET',
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (!res.ok) {
console.error('Failed to fetch visited regions');
return { status: 500 };
} else {
visitedCities = (await res.json()) as VisitedCity[];
}
return { return {
props: { props: {
cities, cities,
region region,
visitedCities
} }
}; };
}) satisfies PageServerLoad; }) satisfies PageServerLoad;

View file

@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import CityCard from '$lib/components/CityCard.svelte'; import CityCard from '$lib/components/CityCard.svelte';
import CountryCard from '$lib/components/CountryCard.svelte'; import { addToast } from '$lib/toasts';
import type { City, Country } from '$lib/types'; import type { City } from '$lib/types';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { MapLibre, Marker } from 'svelte-maplibre'; import { MapLibre, Marker } from 'svelte-maplibre';
@ -14,10 +13,7 @@
let filteredCities: City[] = []; let filteredCities: City[] = [];
const allCities: City[] = data.props?.cities || []; const allCities: City[] = data.props?.cities || [];
let showMap: boolean = false; let visitedCities = data.props?.visitedCities || [];
let filterOption: string = 'all';
let subRegionOption: string = '';
$: { $: {
if (searchQuery === '') { if (searchQuery === '') {
@ -29,15 +25,78 @@
); );
} }
} }
function togleVisited(city: City) {
return () => {
const visitedCity = visitedCities.find((visitedCity) => visitedCity.city === city.id);
if (visitedCity) {
visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== city.id);
removeVisit(city);
} else {
markVisited(city);
}
};
}
async function markVisited(city: City) {
let res = await fetch(`/api/visitedcity/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ city: city.id })
});
if (!res.ok) {
console.error('Failed to mark city as visited');
addToast('error', `Failed to mark visit to ${city.name}`);
return;
} else {
visitedCities = [...visitedCities, await res.json()];
addToast('success', `Visit to ${city.name} marked`);
}
}
async function removeVisit(region: City) {
let res = await fetch(`/api/visitedcity/${region.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}`);
return;
} else {
visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== region.id);
addToast('info', `Visit to ${region.name} removed`);
}
}
let numCities: number = data.props?.cities?.length || 0;
let numVisitedCities: number = visitedCities.length;
$: {
numVisitedCities = visitedCities.length;
}
</script> </script>
<h1 class="text-center font-bold text-4xl">{$t('worldtravel.country_list')}</h1> <h1 class="text-center font-bold text-4xl">Cities in {data.props?.region.name}</h1>
<!-- result count --> <!-- result count -->
<p class="text-center mb-4"> <p class="text-center mb-4">
{allCities.length} {allCities.length}
Cities Found Cities Found
</p> </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>
<!-- <div class="flex items-center justify-center mb-4"> <!-- <div class="flex items-center justify-center mb-4">
<div class="join"> <div class="join">
<input <input
@ -88,58 +147,74 @@
</div> </div>
</div> --> </div> -->
<div class="flex items-center justify-center mb-4"> {#if allCities.length > 0}
<input <div class="flex items-center justify-center mb-4">
type="text" <input
placeholder={$t('navbar.search')} type="text"
class="input input-bordered w-full max-w-xs" placeholder={$t('navbar.search')}
bind:value={searchQuery} class="input input-bordered w-full max-w-xs"
/> bind:value={searchQuery}
{#if searchQuery.length > 0} />
<!-- clear button --> {#if searchQuery.length > 0}
<div class="flex items-center justify-center ml-4"> <!-- clear button -->
<button class="btn btn-neutral" on:click={() => (searchQuery = '')}> <div class="flex items-center justify-center ml-4">
{$t('worldtravel.clear_search')} <button class="btn btn-neutral" on:click={() => (searchQuery = '')}>
</button> {$t('worldtravel.clear_search')}
</div> </button>
{/if} </div>
</div> {/if}
</div>
<div class="mt-4 mb-4 flex justify-center"> <div class="mt-4 mb-4 flex justify-center">
<!-- checkbox to toggle marker --> <!-- checkbox to toggle marker -->
<MapLibre <MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json" style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg" class="aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"
standardControls standardControls
center={allCities[0] && allCities[0].longitude !== null && allCities[0].latitude !== null center={allCities[0] && allCities[0].longitude !== null && allCities[0].latitude !== null
? [allCities[0].longitude, allCities[0].latitude] ? [allCities[0].longitude, allCities[0].latitude]
: [0, 0]} : [0, 0]}
zoom={8} zoom={8}
> >
{#each filteredCities as city} {#each filteredCities as city}
{#if city.latitude && city.longitude} {#if city.latitude && city.longitude}
<Marker <Marker
lngLat={[city.longitude, city.latitude]} lngLat={[city.longitude, city.latitude]}
class="grid px-2 py-1 place-items-center rounded-full border border-gray-200 bg-green-200 text-black focus:outline-6 focus:outline-black" class="grid px-2 py-1 place-items-center rounded-full border border-gray-200 {visitedCities.some(
> (visitedCity) => visitedCity.city === city.id
<span class="text-xs"> )
{city.name} ? 'bg-red-300'
</span> : 'bg-blue-300'} text-black focus:outline-6 focus:outline-black"
</Marker> on:click={togleVisited(city)}
{/if} >
{/each} <span class="text-xs">
</MapLibre> {city.name}
</div> </span>
</Marker>
{/if}
{/each}
</MapLibre>
</div>
{/if}
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center"> <div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each filteredCities as city} {#each filteredCities as city}
<CityCard {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} {/each}
</div> </div>
{#if filteredCities.length === 0} {#if filteredCities.length === 0}
<p class="text-center font-bold text-2xl mt-12">{$t('worldtravel.no_countries_found')}</p> <p class="text-center font-bold text-2xl mt-12">{$t('worldtravel.no_cities_found')}</p>
{/if} {/if}
<svelte:head> <svelte:head>