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:
@@ -5,6 +5,24 @@
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="/manifest.json" />
|
||||
|
||||
<!-- iOS / Safari PWA support -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<!-- Apple touch icons (place files in frontend/static/) -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="152x152"
|
||||
href="%sveltekit.assets%/apple-touch-icon-152.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="120x120"
|
||||
href="%sveltekit.assets%/apple-touch-icon-120.png"
|
||||
/>
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
BIN
frontend/src/lib/assets/apple-touch-icon-120.png
Normal file
BIN
frontend/src/lib/assets/apple-touch-icon-120.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/src/lib/assets/apple-touch-icon-152.png
Normal file
BIN
frontend/src/lib/assets/apple-touch-icon-152.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/src/lib/assets/apple-touch-icon.png
Normal file
BIN
frontend/src/lib/assets/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
171
frontend/src/lib/components/ClusterMap.svelte
Normal file
171
frontend/src/lib/components/ClusterMap.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { CircleLayer, GeoJSON, MapLibre, MarkerLayer, SymbolLayer } from 'svelte-maplibre';
|
||||
import type { ClusterOptions, LayerClickInfo } from 'svelte-maplibre';
|
||||
import { getBasemapUrl } from '$lib';
|
||||
|
||||
type PointGeometry = {
|
||||
type: 'Point';
|
||||
coordinates: [number, number];
|
||||
};
|
||||
|
||||
type MarkerProps = {
|
||||
name?: string;
|
||||
visitStatus?: string;
|
||||
country_code?: string;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
type ClusterFeature<P = Record<string, unknown>> = {
|
||||
type: 'Feature';
|
||||
geometry: PointGeometry;
|
||||
properties: P;
|
||||
};
|
||||
|
||||
type ClusterFeatureCollection<P = Record<string, unknown>> = {
|
||||
type: 'FeatureCollection';
|
||||
features: ClusterFeature<P>[];
|
||||
};
|
||||
|
||||
type ClusterSource = {
|
||||
getClusterExpansionZoom: (
|
||||
clusterId: number,
|
||||
callback: (error: unknown, zoom: number) => void
|
||||
) => void;
|
||||
};
|
||||
|
||||
export let geoJson: ClusterFeatureCollection = { type: 'FeatureCollection', features: [] };
|
||||
export let clusterOptions: ClusterOptions = { radius: 300, maxZoom: 5, minPoints: 1 };
|
||||
export let sourceId = 'cluster-source';
|
||||
export let mapStyle: string = getBasemapUrl();
|
||||
export let mapClass = '';
|
||||
export let zoom = 2;
|
||||
export let standardControls = true;
|
||||
|
||||
export let getMarkerProps: (feature: unknown) => MarkerProps = (feature) =>
|
||||
feature && typeof feature === 'object' && feature !== null && 'properties' in (feature as any)
|
||||
? ((feature as any).properties as MarkerProps)
|
||||
: null;
|
||||
|
||||
export let markerBaseClass =
|
||||
'grid px-2 py-1 place-items-center rounded-full border border-gray-200 text-black focus:outline-6 focus:outline-black cursor-pointer whitespace-nowrap';
|
||||
|
||||
export let markerClass: (props: MarkerProps) => string = (props) =>
|
||||
props && typeof props.visitStatus === 'string' ? props.visitStatus : '';
|
||||
|
||||
export let markerTitle: (props: MarkerProps) => string = (props) =>
|
||||
props && typeof props.name === 'string' ? props.name : '';
|
||||
|
||||
export let markerLabel: (props: MarkerProps) => string = markerTitle;
|
||||
|
||||
export let clusterCirclePaint = {
|
||||
'circle-color': ['step', ['get', 'point_count'], '#60a5fa', 20, '#facc15', 60, '#f472b6'],
|
||||
'circle-radius': ['step', ['get', 'point_count'], 24, 20, 34, 60, 46],
|
||||
'circle-opacity': 0.85
|
||||
};
|
||||
|
||||
export let clusterSymbolLayout = {
|
||||
'text-field': '{point_count_abbreviated}',
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-size': 12
|
||||
};
|
||||
|
||||
export let clusterSymbolPaint = { 'text-color': '#1f2937' };
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
markerSelect: { feature: unknown; markerProps: MarkerProps; countryCode?: string };
|
||||
clusterClick: LayerClickInfo;
|
||||
}>();
|
||||
|
||||
let resolvedClusterCirclePaint: Record<string, unknown> = clusterCirclePaint;
|
||||
$: resolvedClusterCirclePaint = clusterCirclePaint as Record<string, unknown>;
|
||||
|
||||
function handleClusterClick(event: CustomEvent<LayerClickInfo>) {
|
||||
const { clusterId, features, map, source } = event.detail;
|
||||
if (!clusterId || !features?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clusterFeature = features[0] as {
|
||||
geometry?: { type?: string; coordinates?: [number, number] };
|
||||
};
|
||||
|
||||
const coordinates =
|
||||
clusterFeature?.geometry?.type === 'Point' ? clusterFeature.geometry.coordinates : undefined;
|
||||
if (!coordinates) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geoJsonSource = map.getSource(source) as ClusterSource | undefined;
|
||||
if (!geoJsonSource || typeof geoJsonSource.getClusterExpansionZoom !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
geoJsonSource.getClusterExpansionZoom(
|
||||
Number(clusterId),
|
||||
(error: unknown, zoomLevel: number) => {
|
||||
if (error) {
|
||||
console.error('Failed to expand cluster', error);
|
||||
return;
|
||||
}
|
||||
|
||||
map.easeTo({
|
||||
center: coordinates,
|
||||
zoom: zoomLevel
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
dispatch('clusterClick', event.detail);
|
||||
}
|
||||
|
||||
function handleMarkerClick(event: CustomEvent<any>) {
|
||||
const feature = event.detail?.feature;
|
||||
const markerProps = getMarkerProps(feature);
|
||||
const countryCode =
|
||||
markerProps && typeof markerProps.country_code === 'string'
|
||||
? markerProps.country_code
|
||||
: undefined;
|
||||
|
||||
dispatch('markerSelect', { feature, markerProps, countryCode });
|
||||
}
|
||||
</script>
|
||||
|
||||
<MapLibre style={mapStyle} class={mapClass} {standardControls} {zoom}>
|
||||
<GeoJSON id={sourceId} data={geoJson} cluster={clusterOptions} generateId>
|
||||
<CircleLayer
|
||||
id={`${sourceId}-clusters`}
|
||||
applyToClusters
|
||||
hoverCursor="pointer"
|
||||
paint={resolvedClusterCirclePaint}
|
||||
on:click={handleClusterClick}
|
||||
/>
|
||||
<SymbolLayer
|
||||
id={`${sourceId}-cluster-count`}
|
||||
applyToClusters
|
||||
layout={clusterSymbolLayout}
|
||||
paint={clusterSymbolPaint}
|
||||
/>
|
||||
<MarkerLayer applyToClusters={false} on:click={handleMarkerClick} let:feature={featureData}>
|
||||
{@const markerProps = getMarkerProps(featureData)}
|
||||
<slot name="marker" {featureData} {markerProps}>
|
||||
{#if markerProps}
|
||||
<button
|
||||
type="button"
|
||||
class={`${markerBaseClass} ${markerClass(markerProps)}`.trim()}
|
||||
title={markerTitle(markerProps)}
|
||||
aria-label={markerLabel(markerProps)}
|
||||
>
|
||||
<span class="text-xs font-medium">{markerLabel(markerProps)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</slot>
|
||||
</MarkerLayer>
|
||||
</GeoJSON>
|
||||
</MapLibre>
|
||||
|
||||
<style>
|
||||
:global(.mapboxgl-canvas) {
|
||||
border-radius: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,13 @@
|
||||
</script>
|
||||
|
||||
<div class="dropdown dropdown-left">
|
||||
<div tabindex="0" role="button" class="btn btn-sm btn-ghost gap-2 min-h-0 h-8 px-3">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
class="btn btn-sm btn-ghost gap-2 min-h-0 h-8 px-3"
|
||||
>
|
||||
<MapIcon class="w-4 h-4" />
|
||||
<span class="text-xs font-medium">{getBasemapLabel(basemapType)}</span>
|
||||
<svg class="w-3 h-3 fill-none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -22,7 +28,13 @@
|
||||
option.value
|
||||
? 'bg-primary/10 font-medium'
|
||||
: ''}"
|
||||
on:pointerdown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
basemapType = option.value;
|
||||
}}
|
||||
on:click={() => (basemapType = option.value)}
|
||||
role="menuitem"
|
||||
>
|
||||
<span class="text-lg">{option.icon}</span>
|
||||
<span>{option.label}</span>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
let dropdownOpen = false;
|
||||
let searchQuery = '';
|
||||
let searchInput: HTMLInputElement | null = null;
|
||||
let rootRef: HTMLElement | null = null;
|
||||
const timezones = Intl.supportedValuesOf('timeZone');
|
||||
|
||||
// Filter timezones based on search query
|
||||
@@ -42,18 +43,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown if clicked outside
|
||||
// Close dropdown if clicked/touched outside. Use composedPath and pointer events
|
||||
onMount(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const dropdown = document.getElementById(instanceId);
|
||||
if (dropdown && !dropdown.contains(e.target as Node)) dropdownOpen = false;
|
||||
const handlePointerDownOutside = (e: Event) => {
|
||||
const ev: any = e as any;
|
||||
const path: EventTarget[] = ev.composedPath ? ev.composedPath() : ev.path || [];
|
||||
if (!rootRef) return;
|
||||
if (Array.isArray(path)) {
|
||||
if (!path.includes(rootRef)) dropdownOpen = false;
|
||||
} else {
|
||||
if (!(e.target instanceof Node) || !(rootRef as HTMLElement).contains(e.target as Node))
|
||||
dropdownOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDownOutside, true);
|
||||
document.addEventListener('touchstart', handlePointerDownOutside, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDownOutside, true);
|
||||
document.removeEventListener('touchstart', handlePointerDownOutside, true);
|
||||
};
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="form-control w-full max-w-xs relative" id={instanceId}>
|
||||
<div class="form-control w-full max-w-xs relative" bind:this={rootRef} id={instanceId}>
|
||||
<label class="label" for={`timezone-display-${instanceId}`}>
|
||||
<span class="label-text">{$t('adventures.timezone')}</span>
|
||||
</label>
|
||||
@@ -66,6 +79,11 @@
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={dropdownOpen}
|
||||
class="input input-bordered flex justify-between items-center cursor-pointer"
|
||||
on:pointerdown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropdownOpen = !dropdownOpen;
|
||||
}}
|
||||
on:click={() => (dropdownOpen = !dropdownOpen)}
|
||||
on:keydown={handleKeydown}
|
||||
>
|
||||
@@ -108,6 +126,11 @@
|
||||
<button
|
||||
type="button"
|
||||
class={`w-full text-left truncate ${tz === selectedTimezone ? 'active font-bold' : ''}`}
|
||||
on:pointerdown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectTimezone(tz);
|
||||
}}
|
||||
on:click={() => selectTimezone(tz)}
|
||||
on:keydown={(e) => handleKeydown(e, tz)}
|
||||
role="option"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export let appVersion = 'v0.11.0-main-10112025';
|
||||
export let appVersion = 'v0.11.0-main-120725';
|
||||
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0';
|
||||
export let appTitle = 'AdventureLog';
|
||||
export let copyrightYear = '2023-2025';
|
||||
|
||||
@@ -550,7 +550,10 @@
|
||||
"spin_again": "Spin Again",
|
||||
"globe_spin_error_desc": "Error fetching globe spin data",
|
||||
"try_again": "Try Again",
|
||||
"no_globe_spin_data": "No Globe Spin Data"
|
||||
"no_globe_spin_data": "No Globe Spin Data",
|
||||
"show_less": "Show Less",
|
||||
"show_more": "Show More",
|
||||
"about_country": "About Country"
|
||||
},
|
||||
"auth": {
|
||||
"username": "Username",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user