World Travel Improvements (#925)

* Security Patch Django 5.2.8

* Fix Menus on Safari Browser

* Enhance touch support and event handling for emoji picker and dropdown

* Add touch and pointer event handling to category selection for better mobile support

* Add PWA support for iOS/Safari with touch icons

* Refactor event listener for dropdown to use non-capturing 'click' for improved compatibility on Safari

* Enhance country and region description fetching from Wikipedia

- Refactor `generate_description_view.py` to improve candidate page selection and description retrieval.
- Update `CategoryDropdown.svelte` to simplify emoji selection handling and improve dropdown behavior.
- Add new translation keys in `en.json` for UI elements related to country descriptions.
- Modify `+page.svelte` and `+page.server.ts` in world travel routes to fetch and display country and region descriptions.
- Implement a toggle for showing full descriptions in the UI.

* Update Unraid installation documentation with improved variable formatting and additional resources

* Implement cache invalidation for visited regions and cities to ensure updated visit lists

* Add ClusterMap component for enhanced geographical data visualization
This commit is contained in:
Sean Morley
2025-12-07 11:46:44 -05:00
committed by GitHub
parent 5d799ceacc
commit 037b45fc17
17 changed files with 998 additions and 240 deletions

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import CountryCard from '$lib/components/CountryCard.svelte';
import ClusterMap from '$lib/components/ClusterMap.svelte';
import type { Country } from '$lib/types';
import type { PageData } from './$types';
import { t } from 'svelte-i18n';
import { MapLibre, Marker } from 'svelte-maplibre';
import type { ClusterOptions } from 'svelte-maplibre';
// Icons
import Globe from '~icons/mdi/earth';
@@ -29,6 +30,133 @@
let showGlobeSpin: boolean = false;
let sidebarOpen = false;
type VisitStatus = 'not_visited' | 'partial' | 'complete';
type CountryFeatureProperties = {
name: string;
country_code: string;
visitStatus: VisitStatus;
num_visits: number;
num_regions: number;
};
type CountryFeature = {
type: 'Feature';
geometry: {
type: 'Point';
coordinates: [number, number];
};
properties: CountryFeatureProperties;
};
type CountryFeatureCollection = {
type: 'FeatureCollection';
features: CountryFeature[];
};
const COUNTRY_SOURCE_ID = 'worldtravel-countries';
const countryClusterOptions: ClusterOptions = {
radius: 300,
maxZoom: 5,
minPoints: 1
};
let countriesGeoJson: CountryFeatureCollection = {
type: 'FeatureCollection',
features: []
};
function parseCoordinate(value: number | string | null | undefined): number | null {
if (value === null || value === undefined) {
return null;
}
const numeric = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function getCountryCoordinates(country: Country): [number, number] | null {
const latitude = parseCoordinate(country.latitude);
const longitude = parseCoordinate(country.longitude);
if (latitude === null || longitude === null) {
return null;
}
return [longitude, latitude];
}
function getVisitStatus(country: Country): VisitStatus {
if (country.num_visits === 0) {
return 'not_visited';
}
if (country.num_regions > 0 && country.num_visits >= country.num_regions) {
return 'complete';
}
return 'partial';
}
function countryToFeature(country: Country, coordinates: [number, number]): CountryFeature {
const visitStatus = getVisitStatus(country);
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates
},
properties: {
name: country.name,
country_code: country.country_code,
visitStatus,
num_visits: country.num_visits,
num_regions: country.num_regions
}
};
}
function getVisitStatusClass(status: VisitStatus): string {
switch (status) {
case 'not_visited':
return 'bg-red-200';
case 'complete':
return 'bg-green-200';
default:
return 'bg-blue-200';
}
}
function getMarkerProps(feature: any): CountryFeatureProperties | null {
if (!feature) {
return null;
}
return feature.properties ?? null;
}
function markerClassResolver(props: { visitStatus?: string } | null): string {
if (!props?.visitStatus) {
return '';
}
if (
props.visitStatus === 'not_visited' ||
props.visitStatus === 'partial' ||
props.visitStatus === 'complete'
) {
return getVisitStatusClass(props.visitStatus);
}
return '';
}
function handleMarkerSelect(event: CustomEvent<{ countryCode?: string }>) {
const countryCode = event.detail.countryCode;
if (!countryCode) {
return;
}
goto(`/worldtravel/${countryCode}`);
}
worldSubregions = [...new Set(allCountries.map((country) => country.subregion))];
worldSubregions = worldSubregions.filter((subregion) => subregion !== '');
console.log(worldSubregions);
@@ -75,6 +203,20 @@
}
}
$: countriesGeoJson = {
type: 'FeatureCollection',
features: filteredCountries
.map((country) => {
const coordinates = getCountryCoordinates(country);
if (!coordinates) {
return null;
}
return countryToFeature(country, coordinates);
})
.filter((feature): feature is CountryFeature => feature !== null)
};
// when isGlobeSpin is enabled, fetch /api/globespin/
type GlobeSpinData = {
country: {
@@ -285,32 +427,16 @@
<div class="container mx-auto px-6 py-4">
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-4">
<MapLibre
style={getBasemapUrl()}
class="aspect-[16/10] w-full rounded-lg"
standardControls
zoom={2}
>
{#each filteredCountries as country}
{#if country.latitude && country.longitude}
<Marker
lngLat={[country.longitude, country.latitude]}
class={`grid px-2 py-1 place-items-center rounded-full border border-gray-200 ${
country.num_visits === 0
? 'bg-red-200'
: country.num_visits === country.num_regions
? 'bg-green-200'
: 'bg-blue-200'
} text-black focus:outline-6 focus:outline-black cursor-pointer`}
on:click={() => goto(`/worldtravel/${country.country_code}`)}
>
<span class="text-xs font-medium">
{country.name}
</span>
</Marker>
{/if}
{/each}
</MapLibre>
<ClusterMap
geoJson={countriesGeoJson}
sourceId={COUNTRY_SOURCE_ID}
clusterOptions={countryClusterOptions}
mapStyle={getBasemapUrl()}
mapClass="aspect-[16/10] w-full rounded-lg"
on:markerSelect={handleMarkerSelect}
{getMarkerProps}
markerClass={markerClassResolver}
/>
</div>
</div>
</div>

View File

@@ -57,11 +57,36 @@ export const load = (async (event) => {
country = (await res.json()) as Country;
}
// Attempt to fetch a short description (Wikipedia/Wikidata generated) for the country
let description: string | null = null;
try {
const descRes = await fetch(
`${endpoint}/api/generate/desc/?name=${encodeURIComponent(country.name)}`,
{
method: 'GET',
headers: {
Cookie: `sessionid=${sessionId}`
}
}
);
if (descRes.ok) {
const descJson = await descRes.json();
if (descJson && typeof descJson.extract === 'string') {
description = descJson.extract;
}
} else {
console.debug('No description available for', country.name);
}
} catch (e) {
console.debug('Failed to fetch description:', e);
}
return {
props: {
regions,
visitedRegions,
country
country,
description
}
};
}) satisfies PageServerLoad;

View File

@@ -12,9 +12,8 @@
import Clear from '~icons/mdi/close';
import Filter from '~icons/mdi/filter-variant';
import Map from '~icons/mdi/map';
import Pin from '~icons/mdi/map-marker-outline';
import Check from '~icons/mdi/check-circle';
import Progress from '~icons/mdi/progress-check';
import Info from '~icons/mdi/information-outline';
import Cancel from '~icons/mdi/cancel';
import Trophy from '~icons/mdi/trophy';
import Target from '~icons/mdi/target';
@@ -25,6 +24,8 @@
let regions: Region[] = data.props?.regions || [];
let visitedRegions: VisitedRegion[] = data.props?.visitedRegions || [];
let description: string = data.props?.description || '';
let showFullDesc = false;
let filteredRegions: Region[] = [];
let searchQuery: string = '';
let showGeo: boolean = true;
@@ -186,7 +187,8 @@
</div>
<!-- Search and Filters -->
<div class="mt-4 flex items-center gap-4">
<div class="mt-4 flex flex-col lg:flex-row lg:items-center gap-4">
<!-- Search Bar -->
<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"
@@ -206,47 +208,80 @@
</button>
{/if}
</div>
</div>
<!-- Filter Chips -->
<div class="mt-4 flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-base-content/60"
>{$t('worldtravel.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')}
<!-- Filter Chips -->
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-base-content/60 hidden sm:inline"
>{$t('worldtravel.filter_by')}:</span
>
<MapMarker class="w-3 h-3" />
{$t('adventures.all')}
</button>
<button
class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}"
on:click={() => (filterOption = 'visited')}
>
<Check class="w-3 h-3" />
{$t('adventures.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" />
{$t('adventures.not_visited')}
</button>
<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" />
{$t('adventures.all')}
</button>
<button
class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}"
on:click={() => (filterOption = 'visited')}
>
<Check class="w-3 h-3" />
{$t('adventures.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" />
{$t('adventures.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" />
{$t('worldtravel.clear_all')}
</button>
{/if}
</div>
{#if searchQuery || filterOption !== 'all'}
<button class="btn btn-ghost btn-xs gap-1" on:click={clearFilters}>
<Clear class="w-3 h-3" />
{$t('worldtravel.clear_all')}
</button>
{/if}
</div>
</div>
</div>
<!-- Description Section -->
{#if description}
<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 gap-2 mb-4">
<Info class="w-5 h-5 text-primary" />
<h2 class="text-lg font-semibold">{$t('worldtravel.about_country')}</h2>
</div>
<p
class="text-base-content/70 leading-relaxed"
class:overflow-hidden={!showFullDesc}
style={!showFullDesc && description.length > 400
? 'max-height:8rem;overflow:hidden;'
: ''}
>
{description}
</p>
{#if description.length > 400}
<button
class="btn btn-ghost btn-sm mt-3"
on:click={() => (showFullDesc = !showFullDesc)}
>
{#if showFullDesc}{$t('worldtravel.show_less')}{:else}{$t(
'worldtravel.show_more'
)}{/if}
</button>
{/if}
</div>
</div>
</div>
{/if}
<!-- Map Section -->
{#if regions.some((region) => region.latitude && region.longitude)}
<div class="container mx-auto px-6 py-4">

View File

@@ -44,6 +44,59 @@ export const load = (async (event) => {
region = (await res.json()) as Region;
}
// Fetch country details (if available) to improve description search
let country: Country | null = null;
if (region?.country) {
res = await fetch(`${endpoint}/api/countries/${region.country}/`, {
method: 'GET',
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (res.ok) {
country = (await res.json()) as Country;
} else {
console.debug('Failed to fetch country for region description');
}
}
// Attempt to fetch a short description (Wikipedia/Wikidata generated) for the region.
// Try multiple candidate queries to improve the chance of a match: region name, "region, country", then country name.
let description: string | null = null;
try {
const candidates: string[] = [];
if (region?.name) candidates.push(region.name);
if (region?.name && country?.name) candidates.push(`${region.name}, ${country.name}`);
if (country?.name) candidates.push(country.name);
for (const name of candidates) {
try {
const descRes = await fetch(
`${endpoint}/api/generate/desc/?name=${encodeURIComponent(name)}`,
{
method: 'GET',
headers: {
Cookie: `sessionid=${sessionId}`
}
}
);
if (descRes.ok) {
const descJson = await descRes.json();
if (descJson && typeof descJson.extract === 'string') {
description = descJson.extract;
break;
}
} else {
console.debug('No description available for', name);
}
} catch (e) {
console.debug('Failed to fetch description for', name, e);
}
}
} catch (e) {
console.debug('Description generation attempt failed', e);
}
res = await fetch(`${endpoint}/api/regions/${region.id}/cities/visits/`, {
method: 'GET',
headers: {
@@ -61,7 +114,8 @@ export const load = (async (event) => {
props: {
cities,
region,
visitedCities
visitedCities,
description
}
};
}) satisfies PageServerLoad;

View File

@@ -17,6 +17,7 @@
import Cancel from '~icons/mdi/cancel';
import Trophy from '~icons/mdi/trophy';
import Target from '~icons/mdi/target';
import Info from '~icons/mdi/information-outline';
import CityIcon from '~icons/mdi/city';
export let data: PageData;
@@ -30,6 +31,8 @@
const allCities: City[] = data.props?.cities || [];
let visitedCities: VisitedCity[] = data.props?.visitedCities || [];
const region = data.props?.region || null;
let description: string = data.props?.description || '';
let showFullDesc = false;
console.log(data);
@@ -181,7 +184,7 @@
</div>
<!-- Search and Filters -->
<div class="mt-4 flex items-center gap-4">
<div class="mt-4 flex flex-col lg:flex-row items-start lg: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"
@@ -201,47 +204,80 @@
</button>
{/if}
</div>
</div>
<!-- Filter Chips -->
<div class="mt-4 flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-base-content/60"
>{$t('worldtravel.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')}
<!-- Filter Chips -->
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-base-content/60"
>{$t('worldtravel.filter_by')}:</span
>
<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" />
{$t('adventures.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" />
{$t('adventures.not_visited')}
</button>
<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" />
{$t('adventures.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" />
{$t('adventures.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" />
{$t('worldtravel.clear_all')}
</button>
{/if}
</div>
{#if searchQuery || filterOption !== 'all'}
<button class="btn btn-ghost btn-xs gap-1" on:click={clearFilters}>
<Clear class="w-3 h-3" />
{$t('worldtravel.clear_all')}
</button>
{/if}
</div>
</div>
</div>
<!-- Description Section -->
{#if description}
<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 gap-2 mb-4">
<Info class="w-5 h-5 text-primary" />
<h2 class="text-lg font-semibold">{$t('worldtravel.about_country')}</h2>
</div>
<p
class="text-base-content/70 leading-relaxed"
class:overflow-hidden={!showFullDesc}
style={!showFullDesc && description.length > 400
? 'max-height:8rem;overflow:hidden;'
: ''}
>
{description}
</p>
{#if description.length > 400}
<button
class="btn btn-ghost btn-sm mt-3"
on:click={() => (showFullDesc = !showFullDesc)}
>
{#if showFullDesc}{$t('worldtravel.show_less')}{:else}{$t(
'worldtravel.show_more'
)}{/if}
</button>
{/if}
</div>
</div>
</div>
{/if}
<!-- Map Section -->
{#if allCities.length > 0}
<div class="container mx-auto px-6 py-4">