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

feat: Add location_name to ReverseGeocode type and implement location fetching in stats view

This commit is contained in:
Sean Morley 2025-02-15 19:44:11 -05:00
parent 60b5bbb3c8
commit b5d6788c11
21 changed files with 1048 additions and 901 deletions

View file

@ -21,11 +21,15 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
country_code = None country_code = None
city = None city = None
visited_city = None visited_city = None
location_name = None
# town = None # town = None
# city = None # city = None
# county = None # county = None
if 'name' in data.keys():
location_name = data['name']
if 'address' in data.keys(): if 'address' in data.keys():
keys = data['address'].keys() keys = data['address'].keys()
for key in keys: for key in keys:
@ -58,7 +62,7 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
if visited_city: if visited_city:
city_visited = True city_visited = True
if region: if region:
return {"region_id": iso_code, "region": region.name, "country": region.country.name, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited} return {"region_id": iso_code, "region": region.name, "country": region.country.name, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited, 'location_name': location_name}
return {"error": "No region found"} return {"error": "No region found"}
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])

View file

@ -49,3 +49,10 @@ class StatsViewSet(viewsets.ViewSet):
'visited_country_count': visited_country_count, 'visited_country_count': visited_country_count,
'total_countries': total_countries 'total_countries': total_countries
}) })
# locations - returns a list of all of the latitude, longitude locations of all public adventrues on the server
@action(detail=False, methods=['get'], url_path='locations')
def locations(self, request):
adventures = Adventure.objects.filter(
is_public=True).values('latitude', 'longitude', 'id', 'name')
return Response(adventures)

View file

@ -61,7 +61,7 @@ INSTALLED_APPS = (
'users', 'users',
'integrations', 'integrations',
'django.contrib.gis', 'django.contrib.gis',
'achievements', # 'achievements', # Not done yet, will be added later in a future update
# 'widget_tweaks', # 'widget_tweaks',
# 'slippers', # 'slippers',
@ -303,4 +303,7 @@ LOGGING = {
}, },
} }
ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app') # ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app')
# https://github.com/dr5hn/countries-states-cities-database/tags
COUNTRY_REGION_JSON_VERSION = 'v2.5'

View file

@ -68,6 +68,9 @@ class PublicUserListView(APIView):
for user in users: for user in users:
user.email = None user.email = None
serializer = PublicUserSerializer(users, many=True) serializer = PublicUserSerializer(users, many=True)
# for every user, remove the field has_password
for user in serializer.data:
user.pop('has_password', None)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
class PublicUserDetailView(APIView): class PublicUserDetailView(APIView):
@ -87,6 +90,8 @@ class PublicUserDetailView(APIView):
else: else:
user = get_object_or_404(User, username=username, public_profile=True) user = get_object_or_404(User, username=username, public_profile=True)
serializer = PublicUserSerializer(user) serializer = PublicUserSerializer(user)
# for every user, remove the field has_password
serializer.data.pop('has_password', None)
# remove the email address from the response # remove the email address from the response
user.email = None user.email = None

View file

@ -8,7 +8,7 @@ import ijson
from django.conf import settings from django.conf import settings
ADVENTURELOG_CDN_URL = settings.ADVENTURELOG_CDN_URL COUNTRY_REGION_JSON_VERSION = settings.COUNTRY_REGION_JSON_VERSION
media_root = settings.MEDIA_ROOT media_root = settings.MEDIA_ROOT
@ -27,7 +27,7 @@ def saveCountryFlag(country_code):
print(f'Flag for {country_code} already exists') print(f'Flag for {country_code} already exists')
return return
res = requests.get(f'{ADVENTURELOG_CDN_URL}/data/flags/{country_code}.png'.lower()) res = requests.get(f'https://flagcdn.com/h240/{country_code}.png'.lower())
if res.status_code == 200: if res.status_code == 200:
with open(flag_path, 'wb') as f: with open(flag_path, 'wb') as f:
f.write(res.content) f.write(res.content)
@ -39,56 +39,30 @@ class Command(BaseCommand):
help = 'Imports the world travel data' help = 'Imports the world travel data'
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('--force', action='store_true', help='Force re-download of AdventureLog setup content from the CDN') parser.add_argument('--force', action='store_true', help='Force download the countries+regions+states.json file')
def handle(self, **options): def handle(self, **options):
force = options['force'] force = options['force']
batch_size = 100 batch_size = 100
current_version_json = os.path.join(settings.MEDIA_ROOT, 'data_version.json') countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions+states-{COUNTRY_REGION_JSON_VERSION}.json')
try: if not os.path.exists(countries_json_path) or force:
cdn_version_json = requests.get(f'{ADVENTURELOG_CDN_URL}/data/version.json') res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/json/countries%2Bstates%2Bcities.json')
cdn_version_json.raise_for_status() if res.status_code == 200:
cdn_version = cdn_version_json.json().get('version') with open(countries_json_path, 'w') as f:
if os.path.exists(current_version_json): f.write(res.text)
with open(current_version_json, 'r') as f: self.stdout.write(self.style.SUCCESS('countries+regions+states.json downloaded successfully'))
local_version = f.read().strip()
self.stdout.write(self.style.SUCCESS(f'Local version: {local_version}'))
else: else:
local_version = None self.stdout.write(self.style.ERROR('Error downloading countries+regions+states.json'))
if force or local_version != cdn_version:
with open(current_version_json, 'w') as f:
f.write(cdn_version)
self.stdout.write(self.style.SUCCESS('Version updated successfully to ' + cdn_version))
else:
self.stdout.write(self.style.SUCCESS('Data is already up-to-date. Run with --force to re-download'))
return return
except requests.RequestException as e: elif not os.path.isfile(countries_json_path):
self.stdout.write(self.style.ERROR(f'Error fetching version from the CDN: {e}, skipping data import. Try restarting the container once CDN connection has been restored.')) self.stdout.write(self.style.ERROR('countries+regions+states.json is not a file'))
return return
elif os.path.getsize(countries_json_path) == 0:
self.stdout.write(self.style.SUCCESS('Fetching latest data from the AdventureLog CDN located at: ' + ADVENTURELOG_CDN_URL)) self.stdout.write(self.style.ERROR('countries+regions+states.json is empty'))
elif Country.objects.count() == 0 or Region.objects.count() == 0 or City.objects.count() == 0:
# Delete the existing flags self.stdout.write(self.style.WARNING('Some region data is missing. Re-importing all data.'))
flags_dir = os.path.join(media_root, 'flags')
if os.path.exists(flags_dir):
for file in os.listdir(flags_dir):
os.remove(os.path.join(flags_dir, file))
# Delete the existing countries, regions, and cities json files
countries_json_path = os.path.join(media_root, 'countries_states_cities.json')
if os.path.exists(countries_json_path):
os.remove(countries_json_path)
self.stdout.write(self.style.SUCCESS('countries_states_cities.json deleted successfully'))
# Download the latest countries, regions, and cities json file
res = requests.get(f'{ADVENTURELOG_CDN_URL}/data/countries_states_cities.json')
if res.status_code == 200:
with open(countries_json_path, 'w') as f:
f.write(res.text)
self.stdout.write(self.style.SUCCESS('countries_states_cities.json downloaded successfully'))
else: else:
self.stdout.write(self.style.ERROR('Error downloading countries_states_cities.json')) self.stdout.write(self.style.SUCCESS('Latest country, region, and state data already downloaded.'))
return return
with open(countries_json_path, 'r') as f: with open(countries_json_path, 'r') as f:

