Bug Fixes + Speed Improvements (#871)

* Show timezone information on visits list (#865)

* Initial plan

* Initial investigation: Show timezone on visits list - planning implementation

Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com>

* Show timezone on visits list - add timezone badge display

Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com>

* fix: add additional timezones (#852)

* [BUG] Transportation node does not accept complex links in link parameter
Fixes #856

* Squashed commit of the following:

commit 59d5128cc642d133b0c166fbaf2d41a88c237d92
Merge: 0f9d31f4 7b8961e0
Author: Hosted Weblate <hosted@weblate.org>
Date:   Fri Sep 19 04:22:36 2025 +0200

    Merge branch 'origin/development' into Weblate.

commit 7b8961e02430b9b6fab7b22a7a8c1f7b06ff950b
Author: Orhun <orhunavcu@gmail.com>
Date:   Fri Sep 19 00:30:25 2025 +0200

    Translated using Weblate (Turkish)

    Currently translated at 16.7% (160 of 958 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/tr/

commit a8134bdbda318d00175c984785d150e38a1e24bf
Author: Orhun <orhunavcu@gmail.com>
Date:   Thu Sep 18 22:52:56 2025 +0200

    Added translation using Weblate (Turkish)

commit ac8a8ee8c9fc55da2d4ded1c4beac04a1ea66bb8
Merge: 2527e345 3fca3872
Author: Hosted Weblate <hosted@weblate.org>
Date:   Thu Sep 18 14:11:58 2025 +0200

    Merge branch 'origin/development' into Weblate.

commit 3fca387272d52dfcb634751a74e4a4b4fcf7ac6b
Merge: 4907ba87 85d8b45c
Author: Hosted Weblate <hosted@weblate.org>
Date:   Thu Sep 18 05:13:16 2025 +0200

    Merge branch 'origin/development' into Weblate.

commit 85d8b45c4e
Merge: 9f5cc9cc ae07c440
Author: Sean Morley <mail@seanmorley.com>
Date:   Wed Sep 17 19:25:35 2025 -0400

    Merge branch 'development' of github.com:seanmorley15/AdventureLog into development

commit 9f5cc9ccb8
Author: Sean Morley <mail@seanmorley.com>
Date:   Wed Sep 17 19:23:47 2025 -0400

    Remove duplicate comment for syncing development branch with main in .env.example

commit 77c1f51626
Author: Sean Morley <mail@seanmorley.com>
Date:   Wed Sep 17 19:22:36 2025 -0400

    Squashed commit of the following:

    commit 9d4f1b8f53
    Author: Jacob <jacob.aulin@proton.me>
    Date:   Sat Sep 13 15:17:22 2025 +0200

        Translated using Weblate (Swedish)

        Currently translated at 99.8% (957 of 958 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sv/

    commit 8fac40cfde
    Author: Christian S <schuld.christian@gmail.com>
    Date:   Sat Sep 13 12:54:52 2025 +0200

        Translated using Weblate (German)

        Currently translated at 100.0% (958 of 958 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

    commit 8e8c42396e
    Author: Patricio Carrau <duckycb@proton.me>
    Date:   Tue Sep 9 21:59:48 2025 +0200

        Translated using Weblate (Spanish)

        Currently translated at 100.0% (958 of 958 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

    commit be818ab408
    Author: pplulee <hi@pplulee.me>
    Date:   Mon Sep 8 04:06:54 2025 +0100

        fix(i18n): update Chinese translations for location-related terms (#829)

        Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>

    commit 9e40dcf6a1
    Merge: af2f2809 733eefce
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sun Sep 7 23:03:57 2025 -0400

        Merge remote-tracking branch 'weblate/development' into development

    commit af2f28090b
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sun Sep 7 23:00:33 2025 -0400

        [BUG] Location Visit End Date not affected by Location Timezone
        Fixes #843

    commit 733eefcedd
    Author: Alex <div@alexe.at>
    Date:   Sun Sep 7 23:28:20 2025 +0200

        Translated using Weblate (German)

        Currently translated at 100.0% (958 of 958 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

    commit 6c750d1c8f
    Author: fantastron27 <fantastron27@gmail.com>
    Date:   Sun Sep 7 09:17:16 2025 +0200

        Translated using Weblate (Slovak)

        Currently translated at 100.0% (958 of 958 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sk/

    commit f733b3b96b
    Merge: 769ea6ad af4e541c
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 21:36:35 2025 -0400

        Merge branch 'development' of github.com:seanmorley15/AdventureLog into development

    commit 769ea6ad71
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 21:36:33 2025 -0400

        Implement code changes to enhance functionality and improve performance

    commit af4e541c1c
    Author: fantastron27 <fantastron27@gmail.com>
    Date:   Sun Sep 7 03:36:23 2025 +0200

        Added Slovak translations (#815)

        * Created sk.json

        * Update Navbar.svelte

        * Update +layout.svelte

        ---------

        Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>

    commit 904474d757
    Merge: d4709434 f87a5fe3
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 21:31:58 2025 -0400

        Merge remote-tracking branch 'weblate/development' into development

    commit d47094346c
    Merge: 4a5f59bf 6366a3eb
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 21:29:39 2025 -0400

        Merge remote-tracking branch 'weblate/development' into development

    commit f87a5fe3bc
    Author: Sergio <garcia.sergio@me.com>
    Date:   Sun Sep 7 01:12:50 2025 +0200

        Translated using Weblate (Spanish)

        Currently translated at 100.0% (956 of 956 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

    commit 4a5f59bfd2
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 21:06:17 2025 -0400

        Fixes #654

    commit c1302bb54a
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 20:52:05 2025 -0400

        [BUG] Single day Collections will think location visits are out of date range
        Fixes #827

    commit 773f2d65bb
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 19:52:28 2025 -0400

        [BUG] Server Error (500) when trying to access the API docs
        Fixes #712

    commit 4228db249e
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 19:44:00 2025 -0400

        [BUG]Ordered Itinerary includes visits that are outside itinerary date range
        Fixes #746

    commit 26f36cabb0
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 18:36:50 2025 +0200

        Translated using Weblate (Spanish)

        Currently translated at 100.0% (956 of 956 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

    commit 3bfd2dd561
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 12:33:23 2025 -0400

        Remove empty English (United States) locale file

    commit 6366a3eba6
    Author: Nikolai Eidsheim <nikolai.eidsheim@gmail.com>
    Date:   Sat Sep 6 18:10:15 2025 +0200

        Translated using Weblate (Norwegian Bokmål)

        Currently translated at 100.0% (956 of 956 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/nb_NO/

    commit 671cd3701f
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 16:58:04 2025 +0200

        Added translation using Weblate (English (United States))

    commit bdbbe5f497
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 16:54:43 2025 +0200

        Translated using Weblate (Spanish)

        Currently translated at 100.0% (956 of 956 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

commit f310771702
Author: Sean Morley <98704938+seanmorley15@users.noreply.github.com>
Date:   Mon Sep 8 22:38:08 2025 -0400

    Update issue templates for bug reports, deployment issues, and feature requests (#849)

commit 02ed89fa46
Author: Sean Morley <98704938+seanmorley15@users.noreply.github.com>
Date:   Sun Sep 7 23:16:12 2025 -0400

    Timezone fixes, Translations, and Misc Fixes (#844)

    * Translated using Weblate (Spanish)

    Currently translated at 100.0% (956 of 956 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

    * Added translation using Weblate (English (United States))

    * Translated using Weblate (Norwegian Bokmål)

    Currently translated at 100.0% (956 of 956 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/nb_NO/

    * Remove empty English (United States) locale file

    * Translated using Weblate (Spanish)

    Currently translated at 100.0% (956 of 956 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

    * [BUG]Ordered Itinerary includes visits that are outside itinerary date range
    Fixes #746

    * [BUG] Server Error (500) when trying to access the API docs
    Fixes #712

    * [BUG] Single day Collections will think location visits are out of date range
    Fixes #827

    * Fixes #654

    * Translated using Weblate (Spanish)

    Currently translated at 100.0% (956 of 956 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

    * Added Slovak translations (#815)

    * Created sk.json

    * Update Navbar.svelte

    * Update +layout.svelte

    ---------

    Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>

    * Implement code changes to enhance functionality and improve performance

    * Translated using Weblate (Slovak)

    Currently translated at 100.0% (958 of 958 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sk/

    * Translated using Weblate (German)

    Currently translated at 100.0% (958 of 958 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

    * [BUG] Location Visit End Date not affected by Location Timezone
    Fixes #843

    * fix(i18n): update Chinese translations for location-related terms (#829)

    Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>

    ---------

    Co-authored-by: Nikolai Eidsheim <nikolai.eidsheim@gmail.com>
    Co-authored-by: Sergio <garcia.sergio@me.com>
    Co-authored-by: fantastron27 <fantastron27@gmail.com>
    Co-authored-by: Alex <div@alexe.at>
    Co-authored-by: pplulee <hi@pplulee.me>

commit ae07c44030
Author: Sean Morley <mail@seanmorley.com>
Date:   Wed Sep 17 19:23:47 2025 -0400

    Remove duplicate comment for syncing development branch with main in .env.example

commit 94964f1fb1
Author: Sean Morley <mail@seanmorley.com>
Date:   Wed Sep 17 19:22:36 2025 -0400

    Squashed commit of the following:

    commit 9d4f1b8f53
    Author: Jacob <jacob.aulin@proton.me>
    Date:   Sat Sep 13 15:17:22 2025 +0200

        Translated using Weblate (Swedish)

        Currently translated at 99.8% (957 of 958 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sv/

    commit 8fac40cfde
    Author: Christian S <schuld.christian@gmail.com>
    Date:   Sat Sep 13 12:54:52 2025 +0200

        Translated using Weblate (German)

        Currently translated at 100.0% (958 of 958 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

    commit 8e8c42396e
    Author: Patricio Carrau <duckycb@proton.me>
    Date:   Tue Sep 9 21:59:48 2025 +0200

        Translated using Weblate (Spanish)

        Currently translated at 100.0% (958 of 958 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

    commit be818ab408
    Author: pplulee <hi@pplulee.me>
    Date:   Mon Sep 8 04:06:54 2025 +0100

        fix(i18n): update Chinese translations for location-related terms (#829)

        Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>

    commit 9e40dcf6a1
    Merge: af2f2809 733eefce
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sun Sep 7 23:03:57 2025 -0400

        Merge remote-tracking branch 'weblate/development' into development

    commit af2f28090b
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sun Sep 7 23:00:33 2025 -0400

        [BUG] Location Visit End Date not affected by Location Timezone
        Fixes #843

    commit 733eefcedd
    Author: Alex <div@alexe.at>
    Date:   Sun Sep 7 23:28:20 2025 +0200

        Translated using Weblate (German)

        Currently translated at 100.0% (958 of 958 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

    commit 6c750d1c8f
    Author: fantastron27 <fantastron27@gmail.com>
    Date:   Sun Sep 7 09:17:16 2025 +0200

        Translated using Weblate (Slovak)

        Currently translated at 100.0% (958 of 958 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sk/

    commit f733b3b96b
    Merge: 769ea6ad af4e541c
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 21:36:35 2025 -0400

        Merge branch 'development' of github.com:seanmorley15/AdventureLog into development

    commit 769ea6ad71
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 21:36:33 2025 -0400

        Implement code changes to enhance functionality and improve performance

    commit af4e541c1c
    Author: fantastron27 <fantastron27@gmail.com>
    Date:   Sun Sep 7 03:36:23 2025 +0200

        Added Slovak translations (#815)

        * Created sk.json

        * Update Navbar.svelte

        * Update +layout.svelte

        ---------

        Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>

    commit 904474d757
    Merge: d4709434 f87a5fe3
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 21:31:58 2025 -0400

        Merge remote-tracking branch 'weblate/development' into development

    commit d47094346c
    Merge: 4a5f59bf 6366a3eb
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 21:29:39 2025 -0400

        Merge remote-tracking branch 'weblate/development' into development

    commit f87a5fe3bc
    Author: Sergio <garcia.sergio@me.com>
    Date:   Sun Sep 7 01:12:50 2025 +0200

        Translated using Weblate (Spanish)

        Currently translated at 100.0% (956 of 956 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

    commit 4a5f59bfd2
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 21:06:17 2025 -0400

        Fixes #654

    commit c1302bb54a
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 20:52:05 2025 -0400

        [BUG] Single day Collections will think location visits are out of date range
        Fixes #827

    commit 773f2d65bb
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 19:52:28 2025 -0400

        [BUG] Server Error (500) when trying to access the API docs
        Fixes #712

    commit 4228db249e
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 19:44:00 2025 -0400

        [BUG]Ordered Itinerary includes visits that are outside itinerary date range
        Fixes #746

    commit 26f36cabb0
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 18:36:50 2025 +0200

        Translated using Weblate (Spanish)

        Currently translated at 100.0% (956 of 956 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

    commit 3bfd2dd561
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 12:33:23 2025 -0400

        Remove empty English (United States) locale file

    commit 6366a3eba6
    Author: Nikolai Eidsheim <nikolai.eidsheim@gmail.com>
    Date:   Sat Sep 6 18:10:15 2025 +0200

        Translated using Weblate (Norwegian Bokmål)

        Currently translated at 100.0% (956 of 956 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/nb_NO/

    commit 671cd3701f
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 16:58:04 2025 +0200

        Added translation using Weblate (English (United States))

    commit bdbbe5f497
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 16:54:43 2025 +0200

        Translated using Weblate (Spanish)

        Currently translated at 100.0% (956 of 956 strings)

        Translation: AdventureLog/Web App
        Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

commit 9d4f1b8f53
Author: Jacob <jacob.aulin@proton.me>
Date:   Sat Sep 13 15:17:22 2025 +0200

    Translated using Weblate (Swedish)

    Currently translated at 99.8% (957 of 958 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sv/

commit 8fac40cfde
Author: Christian S <schuld.christian@gmail.com>
Date:   Sat Sep 13 12:54:52 2025 +0200

    Translated using Weblate (German)

    Currently translated at 100.0% (958 of 958 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

commit 8e8c42396e
Author: Patricio Carrau <duckycb@proton.me>
Date:   Tue Sep 9 21:59:48 2025 +0200

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (958 of 958 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

commit be818ab408
Author: pplulee <hi@pplulee.me>
Date:   Mon Sep 8 04:06:54 2025 +0100

    fix(i18n): update Chinese translations for location-related terms (#829)

    Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>

commit 9e40dcf6a1
Merge: af2f2809 733eefce
Author: Sean Morley <mail@seanmorley.com>
Date:   Sun Sep 7 23:03:57 2025 -0400

    Merge remote-tracking branch 'weblate/development' into development

commit af2f28090b
Author: Sean Morley <mail@seanmorley.com>
Date:   Sun Sep 7 23:00:33 2025 -0400

    [BUG] Location Visit End Date not affected by Location Timezone
    Fixes #843

commit 733eefcedd
Author: Alex <div@alexe.at>
Date:   Sun Sep 7 23:28:20 2025 +0200

    Translated using Weblate (German)

    Currently translated at 100.0% (958 of 958 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

commit 6c750d1c8f
Author: fantastron27 <fantastron27@gmail.com>
Date:   Sun Sep 7 09:17:16 2025 +0200

    Translated using Weblate (Slovak)

    Currently translated at 100.0% (958 of 958 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sk/

commit f733b3b96b
Merge: 769ea6ad af4e541c
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 21:36:35 2025 -0400

    Merge branch 'development' of github.com:seanmorley15/AdventureLog into development

commit 769ea6ad71
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 21:36:33 2025 -0400

    Implement code changes to enhance functionality and improve performance

commit af4e541c1c
Author: fantastron27 <fantastron27@gmail.com>
Date:   Sun Sep 7 03:36:23 2025 +0200

    Added Slovak translations (#815)

    * Created sk.json

    * Update Navbar.svelte

    * Update +layout.svelte

    ---------

    Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>

commit 904474d757
Merge: d4709434 f87a5fe3
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 21:31:58 2025 -0400

    Merge remote-tracking branch 'weblate/development' into development

commit d47094346c
Merge: 4a5f59bf 6366a3eb
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 21:29:39 2025 -0400

    Merge remote-tracking branch 'weblate/development' into development

commit f87a5fe3bc
Author: Sergio <garcia.sergio@me.com>
Date:   Sun Sep 7 01:12:50 2025 +0200

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (956 of 956 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

commit 4a5f59bfd2
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 21:06:17 2025 -0400

    Fixes #654

commit c1302bb54a
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 20:52:05 2025 -0400

    [BUG] Single day Collections will think location visits are out of date range
    Fixes #827

commit 773f2d65bb
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 19:52:28 2025 -0400

    [BUG] Server Error (500) when trying to access the API docs
    Fixes #712

commit 4228db249e
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 19:44:00 2025 -0400

    [BUG]Ordered Itinerary includes visits that are outside itinerary date range
    Fixes #746

commit 26f36cabb0
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 18:36:50 2025 +0200

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (956 of 956 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

commit 3bfd2dd561
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 12:33:23 2025 -0400

    Remove empty English (United States) locale file

commit 6366a3eba6
Author: Nikolai Eidsheim <nikolai.eidsheim@gmail.com>
Date:   Sat Sep 6 18:10:15 2025 +0200

    Translated using Weblate (Norwegian Bokmål)

    Currently translated at 100.0% (956 of 956 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/nb_NO/

commit 671cd3701f
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 16:58:04 2025 +0200

    Added translation using Weblate (English (United States))

commit bdbbe5f497
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 16:54:43 2025 +0200

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (956 of 956 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

* Translated using Weblate (French)

Currently translated at 100.0% (958 of 958 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/fr/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (958 of 958 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/tr/

* feat: enhance serializers and views for nested representations and lightweight data fetching

* feat: implement caching for visits by country and region endpoints

* feat: update LocationSerializer to include debug logging and adjust nested context handling in CollectionSerializer

* feat: enhance CollectionSerializer and CollectionViewSet to support nested context handling

* feat(worldtravel): add globe spin feature with loading and result display

- Introduced a toggle button for the globe spin feature.
- Implemented fetch logic for globe spin data from the API.
- Added loading state with a spinning globe animation.
- Displayed country information, including flag, subregion, and visit statistics.
- Enhanced UI with animations for loading and displaying results.
- Included error handling for API fetch failures.

* Add Turkish language support to the application

* feat(map): enhance pin styling with gradient backgrounds and improved hover effects

* Squashed commit of the following:

commit 990b0645059421c4a293fbf64830a6d864ceb40e
Author: Henrique Fonseca Veloso <henriquefv@tutamail.com>
Date:   Sun Sep 21 00:27:59 2025 +0200

    Translated using Weblate (Portuguese (Brazil))

    Currently translated at 100.0% (958 of 958 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/pt_BR/

commit 03a4b9235faa849fb817348a2774e834a6851dc3
Author: Orhun <orhunavcu@gmail.com>
Date:   Fri Sep 19 19:38:12 2025 +0200

    Translated using Weblate (Turkish)

    Currently translated at 100.0% (958 of 958 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/tr/

commit 1ccdc678627eb5915b56a8bfb3465928c80a524f
Author: Henrique Fonseca Veloso <henriquefv@tutamail.com>
Date:   Sat Sep 20 20:01:36 2025 +0200

    Translated using Weblate (Portuguese (Brazil))

    Currently translated at 97.0% (930 of 958 strings)

    Translation: AdventureLog/Web App
    Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/pt_BR/

* fix(config): update appVersion to v0.11.0-main-09212025

* fix duration calculation on ordered collection view (#867)

Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>

* Update frontend/src/routes/worldtravel/+page.svelte

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update backend/server/worldtravel/views.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update frontend/src/routes/map/+page.svelte

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Rohan <Alchez@users.noreply.github.com>
Co-authored-by: Orhun <orhunavcu@gmail.com>
Co-authored-by: vorbeiei <vorbeiei@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sean Morley
2025-09-21 22:06:24 -04:00
committed by GitHub
parent 4907ba8700
commit 240c617010
43 changed files with 13002 additions and 5398 deletions

View File

File diff suppressed because one or more lines are too long

View File

@@ -305,7 +305,7 @@ class Transportation(models.Model):
name = models.CharField(max_length=200)
description = models.TextField(blank=True, null=True)
rating = models.FloatField(blank=True, null=True)
link = models.URLField(blank=True, null=True)
link = models.URLField(blank=True, null=True, max_length=2083)
date = models.DateTimeField(blank=True, null=True)
end_date = models.DateTimeField(blank=True, null=True)
start_timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True)

View File

@@ -234,9 +234,26 @@ class LocationSerializer(CustomModelSerializer):
# Makes it so the whole user object is returned in the serializer instead of just the user uuid
def to_representation(self, instance):
representation = super().to_representation(instance)
representation['user'] = CustomUserDetailsSerializer(instance.user, context=self.context).data
is_nested = self.context.get('nested', False)
allowed_nested_fields = set(self.context.get('allowed_nested_fields', []))
if not is_nested:
# Full representation for standalone locations
representation['user'] = CustomUserDetailsSerializer(instance.user, context=self.context).data
else:
# Slim representation for nested contexts, but keep allowed fields
fields_to_remove = [
'visits', 'attachments', 'trails', 'collections',
'user', 'city', 'country', 'region'
]
for field in fields_to_remove:
# Keep field if explicitly allowed for nested mode
if field not in allowed_nested_fields:
representation.pop(field, None)
return representation
def get_images(self, obj):
serializer = ContentImageSerializer(obj.images.all(), many=True, context=self.context)
# Filter out None values from the serialized data
@@ -349,7 +366,6 @@ class LocationSerializer(CustomModelSerializer):
category_data = validated_data.pop('category', None)
collections_data = validated_data.pop('collections', [])
print(category_data)
location = Location.objects.create(**validated_data)
# Handle category
@@ -391,6 +407,18 @@ class LocationSerializer(CustomModelSerializer):
instance.save()
return instance
class MapPinSerializer(serializers.ModelSerializer):
is_visited = serializers.SerializerMethodField()
category = CategorySerializer(read_only=True, required=False)
class Meta:
model = Location
fields = ['id', 'name', 'latitude', 'longitude', 'is_visited', 'category']
read_only_fields = ['id', 'name', 'latitude', 'longitude', 'is_visited', 'category']
def get_is_visited(self, obj):
return obj.is_visited_status()
class TransportationSerializer(CustomModelSerializer):
distance = serializers.SerializerMethodField()
@@ -555,24 +583,67 @@ class ChecklistSerializer(CustomModelSerializer):
return data
class CollectionSerializer(CustomModelSerializer):
locations = LocationSerializer(many=True, read_only=True)
transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set')
notes = NoteSerializer(many=True, read_only=True, source='note_set')
checklists = ChecklistSerializer(many=True, read_only=True, source='checklist_set')
lodging = LodgingSerializer(many=True, read_only=True, source='lodging_set')
locations = serializers.SerializerMethodField()
transportations = serializers.SerializerMethodField()
notes = serializers.SerializerMethodField()
checklists = serializers.SerializerMethodField()
lodging = serializers.SerializerMethodField()
class Meta:
model = Collection
fields = ['id', 'description', 'user', 'name', 'is_public', 'locations', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'lodging']
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'shared_with']
def get_locations(self, obj):
if self.context.get('nested', False):
allowed_nested_fields = set(self.context.get('allowed_nested_fields', []))
return LocationSerializer(
obj.locations.all(),
many=True,
context={**self.context, 'nested': True, 'allowed_nested_fields': allowed_nested_fields}
).data
return LocationSerializer(obj.locations.all(), many=True, context=self.context).data
def get_transportations(self, obj):
# Only include transportations if not in nested context
if self.context.get('nested', False):
return []
return TransportationSerializer(obj.transportation_set.all(), many=True, context=self.context).data
def get_notes(self, obj):
# Only include notes if not in nested context
if self.context.get('nested', False):
return []
return NoteSerializer(obj.note_set.all(), many=True, context=self.context).data
def get_checklists(self, obj):
# Only include checklists if not in nested context
if self.context.get('nested', False):
return []
return ChecklistSerializer(obj.checklist_set.all(), many=True, context=self.context).data
def get_lodging(self, obj):
# Only include lodging if not in nested context
if self.context.get('nested', False):
return []
return LodgingSerializer(obj.lodging_set.all(), many=True, context=self.context).data
def to_representation(self, instance):
representation = super().to_representation(instance)
# Make it display the user uuid for the shared users instead of the PK
shared_uuids = []
for user in instance.shared_with.all():
shared_uuids.append(str(user.uuid))
representation['shared_with'] = shared_uuids
# If nested, remove the heavy fields entirely from the response
if self.context.get('nested', False):
fields_to_remove = ['transportations', 'notes', 'checklists', 'lodging']
for field in fields_to_remove:
representation.pop(field, None)
return representation
class CollectionInviteSerializer(serializers.ModelSerializer):

View File

@@ -3,6 +3,7 @@ TIMEZONES = [
"Africa/Accra",
"Africa/Addis_Ababa",
"Africa/Algiers",
"Africa/Asmara",
"Africa/Asmera",
"Africa/Bamako",
"Africa/Bangui",
@@ -56,7 +57,12 @@ TIMEZONES = [
"America/Anguilla",
"America/Antigua",
"America/Araguaina",
"America/Argentina/Buenos_Aires",
"America/Argentina/Catamarca",
"America/Argentina/Cordoba",
"America/Argentina/Jujuy",
"America/Argentina/La_Rioja",
"America/Argentina/Mendoza",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
@@ -65,6 +71,7 @@ TIMEZONES = [
"America/Argentina/Ushuaia",
"America/Aruba",
"America/Asuncion",
"America/Atikokan",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
@@ -88,6 +95,7 @@ TIMEZONES = [
"America/Coral_Harbour",
"America/Cordoba",
"America/Costa_Rica",
"America/Coyhaique",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
@@ -114,6 +122,7 @@ TIMEZONES = [
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Indianapolis",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
@@ -127,6 +136,7 @@ TIMEZONES = [
"America/Jamaica",
"America/Jujuy",
"America/Juneau",
"America/Kentucky/Louisville",
"America/Kentucky/Monticello",
"America/Kralendijk",
"America/La_Paz",
@@ -158,6 +168,7 @@ TIMEZONES = [
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Nuuk",
"America/Ojinaga",
"America/Panama",
"America/Paramaribo",
@@ -233,6 +244,7 @@ TIMEZONES = [
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Hebron",
"Asia/Ho_Chi_Minh",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
@@ -243,7 +255,9 @@ TIMEZONES = [
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Katmandu",
"Asia/Kathmandu",
"Asia/Khandyga",
"Asia/Kolkata",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
@@ -286,6 +300,7 @@ TIMEZONES = [
"Asia/Vientiane",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yangon",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
@@ -309,6 +324,32 @@ TIMEZONES = [
"Australia/Melbourne",
"Australia/Perth",
"Australia/Sydney",
"Etc/GMT+1",
"Etc/GMT+10",
"Etc/GMT+11",
"Etc/GMT+12",
"Etc/GMT+2",
"Etc/GMT+3",
"Etc/GMT+4",
"Etc/GMT+5",
"Etc/GMT+6",
"Etc/GMT+7",
"Etc/GMT+8",
"Etc/GMT+9",
"Etc/GMT-1",
"Etc/GMT-10",
"Etc/GMT-11",
"Etc/GMT-12",
"Etc/GMT-13",
"Etc/GMT-14",
"Etc/GMT-2",
"Etc/GMT-3",
"Etc/GMT-4",
"Etc/GMT-5",
"Etc/GMT-6",
"Etc/GMT-7",
"Etc/GMT-8",
"Etc/GMT-9",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
@@ -332,6 +373,7 @@ TIMEZONES = [
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Kyiv",
"Europe/Lisbon",
"Europe/Ljubljana",
"Europe/London",
@@ -382,6 +424,7 @@ TIMEZONES = [
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Chuuk",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
@@ -393,6 +436,7 @@ TIMEZONES = [
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Kanton",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
@@ -407,6 +451,7 @@ TIMEZONES = [
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Ponape",
"Pacific/Pohnpei",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Saipan",

View File

@@ -16,7 +16,6 @@ class CollectionViewSet(viewsets.ModelViewSet):
permission_classes = [CollectionShared]
pagination_class = pagination.StandardResultsSetPagination
def apply_sorting(self, queryset):
order_by = self.request.query_params.get('order_by', 'name')
order_direction = self.request.query_params.get('order_direction', 'asc')
@@ -48,14 +47,36 @@ class CollectionViewSet(viewsets.ModelViewSet):
return queryset.order_by(ordering)
def list(self, request, *args, **kwargs):
def get_serializer_context(self):
"""Override to add nested and exclusion contexts based on query parameters"""
context = super().get_serializer_context()
# Handle nested parameter
is_nested = self.request.query_params.get('nested', 'false').lower() == 'true'
if is_nested:
context['nested'] = True
# Handle individual exclusion parameters (if using granular approach)
exclude_params = [
'exclude_transportations',
'exclude_notes',
'exclude_checklists',
'exclude_lodging'
]
for param in exclude_params:
if self.request.query_params.get(param, 'false').lower() == 'true':
context[param] = True
return context
def list(self, request):
# make sure the user is authenticated
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
queryset = Collection.objects.filter(user=request.user, is_archived=False)
queryset = self.apply_sorting(queryset)
collections = self.paginate_and_respond(queryset, request)
return collections
return self.paginate_and_respond(queryset, request)
@action(detail=False, methods=['get'])
def all(self, request):
@@ -415,3 +436,12 @@ class CollectionViewSet(viewsets.ModelViewSet):
return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def get_serializer(self, *args, **kwargs):
# Add nested=True to serializer context for GET list requests
context = self.get_serializer_context()
# If this is a list action, make sure nested=True in context
if self.action == 'list':
context['nested'] = True
kwargs['context'] = context
return super().get_serializer(*args, **kwargs)

View File

@@ -13,7 +13,8 @@ class IcsCalendarGeneratorViewSet(viewsets.ViewSet):
@action(detail=False, methods=['get'])
def generate(self, request):
locations = Location.objects.filter(user=request.user)
serializer = LocationSerializer(locations, many=True)
context={'nested': True, 'allowed_nested_fields': ['visits']}
serializer = LocationSerializer(locations, many=True, context=context)
user = request.user
name = f"{user.first_name} {user.last_name}"

View File

@@ -9,7 +9,7 @@ from rest_framework.response import Response
import requests
from adventures.models import Location, Category
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from adventures.serializers import LocationSerializer
from adventures.serializers import LocationSerializer, MapPinSerializer
from adventures.utils import pagination
class LocationViewSet(viewsets.ModelViewSet):
@@ -193,6 +193,8 @@ class LocationViewSet(viewsets.ModelViewSet):
return Response({"error": "User is not authenticated"}, status=400)
include_collections = request.query_params.get('include_collections', 'false') == 'true'
nested = request.query_params.get('nested', 'false') == 'true'
allowedNestedFields = request.query_params.get('allowed_nested_fields', '').split(',')
# Build queryset with collection filtering
base_filter = Q(user=request.user.id)
@@ -203,7 +205,7 @@ class LocationViewSet(viewsets.ModelViewSet):
queryset = Location.objects.filter(base_filter, collections__isnull=True)
queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True)
serializer = self.get_serializer(queryset, many=True, context={'nested': nested, 'allowed_nested_fields': allowedNestedFields})
return Response(serializer.data)
@action(detail=True, methods=['get'], url_path='additional-info')
@@ -227,6 +229,17 @@ class LocationViewSet(viewsets.ModelViewSet):
response_data['sun_times'] = self._get_sun_times(adventure, response_data.get('visits', []))
return Response(response_data)
# view to return location name and lat/lon for all locations a user owns for the golobal map
@action(detail=False, methods=['get'], url_path='pins')
def map_locations(self, request):
"""Get all locations with name and lat/lon for map display."""
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
locations = Location.objects.filter(user=request.user)
serializer = MapPinSerializer(locations, many=True)
return Response(serializer.data)
# ==================== HELPER METHODS ====================

View File

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

View File

@@ -1,24 +1,23 @@
from django.shortcuts import render
from django.shortcuts import render, get_object_or_404
from .models import Country, Region, VisitedRegion, City, VisitedCity
from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer, VisitedCitySerializer
from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404
from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes
import os
import json
from django.http import JsonResponse
from rest_framework.decorators import api_view, permission_classes, action
from django.contrib.gis.geos import Point
from django.conf import settings
from rest_framework.decorators import action
from django.contrib.staticfiles import finders
from django.core.cache import cache
from django.views.decorators.cache import cache_page
from django.utils.decorators import method_decorator
from adventures.models import Location
# Cache TTL
CACHE_TTL = 60 * 60 * 24 # 1 day
@cache_page(CACHE_TTL)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def regions_by_country(request, country_code):
# require authentication
country = get_object_or_404(Country, country_code=country_code)
regions = Region.objects.filter(country=country).order_by('name')
serializer = RegionSerializer(regions, many=True)
@@ -27,12 +26,17 @@ def regions_by_country(request, country_code):
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def visits_by_country(request, country_code):
cache_key = f"visits_by_country_{country_code}_{request.user.id}"
data = cache.get(cache_key)
if data is not None:
return Response(data)
country = get_object_or_404(Country, country_code=country_code)
visits = VisitedRegion.objects.filter(region__country=country, user=request.user.id)
serializer = VisitedRegionSerializer(visits, many=True)
cache.set(cache_key, serializer.data, CACHE_TTL)
return Response(serializer.data)
@cache_page(CACHE_TTL)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def cities_by_region(request, region_id):
@@ -44,12 +48,38 @@ def cities_by_region(request, region_id):
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def visits_by_region(request, region_id):
cache_key = f"visits_by_region_{region_id}_{request.user.id}"
data = cache.get(cache_key)
if data is not None:
return Response(data)
region = get_object_or_404(Region, id=region_id)
visits = VisitedCity.objects.filter(city__region=region, user=request.user.id)
serializer = VisitedCitySerializer(visits, many=True)
cache.set(cache_key, serializer.data, CACHE_TTL)
return Response(serializer.data)
# view called spin the globe that return a random country, a random region in that country and a random city in that region
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def globespin(request):
country = Country.objects.order_by('?').first()
data = {
"country": CountrySerializer(country).data,
}
regions = Region.objects.filter(country=country)
if regions.exists():
region = regions.order_by('?').first()
data["region"] = RegionSerializer(region).data
cities = City.objects.filter(region=region)
if cities.exists():
city = cities.order_by('?').first()
data["city"] = CitySerializer(city).data
return Response(data)
@method_decorator(cache_page(CACHE_TTL), name='list')
class CountryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Country.objects.all().order_by('name')
serializer_class = CountrySerializer
@@ -60,15 +90,12 @@ class CountryViewSet(viewsets.ReadOnlyModelViewSet):
lat = float(request.query_params.get('lat'))
lon = float(request.query_params.get('lon'))
point = Point(lon, lat, srid=4326)
region = Region.objects.filter(geometry__contains=point).first()
if region:
return Response({'in_region': True, 'region_name': region.name, 'region_id': region.id})
else:
return Response({'in_region': False})
# make a post action that will get all of the users adventures and check if the point is in any of the regions if so make a visited region object for that user if it does not already exist
@action(detail=False, methods=['post'])
def region_check_all_adventures(self, request):
adventures = Location.objects.filter(user=request.user.id, type='visited')
@@ -87,6 +114,7 @@ class CountryViewSet(viewsets.ReadOnlyModelViewSet):
continue
return Response({'regions_visited': count})
@method_decorator(cache_page(CACHE_TTL), name='list')
class RegionViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Region.objects.all()
serializer_class = RegionSerializer
@@ -113,7 +141,6 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, **kwargs):
# delete by region id
region = get_object_or_404(Region, id=kwargs['pk'])
visited_region = VisitedRegion.objects.filter(user=request.user.id, region=region)
if visited_region.exists():
@@ -137,7 +164,6 @@ class VisitedCityViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
# if the region is not visited, visit it
region = serializer.validated_data['city'].region
if not VisitedRegion.objects.filter(user=request.user.id, region=region).exists():
VisitedRegion.objects.create(user=request.user, region=region)
@@ -145,11 +171,10 @@ class VisitedCityViewSet(viewsets.ModelViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, **kwargs):
# delete by city id
city = get_object_or_404(City, id=kwargs['pk'])
visited_city = VisitedCity.objects.filter(user=request.user.id, city=city)
if visited_city.exists():
visited_city.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
else:
return Response({"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND)
return Response({"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND)

5551
frontend/package-lock.json generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -43,8 +43,8 @@
try {
// Fetch both own collections and shared collections
const [ownRes, sharedRes] = await Promise.all([
fetch(`/api/collections/all/`, { method: 'GET' }),
fetch(`/api/collections/shared`, { method: 'GET' })
fetch(`/api/collections/all?nested=true`, { method: 'GET' }),
fetch(`/api/collections/shared?nested=true`, { method: 'GET' })
]);
const ownResult = await ownRes.json();

View File

@@ -52,10 +52,12 @@
let outsideCollectionRange: boolean = false;
$: {
if (collection) {
if (collection && collection.start_date && collection.end_date) {
outsideCollectionRange = adventure.visits.every((visit) =>
isEntityOutsideCollectionDateRange(visit, collection)
);
} else {
outsideCollectionRange = false;
}
}

View File

@@ -64,7 +64,8 @@
ja: '日本語',
ar: 'العربية',
'pt-br': 'Português (Brasil)',
'sk': 'Slovenský'
sk: 'Slovenský',
tr: 'Türkçe'
};
const submitLocaleChange = (event: Event) => {

View File

@@ -899,6 +899,9 @@
{:else}
<ClockIcon class="w-3 h-3 text-base-content/50" />
{/if}
{#if visit.timezone && !isAllDay(visit.start_date)}
<span class="badge badge-outline badge-sm">{visit.timezone}</span>
{/if}
<div class="text-sm font-medium truncate">
{#if isAllDay(visit.start_date)}
{visit.start_date && typeof visit.start_date === 'string'

View File

@@ -1,4 +1,4 @@
export let appVersion = 'v0.11.0-main-09172025';
export let appVersion = 'v0.11.0-main-09212025';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0';
export let appTitle = 'AdventureLog';
export let copyrightYear = '2023-2025';

View File

@@ -465,3 +465,12 @@ export type WandererTrail = {
updated: string; // ISO 8601 date string
waypoints: string[];
};
export type Pin = {
id: string;
name: string;
latitude: string;
longitude: string;
is_visited?: boolean;
category: Category | null;
};

View File

@@ -902,7 +902,13 @@
"shared": "مشترك",
"shared_with": "مشترك مع",
"unshared": "غير مشترك",
"with": "مع"
"with": "مع",
"available_users": "المستخدمين المتاحين",
"invite_failed": "فشل دعوة",
"invite_revoked": "دعوة إلغاء",
"invite_sent": "دعوة إرسال",
"revoke_failed": "فشل الإلغاء",
"unshare_failed": "فشل Unshare"
},
"strava": {
"account_connected": "حساب متصل",
@@ -1013,6 +1019,21 @@
"visit_remove_failed": "فشل في إزالة الزيارة",
"visit_to": "زيارة",
"visited_first": "زار أولاً",
"getting_location_details": "الحصول على تفاصيل الموقع"
"getting_location_details": "الحصول على تفاصيل الموقع",
"cities_available": "المدن المتاحة",
"destination_revealed": "كشفت الوجهة!",
"dive_deeper": "الغوص أعمق",
"exploration_progress": "تقدم الاستكشاف",
"explore_country": "استكشف البلد",
"globe_spin_error_desc": "خطأ جلب بيانات الدوران العالمي",
"hide_globe_spin": "إخفاء الدوران العالمي",
"in": "في",
"loading_globe_spin": "تحميل الكرة الأرضية",
"no_globe_spin_data": "لا توجد بيانات تدور حول العالم",
"show_globe_spin": "عرض Globe Spin",
"spin_again": "تدور مرة أخرى",
"spinning_globe": "كرة الغزل",
"try_again": "حاول ثانية",
"your_random_adventure_awaits": "مغامرتك العشوائية تنتظر!"
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -535,7 +535,22 @@
"oldest_first": "Oldest First",
"visited_first": "Visited First",
"unvisited_first": "Unvisited First",
"getting_location_details": "Getting location details"
"getting_location_details": "Getting location details",
"hide_globe_spin": "Hide Globe Spin",
"show_globe_spin": "Show Globe Spin",
"loading_globe_spin": "Loading Globe Spin",
"spinning_globe": "Spinning Globe",
"destination_revealed": "Destination Revealed!",
"your_random_adventure_awaits": "Your Random Adventure Awaits!",
"exploration_progress": "Exploration Progress",
"dive_deeper": "Dive Deeper",
"cities_available": "Cities Available",
"in": "in",
"explore_country": "Explore Country",
"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"
},
"auth": {
"username": "Username",
@@ -874,7 +889,13 @@
"revoke_invite": "Revoke Invite",
"send_invite": "Send Invite",
"available": "Available",
"pending": "Pending"
"pending": "Pending",
"available_users": "Available Users",
"revoke_failed": "Revoke Failed",
"invite_revoked": "Invite Revoked",
"unshare_failed": "Unshare Failed",
"invite_failed": "Invite Failed",
"invite_sent": "Invite Sent"
},
"languages": {},
"profile": {

View File

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,6 @@
"view_license": "Afficher la licence"
},
"adventures": {
"activities": {},
"add_to_collection": "Ajouter à la collection",
"delete": "Supprimer",
"edit_adventure": "Modifier l'aventure",
@@ -189,7 +188,7 @@
"display_name": "Nom d'affichage",
"location_details": "Détails du lieu",
"lodging": "Hébergement",
"lodging_delete_confirm": "Êtes-vous sûr de vouloir supprimer cet hébergement? \nCette action ne peut pas être annulée.",
"lodging_delete_confirm": "Êtes-vous sûr de vouloir supprimer cet hébergement? Cette action ne peut pas être annulée.",
"lodging_information": "Informations sur l'hébergement",
"price": "Prix",
"region": "Région",
@@ -244,7 +243,7 @@
"edit_location": "Modifier l'emplacement",
"location_create_error": "Échec de la création de l'emplacement",
"location_created": "Emplacement créé",
"location_delete_confirm": "Êtes-vous sûr de vouloir supprimer cet emplacement? \nCette action ne peut pas être annulée.",
"location_delete_confirm": "Êtes-vous sûr de vouloir supprimer cet emplacement? Cette action ne peut pas être annulée.",
"location_delete_success": "Emplacement supprimé avec succès!",
"location_not_found": "Emplacement introuvable",
"location_not_found_desc": "L'emplacement que vous recherchiez n'a pas pu être trouvé. \nVeuillez essayer un autre emplacement ou revenir plus tard.",
@@ -564,7 +563,22 @@
"unvisited_first": "Sans visité d'abord",
"visited_first": "Visité en premier",
"total_items": "Total des articles",
"getting_location_details": "Obtenir les détails de l'emplacement"
"getting_location_details": "Obtenir les détails de l'emplacement",
"cities_available": "Villes disponibles",
"destination_revealed": "Destination révélée!",
"dive_deeper": "Plonger plus profondément",
"exploration_progress": "Progrès de l'exploration",
"explore_country": "Explorer le pays",
"globe_spin_error_desc": "Erreur pour récupérer les données de spin globe",
"hide_globe_spin": "Hide Globe Spin",
"in": "dans",
"loading_globe_spin": "Chargement du globe Spin",
"no_globe_spin_data": "Pas de données de spin globe",
"show_globe_spin": "Montrer le spin au globe",
"spin_again": "Remonter",
"spinning_globe": "Globe de rotation",
"try_again": "Essayer à nouveau",
"your_random_adventure_awaits": "Votre aventure aléatoire vous attend!"
},
"settings": {
"account_settings": "Paramètres du compte utilisateur",
@@ -838,7 +852,6 @@
"show_activities": "Montrer les activités",
"show_visited_cities": "Villes visites"
},
"languages": {},
"share": {
"no_users_shared": "Aucun utilisateur",
"not_shared_with": "Pas encore partagé avec",
@@ -853,7 +866,13 @@
"available": "Disponible",
"pending": "En attente",
"revoke_invite": "Revoke Inviter",
"send_invite": "Envoyer l'invitation"
"send_invite": "Envoyer l'invitation",
"available_users": "Utilisateurs disponibles",
"invite_failed": "L'invitation a échoué",
"invite_revoked": "Inviter révoqué",
"invite_sent": "Inviter envoyé",
"revoke_failed": "Revoke a échoué",
"unshare_failed": "Sans partage a échoué"
},
"profile": {
"member_since": "Membre depuis",

View File

@@ -564,7 +564,22 @@
"unvisited_first": "Non visitato per primo",
"visited_first": "Visitato per primo",
"total_items": "Articoli totali",
"getting_location_details": "Ottenere dettagli sulla posizione"
"getting_location_details": "Ottenere dettagli sulla posizione",
"cities_available": "Città disponibili",
"destination_revealed": "Destinazione rivelata!",
"dive_deeper": "Immergersi più in profondità",
"exploration_progress": "Progressi di esplorazione",
"explore_country": "Esplora il paese",
"globe_spin_error_desc": "Errore che recupera i dati di spin Globe",
"hide_globe_spin": "Nascondi lo spin di globo",
"in": "In",
"loading_globe_spin": "Caricamento di rotazione del globo",
"no_globe_spin_data": "Nessun dati di spin Globe",
"show_globe_spin": "Mostra lo spin globo",
"spin_again": "Girare di nuovo",
"spinning_globe": "Globe rotante",
"try_again": "Riprova",
"your_random_adventure_awaits": "La tua avventura casuale ti aspetta!"
},
"settings": {
"account_settings": "Impostazioni dell'account utente",
@@ -853,7 +868,13 @@
"available": "Disponibile",
"pending": "In attesa di",
"revoke_invite": "Revoca invito",
"send_invite": "Invia invito"
"send_invite": "Invia invito",
"available_users": "Utenti disponibili",
"invite_failed": "Invito fallito",
"invite_revoked": "Invita revocato",
"invite_sent": "Invito inviato",
"revoke_failed": "Revoca fallita",
"unshare_failed": "Unshare non è riuscito"
},
"profile": {
"member_since": "Membro da",

View File

@@ -902,7 +902,13 @@
"shared": "共有",
"shared_with": "共有",
"unshared": "非共有",
"with": "と"
"with": "と",
"available_users": "利用可能なユーザー",
"invite_failed": "招待は失敗しました",
"invite_revoked": "招待された招待",
"invite_sent": "送信招待",
"revoke_failed": "取り消しは失敗しました",
"unshare_failed": "UNSHAREは失敗しました"
},
"strava": {
"account_connected": "接続されたアカウント",
@@ -1013,6 +1019,21 @@
"visit_remove_failed": "訪問を削除できませんでした",
"visit_to": "訪問",
"visited_first": "最初に訪問しました",
"getting_location_details": "場所の詳細を取得します"
"getting_location_details": "場所の詳細を取得します",
"cities_available": "利用可能な都市",
"destination_revealed": "目的地が明らかに!",
"dive_deeper": "より深く潜ります",
"exploration_progress": "探索の進行",
"explore_country": "国を探索します",
"globe_spin_error_desc": "グローブスピンデータの取得エラー",
"hide_globe_spin": "グローブスピンを隠します",
"in": "で",
"loading_globe_spin": "グローブスピンのロード",
"no_globe_spin_data": "グローブスピンデータはありません",
"show_globe_spin": "グローブスピンを表示します",
"spin_again": "もう一度スピンします",
"spinning_globe": "スピニンググローブ",
"try_again": "もう一度やり直してください",
"your_random_adventure_awaits": "あなたのランダムな冒険が待っています!"
}
}

View File

@@ -846,7 +846,13 @@
"available": "사용 가능",
"pending": "보류 중",
"revoke_invite": "취소 초대",
"send_invite": "초대를 보내십시오"
"send_invite": "초대를 보내십시오",
"available_users": "사용 가능한 사용자",
"invite_failed": "초대 실패",
"invite_revoked": "취소 된 초대",
"invite_sent": "초대장",
"revoke_failed": "취소가 실패했습니다",
"unshare_failed": "공해를 실패했습니다"
},
"transportation": {
"edit": "편집",
@@ -938,7 +944,22 @@
"unvisited_first": "먼저 방문하지 않습니다",
"visited_first": "먼저 방문했습니다",
"total_items": "총 항목",
"getting_location_details": "위치 세부 정보 얻기"
"getting_location_details": "위치 세부 정보 얻기",
"dive_deeper": "더 깊이 다이빙하십시오",
"exploration_progress": "탐사 진행",
"explore_country": "국가를 탐험하십시오",
"globe_spin_error_desc": "오류 페치 글로브 스핀 데이터",
"hide_globe_spin": "글로브 스핀을 숨기십시오",
"in": "~에",
"loading_globe_spin": "로드 글로브 스핀",
"no_globe_spin_data": "글로브 스핀 데이터가 없습니다",
"show_globe_spin": "글로브 스핀을 보여주십시오",
"spin_again": "다시 회전하십시오",
"spinning_globe": "회전하는 글로브",
"try_again": "다시 시도하십시오",
"your_random_adventure_awaits": "당신의 임의의 모험이 기다리고 있습니다!",
"cities_available": "이용 가능",
"destination_revealed": "목적지 공개!"
},
"lodging": {
"apartment": "아파트",

View File

@@ -564,7 +564,22 @@
"unvisited_first": "Eerst niet bezocht",
"visited_first": "Eerst bezocht",
"total_items": "Totale items",
"getting_location_details": "Locatiegegevens krijgen"
"getting_location_details": "Locatiegegevens krijgen",
"cities_available": "Steden beschikbaar",
"destination_revealed": "Bestemming onthuld!",
"dive_deeper": "Duik dieper",
"exploration_progress": "Verkennings voortgang",
"explore_country": "Verken het land",
"globe_spin_error_desc": "Fout bij het ophalen van globe spin -gegevens",
"hide_globe_spin": "Globe spin verbergen",
"in": "in",
"loading_globe_spin": "Globe spin laden",
"no_globe_spin_data": "Geen Globe spin -gegevens",
"show_globe_spin": "Toon Globe Spin",
"spin_again": "Weer spinnen",
"spinning_globe": "Spinnende bol",
"try_again": "Probeer het opnieuw",
"your_random_adventure_awaits": "Je willekeurige avontuur wacht!"
},
"settings": {
"account_settings": "Gebruikersaccount instellingen",
@@ -853,7 +868,13 @@
"available": "Beschikbaar",
"pending": "In behandeling",
"revoke_invite": "Revoke uitnodigen",
"send_invite": "Stuur uitnodiging"
"send_invite": "Stuur uitnodiging",
"available_users": "Beschikbare gebruikers",
"invite_failed": "Uitnodigen mislukt",
"invite_revoked": "Uitnodigen ingetrokken",
"invite_sent": "Uitnodigen verzonden",
"revoke_failed": "Revoke mislukt",
"unshare_failed": "Onverschuiving mislukt"
},
"profile": {
"member_since": "Lid sinds",

View File

@@ -534,7 +534,22 @@
"unvisited_first": "Uvisitert først",
"visited_first": "Besøkte først",
"total_items": "Totalt gjenstander",
"getting_location_details": "Få stedsdetaljer"
"getting_location_details": "Få stedsdetaljer",
"cities_available": "Byer tilgjengelig",
"destination_revealed": "Destinasjon avslørt!",
"dive_deeper": "Dykk dypere",
"exploration_progress": "Utforskningsfremgang",
"explore_country": "Utforsk landet",
"globe_spin_error_desc": "Feilhåndtering av klode -spinndata",
"hide_globe_spin": "Skjul klode spinn",
"in": "i",
"loading_globe_spin": "Laster klode spinn",
"no_globe_spin_data": "Ingen klode spinndata",
"show_globe_spin": "Vis Globe Spin",
"spin_again": "Spinn igjen",
"spinning_globe": "Spinnende klode",
"try_again": "Prøv igjen",
"your_random_adventure_awaits": "Ditt tilfeldige eventyr venter!"
},
"auth": {
"username": "Brukernavn",
@@ -873,7 +888,13 @@
"available": "Tilgjengelig",
"pending": "I påvente av",
"revoke_invite": "Revoke Inviter",
"send_invite": "Send invitasjon"
"send_invite": "Send invitasjon",
"available_users": "Tilgjengelige brukere",
"invite_failed": "Inviter mislyktes",
"invite_revoked": "Inviter tilbakekalt",
"invite_sent": "Inviter sendt",
"revoke_failed": "Revoke mislyktes",
"unshare_failed": "Unshare mislyktes"
},
"profile": {
"member_since": "Medlem siden",

View File

@@ -535,7 +535,22 @@
"unvisited_first": "Najpierw niewidziane",
"visited_first": "Odwiedziłem pierwszy",
"total_items": "Całkowite przedmioty",
"getting_location_details": "Uzyskanie szczegółów lokalizacji"
"getting_location_details": "Uzyskanie szczegółów lokalizacji",
"cities_available": "Dostępne miasta",
"destination_revealed": "Ujawnione miejsce docelowe!",
"dive_deeper": "Nurkuj głębiej",
"exploration_progress": "Postęp eksploracyjny",
"explore_country": "Poznaj kraj",
"globe_spin_error_desc": "Błąd przyciąganie danych spinowych globe",
"hide_globe_spin": "Ukryj globe spin",
"in": "W",
"loading_globe_spin": "Ładowanie globowego spinu",
"no_globe_spin_data": "Brak danych spinowych globe",
"show_globe_spin": "Pokaż globe spin",
"spin_again": "Obrócić ponownie",
"spinning_globe": "Spinning Globe",
"try_again": "Spróbuj ponownie",
"your_random_adventure_awaits": "Twoja przypadkowa przygoda czeka!"
},
"auth": {
"username": "Nazwa użytkownika",
@@ -852,7 +867,13 @@
"available": "Dostępny",
"pending": "Aż do",
"revoke_invite": "Cofnij zaproszenie",
"send_invite": "Wyślij zaproszenie"
"send_invite": "Wyślij zaproszenie",
"available_users": "Dostępni użytkownicy",
"invite_failed": "Zaproś nieudane",
"invite_revoked": "Zaproś cofnięte",
"invite_sent": "Zaproś wysłane",
"revoke_failed": "Revoke nie powiodło się",
"unshare_failed": "Unhare nie powiodło się"
},
"languages": {},
"profile": {

View File

File diff suppressed because it is too large Load Diff

View File

@@ -535,7 +535,22 @@
"unvisited_first": "Сначала не посещенные",
"visited_first": "Сначала посещенные",
"total_items": "Общие предметы",
"getting_location_details": "Получение деталей локации"
"getting_location_details": "Получение деталей локации",
"cities_available": "Города доступны",
"destination_revealed": "Открыто место!",
"dive_deeper": "Погрузитесь глубже",
"exploration_progress": "Прогресс исследования",
"explore_country": "Исследуйте страну",
"globe_spin_error_desc": "Ошибка извлечения данных спиновых глобусов",
"hide_globe_spin": "Скрыть глобус спин",
"in": "в",
"loading_globe_spin": "Загрузка глобуса спина",
"no_globe_spin_data": "Нет данных о вращении Globe",
"show_globe_spin": "Показать Globe Spin",
"spin_again": "Снова спите",
"spinning_globe": "Вращающийся глобус",
"try_again": "Попробуйте еще раз",
"your_random_adventure_awaits": "Ваше случайное приключение ждет!"
},
"auth": {
"username": "Имя пользователя",
@@ -874,7 +889,13 @@
"available": "Доступный",
"pending": "В ожидании",
"revoke_invite": "Отменить приглашение",
"send_invite": "Отправить приглашение"
"send_invite": "Отправить приглашение",
"available_users": "Доступные пользователи",
"invite_failed": "Приглашение не удалось",
"invite_revoked": "Пригласить отменен",
"invite_sent": "Пригласить отправлено",
"revoke_failed": "Отмена не удалась",
"unshare_failed": "UNSHARE не удалось"
},
"languages": {},
"profile": {

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

1018
frontend/src/locales/tr.json Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -561,7 +561,22 @@
"unvisited_first": "未访问优先",
"visited_first": "已访问优先",
"total_items": "总项目",
"getting_location_details": "获取地点详细信息"
"getting_location_details": "获取地点详细信息",
"cities_available": "可用的城市",
"destination_revealed": "目的地揭示了!",
"dive_deeper": "深入潜水",
"exploration_progress": "勘探进度",
"explore_country": "探索国家",
"globe_spin_error_desc": "错误获取地球旋转数据",
"hide_globe_spin": "隐藏环球旋转",
"in": "在",
"loading_globe_spin": "加载地球旋转",
"no_globe_spin_data": "没有地球旋转数据",
"show_globe_spin": "显示环球旋转",
"spin_again": "再次旋转",
"spinning_globe": "旋转地球",
"try_again": "再试一次",
"your_random_adventure_awaits": "您的随机冒险在等待!"
},
"users": {
"no_users_found": "未找到已公开的用户。"
@@ -854,8 +869,12 @@
"pending": "待办的",
"revoke_invite": "撤销邀请",
"send_invite": "发送邀请",
"available_users": "可邀请的用户",
"no_available_users": "没有可邀请的用户"
"available_users": "可用户",
"invite_failed": "邀请失败",
"invite_revoked": "邀请被撤销",
"invite_sent": "邀请发送",
"revoke_failed": "撤销失败",
"unshare_failed": "没有共享失败"
},
"profile": {
"member_since": "会员自",

View File

@@ -21,6 +21,7 @@
register('ar', () => import('../locales/ar.json'));
register('pt-br', () => import('../locales/pt-br.json'));
register('sk', () => import('../locales/sk.json'));
register('tr', () => import('../locales/tr.json'));
let locales = [
'en',
@@ -38,7 +39,8 @@
'ja',
'ar',
'pt-br',
'sk'
'sk',
'tr'
];
if (browser) {

View File

@@ -8,11 +8,14 @@ const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
let sessionId = event.cookies.get('sessionid');
let visitedFetch = await fetch(`${endpoint}/api/locations/all/?include_collections=true`, {
headers: {
Cookie: `sessionid=${sessionId}`
let visitedFetch = await fetch(
`${endpoint}/api/locations/all?include_collections=true&nested=true&allowed_nested_fields=visits`,
{
headers: {
Cookie: `sessionid=${sessionId}`
}
}
});
);
let adventures = (await visitedFetch.json()) as Location[];
// Get user's local timezone as fallback

View File

@@ -12,88 +12,66 @@ const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
if (!event.locals.user) {
return redirect(302, '/login');
} else {
let next = null;
let previous = null;
let count = 0;
let collections: Location[] = [];
let sessionId = event.cookies.get('sessionid');
}
// Get sorting parameters from URL
const order_by = event.url.searchParams.get('order_by') || 'updated_at';
const order_direction = event.url.searchParams.get('order_direction') || 'desc';
const page = event.url.searchParams.get('page') || '1';
const sessionId = event.cookies.get('sessionid');
if (!sessionId) {
return redirect(302, '/login');
}
// Build API URL with parameters
let apiUrl = `${serverEndpoint}/api/collections/?order_by=${order_by}&order_direction=${order_direction}&page=${page}`;
// Get sorting parameters from URL
const order_by = event.url.searchParams.get('order_by') || 'updated_at';
const order_direction = event.url.searchParams.get('order_direction') || 'desc';
const page = event.url.searchParams.get('page') || '1';
const currentPage = parseInt(page);
let initialFetch = await fetch(apiUrl, {
headers: {
Cookie: `sessionid=${sessionId}`
},
credentials: 'include'
});
if (!initialFetch.ok) {
console.error('Failed to fetch collections');
return redirect(302, '/login');
} else {
let res = await initialFetch.json();
let visited = res.results as Location[];
next = res.next;
previous = res.previous;
count = res.count;
collections = [...collections, ...visited];
}
// Common headers for all requests
const headers = {
Cookie: `sessionid=${sessionId}`
};
let sharedRes = await fetch(`${serverEndpoint}/api/collections/shared/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (!sharedRes.ok) {
console.error('Failed to fetch shared collections');
// Build API URL with nested=true for lighter payload
const apiUrl = `${serverEndpoint}/api/collections/?order_by=${order_by}&order_direction=${order_direction}&page=${page}&nested=true`;
try {
// Execute all API calls in parallel
const [collectionsRes, sharedRes, archivedRes, invitesRes] = await Promise.all([
fetch(apiUrl, { headers, credentials: 'include' }),
fetch(`${serverEndpoint}/api/collections/shared/?nested=true`, { headers }),
fetch(`${serverEndpoint}/api/collections/archived/?nested=true`, { headers }),
fetch(`${serverEndpoint}/api/collections/invites/`, { headers })
]);
// Check if main collections request failed (most critical)
if (!collectionsRes.ok) {
console.error('Failed to fetch collections:', collectionsRes.status);
return redirect(302, '/login');
}
let sharedCollections = (await sharedRes.json()) as Collection[];
let archivedRes = await fetch(`${serverEndpoint}/api/collections/archived/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (!archivedRes.ok) {
console.error('Failed to fetch archived collections');
return redirect(302, '/login');
}
let archivedCollections = (await archivedRes.json()) as Collection[];
let inviteRes = await fetch(`${serverEndpoint}/api/collections/invites/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (!inviteRes.ok) {
console.error('Failed to fetch invites');
return redirect(302, '/login');
}
let invites = await inviteRes.json();
// Calculate current page from URL
const currentPage = parseInt(page);
// Parse responses in parallel
const [collectionsData, sharedData, archivedData, invitesData] = await Promise.all([
collectionsRes.json(),
sharedRes.ok ? sharedRes.json() : [],
archivedRes.ok ? archivedRes.json() : [],
invitesRes.ok ? invitesRes.json() : []
]);
return {
props: {
adventures: collections,
next,
previous,
count,
sharedCollections,
adventures: collectionsData.results as Location[],
next: collectionsData.next,
previous: collectionsData.previous,
count: collectionsData.count,
sharedCollections: sharedData as Collection[],
currentPage,
order_by,
order_direction,
archivedCollections,
invites
archivedCollections: archivedData as Collection[],
invites: invitesData
}
};
} catch (error) {
console.error('Error fetching data:', error);
return redirect(302, '/login');
}
}) satisfies PageServerLoad;

View File

@@ -33,7 +33,8 @@
groupLodgingByDate,
LODGING_TYPES_ICONS,
getBasemapUrl,
isAllDay
isAllDay,
getActivityColor
} from '$lib';
import { formatDateInTimezone, formatAllDayDate } from '$lib/dateUtils';
@@ -1230,7 +1231,7 @@
</div>
<div>
<!-- Duration -->
{Math.round(
{Math.floor(
(new Date(orderedItem.end).getTime() -
new Date(orderedItem.start).getTime()) /
1000 /
@@ -1375,6 +1376,39 @@
</Marker>
{/if}
{/each}
<!-- Shows activity GPX on the map -->
{#each adventures as adventure}
{#each adventure.visits as visit}
{#each visit.activities as activity}
{#if activity.geojson}
<GeoJSON data={activity.geojson}>
<LineLayer
paint={{
'line-color': getActivityColor(activity.sport_type),
'line-width': 3,
'line-opacity': 0.8
}}
/>
</GeoJSON>
{/if}
{/each}
{/each}
{#each adventure.attachments as attachment}
{#if attachment.geojson}
<GeoJSON data={attachment.geojson}>
<LineLayer
paint={{
'line-color': '#00FF00',
'line-width': 2,
'line-opacity': 0.6
}}
/>
</GeoJSON>
{/if}
{/each}
{/each}
{#if lineData && collection.start_date && collection.end_date}
<GeoJSON data={lineData}>
<LineLayer

View File

@@ -1,7 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { Location, VisitedRegion } from '$lib/types';
import type { Location, Pin, VisitedRegion } from '$lib/types';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
@@ -9,7 +9,7 @@ export const load = (async (event) => {
return redirect(302, '/login');
} else {
let sessionId = event.cookies.get('sessionid');
let visitedFetch = await fetch(`${endpoint}/api/locations/all/?include_collections=true`, {
let pinFetch = await fetch(`${endpoint}/api/locations/pins/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
@@ -22,19 +22,19 @@ export const load = (async (event) => {
});
let visitedRegions = (await visitedRegionsFetch.json()) as VisitedRegion[];
let adventures = (await visitedFetch.json()) as Location[];
let pins = (await pinFetch.json()) as Pin[];
if (!visitedRegionsFetch.ok) {
console.error('Failed to fetch visited regions');
return redirect(302, '/login');
} else if (!visitedFetch.ok) {
console.error('Failed to fetch visited adventures');
} else if (!pinFetch.ok) {
console.error('Failed to fetch location pins');
return redirect(302, '/login');
} else {
return {
props: {
visitedRegions,
adventures
pins
}
};
}

View File

@@ -9,7 +9,7 @@
LineLayer
} from 'svelte-maplibre';
import { t } from 'svelte-i18n';
import type { Activity, Location, VisitedCity, VisitedRegion } from '$lib/types.js';
import type { Activity, Location, VisitedCity, VisitedRegion, Pin } from '$lib/types.js';
import CardCarousel from '$lib/components/CardCarousel.svelte';
import { goto } from '$app/navigation';
import { basemapOptions, getActivityColor, getBasemapLabel, getBasemapUrl } from '$lib';
@@ -20,7 +20,7 @@
import Plus from '~icons/mdi/plus';
import Clear from '~icons/mdi/close';
import Eye from '~icons/mdi/eye';
import Pin from '~icons/mdi/map-marker';
import PinIcon from '~icons/mdi/map-marker';
import Calendar from '~icons/mdi/calendar';
import LocationIcon from '~icons/mdi/crosshairs-gps';
import NewLocationModal from '$lib/components/NewLocationModal.svelte';
@@ -35,17 +35,16 @@
let showCities: boolean = false;
let sidebarOpen: boolean = false;
let basemapType: string = 'default'; // default
let basemapType: string = 'default';
export let initialLatLng: { lat: number; lng: number } | null = null;
let visitedRegions: VisitedRegion[] = data.props.visitedRegions;
let visitedCities: VisitedCity[] = [];
let adventures: Location[] = data.props.adventures;
let pins: Pin[] = data.props.pins; // Lightweight pin objects
let activities: Activity[] = [];
let filteredAdventures = adventures;
let filteredPins = pins;
let showVisited: boolean = true;
let showPlanned: boolean = true;
@@ -54,23 +53,25 @@
let newLongitude: number | null = null;
let newLatitude: number | null = null;
let isPopupOpen = false;
// Cache for full location data
let locationCache: Map<string, Location> = new Map();
let loadingLocations: Set<string> = new Set();
let locationBeingUpdated: Location | undefined = undefined;
// Statistics
$: totalAdventures = adventures.length;
$: visitedAdventures = adventures.filter((adventure) => adventure.is_visited).length;
$: plannedAdventures = adventures.filter((adventure) => !adventure.is_visited).length;
$: totalAdventures = pins.length;
$: visitedAdventures = pins.filter((pin) => pin.is_visited).length;
$: plannedAdventures = pins.filter((pin) => !pin.is_visited).length;
$: totalRegions = visitedRegions.length;
// Get unique categories for filtering
$: categories = [
...new Set(adventures.map((adventure) => adventure.category?.display_name).filter(Boolean))
];
$: categories = [...new Set(pins.map((pin) => pin.category?.display_name).filter(Boolean))];
// Updates the filtered adventures based on the checkboxes
// Updates the filtered pins based on the checkboxes
$: {
filteredAdventures = adventures.filter(
(adventure) => (showVisited && adventure.is_visited) || (showPlanned && !adventure.is_visited)
filteredPins = pins.filter(
(pin) => (showVisited && pin.is_visited === true) || (showPlanned && pin.is_visited !== true)
);
}
@@ -82,22 +83,37 @@
}
}
let locationBeingUpdated: Location | undefined = undefined;
// Sync the locationBeingUpdated with the adventures array
// Sync the locationBeingUpdated with the pins array
$: {
if (locationBeingUpdated && locationBeingUpdated.id) {
const index = adventures.findIndex((adventure) => adventure.id === locationBeingUpdated?.id);
const index = pins.findIndex((pin) => pin.id === locationBeingUpdated?.id);
if (index !== -1) {
adventures[index] = { ...locationBeingUpdated };
adventures = adventures; // Trigger reactivity
// Update existing pin with new data
pins[index] = {
id: locationBeingUpdated.id,
name: locationBeingUpdated.name,
latitude: locationBeingUpdated.latitude?.toString() || '',
longitude: locationBeingUpdated.longitude?.toString() || '',
is_visited: locationBeingUpdated.is_visited,
category: locationBeingUpdated.category
};
pins = pins; // Trigger reactivity
} else {
adventures = [{ ...locationBeingUpdated }, ...adventures];
if (data.props.adventures) {
data.props.adventures = adventures; // Update data.props.adventure.locations as well
}
// Add new pin
const newPin: Pin = {
id: locationBeingUpdated.id,
name: locationBeingUpdated.name,
latitude: locationBeingUpdated.latitude?.toString() || '',
longitude: locationBeingUpdated.longitude?.toString() || '',
is_visited: locationBeingUpdated.is_visited,
category: locationBeingUpdated.category
};
pins = [newPin, ...pins];
}
// Also update the cache
locationCache.set(locationBeingUpdated.id, locationBeingUpdated);
}
}
@@ -124,6 +140,36 @@
visitedCities = await response.json();
}
async function fetchLocationDetails(locationId: string): Promise<Location | null> {
// Check cache first
if (locationCache.has(locationId)) {
return locationCache.get(locationId)!;
}
// Prevent duplicate requests
if (loadingLocations.has(locationId)) {
return null;
}
try {
loadingLocations.add(locationId);
const response = await fetch(`/api/locations/${locationId}`);
if (!response.ok) {
throw new Error(`Failed to fetch location: ${response.statusText}`);
}
const location: Location = await response.json();
locationCache.set(locationId, location);
return location;
} catch (error) {
console.error('Error fetching location details:', error);
return null;
} finally {
loadingLocations.delete(locationId);
}
}
function addMarker(e: { detail: { lngLat: { lng: any; lat: any } } }) {
newMarker = null;
newMarker = { lngLat: e.detail.lngLat };
@@ -137,22 +183,43 @@
}
function createNewAdventure(event: CustomEvent) {
adventures = [...adventures, event.detail];
const location: Location = event.detail;
// Add to pins array
const newPin: Pin = {
id: location.id,
name: location.name,
latitude: location.latitude?.toString() || '',
longitude: location.longitude?.toString() || '',
is_visited: location.is_visited,
category: location.category
};
pins = [...pins, newPin];
// Add to cache
locationCache.set(location.id, location);
newMarker = null;
createModalOpen = false;
}
function togglePopup() {
isPopupOpen = !isPopupOpen;
}
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}
function clearMarker() {
newMarker = null;
}
// Function to handle popup opening - only fetch when actually needed
let openPopups = new Set<string>();
function handlePopupOpen(pinId: string) {
openPopups.add(pinId);
openPopups = openPopups; // Trigger reactivity
}
function handlePopupClose(pinId: string) {
openPopups.delete(pinId);
openPopups = openPopups; // Trigger reactivity
}
</script>
<svelte:head>
@@ -170,7 +237,10 @@
<div class="container mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<button class="btn btn-ghost btn-square lg:hidden" on:click={toggleSidebar}>
<button
class="btn btn-ghost btn-square lg:hidden"
on:click={() => (sidebarOpen = !sidebarOpen)}
>
<Filter class="w-5 h-5" />
</button>
<div class="flex items-center gap-3">
@@ -182,7 +252,7 @@
{$t('map.location_map')}
</h1>
<p class="text-sm text-base-content/60">
{filteredAdventures.length}
{filteredPins.length}
{$t('worldtravel.of')}
{totalAdventures}
{$t('map.locations_shown')}
@@ -252,101 +322,146 @@
class="w-full h-full min-h-[70vh] rounded-lg"
standardControls
>
{#each filteredAdventures as adventure}
{#if adventure.latitude && adventure.longitude}
{#each filteredPins as pin}
{#if pin.latitude && pin.longitude}
<Marker
lngLat={[adventure.longitude, adventure.latitude]}
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200 shadow-lg cursor-pointer hover:scale-110 transition-transform {adventure.is_visited
? 'bg-red-300 hover:bg-red-400'
: 'bg-blue-300 hover:bg-blue-400'} text-black focus:outline-6 focus:outline-black"
on:click={togglePopup}
lngLat={[parseFloat(pin.longitude), parseFloat(pin.latitude)]}
class="grid h-8 w-8 place-items-center rounded-full border-2 border-white shadow-lg cursor-pointer hover:scale-110 transition-all duration-200 {pin.is_visited
? 'bg-gradient-to-br from-emerald-400 to-emerald-600 hover:from-emerald-500 hover:to-emerald-700'
: 'bg-gradient-to-br from-blue-400 to-blue-600 hover:from-blue-500 hover:to-blue-700'} text-white focus:outline-4 focus:outline-primary/50"
>
<span class="text-xl">
{adventure.category?.icon || '📍'}
{pin.category?.icon || '📍'}
</span>
{#if isPopupOpen}
<Popup
openOn="click"
offset={[0, -10]}
on:close={() => (isPopupOpen = false)}
>
<div class="min-w-64 max-w-sm">
{#if adventure.images && adventure.images.length > 0}
<div class="mb-3">
<CardCarousel
images={adventure.images}
name={adventure.name}
icon={adventure?.category?.icon}
/>
<Popup
openOn="click"
offset={[0, -10]}
on:open={() => handlePopupOpen(pin.id)}
on:close={() => handlePopupClose(pin.id)}
>
<div class="min-w-64 max-w-sm">
{#if openPopups.has(pin.id)}
{#await fetchLocationDetails(pin.id)}
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="ml-2 text-sm">Loading details...</span>
</div>
{/if}
<div class="space-y-2">
<div class="text-lg text-black font-bold">{adventure.name}</div>
<div class="flex items-center gap-2">
<span
class="badge {adventure.is_visited
? 'badge-success'
: 'badge-info'} badge-sm"
>
{adventure.is_visited
? $t('adventures.visited')
: $t('adventures.planned')}
</span>
{#if adventure.category}
<span class="badge badge-outline badge-sm">
{adventure.category.display_name}
{adventure.category.icon}
</span>
{:then location}
{#if location}
{#if location.images && location.images.length > 0}
<div class="mb-3">
<CardCarousel
images={location.images}
name={location.name}
icon={location?.category?.icon}
/>
</div>
{/if}
</div>
{#if adventure.visits && adventure.visits.length > 0}
<div class="text-black text-sm space-y-1">
{#each adventure.visits as visit}
<div class="flex items-center gap-1">
<Calendar class="w-3 h-3" />
<span>
{visit.start_date
? new Date(visit.start_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})
: ''}
{visit.end_date &&
visit.end_date !== '' &&
visit.end_date !== visit.start_date
? ' - ' +
new Date(visit.end_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})
: ''}
<div class="space-y-2">
<div class="text-lg text-black font-bold">{location.name}</div>
<div class="flex items-center gap-2">
<span
class="badge {location.is_visited
? 'badge-success'
: 'badge-info'} badge-sm"
>
{location.is_visited
? $t('adventures.visited')
: $t('adventures.planned')}
</span>
{#if location.category}
<span class="badge badge-outline badge-sm">
{location.category.display_name}
{location.category.icon}
</span>
{/if}
</div>
{#if location.visits && location.visits.length > 0}
<div class="text-black text-sm space-y-1">
{#each location.visits as visit}
<div class="flex items-center gap-1">
<Calendar class="w-3 h-3" />
<span>
{visit.start_date
? new Date(visit.start_date).toLocaleDateString(
undefined,
{
timeZone: 'UTC'
}
)
: ''}
{visit.end_date &&
visit.end_date !== '' &&
visit.end_date !== visit.start_date
? ' - ' +
new Date(visit.end_date).toLocaleDateString(
undefined,
{
timeZone: 'UTC'
}
)
: ''}
</span>
</div>
{/each}
</div>
{/each}
{/if}
<div class="flex flex-col gap-2 pt-2">
{#if location.longitude && location.latitude}
<a
class="btn btn-outline btn-sm gap-2"
href={`https://maps.apple.com/?q=${location.latitude},${location.longitude}`}
target="_blank"
rel="noopener noreferrer"
>
<LocationIcon class="w-4 h-4" />
{$t('adventures.open_in_maps')}
</a>
{/if}
<button
class="btn btn-primary btn-sm gap-2"
on:click={() => goto(`/locations/${location.id}`)}
>
<Eye class="w-4 h-4" />
{$t('map.view_details')}
</button>
</div>
</div>
{:else}
<div class="p-4 text-center">
<div class="text-lg text-black font-bold">{pin.name}</div>
<div class="text-sm text-gray-600">Failed to load details</div>
<button
class="btn btn-primary btn-sm gap-2 mt-2"
on:click={() => goto(`/locations/${pin.id}`)}
>
<Eye class="w-4 h-4" />
{$t('map.view_details')}
</button>
</div>
{/if}
<div class="flex flex-col gap-2 pt-2">
{#if adventure.longitude && adventure.latitude}
<a
class="btn btn-outline btn-sm gap-2"
href={`https://maps.apple.com/?q=${adventure.latitude},${adventure.longitude}`}
target="_blank"
rel="noopener noreferrer"
>
<LocationIcon class="w-4 h-4" />
{$t('adventures.open_in_maps')}
</a>
{/if}
{:catch error}
<div class="p-4 text-center">
<div class="text-lg text-black font-bold">{pin.name}</div>
<div class="text-sm text-red-600">Error loading details</div>
<button
class="btn btn-primary btn-sm gap-2"
on:click={() => goto(`/locations/${adventure.id}`)}
class="btn btn-primary btn-sm gap-2 mt-2"
on:click={() => goto(`/locations/${pin.id}`)}
>
<Eye class="w-4 h-4" />
{$t('map.view_details')}
</button>
</div>
{/await}
{:else}
<div class="p-4 text-center">
<div class="text-lg text-black font-bold">{pin.name}</div>
<div class="text-sm text-gray-600">Click to load details...</div>
</div>
</div>
</Popup>
{/if}
{/if}
</div>
</Popup>
</Marker>
{/if}
{/each}
@@ -551,7 +666,7 @@
{#if newMarker}
<div class="space-y-3">
<div class="alert alert-info">
<Pin class="w-4 h-4" />
<PinIcon class="w-4 h-4" />
<span class="text-sm">{$t('map.marker_placed_on_map')}</span>
</div>
<button type="button" class="btn btn-primary w-full gap-2" on:click={newAdventure}>

View File

@@ -26,6 +26,7 @@
const allCountries: Country[] = data.props?.countries || [];
let worldSubregions: string[] = [];
let showMap: boolean = false;
let showGlobeSpin: boolean = false;
let sidebarOpen = false;
worldSubregions = [...new Set(allCountries.map((country) => country.subregion))];
@@ -74,6 +75,39 @@
}
}
// when isGlobeSpin is enabled, fetch /api/globespin/
type GlobeSpinData = {
country: {
flag_url: string;
name: string;
country_code: string;
num_visits: number;
subregion: string;
capital: string;
num_regions: number;
};
region: { name: string; num_cities: number };
city: { name: string; region_name: string };
};
let globeSpinData: GlobeSpinData | null = null;
let isLoadingGlobeSpin = false;
async function fetchGlobeSpin() {
isLoadingGlobeSpin = true;
try {
const response = await fetch('/api/globespin/');
if (response.ok) {
globeSpinData = await response.json();
} else {
console.error('Failed to fetch globe spin data');
}
} catch (error) {
console.error('Error fetching globe spin data:', error);
} finally {
isLoadingGlobeSpin = false;
}
}
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}
@@ -171,6 +205,24 @@
<span class="hidden sm:inline">{$t('worldtravel.show_map')}</span>
{/if}
</button>
<!-- Globe Spin Toggle -->
<button
class="btn btn-outline gap-2 {showGlobeSpin ? 'btn-active' : ''}"
on:click={() => {
showGlobeSpin = !showGlobeSpin;
if (showGlobeSpin) {
fetchGlobeSpin();
}
}}
>
{#if showGlobeSpin}
<Globe class="w-4 h-4" />
<span class="hidden sm:inline">{$t('worldtravel.hide_globe_spin')}</span>
{:else}
<Globe class="w-4 h-4" />
<span class="hidden sm:inline">{$t('worldtravel.show_globe_spin')}</span>
{/if}
</button>
</div>
<!-- Filter Chips -->
@@ -264,6 +316,257 @@
</div>
{/if}
<!-- Globe Spin Section -->
{#if showGlobeSpin}
<div class="container mx-auto px-6 py-4">
<div class="card bg-base-100 shadow-xl overflow-hidden">
<div class="card-body p-6">
{#if isLoadingGlobeSpin}
<!-- Loading State with Spinning Globe -->
<div class="flex flex-col items-center py-12">
<div class="relative">
<!-- Spinning globe with pulse effect -->
<div class="relative animate-spin" style="animation-duration: 3s;">
<div
class="w-24 h-24 rounded-full bg-gradient-to-br from-primary/20 to-accent/30 flex items-center justify-center border-4 border-primary/30"
>
<Globe class="w-12 h-12 text-primary" />
</div>
<!-- Orbit rings -->
<div
class="absolute inset-0 rounded-full border-2 border-dashed border-primary/20 animate-pulse"
></div>
<div
class="absolute -inset-2 rounded-full border border-dashed border-accent/20 animate-pulse"
style="animation-delay: 0.5s;"
></div>
</div>
<!-- Sparkle effects -->
<div
class="absolute -top-2 -right-2 w-3 h-3 bg-yellow-400 rounded-full animate-ping"
></div>
<div
class="absolute -bottom-3 -left-3 w-2 h-2 bg-blue-400 rounded-full animate-ping"
style="animation-delay: 1s;"
></div>
<div
class="absolute top-1/2 -right-4 w-1.5 h-1.5 bg-green-400 rounded-full animate-ping"
style="animation-delay: 2s;"
></div>
</div>
<div class="mt-6 text-center">
<h3 class="text-xl font-bold text-primary mb-2">
{$t('worldtravel.spinning_globe') + '...'}
</h3>
<p class="text-base-content/70 animate-pulse">
{$t('worldtravel.loading_globe_spin')}
</p>
<div class="flex items-center justify-center gap-1 mt-3">
<div class="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
<div
class="w-2 h-2 bg-primary rounded-full animate-bounce"
style="animation-delay: 0.2s;"
></div>
<div
class="w-2 h-2 bg-primary rounded-full animate-bounce"
style="animation-delay: 0.4s;"
></div>
</div>
</div>
</div>
{:else if globeSpinData}
<!-- Result Display with Amazing Animations -->
<div class="text-center">
<div class="mb-6">
<h3
class="text-2xl font-bold text-primary mb-2 flex items-center justify-center gap-3"
>
<Globe class="w-8 h-8 animate-spin" style="animation-duration: 4s;" />
{$t('worldtravel.destination_revealed')}
<Globe
class="w-8 h-8 animate-spin"
style="animation-duration: 4s; animation-direction: reverse;"
/>
</h3>
<p class="text-base-content/60">
{$t('worldtravel.your_random_adventure_awaits')}
</p>
</div>
<!-- Country Card with Entrance Animation -->
<div class="animate-slideInUp" style="animation-duration: 0.8s;">
<!-- Flag with Reveal Effect -->
<div class="relative mb-6 mx-auto w-fit">
<div
class="relative overflow-hidden rounded-2xl shadow-2xl border-4 border-primary/20 hover:border-primary/40 transition-colors duration-300"
>
<img
src={globeSpinData.country.flag_url}
alt="{globeSpinData.country.name} flag"
class="w-64 h-40 object-cover hover:scale-105 transition-transform duration-500"
/>
<!-- Shimmer overlay -->
<div
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full animate-shimmer"
></div>
</div>
<!-- Floating badges -->
<div
class="absolute -top-3 -right-3 badge badge-primary badge-lg animate-bounce shadow-lg"
>
{globeSpinData.country.country_code}
</div>
{#if globeSpinData.country.num_visits > 0}
<div
class="absolute -top-3 -left-3 badge badge-success badge-lg animate-pulse shadow-lg"
>
<Check class="w-4 h-4 mr-1" />
{$t('adventures.visited')}
</div>
{/if}
</div>
<!-- Country Info -->
<div class="space-y-4 animate-fadeInUp" style="animation-delay: 0.2s;">
<h2
class="text-4xl font-bold text-primary bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent pb-2"
>
{globeSpinData.country.name}
</h2>
<div class="flex flex-wrap justify-center gap-4">
<div class="badge badge-lg badge-outline gap-2">
<Pin class="w-4 h-4" />
{globeSpinData.country.subregion}
</div>
{#if globeSpinData.country.capital}
<div class="badge badge-lg badge-outline gap-2">
<Globe class="w-4 h-4" />
{globeSpinData.country.capital}
</div>
{/if}
</div>
<!-- Progress Info -->
<div
class="card bg-gradient-to-br from-base-200/50 to-base-300/30 p-4 max-w-md mx-auto"
>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium"
>{$t('worldtravel.exploration_progress')}</span
>
<span class="text-lg font-bold text-primary">
{globeSpinData.country.num_visits}/{globeSpinData.country.num_regions}
</span>
</div>
<progress
class="progress progress-primary w-full"
value={globeSpinData.country.num_visits}
max={globeSpinData.country.num_regions}
></progress>
<div class="text-xs text-base-content/60 mt-1">
{Math.round(
(globeSpinData.country.num_visits / globeSpinData.country.num_regions) *
100
)}% explored
</div>
</div>
</div>
</div>
<!-- Region & City Info (if available) -->
{#if globeSpinData.region || globeSpinData.city}
<div class="mt-8 space-y-4 animate-fadeInUp" style="animation-delay: 0.4s;">
<div class="divider">
<span class="text-primary font-semibold"
>{$t('worldtravel.dive_deeper')}</span
>
</div>
<div class="grid md:grid-cols-2 gap-4 max-w-2xl mx-auto">
{#if globeSpinData.region}
<div
class="card bg-gradient-to-br from-accent/10 to-secondary/10 border border-accent/20"
>
<div class="card-body p-4">
<h4 class="font-bold text-accent flex items-center gap-2">
<Pin class="w-5 h-5" />
{$t('adventures.region')}
</h4>
<p class="text-lg font-semibold">{globeSpinData.region.name}</p>
<p class="text-sm text-base-content/60">
{globeSpinData.region.num_cities}
{$t('worldtravel.cities_available')}
</p>
</div>
</div>
{/if}
{#if globeSpinData.city}
<div
class="card bg-gradient-to-br from-success/10 to-info/10 border border-success/20"
>
<div class="card-body p-4">
<h4 class="font-bold text-success flex items-center gap-2">
<Map class="w-5 h-5" />
{$t('adventures.city')}
</h4>
<p class="text-lg font-semibold">{globeSpinData.city.name}</p>
<p class="text-sm text-base-content/60">
{$t('worldtravel.in')}
{globeSpinData.city.region_name}
</p>
</div>
</div>
{/if}
</div>
</div>
{/if}
<!-- Action Buttons -->
<div
class="mt-8 flex flex-wrap justify-center gap-4 animate-fadeInUp"
style="animation-delay: 0.6s;"
>
<a
href="/worldtravel/{globeSpinData.country.country_code}"
class="btn btn-primary btn-lg gap-2 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105"
>
<Globe class="w-5 h-5" />
{$t('worldtravel.explore_country')}
</a>
<button
class="btn btn-outline btn-lg gap-2 hover:scale-105 transition-all duration-300"
on:click={fetchGlobeSpin}
>
<Globe class="w-5 h-5 animate-spin" style="animation-duration: 2s;" />
{$t('worldtravel.spin_again')}
</button>
</div>
</div>
{:else}
<!-- No Data State -->
<div class="flex flex-col items-center py-12">
<div class="p-6 bg-error/10 rounded-2xl mb-6">
<Cancel class="w-16 h-16 text-error/50" />
</div>
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
{$t('worldtravel.no_globe_spin_data')}
</h3>
<p class="text-base-content/50 text-center max-w-md mb-6">
{$t('worldtravel.globe_spin_error_desc')}
</p>
<button class="btn btn-primary gap-2" on:click={fetchGlobeSpin}>
<Globe class="w-4 h-4" />
{$t('worldtravel.try_again')}
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Main Content -->
<div class="container mx-auto px-6 py-8">
{#if filteredCountries.length === 0}
@@ -433,3 +736,48 @@
</div>
</div>
</div>
<style>
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-slideInUp {
animation: slideInUp ease-out forwards;
}
.animate-fadeInUp {
animation: fadeInUp ease-out forwards;
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
</style>

View File

@@ -5,56 +5,56 @@ import { build, files, version } from '$service-worker';
const CACHE = `cache-${version}`;
const ASSETS = [
...build, // the app itself
...files // everything in `static`
...build, // the app itself
...files // everything in `static`
];
self.addEventListener('install', (event) => {
// Create a new cache and add all files to it
async function addFilesToCache() {
const cache = await caches.open(CACHE);
await cache.addAll(ASSETS);
}
event.waitUntil(addFilesToCache());
// Create a new cache and add all files to it
async function addFilesToCache() {
const cache = await caches.open(CACHE);
await cache.addAll(ASSETS);
}
event.waitUntil(addFilesToCache());
});
self.addEventListener('activate', (event) => {
// Remove previous cached data from disk
async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) await caches.delete(key);
}
}
event.waitUntil(deleteOldCaches());
// Remove previous cached data from disk
async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) await caches.delete(key);
}
}
event.waitUntil(deleteOldCaches());
});
self.addEventListener('fetch', (event) => {
// ignore POST requests, etc
if (event.request.method !== 'GET') return;
// ignore POST requests, etc
if (event.request.method !== 'GET') return;
async function respond() {
const url = new URL(event.request.url);
const cache = await caches.open(CACHE);
async function respond() {
const url = new URL(event.request.url);
const cache = await caches.open(CACHE);
// `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) {
return cache.match(url.pathname);
}
// `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) {
return cache.match(url.pathname);
}
// for everything else, try the network first, but
// fall back to the cache if we're offline
try {
const response = await fetch(event.request);
// for everything else, try the network first, but
// fall back to the cache if we're offline
try {
const response = await fetch(event.request);
if (response.status === 200) {
cache.put(event.request, response.clone());
}
if (response.status === 200) {
cache.put(event.request, response.clone());
}
return response;
} catch {
return cache.match(event.request);
}
}
return response;
} catch {
return cache.match(event.request);
}
}
event.respondWith(respond());
event.respondWith(respond());
});

View File

@@ -1,16 +1,16 @@
{
"short_name": "AdventureLog",
"name": "AdventureLog",
"start_url": "/dashboard",
"icons": [
{
"src": "adventurelog.svg",
"type": "image/svg+xml",
"sizes": "any"
}
],
"background_color": "#2a323c",
"display": "standalone",
"scope": "/",
"description": "Self-hostable travel tracker and trip planner."
}
"short_name": "AdventureLog",
"name": "AdventureLog",
"start_url": "/dashboard",
"icons": [
{
"src": "adventurelog.svg",
"type": "image/svg+xml",
"sizes": "any"
}
],
"background_color": "#2a323c",
"display": "standalone",
"scope": "/",
"description": "Self-hostable travel tracker and trip planner."
}