View file

@ -18,9 +18,7 @@
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}); });
console.log(res);
let data = await res.json(); let data = await res.json();
console.log('ACTIVITIES' + data.activities);
if (data && data.activities) { if (data && data.activities) {
allActivities = data.activities; allActivities = data.activities;
} }

View file

@ -14,6 +14,8 @@
let categories: Category[] = []; let categories: Category[] = [];
export let initialLatLng: { lat: number; lng: number } | null = null; // Used to pass the location from the map selection to the modal
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
let immichIntegration: boolean = false; let immichIntegration: boolean = false;
@ -87,7 +89,6 @@
onMount(async () => { onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal.showModal(); modal.showModal();
console.log('open');
}); });
let url: string = ''; let url: string = '';
@ -142,14 +143,11 @@
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
if (input.files && input.files.length) { if (input.files && input.files.length) {
selectedFile = input.files[0]; selectedFile = input.files[0];
console.log('Selected file:', selectedFile);
} }
} }
async function uploadAttachment(event: Event) { async function uploadAttachment(event: Event) {
event.preventDefault(); event.preventDefault();
console.log('UPLOAD');
console.log(selectedFile);
if (!selectedFile) { if (!selectedFile) {
console.error('No files selected'); console.error('No files selected');
@ -157,23 +155,18 @@
} }
const file = selectedFile; const file = selectedFile;
console.log(file);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('adventure', adventure.id); formData.append('adventure', adventure.id);
formData.append('name', attachmentName); formData.append('name', attachmentName);
console.log(formData);
try { try {
const res = await fetch('/adventures?/attachment', { const res = await fetch('/adventures?/attachment', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
console.log(res);
if (res.ok) { if (res.ok) {
const newData = deserialize(await res.text()) as { data: Attachment }; const newData = deserialize(await res.text()) as { data: Attachment };
adventure.attachments = [...adventure.attachments, newData.data]; adventure.attachments = [...adventure.attachments, newData.data];
@ -202,7 +195,6 @@
if (res.status === 204) { if (res.status === 204) {
images = images.filter((image) => image.id !== id); images = images.filter((image) => image.id !== id);
adventure.images = images; adventure.images = images;
console.log(images);
addToast('success', $t('adventures.image_removed_success')); addToast('success', $t('adventures.image_removed_success'));
} else { } else {
addToast('error', $t('adventures.image_removed_error')); addToast('error', $t('adventures.image_removed_error'));
@ -255,9 +247,7 @@
}); });
if (res.ok) { if (res.ok) {
let newData = deserialize(await res.text()) as { data: { id: string; image: string } }; let newData = deserialize(await res.text()) as { data: { id: string; image: string } };
console.log(newData);
let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false }; let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false };
console.log(newImage);
images = [...images, newImage]; images = [...images, newImage];
adventure.images = images; adventure.images = images;
addToast('success', $t('adventures.image_upload_success')); addToast('success', $t('adventures.image_upload_success'));
@ -308,9 +298,7 @@
}); });
if (res2.ok) { if (res2.ok) {
let newData = deserialize(await res2.text()) as { data: { id: string; image: string } }; let newData = deserialize(await res2.text()) as { data: { id: string; image: string } };
console.log(newData);
let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false }; let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false };
console.log(newImage);
images = [...images, newImage]; images = [...images, newImage];
adventure.images = images; adventure.images = images;
addToast('success', $t('adventures.image_upload_success')); addToast('success', $t('adventures.image_upload_success'));
@ -371,30 +359,11 @@
} }
} }
function imageSubmit() {
return async ({ result }: any) => {
if (result.type === 'success') {
if (result.data.id && result.data.image) {
adventure.images = [...adventure.images, result.data];
images = [...images, result.data];
addToast('success', $t('adventures.image_upload_success'));
fileInput.value = '';
console.log(adventure);
} else {
addToast('error', result.data.error || $t('adventures.image_upload_error'));
}
}
};
}
async function handleSubmit(event: Event) { async function handleSubmit(event: Event) {
event.preventDefault(); event.preventDefault();
triggerMarkVisted = true; triggerMarkVisted = true;
console.log(adventure);
if (adventure.id === '') { if (adventure.id === '') {
console.log(categories);
if (adventure.category?.display_name == '') { if (adventure.category?.display_name == '') {
if (categories.some((category) => category.name === 'general')) { if (categories.some((category) => category.name === 'general')) {
adventure.category = categories.find( adventure.category = categories.find(
@ -597,7 +566,7 @@
</div> </div>
</div> </div>
<LocationDropdown bind:item={adventure} bind:triggerMarkVisted /> <LocationDropdown bind:item={adventure} bind:triggerMarkVisted {initialLatLng} />
<div class="collapse collapse-plus bg-base-200 mb-4 overflow-visible"> <div class="collapse collapse-plus bg-base-200 mb-4 overflow-visible">
<input type="checkbox" /> <input type="checkbox" />
@ -958,7 +927,7 @@
{/if} {/if}
{#if adventure.is_public && adventure.id} {#if adventure.is_public && adventure.id}
<div class="bg-neutral p-4 mt-2 rounded-md shadow-sm"> <div class="bg-neutral p-4 mt-2 rounded-md shadow-sm text-neutral-content">
<p class=" font-semibold">{$t('adventures.share_adventure')}</p> <p class=" font-semibold">{$t('adventures.share_adventure')}</p>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="text-card-foreground font-mono"> <p class="text-card-foreground font-mono">

View file

@ -40,6 +40,9 @@
<li><button on:click={() => goto('/adventures')}>{$t('navbar.my_adventures')}</button></li> <li><button on:click={() => goto('/adventures')}>{$t('navbar.my_adventures')}</button></li>
<li><button on:click={() => goto('/shared')}>{$t('navbar.shared_with_me')}</button></li> <li><button on:click={() => goto('/shared')}>{$t('navbar.shared_with_me')}</button></li>
<li><button on:click={() => goto('/settings')}>{$t('navbar.settings')}</button></li> <li><button on:click={() => goto('/settings')}>{$t('navbar.settings')}</button></li>
{#if user.is_staff}
<li><button on:click={() => goto('/admin')}>{$t('navbar.admin_panel')}</button></li>
{/if}
<form method="post"> <form method="post">
<li><button formaction="/?/logout">{$t('navbar.logout')}</button></li> <li><button formaction="/?/logout">{$t('navbar.logout')}</button></li>
</form> </form>

View file

@ -2,12 +2,15 @@
import { appVersion } from '$lib/config'; import { appVersion } from '$lib/config';
import { addToast } from '$lib/toasts'; import { addToast } from '$lib/toasts';
import type { Adventure, Lodging, OpenStreetMapPlace, Point, ReverseGeocode } from '$lib/types'; import type { Adventure, Lodging, OpenStreetMapPlace, Point, ReverseGeocode } from '$lib/types';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre'; import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre';
export let item: Adventure | Lodging; export let item: Adventure | Lodging;
export let triggerMarkVisted: boolean = false; export let triggerMarkVisted: boolean = false;
export let initialLatLng: { lat: number; lng: number } | null = null; // Used to pass the location from the map selection to the modal
let reverseGeocodePlace: ReverseGeocode | null = null; let reverseGeocodePlace: ReverseGeocode | null = null;
let markers: Point[] = []; let markers: Point[] = [];
@ -19,6 +22,22 @@
let places: OpenStreetMapPlace[] = []; let places: OpenStreetMapPlace[] = [];
let noPlaces: boolean = false; let noPlaces: boolean = false;
onMount(() => {
if (initialLatLng) {
markers = [
{
lngLat: { lng: initialLatLng.lng, lat: initialLatLng.lat },
name: '',
location: '',
activity_type: ''
}
];
item.latitude = initialLatLng.lat;
item.longitude = initialLatLng.lng;
reverseGeocode();
}
});
$: if (markers.length > 0) { $: if (markers.length > 0) {
const newLat = Math.round(markers[0].lngLat.lat * 1e6) / 1e6; const newLat = Math.round(markers[0].lngLat.lat * 1e6) / 1e6;
const newLng = Math.round(markers[0].lngLat.lng * 1e6) / 1e6; const newLng = Math.round(markers[0].lngLat.lng * 1e6) / 1e6;
@ -35,10 +54,6 @@
} }
} }
$: {
console.log(triggerMarkVisted);
}
$: if (triggerMarkVisted && willBeMarkedVisited) { $: if (triggerMarkVisted && willBeMarkedVisited) {
markVisited(); markVisited();
triggerMarkVisted = false; triggerMarkVisted = false;
@ -84,8 +99,6 @@
break; // Exit the loop since we've determined the result break; // Exit the loop since we've determined the result
} }
} }
console.log('WMBV:', willBeMarkedVisited);
} }
} }
@ -180,6 +193,9 @@
) { ) {
old_display_name = reverseGeocodePlace.display_name; old_display_name = reverseGeocodePlace.display_name;
item.location = reverseGeocodePlace.display_name; item.location = reverseGeocodePlace.display_name;
if (reverseGeocodePlace.location_name) {
item.name = reverseGeocodePlace.location_name;
}
} }
console.log(data); console.log(data);
} }
@ -187,6 +203,8 @@
function clearMap() { function clearMap() {
console.log('CLEAR'); console.log('CLEAR');
markers = []; markers = [];
item.latitude = null;
item.longitude = null;
} }
</script> </script>
@ -268,6 +286,8 @@
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json" style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg" class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
standardControls standardControls
zoom={item.latitude && item.longitude ? 12 : 1}
center={{ lng: item.longitude || 0, lat: item.latitude || 0 }}
> >
<!-- MapEvents gives you access to map events even from other components inside the map, <!-- MapEvents gives you access to map events even from other components inside the map,
where you might not have access to the top-level `MapLibre` component. In this case where you might not have access to the top-level `MapLibre` component. In this case

View file

@ -6,7 +6,6 @@
import { addToast } from '$lib/toasts'; import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import DeleteWarning from './DeleteWarning.svelte'; import DeleteWarning from './DeleteWarning.svelte';
// import ArrowDownThick from '~icons/mdi/arrow-down-thick';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -36,20 +35,6 @@
let collectionStartDate = new Date(collection.start_date); let collectionStartDate = new Date(collection.start_date);
let collectionEndDate = new Date(collection.end_date); let collectionEndDate = new Date(collection.end_date);
// // Debugging outputs
// console.log(
// 'Transportation Start Date:',
// transportationStartDate,
// 'Transportation End Date:',
// transportationEndDate
// );
// console.log(
// 'Collection Start Date:',
// collectionStartDate,
// 'Collection End Date:',
// collectionEndDate
// );
// Check if the collection range is outside the transportation range // Check if the collection range is outside the transportation range
const startOutsideRange = const startOutsideRange =
transportationStartDate && transportationStartDate &&
@ -88,9 +73,9 @@
{#if isWarningModalOpen} {#if isWarningModalOpen}
<DeleteWarning <DeleteWarning
title={$t('adventures.delete_transportation')} title={$t('adventures.delete_lodging')}
button_text="Delete" button_text="Delete"
description={$t('adventures.transportation_delete_confirm')} description={$t('adventures.lodging_delete_confirm')}
is_warning={false} is_warning={false}
on:close={() => (isWarningModalOpen = false)} on:close={() => (isWarningModalOpen = false)}
on:confirm={deleteTransportation} on:confirm={deleteTransportation}
@ -106,7 +91,7 @@
<h2 class="card-title text-lg font-semibold truncate">{lodging.name}</h2> <h2 class="card-title text-lg font-semibold truncate">{lodging.name}</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="badge badge-secondary"> <div class="badge badge-secondary">
{lodging.type} {$t(`lodging.${lodging.type}`)}
</div> </div>
<!-- {#if hotel.type == 'plane' && hotel.flight_number} <!-- {#if hotel.type == 'plane' && hotel.flight_number}
<div class="badge badge-neutral-200">{hotel.flight_number}</div> <div class="badge badge-neutral-200">{hotel.flight_number}</div>
@ -117,36 +102,35 @@
<div class="badge badge-error">{$t('adventures.out_of_range')}</div> <div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if} {/if}
<!-- Locations --> <!-- Location -->
<div class="space-y-2"> <div class="space-y-2">
{#if lodging.location} {#if lodging.location}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.from')}:</span> <span class="font-medium text-sm">{$t('adventures.location')}:</span>
<p class="break-words">{lodging.location}</p> <p>{lodging.location}</p>
</div> </div>
{/if} {/if}
{#if lodging.check_in && lodging.check_out} {#if lodging.check_in && lodging.check_out}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.start')}:</span> <span class="font-medium text-sm">{$t('adventures.dates')}:</span>
<p>{new Date(lodging.check_in).toLocaleDateString(undefined, { timeZone: 'UTC' })}</p> <p>
</div> {new Date(lodging.check_in).toLocaleString('en-US', {
{/if} month: 'short',
</div> day: 'numeric',
year: 'numeric',
<!-- Dates --> hour: 'numeric',
<div class="space-y-2"> minute: 'numeric'
{#if lodging.location} })}
<!-- <ArrowDownThick class="w-4 h-4" /> --> -
<div class="flex items-center gap-2"> {new Date(lodging.check_out).toLocaleString('en-US', {
<span class="font-medium text-sm">{$t('adventures.to')}:</span> month: 'short',
day: 'numeric',
<p class="break-words">{lodging.location}</p> year: 'numeric',
</div> hour: 'numeric',
{/if} minute: 'numeric'
{#if lodging.check_out} })}
<div class="flex items-center gap-2"> </p>
<span class="font-medium text-sm">{$t('adventures.end')}:</span>
<p>{new Date(lodging.check_out).toLocaleDateString(undefined, { timeZone: 'UTC' })}</p>
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -53,8 +53,8 @@
description: hotelToEdit?.description || '', description: hotelToEdit?.description || '',
rating: hotelToEdit?.rating || NaN, rating: hotelToEdit?.rating || NaN,
link: hotelToEdit?.link || '', link: hotelToEdit?.link || '',
check_in: hotelToEdit?.check_in || null, check_in: hotelToEdit?.check_in ? toLocalDatetime(hotelToEdit.check_in) : null,
check_out: hotelToEdit?.check_out || null, check_out: hotelToEdit?.check_out ? toLocalDatetime(hotelToEdit.check_out) : null,
reservation_number: hotelToEdit?.reservation_number || '', reservation_number: hotelToEdit?.reservation_number || '',
price: hotelToEdit?.price || null, price: hotelToEdit?.price || null,
latitude: hotelToEdit?.latitude || null, latitude: hotelToEdit?.latitude || null,
@ -166,6 +166,32 @@
required required
/> />
</div> </div>
<div>
<label for="type">
{$t('transportation.type')}<span class="text-red-500">*</span>
</label>
<div>
<select
class="select select-bordered w-full max-w-xs"
name="type"
id="type"
bind:value={lodging.type}
>
<option disabled selected>{$t('transportation.type')}</option>
<option value="hotel">{$t('lodging.hotel')}</option>
<option value="hostel">{$t('lodging.hostel')}</option>
<option value="resort">{$t('lodging.resort')}</option>
<option value="bnb">{$t('lodging.bnb')}</option>
<option value="campground">{$t('lodging.campground')}</option>
<option value="cabin">{$t('lodging.cabin')}</option>
<option value="apartment">{$t('lodging.apartment')}</option>
<option value="house">{$t('lodging.house')}</option>
<option value="villa">{$t('lodging.villa')}</option>
<option value="motel">{$t('lodging.motel')}</option>
<option value="other">{$t('lodging.other')}</option>
</select>
</div>
</div>
<!-- Description --> <!-- Description -->
<div> <div>
<label for="description">{$t('adventures.description')}</label><br /> <label for="description">{$t('adventures.description')}</label><br />
@ -250,13 +276,53 @@
</div> </div>
</div> </div>
</div> </div>
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.lodging_information')}
</div>
<div class="collapse-content">
<!-- Reservation Number -->
<div>
<label for="date">
{$t('lodging.reservation_number')}
</label>
<div>
<input
type="text"
id="reservation_number"
name="reservation_number"
bind:value={lodging.reservation_number}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
<!-- Price -->
<div>
<label for="price">
{$t('adventures.price')}
</label>
<div>
<input
type="number"
id="price"
name="price"
bind:value={lodging.price}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 mb-4"> <div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium"> <div class="collapse-title text-xl font-medium">
{$t('adventures.date_information')} {$t('adventures.date_information')}
</div> </div>
<div class="collapse-content"> <div class="collapse-content">
<!-- Start Date --> <!-- Check In -->
<div> <div>
<label for="date"> <label for="date">
{$t('lodging.check_in')} {$t('lodging.check_in')}

View file

@ -243,6 +243,20 @@
{$t('adventures.basic_information')} {$t('adventures.basic_information')}
</div> </div>
<div class="collapse-content"> <div class="collapse-content">
<!-- Name -->
<div>
<label for="name">
{$t('adventures.name')}<span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={transportation.name}
class="input input-bordered w-full"
required
/>
</div>
<!-- Type selection --> <!-- Type selection -->
<div> <div>
<label for="type"> <label for="type">
@ -267,20 +281,6 @@
</select> </select>
</div> </div>
</div> </div>
<!-- Name -->
<div>
<label for="name">
{$t('adventures.name')}<span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={transportation.name}
class="input input-bordered w-full"
required
/>
</div>
<!-- Description --> <!-- Description -->
<div> <div>
<label for="description">{$t('adventures.description')}</label><br /> <label for="description">{$t('adventures.description')}</label><br />

View file

@ -5,6 +5,7 @@ import type {
Background, Background,
Checklist, Checklist,
Collection, Collection,
Lodging,
Note, Note,
Transportation, Transportation,
User User
@ -149,6 +150,50 @@ export function groupTransportationsByDate(
return groupedTransportations; return groupedTransportations;
} }
export function groupLodgingByDate(
transportations: Lodging[],
startDate: Date,
numberOfDays: number
): Record<string, Lodging[]> {
const groupedTransportations: Record<string, Lodging[]> = {};
// Initialize all days in the range
for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i);
const dateString = currentDate.toISOString().split('T')[0];
groupedTransportations[dateString] = [];
}
transportations.forEach((transportation) => {
if (transportation.check_in) {
const transportationDate = new Date(transportation.check_in).toISOString().split('T')[0];
if (transportation.check_out) {
const endDate = new Date(transportation.check_out).toISOString().split('T')[0];
// Loop through all days and include transportation if it falls within the range
for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i);
const dateString = currentDate.toISOString().split('T')[0];
// Include the current day if it falls within the transportation date range
if (dateString >= transportationDate && dateString <= endDate) {
if (groupedTransportations[dateString]) {
groupedTransportations[dateString].push(transportation);
}
}
}
} else if (groupedTransportations[transportationDate]) {
// If there's no end date, add transportation to the start date only
groupedTransportations[transportationDate].push(transportation);
}
}
});
return groupedTransportations;
}
export function groupNotesByDate( export function groupNotesByDate(
notes: Note[], notes: Note[],
startDate: Date, startDate: Date,

View file

@ -210,6 +210,7 @@ export type ReverseGeocode = {
display_name: string; display_name: string;
city: string; city: string;
city_id: string; city_id: string;
location_name: string;
}; };
export type Category = { export type Category = {

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,19 @@ const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => { export const load = (async (event) => {
if (event.locals.user) { if (event.locals.user) {
return redirect(302, '/dashboard'); return redirect(302, '/dashboard');
} else {
let res = await fetch(`${serverEndpoint}/api/stats/locations/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
let data = await res.json();
return {
props: {
locations: data
}
};
} }
}) satisfies PageServerLoad; }) satisfies PageServerLoad;

View file

@ -1,126 +1,141 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import AdventureOverlook from '$lib/assets/AdventureOverlook.webp';
import MapWithPins from '$lib/assets/MapWithPins.webp';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { DefaultMarker, MapLibre, Marker, Popup } from 'svelte-maplibre';
import MapWithPins from '$lib/assets/MapWithPins.webp';
import InformationSlabCircleOutline from '~icons/mdi/information-slab-circle-outline';
export let data; export let data;
</script> </script>
<section class="flex items-center justify-center w-full py-12 md:py-24 lg:py-32"> <!-- Hero Section -->
<div class="container px-4 md:px-6"> <section class="flex items-center justify-center w-full py-20 bg-gray-50 dark:bg-gray-800">
<div class="grid gap-6 lg:grid-cols-[1fr_550px] lg:gap-12 xl:grid-cols-[1fr_650px]"> <div class="container mx-auto px-4 flex flex-col-reverse md:flex-row items-center gap-8">
<div class="flex flex-col justify-center space-y-4"> <!-- Text Content -->
<div class="space-y-2"> <div class="w-full md:w-1/2 space-y-6">
{#if data.user} {#if data.user}
{#if data.user.first_name && data.user.first_name !== null} {#if data.user.first_name && data.user.first_name !== null}
<h1 <h1
class="text-3xl font-bold tracking-tighter sm:text-5xl xl:text-6xl/none bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent pb-4" 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)}, {data.user.first_name.charAt(0).toUpperCase() + data.user.first_name.slice(1)}, {$t(
{$t('home.hero_1')} 'home.hero_1'
</h1> )}
{:else} </h1>
<h1 {:else}
class="text-3xl font-bold tracking-tighter sm:text-5xl xl:text-6xl/none bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent pb-4" <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> {$t('home.hero_1')}
{/if} </h1>
{:else} {/if}
<h1 {:else}
class="text-3xl font-bold tracking-tighter sm:text-5xl xl:text-6xl/none bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent pb-4" <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> {$t('home.hero_1')}
{/if} </h1>
<p class="max-w-[600px] text-gray-500 md:text-xl dark:text-gray-400"> {/if}
{$t('home.hero_2')} <p class="text-xl text-gray-600 dark:text-gray-300 max-w-xl">
</p> {$t('home.hero_2')}
</div> </p>
<div class="flex flex-col gap-2 min-[400px]:flex-row"> <div class="flex flex-col sm:flex-row gap-4">
{#if data.user} {#if data.user}
<button on:click={() => goto('/adventures')} class="btn btn-primary"> <button on:click={() => goto('/adventures')} class="btn btn-primary">
{$t('home.go_to')} {$t('home.go_to')}
</button> </button>
{:else} {:else}
<button on:click={() => goto('/login')} class="btn btn-primary"> <button on:click={() => goto('/login')} class="btn btn-primary">
{$t('auth.login')} {$t('auth.login')}
</button> </button>
<button on:click={() => goto('/signup')} class="btn btn-neutral"> <button on:click={() => goto('/signup')} class="btn btn-secondary">
{$t('auth.signup')} {$t('auth.signup')}
</button> </button>
{/if} {/if}
</div>
</div> </div>
<img </div>
src={AdventureOverlook} <!-- Image -->
width="550" <div class="w-full md:w-1/2">
height="550" <p class="flex items-center text-neutral-content">
alt="Hero" <InformationSlabCircleOutline class="w-4 h-4 mr-1" />
class="mx-auto aspect-video overflow-hidden rounded-xl object-cover sm:w-full lg:order-last" {$t('adventures.welcome_map_info')}
/> </p>
<MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"
standardControls
>
{#each data.props.locations as location}
{#if location.latitude && location.longitude}
<DefaultMarker
lngLat={[location.longitude, location.latitude]}
on:click={() => goto(`/locations/${location.id}`)}
>
<span class="text-xl">{location.name}</span>
<Popup openOn="click" offset={[0, -10]}>
<div class="text-lg text-black font-bold">{location.name}</div>
<button
class="btn btn-neutral btn-wide btn-sm mt-4"
on:click={() => goto(`/adventures/${location.id}`)}
>
{$t('map.view_details')}
</button>
</Popup>
</DefaultMarker>
{/if}
{/each}
</MapLibre>
</div> </div>
</div> </div>
</section> </section>
<section
class="flex items-center justify-center w-full py-12 md:py-24 lg:py-32 bg-gray-100 dark:bg-gray-800" <!-- Features Section -->
> <section id="features" class="py-16 bg-white dark:bg-gray-900">
<div class="container px-4 md:px-6"> <div class="container mx-auto px-4">
<div class="flex flex-col items-center justify-center space-y-4 text-center"> <div class="text-center mb-12">
<div class="space-y-2"> <div class="inline-block text-neutral-content bg-neutral px-4 py-2 rounded-full">
<div {$t('home.key_features')}
class="inline-block rounded-lg bg-gray-100 px-3 py-1 text-md dark:bg-gray-800 dark:text-gray-400"
>
{$t('home.key_features')}
</div>
<h2
class="text-3xl font-bold tracking-tighter sm:text-5xl bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"
>
{$t('home.desc_1')}
</h2>
<p
class="max-w-[900px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400"
>
{$t('home.desc_2')}
</p>
</div> </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>
<div class="mx-auto grid max-w-5xl items-center gap-6 py-12 lg:grid-cols-2 lg:gap-12"> <div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
<!-- svelte-ignore a11y-img-redundant-alt --> <!-- Image for Features -->
<img <div class="order-1 md:order-2">
src={MapWithPins} <img
width="550" src={MapWithPins}
height="310" alt="World map with pins"
alt="Image" class="rounded-lg shadow-lg object-cover"
class="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center sm:w-full lg:order-last" />
/> </div>
<div class="flex flex-col justify-center space-y-4"> <!-- Feature List -->
<ul class="grid gap-6"> <div class="order-2 md:order-1">
<li> <ul class="space-y-6">
<div class="grid gap-1"> <li class="space-y-2">
<h3 class="text-xl font-bold dark:text-gray-400">{$t('home.feature_1')}</h3> <h3 class="text-xl font-semibold dark:text-gray-300">{$t('home.feature_1')}</h3>
<p class="text-gray-500 dark:text-gray-400"> <p class="text-gray-600 dark:text-gray-400">
{$t('home.feature_1_desc')} {$t('home.feature_1_desc')}
</p> </p>
</div>
</li> </li>
<li> <li class="space-y-2">
<div class="grid gap-1"> <h3 class="text-xl font-semibold dark:text-gray-300">{$t('home.feature_2')}</h3>
<h3 class="text-xl font-bold dark:text-gray-400">{$t('home.feature_2')}</h3> <p class="text-gray-600 dark:text-gray-400">
<p class="text-gray-500 dark:text-gray-400"> {$t('home.feature_2_desc')}
{$t('home.feature_2_desc')} </p>
</p>
</div>
</li> </li>
<li> <li class="space-y-2">
<div class="grid gap-1"> <h3 class="text-xl font-semibold dark:text-gray-300">{$t('home.feature_3')}</h3>
<h3 class="text-xl font-bold dark:text-gray-400">{$t('home.feature_3')}</h3> <p class="text-gray-600 dark:text-gray-400">
<p class="text-gray-500 dark:text-gray-400"> {$t('home.feature_3_desc')}
{$t('home.feature_3_desc')} </p>
</p>
</div>
</li> </li>
</ul> </ul>
</div> </div>

View file

@ -12,7 +12,6 @@
import toGeoJSON from '@mapbox/togeojson'; import toGeoJSON from '@mapbox/togeojson';
import LightbulbOn from '~icons/mdi/lightbulb-on'; import LightbulbOn from '~icons/mdi/lightbulb-on';
import Account from '~icons/mdi/account';
let geojson: any; let geojson: any;
@ -186,7 +185,10 @@
alt={adventure.name} alt={adventure.name}
/> />
</a> </a>
<div class="flex justify-center w-full py-2 gap-2"> <!-- Scrollable button container -->
<div
class="flex w-full py-2 gap-2 overflow-x-auto whitespace-nowrap scrollbar-hide justify-start"
>
{#each adventure.images as _, i} {#each adventure.images as _, i}
<button <button
on:click={() => goToSlide(i)} on:click={() => goToSlide(i)}
@ -198,6 +200,7 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<div class="grid gap-4"> <div class="grid gap-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@ -222,40 +225,43 @@
</div> </div>
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<div class="flex items-center gap-2"> {#if adventure.user}
{#if adventure.user.profile_pic} <div class="flex items-center gap-2">
<div class="avatar"> {#if adventure.user.profile_pic}
<div class="w-8 rounded-full"> <div class="avatar">
<img src={adventure.user.profile_pic} /> <div class="w-8 rounded-full">
<img src={adventure.user.profile_pic} alt={adventure.user.username} />
</div>
</div> </div>
</div>
{:else}
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content w-8 rounded-full">
<span class="text-lg"
>{adventure.user.first_name
? adventure.user.first_name.charAt(0)
: adventure.user.username.charAt(0)}{adventure.user.last_name
? adventure.user.last_name.charAt(0)
: ''}</span
>
</div>
</div>
{/if}
<div>
{#if adventure.user.public_profile}
<a href={`/profile/${adventure.user.username}`} class="text-base font-medium">
{adventure.user.first_name || adventure.user.username}{' '}
{adventure.user.last_name}
</a>
{:else} {:else}
<span class="text-base font-medium"> <div class="avatar placeholder">
{adventure.user.first_name || adventure.user.username}{' '} <div class="bg-neutral text-neutral-content w-8 rounded-full">
{adventure.user.last_name} <span class="text-lg"
</span> >{adventure.user.first_name
? adventure.user.first_name.charAt(0)
: adventure.user.username.charAt(0)}{adventure.user.last_name
? adventure.user.last_name.charAt(0)
: ''}</span
>
</div>
</div>
{/if} {/if}
<div>
{#if adventure.user.public_profile}
<a href={`/profile/${adventure.user.username}`} class="text-base font-medium">
{adventure.user.first_name || adventure.user.username}{' '}
{adventure.user.last_name}
</a>
{:else}
<span class="text-base font-medium">
{adventure.user.first_name || adventure.user.username}{' '}
{adventure.user.last_name}
</span>
{/if}
</div>
</div> </div>
</div> {/if}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View file

@ -27,7 +27,8 @@
groupNotesByDate, groupNotesByDate,
groupTransportationsByDate, groupTransportationsByDate,
groupChecklistsByDate, groupChecklistsByDate,
osmTagToEmoji osmTagToEmoji,
groupLodgingByDate
} from '$lib'; } from '$lib';
import ChecklistCard from '$lib/components/ChecklistCard.svelte'; import ChecklistCard from '$lib/components/ChecklistCard.svelte';
import ChecklistModal from '$lib/components/ChecklistModal.svelte'; import ChecklistModal from '$lib/components/ChecklistModal.svelte';
@ -861,6 +862,10 @@
new Date(collection.start_date), new Date(collection.start_date),
numberOfDays numberOfDays
)[dateString] || []} )[dateString] || []}
{@const dayLodging =
groupLodgingByDate(lodging, new Date(collection.start_date), numberOfDays)[
dateString
] || []}
{@const dayNotes = {@const dayNotes =
groupNotesByDate(notes, new Date(collection.start_date), numberOfDays)[dateString] || groupNotesByDate(notes, new Date(collection.start_date), numberOfDays)[dateString] ||
[]} []}
@ -922,6 +927,18 @@
/> />
{/each} {/each}
{/if} {/if}
{#if dayLodging.length > 0}
{#each dayLodging as hotel}
<LodgingCard
lodging={hotel}
user={data?.user}
on:delete={(event) => {
lodging = lodging.filter((t) => t.id != event.detail);
}}
on:edit={editLodging}
/>
{/each}
{/if}
{#if dayChecklists.length > 0} {#if dayChecklists.length > 0}
{#each dayChecklists as checklist} {#each dayChecklists as checklist}
<ChecklistCard <ChecklistCard
@ -939,7 +956,7 @@
{/if} {/if}
</div> </div>
{#if dayAdventures.length == 0 && dayTransportations.length == 0 && dayNotes.length == 0 && dayChecklists.length == 0} {#if dayAdventures.length == 0 && dayTransportations.length == 0 && dayNotes.length == 0 && dayChecklists.length == 0 && dayLodging.length == 0}
<p class="text-center text-lg mt-2 italic">{$t('adventures.nothing_planned')}</p> <p class="text-center text-lg mt-2 italic">{$t('adventures.nothing_planned')}</p>
{/if} {/if}
</div> </div>
@ -1068,7 +1085,7 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if currentView == 'recommendations'} {#if currentView == 'recommendations' && data.user}
<div class="card bg-base-200 shadow-xl my-8 mx-auto w-10/12"> <div class="card bg-base-200 shadow-xl my-8 mx-auto w-10/12">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-3xl justify-center mb-4">Adventure Recommendations</h2> <h2 class="card-title text-3xl justify-center mb-4">Adventure Recommendations</h2>

View file

@ -10,6 +10,8 @@
let createModalOpen: boolean = false; let createModalOpen: boolean = false;
let showGeo: boolean = false; let showGeo: boolean = false;
export let initialLatLng: { lat: number; lng: number } | null = null;
let visitedRegions: VisitedRegion[] = data.props.visitedRegions; let visitedRegions: VisitedRegion[] = data.props.visitedRegions;
let adventures: Adventure[] = data.props.adventures; let adventures: Adventure[] = data.props.adventures;
@ -49,6 +51,11 @@
newLatitude = e.detail.lngLat.lat; newLatitude = e.detail.lngLat.lat;
} }
function newAdventure() {
initialLatLng = { lat: newLatitude, lng: newLongitude } as { lat: number; lng: number };
createModalOpen = true;
}
function createNewAdventure(event: CustomEvent) { function createNewAdventure(event: CustomEvent) {
adventures = [...adventures, event.detail]; adventures = [...adventures, event.detail];
newMarker = null; newMarker = null;
@ -86,7 +93,7 @@
/> />
<div class="divider divider-horizontal"></div> <div class="divider divider-horizontal"></div>
{#if newMarker} {#if newMarker}
<button type="button" class="btn btn-primary mb-2" on:click={() => (createModalOpen = true)} <button type="button" class="btn btn-primary mb-2" on:click={newAdventure}
>{$t('map.add_adventure_at_marker')}</button >{$t('map.add_adventure_at_marker')}</button
> >
<button type="button" class="btn btn-neutral mb-2" on:click={() => (newMarker = null)} <button type="button" class="btn btn-neutral mb-2" on:click={() => (newMarker = null)}
@ -105,14 +112,13 @@
<AdventureModal <AdventureModal
on:close={() => (createModalOpen = false)} on:close={() => (createModalOpen = false)}
on:save={createNewAdventure} on:save={createNewAdventure}
latitude={newLatitude} {initialLatLng}
longitude={newLongitude}
/> />
{/if} {/if}
<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="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full" class="mx-auto aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"
standardControls standardControls
> >
{#each filteredAdventures as adventure} {#each filteredAdventures as adventure}

View file

@ -458,7 +458,7 @@
<!-- Social Auth Settings --> <!-- Social Auth Settings -->
<section class="space-y-8"> <section class="space-y-8">
<h2 class="text-2xl font-semibold text-center mt-8">{$t('settings.social_oidc_auth')}</h2> <h2 class="text-2xl font-semibold text-center mt-8">{$t('settings.social_oidc_auth')}</h2>
<div class="bg-neutral p-6 rounded-lg shadow-md text-center"> <div class="bg-neutral p-6 rounded-lg shadow-md text-center text-neutral-content">
<p> <p>
{$t('settings.social_auth_desc')} {$t('settings.social_auth_desc')}
</p> </p>