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 85d8b45c4eff77bcc9d5708f7fd8a4fcea91890b
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 9f5cc9ccb8809cd5ae0c01e979dae271e9197987
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 77c1f516266b2db5e1c97c5cbc8a3fb9e08d40cd
Author: Sean Morley <mail@seanmorley.com>
Date:   Wed Sep 17 19:22:36 2025 -0400

    Squashed commit of the following:

    commit 9d4f1b8f534a3cdfb22812f2a25ababd7a236a87
    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 8fac40cfde425c989521c891b3ba9c75ab32e57e
    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 8e8c42396ec77b763983155e8b1e89cabf38ce17
    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 be818ab408d00c5c26dfb3b25632604a415d3570
    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 9e40dcf6a1dc194d4694a114b3c7e88135121016
    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 af2f28090b9242fb7ab263fa5bbb95a5bcc1b27f
    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 733eefceddbdad01726364e5d4523605f095fde2
    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 6c750d1c8f95b42418893e15ad46c3d4ed86d053
    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 f733b3b96bbddc71d426f2e60320a5ad2f6755af
    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 769ea6ad710890e931aabace2c00dc37436f869f
    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 af4e541c1c9e7309857102287199279aec339387
    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 904474d757577229b47441d1378a6fd6788fbe40
    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 d47094346c0b63ea753294a0786414e5e070ae7f
    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 f87a5fe3bcc2fe28cfc206fb5cba517bbffa8df6
    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 4a5f59bfd24e32fdf3558b009a8f636636cb3663
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 21:06:17 2025 -0400

        Fixes #654

    commit c1302bb54ab272c2a98c53ce0d508b7d39e9674b
    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 773f2d65bbfb2a9591b31fabfd6844612b840f1a
    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 4228db249ed5e3261931a1cdb3895d0ddd3ac4ac
    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 26f36cabb0a860f10d7ba62b5279ddd1e282c78e
    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 3bfd2dd5615afdbd04e3451c2ef728f1d7caf466
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 12:33:23 2025 -0400

        Remove empty English (United States) locale file

    commit 6366a3eba6ab72090e52be474212a663799dfe19
    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 671cd3701fc5a601f2f1bad9aef93106f91eec0b
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 16:58:04 2025 +0200

        Added translation using Weblate (English (United States))

    commit bdbbe5f4978f041f620f0503da69fa870cb1997c
    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 f310771702291be93e1c6b3bdb5a76e02fb8cae8
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 02ed89fa46fa22f8c5d96b0e7f0948204dec9306
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 ae07c44030ff597be80988b63460595e8bea132f
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 94964f1fb16583f1ff63ddf4aee2469a532b0c6c
Author: Sean Morley <mail@seanmorley.com>
Date:   Wed Sep 17 19:22:36 2025 -0400

    Squashed commit of the following:

    commit 9d4f1b8f534a3cdfb22812f2a25ababd7a236a87
    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 8fac40cfde425c989521c891b3ba9c75ab32e57e
    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 8e8c42396ec77b763983155e8b1e89cabf38ce17
    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 be818ab408d00c5c26dfb3b25632604a415d3570
    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 9e40dcf6a1dc194d4694a114b3c7e88135121016
    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 af2f28090b9242fb7ab263fa5bbb95a5bcc1b27f
    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 733eefceddbdad01726364e5d4523605f095fde2
    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 6c750d1c8f95b42418893e15ad46c3d4ed86d053
    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 f733b3b96bbddc71d426f2e60320a5ad2f6755af
    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 769ea6ad710890e931aabace2c00dc37436f869f
    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 af4e541c1c9e7309857102287199279aec339387
    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 904474d757577229b47441d1378a6fd6788fbe40
    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 d47094346c0b63ea753294a0786414e5e070ae7f
    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 f87a5fe3bcc2fe28cfc206fb5cba517bbffa8df6
    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 4a5f59bfd24e32fdf3558b009a8f636636cb3663
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 21:06:17 2025 -0400

        Fixes #654

    commit c1302bb54ab272c2a98c53ce0d508b7d39e9674b
    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 773f2d65bbfb2a9591b31fabfd6844612b840f1a
    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 4228db249ed5e3261931a1cdb3895d0ddd3ac4ac
    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 26f36cabb0a860f10d7ba62b5279ddd1e282c78e
    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 3bfd2dd5615afdbd04e3451c2ef728f1d7caf466
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 12:33:23 2025 -0400

        Remove empty English (United States) locale file

    commit 6366a3eba6ab72090e52be474212a663799dfe19
    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 671cd3701fc5a601f2f1bad9aef93106f91eec0b
    Author: Sean Morley <mail@seanmorley.com>
    Date:   Sat Sep 6 16:58:04 2025 +0200

        Added translation using Weblate (English (United States))

    commit bdbbe5f4978f041f620f0503da69fa870cb1997c
    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 9d4f1b8f534a3cdfb22812f2a25ababd7a236a87
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 8fac40cfde425c989521c891b3ba9c75ab32e57e
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 8e8c42396ec77b763983155e8b1e89cabf38ce17
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 be818ab408d00c5c26dfb3b25632604a415d3570
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 9e40dcf6a1dc194d4694a114b3c7e88135121016
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 af2f28090b9242fb7ab263fa5bbb95a5bcc1b27f
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 733eefceddbdad01726364e5d4523605f095fde2
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 6c750d1c8f95b42418893e15ad46c3d4ed86d053
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 f733b3b96bbddc71d426f2e60320a5ad2f6755af
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 769ea6ad710890e931aabace2c00dc37436f869f
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 af4e541c1c9e7309857102287199279aec339387
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 904474d757577229b47441d1378a6fd6788fbe40
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 d47094346c0b63ea753294a0786414e5e070ae7f
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 f87a5fe3bcc2fe28cfc206fb5cba517bbffa8df6
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 4a5f59bfd24e32fdf3558b009a8f636636cb3663
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 21:06:17 2025 -0400

    Fixes #654

commit c1302bb54ab272c2a98c53ce0d508b7d39e9674b
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 773f2d65bbfb2a9591b31fabfd6844612b840f1a
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 4228db249ed5e3261931a1cdb3895d0ddd3ac4ac
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 26f36cabb0a860f10d7ba62b5279ddd1e282c78e
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 3bfd2dd5615afdbd04e3451c2ef728f1d7caf466
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 12:33:23 2025 -0400

    Remove empty English (United States) locale file

commit 6366a3eba6ab72090e52be474212a663799dfe19
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 671cd3701fc5a601f2f1bad9aef93106f91eec0b
Author: Sean Morley <mail@seanmorley.com>
Date:   Sat Sep 6 16:58:04 2025 +0200

    Added translation using Weblate (English (United States))

commit bdbbe5f4978f041f620f0503da69fa870cb1997c
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

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) name = models.CharField(max_length=200)
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)
rating = models.FloatField(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) date = models.DateTimeField(blank=True, null=True)
end_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) 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 # Makes it so the whole user object is returned in the serializer instead of just the user uuid
def to_representation(self, instance): def to_representation(self, instance):
representation = super().to_representation(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 return representation
def get_images(self, obj): def get_images(self, obj):
serializer = ContentImageSerializer(obj.images.all(), many=True, context=self.context) serializer = ContentImageSerializer(obj.images.all(), many=True, context=self.context)
# Filter out None values from the serialized data # Filter out None values from the serialized data
@@ -349,7 +366,6 @@ class LocationSerializer(CustomModelSerializer):
category_data = validated_data.pop('category', None) category_data = validated_data.pop('category', None)
collections_data = validated_data.pop('collections', []) collections_data = validated_data.pop('collections', [])
print(category_data)
location = Location.objects.create(**validated_data) location = Location.objects.create(**validated_data)
# Handle category # Handle category
@@ -392,6 +408,18 @@ class LocationSerializer(CustomModelSerializer):
return instance 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): class TransportationSerializer(CustomModelSerializer):
distance = serializers.SerializerMethodField() distance = serializers.SerializerMethodField()
images = serializers.SerializerMethodField() images = serializers.SerializerMethodField()
@@ -555,24 +583,67 @@ class ChecklistSerializer(CustomModelSerializer):
return data return data
class CollectionSerializer(CustomModelSerializer): class CollectionSerializer(CustomModelSerializer):
locations = LocationSerializer(many=True, read_only=True) locations = serializers.SerializerMethodField()
transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set') transportations = serializers.SerializerMethodField()
notes = NoteSerializer(many=True, read_only=True, source='note_set') notes = serializers.SerializerMethodField()
checklists = ChecklistSerializer(many=True, read_only=True, source='checklist_set') checklists = serializers.SerializerMethodField()
lodging = LodgingSerializer(many=True, read_only=True, source='lodging_set') lodging = serializers.SerializerMethodField()
class Meta: class Meta:
model = Collection 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'] 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'] 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): def to_representation(self, instance):
representation = super().to_representation(instance) representation = super().to_representation(instance)
# Make it display the user uuid for the shared users instead of the PK # Make it display the user uuid for the shared users instead of the PK
shared_uuids = [] shared_uuids = []
for user in instance.shared_with.all(): for user in instance.shared_with.all():
shared_uuids.append(str(user.uuid)) shared_uuids.append(str(user.uuid))
representation['shared_with'] = shared_uuids 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 return representation
class CollectionInviteSerializer(serializers.ModelSerializer): class CollectionInviteSerializer(serializers.ModelSerializer):

View File

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

View File

@@ -16,7 +16,6 @@ class CollectionViewSet(viewsets.ModelViewSet):
permission_classes = [CollectionShared] permission_classes = [CollectionShared]
pagination_class = pagination.StandardResultsSetPagination pagination_class = pagination.StandardResultsSetPagination
def apply_sorting(self, queryset): def apply_sorting(self, queryset):
order_by = self.request.query_params.get('order_by', 'name') order_by = self.request.query_params.get('order_by', 'name')
order_direction = self.request.query_params.get('order_direction', 'asc') order_direction = self.request.query_params.get('order_direction', 'asc')
@@ -48,14 +47,36 @@ class CollectionViewSet(viewsets.ModelViewSet):
return queryset.order_by(ordering) 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 # make sure the user is authenticated
if not request.user.is_authenticated: if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400) return Response({"error": "User is not authenticated"}, status=400)
queryset = Collection.objects.filter(user=request.user, is_archived=False) queryset = Collection.objects.filter(user=request.user, is_archived=False)
queryset = self.apply_sorting(queryset) queryset = self.apply_sorting(queryset)
collections = self.paginate_and_respond(queryset, request) return self.paginate_and_respond(queryset, request)
return collections
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])
def all(self, request): def all(self, request):
@@ -415,3 +436,12 @@ class CollectionViewSet(viewsets.ModelViewSet):
return paginator.get_paginated_response(serializer.data) return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data) 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']) @action(detail=False, methods=['get'])
def generate(self, request): def generate(self, request):
locations = Location.objects.filter(user=request.user) 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 user = request.user
name = f"{user.first_name} {user.last_name}" name = f"{user.first_name} {user.last_name}"

View File

@@ -9,7 +9,7 @@ from rest_framework.response import Response
import requests import requests
from adventures.models import Location, Category from adventures.models import Location, Category
from adventures.permissions import IsOwnerOrSharedWithFullAccess from adventures.permissions import IsOwnerOrSharedWithFullAccess
from adventures.serializers import LocationSerializer from adventures.serializers import LocationSerializer, MapPinSerializer
from adventures.utils import pagination from adventures.utils import pagination
class LocationViewSet(viewsets.ModelViewSet): class LocationViewSet(viewsets.ModelViewSet):
@@ -193,6 +193,8 @@ class LocationViewSet(viewsets.ModelViewSet):
return Response({"error": "User is not authenticated"}, status=400) return Response({"error": "User is not authenticated"}, status=400)
include_collections = request.query_params.get('include_collections', 'false') == 'true' 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 # Build queryset with collection filtering
base_filter = Q(user=request.user.id) 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 = Location.objects.filter(base_filter, collections__isnull=True)
queryset = self.apply_sorting(queryset) 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) return Response(serializer.data)
@action(detail=True, methods=['get'], url_path='additional-info') @action(detail=True, methods=['get'], url_path='additional-info')
@@ -228,6 +230,17 @@ class LocationViewSet(viewsets.ModelViewSet):
return Response(response_data) 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 ==================== # ==================== HELPER METHODS ====================
def _validate_collection_update_permissions(self, instance, new_collections): def _validate_collection_update_permissions(self, instance, new_collections):

View File

@@ -2,7 +2,7 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region, 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 = DefaultRouter()
router.register(r'countries', CountryViewSet, basename='countries') router.register(r'countries', CountryViewSet, basename='countries')
router.register(r'regions', RegionViewSet, basename='regions') router.register(r'regions', RegionViewSet, basename='regions')
@@ -15,4 +15,5 @@ urlpatterns = [
path('<str:country_code>/visits/', visits_by_country, name='visits-by-country'), path('<str:country_code>/visits/', visits_by_country, name='visits-by-country'),
path('regions/<str:region_id>/cities/', cities_by_region, name='cities-by-region'), path('regions/<str:region_id>/cities/', cities_by_region, name='cities-by-region'),
path('regions/<str:region_id>/cities/visits/', visits_by_region, name='visits-by-region'), 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 .models import Country, Region, VisitedRegion, City, VisitedCity
from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer, VisitedCitySerializer from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer, VisitedCitySerializer
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes, action
import os
import json
from django.http import JsonResponse
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
from django.conf import settings from django.core.cache import cache
from rest_framework.decorators import action from django.views.decorators.cache import cache_page
from django.contrib.staticfiles import finders from django.utils.decorators import method_decorator
from adventures.models import Location from adventures.models import Location
# Cache TTL
CACHE_TTL = 60 * 60 * 24 # 1 day
@cache_page(CACHE_TTL)
@api_view(['GET']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def regions_by_country(request, country_code): def regions_by_country(request, country_code):
# require authentication
country = get_object_or_404(Country, country_code=country_code) country = get_object_or_404(Country, country_code=country_code)
regions = Region.objects.filter(country=country).order_by('name') regions = Region.objects.filter(country=country).order_by('name')
serializer = RegionSerializer(regions, many=True) serializer = RegionSerializer(regions, many=True)
@@ -27,12 +26,17 @@ def regions_by_country(request, country_code):
@api_view(['GET']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def visits_by_country(request, country_code): 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) country = get_object_or_404(Country, country_code=country_code)
visits = VisitedRegion.objects.filter(region__country=country, user=request.user.id) visits = VisitedRegion.objects.filter(region__country=country, user=request.user.id)
serializer = VisitedRegionSerializer(visits, many=True) serializer = VisitedRegionSerializer(visits, many=True)
cache.set(cache_key, serializer.data, CACHE_TTL)
return Response(serializer.data) return Response(serializer.data)
@cache_page(CACHE_TTL)
@api_view(['GET']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def cities_by_region(request, region_id): def cities_by_region(request, region_id):
@@ -44,12 +48,38 @@ def cities_by_region(request, region_id):
@api_view(['GET']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def visits_by_region(request, region_id): 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) region = get_object_or_404(Region, id=region_id)
visits = VisitedCity.objects.filter(city__region=region, user=request.user.id) visits = VisitedCity.objects.filter(city__region=region, user=request.user.id)
serializer = VisitedCitySerializer(visits, many=True) serializer = VisitedCitySerializer(visits, many=True)
cache.set(cache_key, serializer.data, CACHE_TTL)
return Response(serializer.data) 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): class CountryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Country.objects.all().order_by('name') queryset = Country.objects.all().order_by('name')
serializer_class = CountrySerializer serializer_class = CountrySerializer
@@ -60,15 +90,12 @@ class CountryViewSet(viewsets.ReadOnlyModelViewSet):
lat = float(request.query_params.get('lat')) lat = float(request.query_params.get('lat'))
lon = float(request.query_params.get('lon')) lon = float(request.query_params.get('lon'))
point = Point(lon, lat, srid=4326) point = Point(lon, lat, srid=4326)
region = Region.objects.filter(geometry__contains=point).first() region = Region.objects.filter(geometry__contains=point).first()
if region: if region:
return Response({'in_region': True, 'region_name': region.name, 'region_id': region.id}) return Response({'in_region': True, 'region_name': region.name, 'region_id': region.id})
else: else:
return Response({'in_region': False}) 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']) @action(detail=False, methods=['post'])
def region_check_all_adventures(self, request): def region_check_all_adventures(self, request):
adventures = Location.objects.filter(user=request.user.id, type='visited') adventures = Location.objects.filter(user=request.user.id, type='visited')
@@ -87,6 +114,7 @@ class CountryViewSet(viewsets.ReadOnlyModelViewSet):
continue continue
return Response({'regions_visited': count}) return Response({'regions_visited': count})
@method_decorator(cache_page(CACHE_TTL), name='list')
class RegionViewSet(viewsets.ReadOnlyModelViewSet): class RegionViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Region.objects.all() queryset = Region.objects.all()
serializer_class = RegionSerializer serializer_class = RegionSerializer
@@ -113,7 +141,6 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, **kwargs): def destroy(self, request, **kwargs):
# delete by region id
region = get_object_or_404(Region, id=kwargs['pk']) region = get_object_or_404(Region, id=kwargs['pk'])
visited_region = VisitedRegion.objects.filter(user=request.user.id, region=region) visited_region = VisitedRegion.objects.filter(user=request.user.id, region=region)
if visited_region.exists(): if visited_region.exists():
@@ -137,7 +164,6 @@ class VisitedCityViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
self.perform_create(serializer) self.perform_create(serializer)
# if the region is not visited, visit it
region = serializer.validated_data['city'].region region = serializer.validated_data['city'].region
if not VisitedRegion.objects.filter(user=request.user.id, region=region).exists(): if not VisitedRegion.objects.filter(user=request.user.id, region=region).exists():
VisitedRegion.objects.create(user=request.user, region=region) VisitedRegion.objects.create(user=request.user, region=region)
@@ -145,7 +171,6 @@ class VisitedCityViewSet(viewsets.ModelViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, **kwargs): def destroy(self, request, **kwargs):
# delete by city id
city = get_object_or_404(City, id=kwargs['pk']) city = get_object_or_404(City, id=kwargs['pk'])
visited_city = VisitedCity.objects.filter(user=request.user.id, city=city) visited_city = VisitedCity.objects.filter(user=request.user.id, city=city)
if visited_city.exists(): if visited_city.exists():

5551
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -899,6 +899,9 @@
{:else} {:else}
<ClockIcon class="w-3 h-3 text-base-content/50" /> <ClockIcon class="w-3 h-3 text-base-content/50" />
{/if} {/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"> <div class="text-sm font-medium truncate">
{#if isAllDay(visit.start_date)} {#if isAllDay(visit.start_date)}
{visit.start_date && typeof visit.start_date === 'string' {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 versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0';
export let appTitle = 'AdventureLog'; export let appTitle = 'AdventureLog';
export let copyrightYear = '2023-2025'; export let copyrightYear = '2023-2025';

View File

@@ -465,3 +465,12 @@ export type WandererTrail = {
updated: string; // ISO 8601 date string updated: string; // ISO 8601 date string
waypoints: 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": "مشترك",
"shared_with": "مشترك مع", "shared_with": "مشترك مع",
"unshared": "غير مشترك", "unshared": "غير مشترك",
"with": "مع" "with": "مع",
"available_users": "المستخدمين المتاحين",
"invite_failed": "فشل دعوة",
"invite_revoked": "دعوة إلغاء",
"invite_sent": "دعوة إرسال",
"revoke_failed": "فشل الإلغاء",
"unshare_failed": "فشل Unshare"
}, },
"strava": { "strava": {
"account_connected": "حساب متصل", "account_connected": "حساب متصل",
@@ -1013,6 +1019,21 @@
"visit_remove_failed": "فشل في إزالة الزيارة", "visit_remove_failed": "فشل في إزالة الزيارة",
"visit_to": "زيارة", "visit_to": "زيارة",
"visited_first": "زار أولاً", "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": "مغامرتك العشوائية تنتظر!"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -535,7 +535,22 @@
"oldest_first": "Oldest First", "oldest_first": "Oldest First",
"visited_first": "Visited First", "visited_first": "Visited First",
"unvisited_first": "Unvisited 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": { "auth": {
"username": "Username", "username": "Username",
@@ -874,7 +889,13 @@
"revoke_invite": "Revoke Invite", "revoke_invite": "Revoke Invite",
"send_invite": "Send Invite", "send_invite": "Send Invite",
"available": "Available", "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": {}, "languages": {},
"profile": { "profile": {

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,6 @@
"view_license": "Afficher la licence" "view_license": "Afficher la licence"
}, },
"adventures": { "adventures": {
"activities": {},
"add_to_collection": "Ajouter à la collection", "add_to_collection": "Ajouter à la collection",
"delete": "Supprimer", "delete": "Supprimer",
"edit_adventure": "Modifier l'aventure", "edit_adventure": "Modifier l'aventure",
@@ -189,7 +188,7 @@
"display_name": "Nom d'affichage", "display_name": "Nom d'affichage",
"location_details": "Détails du lieu", "location_details": "Détails du lieu",
"lodging": "Hébergement", "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", "lodging_information": "Informations sur l'hébergement",
"price": "Prix", "price": "Prix",
"region": "Région", "region": "Région",
@@ -244,7 +243,7 @@
"edit_location": "Modifier l'emplacement", "edit_location": "Modifier l'emplacement",
"location_create_error": "Échec de la création de l'emplacement", "location_create_error": "Échec de la création de l'emplacement",
"location_created": "Emplacement créé", "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_delete_success": "Emplacement supprimé avec succès!",
"location_not_found": "Emplacement introuvable", "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.", "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", "unvisited_first": "Sans visité d'abord",
"visited_first": "Visité en premier", "visited_first": "Visité en premier",
"total_items": "Total des articles", "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": { "settings": {
"account_settings": "Paramètres du compte utilisateur", "account_settings": "Paramètres du compte utilisateur",
@@ -838,7 +852,6 @@
"show_activities": "Montrer les activités", "show_activities": "Montrer les activités",
"show_visited_cities": "Villes visites" "show_visited_cities": "Villes visites"
}, },
"languages": {},
"share": { "share": {
"no_users_shared": "Aucun utilisateur", "no_users_shared": "Aucun utilisateur",
"not_shared_with": "Pas encore partagé avec", "not_shared_with": "Pas encore partagé avec",
@@ -853,7 +866,13 @@
"available": "Disponible", "available": "Disponible",
"pending": "En attente", "pending": "En attente",
"revoke_invite": "Revoke Inviter", "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": { "profile": {
"member_since": "Membre depuis", "member_since": "Membre depuis",

View File

@@ -564,7 +564,22 @@
"unvisited_first": "Non visitato per primo", "unvisited_first": "Non visitato per primo",
"visited_first": "Visitato per primo", "visited_first": "Visitato per primo",
"total_items": "Articoli totali", "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": { "settings": {
"account_settings": "Impostazioni dell'account utente", "account_settings": "Impostazioni dell'account utente",
@@ -853,7 +868,13 @@
"available": "Disponibile", "available": "Disponibile",
"pending": "In attesa di", "pending": "In attesa di",
"revoke_invite": "Revoca invito", "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": { "profile": {
"member_since": "Membro da", "member_since": "Membro da",

View File

@@ -902,7 +902,13 @@
"shared": "共有", "shared": "共有",
"shared_with": "共有", "shared_with": "共有",
"unshared": "非共有", "unshared": "非共有",
"with": "と" "with": "と",
"available_users": "利用可能なユーザー",
"invite_failed": "招待は失敗しました",
"invite_revoked": "招待された招待",
"invite_sent": "送信招待",
"revoke_failed": "取り消しは失敗しました",
"unshare_failed": "UNSHAREは失敗しました"
}, },
"strava": { "strava": {
"account_connected": "接続されたアカウント", "account_connected": "接続されたアカウント",
@@ -1013,6 +1019,21 @@
"visit_remove_failed": "訪問を削除できませんでした", "visit_remove_failed": "訪問を削除できませんでした",
"visit_to": "訪問", "visit_to": "訪問",
"visited_first": "最初に訪問しました", "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": "사용 가능", "available": "사용 가능",
"pending": "보류 중", "pending": "보류 중",
"revoke_invite": "취소 초대", "revoke_invite": "취소 초대",
"send_invite": "초대를 보내십시오" "send_invite": "초대를 보내십시오",
"available_users": "사용 가능한 사용자",
"invite_failed": "초대 실패",
"invite_revoked": "취소 된 초대",
"invite_sent": "초대장",
"revoke_failed": "취소가 실패했습니다",
"unshare_failed": "공해를 실패했습니다"
}, },
"transportation": { "transportation": {
"edit": "편집", "edit": "편집",
@@ -938,7 +944,22 @@
"unvisited_first": "먼저 방문하지 않습니다", "unvisited_first": "먼저 방문하지 않습니다",
"visited_first": "먼저 방문했습니다", "visited_first": "먼저 방문했습니다",
"total_items": "총 항목", "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": { "lodging": {
"apartment": "아파트", "apartment": "아파트",

View File

@@ -564,7 +564,22 @@
"unvisited_first": "Eerst niet bezocht", "unvisited_first": "Eerst niet bezocht",
"visited_first": "Eerst bezocht", "visited_first": "Eerst bezocht",
"total_items": "Totale items", "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": { "settings": {
"account_settings": "Gebruikersaccount instellingen", "account_settings": "Gebruikersaccount instellingen",
@@ -853,7 +868,13 @@
"available": "Beschikbaar", "available": "Beschikbaar",
"pending": "In behandeling", "pending": "In behandeling",
"revoke_invite": "Revoke uitnodigen", "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": { "profile": {
"member_since": "Lid sinds", "member_since": "Lid sinds",

View File

@@ -534,7 +534,22 @@
"unvisited_first": "Uvisitert først", "unvisited_first": "Uvisitert først",
"visited_first": "Besøkte først", "visited_first": "Besøkte først",
"total_items": "Totalt gjenstander", "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": { "auth": {
"username": "Brukernavn", "username": "Brukernavn",
@@ -873,7 +888,13 @@
"available": "Tilgjengelig", "available": "Tilgjengelig",
"pending": "I påvente av", "pending": "I påvente av",
"revoke_invite": "Revoke Inviter", "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": { "profile": {
"member_since": "Medlem siden", "member_since": "Medlem siden",

View File

@@ -535,7 +535,22 @@
"unvisited_first": "Najpierw niewidziane", "unvisited_first": "Najpierw niewidziane",
"visited_first": "Odwiedziłem pierwszy", "visited_first": "Odwiedziłem pierwszy",
"total_items": "Całkowite przedmioty", "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": { "auth": {
"username": "Nazwa użytkownika", "username": "Nazwa użytkownika",
@@ -852,7 +867,13 @@
"available": "Dostępny", "available": "Dostępny",
"pending": "Aż do", "pending": "Aż do",
"revoke_invite": "Cofnij zaproszenie", "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": {}, "languages": {},
"profile": { "profile": {

File diff suppressed because it is too large Load Diff

View File

@@ -535,7 +535,22 @@
"unvisited_first": "Сначала не посещенные", "unvisited_first": "Сначала не посещенные",
"visited_first": "Сначала посещенные", "visited_first": "Сначала посещенные",
"total_items": "Общие предметы", "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": { "auth": {
"username": "Имя пользователя", "username": "Имя пользователя",
@@ -874,7 +889,13 @@
"available": "Доступный", "available": "Доступный",
"pending": "В ожидании", "pending": "В ожидании",
"revoke_invite": "Отменить приглашение", "revoke_invite": "Отменить приглашение",
"send_invite": "Отправить приглашение" "send_invite": "Отправить приглашение",
"available_users": "Доступные пользователи",
"invite_failed": "Приглашение не удалось",
"invite_revoked": "Пригласить отменен",
"invite_sent": "Пригласить отправлено",
"revoke_failed": "Отмена не удалась",
"unshare_failed": "UNSHARE не удалось"
}, },
"languages": {}, "languages": {},
"profile": { "profile": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

@@ -561,7 +561,22 @@
"unvisited_first": "未访问优先", "unvisited_first": "未访问优先",
"visited_first": "已访问优先", "visited_first": "已访问优先",
"total_items": "总项目", "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": { "users": {
"no_users_found": "未找到已公开的用户。" "no_users_found": "未找到已公开的用户。"
@@ -854,8 +869,12 @@
"pending": "待办的", "pending": "待办的",
"revoke_invite": "撤销邀请", "revoke_invite": "撤销邀请",
"send_invite": "发送邀请", "send_invite": "发送邀请",
"available_users": "可邀请的用户", "available_users": "可用户",
"no_available_users": "没有可邀请的用户" "invite_failed": "邀请失败",
"invite_revoked": "邀请被撤销",
"invite_sent": "邀请发送",
"revoke_failed": "撤销失败",
"unshare_failed": "没有共享失败"
}, },
"profile": { "profile": {
"member_since": "会员自", "member_since": "会员自",

View File

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

View File

@@ -8,11 +8,14 @@ const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => { export const load = (async (event) => {
let sessionId = event.cookies.get('sessionid'); let sessionId = event.cookies.get('sessionid');
let visitedFetch = await fetch(`${endpoint}/api/locations/all/?include_collections=true`, { let visitedFetch = await fetch(
headers: { `${endpoint}/api/locations/all?include_collections=true&nested=true&allowed_nested_fields=visits`,
Cookie: `sessionid=${sessionId}` {
headers: {
Cookie: `sessionid=${sessionId}`
}
} }
}); );
let adventures = (await visitedFetch.json()) as Location[]; let adventures = (await visitedFetch.json()) as Location[];
// Get user's local timezone as fallback // 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) => { export const load = (async (event) => {
if (!event.locals.user) { if (!event.locals.user) {
return redirect(302, '/login'); 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 sessionId = event.cookies.get('sessionid');
const order_by = event.url.searchParams.get('order_by') || 'updated_at'; if (!sessionId) {
const order_direction = event.url.searchParams.get('order_direction') || 'desc'; return redirect(302, '/login');
const page = event.url.searchParams.get('page') || '1'; }
// Build API URL with parameters // Get sorting parameters from URL
let apiUrl = `${serverEndpoint}/api/collections/?order_by=${order_by}&order_direction=${order_direction}&page=${page}`; 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, { // Common headers for all requests
headers: { const headers = {
Cookie: `sessionid=${sessionId}` 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];
}
let sharedRes = await fetch(`${serverEndpoint}/api/collections/shared/`, { // Build API URL with nested=true for lighter payload
headers: { const apiUrl = `${serverEndpoint}/api/collections/?order_by=${order_by}&order_direction=${order_direction}&page=${page}&nested=true`;
Cookie: `sessionid=${sessionId}`
} try {
}); // Execute all API calls in parallel
if (!sharedRes.ok) { const [collectionsRes, sharedRes, archivedRes, invitesRes] = await Promise.all([
console.error('Failed to fetch shared collections'); 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'); return redirect(302, '/login');
} }
let sharedCollections = (await sharedRes.json()) as Collection[];
let archivedRes = await fetch(`${serverEndpoint}/api/collections/archived/`, { // Parse responses in parallel
headers: { const [collectionsData, sharedData, archivedData, invitesData] = await Promise.all([
Cookie: `sessionid=${sessionId}` collectionsRes.json(),
} sharedRes.ok ? sharedRes.json() : [],
}); archivedRes.ok ? archivedRes.json() : [],
if (!archivedRes.ok) { invitesRes.ok ? invitesRes.json() : []
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);
return { return {
props: { props: {
adventures: collections, adventures: collectionsData.results as Location[],
next, next: collectionsData.next,
previous, previous: collectionsData.previous,
count, count: collectionsData.count,
sharedCollections, sharedCollections: sharedData as Collection[],
currentPage, currentPage,
order_by, order_by,
order_direction, order_direction,
archivedCollections, archivedCollections: archivedData as Collection[],
invites invites: invitesData
} }
}; };
} catch (error) {
console.error('Error fetching data:', error);
return redirect(302, '/login');
} }
}) satisfies PageServerLoad; }) satisfies PageServerLoad;

View File

@@ -33,7 +33,8 @@
groupLodgingByDate, groupLodgingByDate,
LODGING_TYPES_ICONS, LODGING_TYPES_ICONS,
getBasemapUrl, getBasemapUrl,
isAllDay isAllDay,
getActivityColor
} from '$lib'; } from '$lib';
import { formatDateInTimezone, formatAllDayDate } from '$lib/dateUtils'; import { formatDateInTimezone, formatAllDayDate } from '$lib/dateUtils';
@@ -1230,7 +1231,7 @@
</div> </div>
<div> <div>
<!-- Duration --> <!-- Duration -->
{Math.round( {Math.floor(
(new Date(orderedItem.end).getTime() - (new Date(orderedItem.end).getTime() -
new Date(orderedItem.start).getTime()) / new Date(orderedItem.start).getTime()) /
1000 / 1000 /
@@ -1375,6 +1376,39 @@
</Marker> </Marker>
{/if} {/if}
{/each} {/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} {#if lineData && collection.start_date && collection.end_date}
<GeoJSON data={lineData}> <GeoJSON data={lineData}>
<LineLayer <LineLayer

View File

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

View File

@@ -9,7 +9,7 @@
LineLayer LineLayer
} from 'svelte-maplibre'; } from 'svelte-maplibre';
import { t } from 'svelte-i18n'; 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 CardCarousel from '$lib/components/CardCarousel.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { basemapOptions, getActivityColor, getBasemapLabel, getBasemapUrl } from '$lib'; import { basemapOptions, getActivityColor, getBasemapLabel, getBasemapUrl } from '$lib';
@@ -20,7 +20,7 @@
import Plus from '~icons/mdi/plus'; import Plus from '~icons/mdi/plus';
import Clear from '~icons/mdi/close'; import Clear from '~icons/mdi/close';
import Eye from '~icons/mdi/eye'; 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 Calendar from '~icons/mdi/calendar';
import LocationIcon from '~icons/mdi/crosshairs-gps'; import LocationIcon from '~icons/mdi/crosshairs-gps';
import NewLocationModal from '$lib/components/NewLocationModal.svelte'; import NewLocationModal from '$lib/components/NewLocationModal.svelte';
@@ -35,17 +35,16 @@
let showCities: boolean = false; let showCities: boolean = false;
let sidebarOpen: boolean = false; let sidebarOpen: boolean = false;
let basemapType: string = 'default'; // default let basemapType: string = 'default';
export let initialLatLng: { lat: number; lng: number } | null = null; export let initialLatLng: { lat: number; lng: number } | null = null;
let visitedRegions: VisitedRegion[] = data.props.visitedRegions; let visitedRegions: VisitedRegion[] = data.props.visitedRegions;
let visitedCities: VisitedCity[] = []; let visitedCities: VisitedCity[] = [];
let adventures: Location[] = data.props.adventures; let pins: Pin[] = data.props.pins; // Lightweight pin objects
let activities: Activity[] = []; let activities: Activity[] = [];
let filteredAdventures = adventures; let filteredPins = pins;
let showVisited: boolean = true; let showVisited: boolean = true;
let showPlanned: boolean = true; let showPlanned: boolean = true;
@@ -54,23 +53,25 @@
let newLongitude: number | null = null; let newLongitude: number | null = null;
let newLatitude: 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 // Statistics
$: totalAdventures = adventures.length; $: totalAdventures = pins.length;
$: visitedAdventures = adventures.filter((adventure) => adventure.is_visited).length; $: visitedAdventures = pins.filter((pin) => pin.is_visited).length;
$: plannedAdventures = adventures.filter((adventure) => !adventure.is_visited).length; $: plannedAdventures = pins.filter((pin) => !pin.is_visited).length;
$: totalRegions = visitedRegions.length; $: totalRegions = visitedRegions.length;
// Get unique categories for filtering // Get unique categories for filtering
$: categories = [ $: categories = [...new Set(pins.map((pin) => pin.category?.display_name).filter(Boolean))];
...new Set(adventures.map((adventure) => adventure.category?.display_name).filter(Boolean))
];
// Updates the filtered adventures based on the checkboxes // Updates the filtered pins based on the checkboxes
$: { $: {
filteredAdventures = adventures.filter( filteredPins = pins.filter(
(adventure) => (showVisited && adventure.is_visited) || (showPlanned && !adventure.is_visited) (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 pins array
// Sync the locationBeingUpdated with the adventures array
$: { $: {
if (locationBeingUpdated && locationBeingUpdated.id) { 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) { if (index !== -1) {
adventures[index] = { ...locationBeingUpdated }; // Update existing pin with new data
adventures = adventures; // Trigger reactivity 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 { } else {
adventures = [{ ...locationBeingUpdated }, ...adventures]; // Add new pin
if (data.props.adventures) { const newPin: Pin = {
data.props.adventures = adventures; // Update data.props.adventure.locations as well 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(); 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 } } }) { function addMarker(e: { detail: { lngLat: { lng: any; lat: any } } }) {
newMarker = null; newMarker = null;
newMarker = { lngLat: e.detail.lngLat }; newMarker = { lngLat: e.detail.lngLat };
@@ -137,22 +183,43 @@
} }
function createNewAdventure(event: CustomEvent) { 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; newMarker = null;
createModalOpen = false; createModalOpen = false;
} }
function togglePopup() {
isPopupOpen = !isPopupOpen;
}
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}
function clearMarker() { function clearMarker() {
newMarker = null; 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> </script>
<svelte:head> <svelte:head>
@@ -170,7 +237,10 @@
<div class="container mx-auto px-6 py-4"> <div class="container mx-auto px-6 py-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-4"> <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" /> <Filter class="w-5 h-5" />
</button> </button>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -182,7 +252,7 @@
{$t('map.location_map')} {$t('map.location_map')}
</h1> </h1>
<p class="text-sm text-base-content/60"> <p class="text-sm text-base-content/60">
{filteredAdventures.length} {filteredPins.length}
{$t('worldtravel.of')} {$t('worldtravel.of')}
{totalAdventures} {totalAdventures}
{$t('map.locations_shown')} {$t('map.locations_shown')}
@@ -252,101 +322,146 @@
class="w-full h-full min-h-[70vh] rounded-lg" class="w-full h-full min-h-[70vh] rounded-lg"
standardControls standardControls
> >
{#each filteredAdventures as adventure} {#each filteredPins as pin}
{#if adventure.latitude && adventure.longitude} {#if pin.latitude && pin.longitude}
<Marker <Marker
lngLat={[adventure.longitude, adventure.latitude]} lngLat={[parseFloat(pin.longitude), parseFloat(pin.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 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-red-300 hover:bg-red-400' ? 'bg-gradient-to-br from-emerald-400 to-emerald-600 hover:from-emerald-500 hover:to-emerald-700'
: 'bg-blue-300 hover:bg-blue-400'} text-black focus:outline-6 focus:outline-black" : '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"
on:click={togglePopup}
> >
<span class="text-xl"> <span class="text-xl">
{adventure.category?.icon || '📍'} {pin.category?.icon || '📍'}
</span> </span>
{#if isPopupOpen}
<Popup <Popup
openOn="click" openOn="click"
offset={[0, -10]} offset={[0, -10]}
on:close={() => (isPopupOpen = false)} on:open={() => handlePopupOpen(pin.id)}
> on:close={() => handlePopupClose(pin.id)}
<div class="min-w-64 max-w-sm"> >
{#if adventure.images && adventure.images.length > 0} <div class="min-w-64 max-w-sm">
<div class="mb-3"> {#if openPopups.has(pin.id)}
<CardCarousel {#await fetchLocationDetails(pin.id)}
images={adventure.images} <div class="flex items-center justify-center p-4">
name={adventure.name} <span class="loading loading-spinner loading-sm"></span>
icon={adventure?.category?.icon} <span class="ml-2 text-sm">Loading details...</span>
/>
</div> </div>
{/if} {:then location}
<div class="space-y-2"> {#if location}
<div class="text-lg text-black font-bold">{adventure.name}</div> {#if location.images && location.images.length > 0}
<div class="flex items-center gap-2"> <div class="mb-3">
<span <CardCarousel
class="badge {adventure.is_visited images={location.images}
? 'badge-success' name={location.name}
: 'badge-info'} badge-sm" icon={location?.category?.icon}
> />
{adventure.is_visited </div>
? $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>
{/if} {/if}
</div> <div class="space-y-2">
{#if adventure.visits && adventure.visits.length > 0} <div class="text-lg text-black font-bold">{location.name}</div>
<div class="text-black text-sm space-y-1"> <div class="flex items-center gap-2">
{#each adventure.visits as visit} <span
<div class="flex items-center gap-1"> class="badge {location.is_visited
<Calendar class="w-3 h-3" /> ? 'badge-success'
<span> : 'badge-info'} badge-sm"
{visit.start_date >
? new Date(visit.start_date).toLocaleDateString(undefined, { {location.is_visited
timeZone: 'UTC' ? $t('adventures.visited')
}) : $t('adventures.planned')}
: ''} </span>
{visit.end_date && {#if location.category}
visit.end_date !== '' && <span class="badge badge-outline badge-sm">
visit.end_date !== visit.start_date {location.category.display_name}
? ' - ' + {location.category.icon}
new Date(visit.end_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})
: ''}
</span> </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> </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> </div>
{/if} {/if}
<div class="flex flex-col gap-2 pt-2"> {:catch error}
{#if adventure.longitude && adventure.latitude} <div class="p-4 text-center">
<a <div class="text-lg text-black font-bold">{pin.name}</div>
class="btn btn-outline btn-sm gap-2" <div class="text-sm text-red-600">Error loading details</div>
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}
<button <button
class="btn btn-primary btn-sm gap-2" class="btn btn-primary btn-sm gap-2 mt-2"
on:click={() => goto(`/locations/${adventure.id}`)} on:click={() => goto(`/locations/${pin.id}`)}
> >
<Eye class="w-4 h-4" /> <Eye class="w-4 h-4" />
{$t('map.view_details')} {$t('map.view_details')}
</button> </button>
</div> </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>
</div> {/if}
</Popup> </div>
{/if} </Popup>
</Marker> </Marker>
{/if} {/if}
{/each} {/each}
@@ -551,7 +666,7 @@
{#if newMarker} {#if newMarker}
<div class="space-y-3"> <div class="space-y-3">
<div class="alert alert-info"> <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> <span class="text-sm">{$t('map.marker_placed_on_map')}</span>
</div> </div>
<button type="button" class="btn btn-primary w-full gap-2" on:click={newAdventure}> <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 || []; const allCountries: Country[] = data.props?.countries || [];
let worldSubregions: string[] = []; let worldSubregions: string[] = [];
let showMap: boolean = false; let showMap: boolean = false;
let showGlobeSpin: boolean = false;
let sidebarOpen = false; let sidebarOpen = false;
worldSubregions = [...new Set(allCountries.map((country) => country.subregion))]; 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() { function toggleSidebar() {
sidebarOpen = !sidebarOpen; sidebarOpen = !sidebarOpen;
} }
@@ -171,6 +205,24 @@
<span class="hidden sm:inline">{$t('worldtravel.show_map')}</span> <span class="hidden sm:inline">{$t('worldtravel.show_map')}</span>
{/if} {/if}
</button> </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> </div>
<!-- Filter Chips --> <!-- Filter Chips -->
@@ -264,6 +316,257 @@
</div> </div>
{/if} {/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 --> <!-- Main Content -->
<div class="container mx-auto px-6 py-8"> <div class="container mx-auto px-6 py-8">
{#if filteredCountries.length === 0} {#if filteredCountries.length === 0}
@@ -433,3 +736,48 @@
</div> </div>
</div> </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 CACHE = `cache-${version}`;
const ASSETS = [ const ASSETS = [
...build, // the app itself ...build, // the app itself
...files // everything in `static` ...files // everything in `static`
]; ];
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
// Create a new cache and add all files to it // Create a new cache and add all files to it
async function addFilesToCache() { async function addFilesToCache() {
const cache = await caches.open(CACHE); const cache = await caches.open(CACHE);
await cache.addAll(ASSETS); await cache.addAll(ASSETS);
} }
event.waitUntil(addFilesToCache()); event.waitUntil(addFilesToCache());
}); });
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
// Remove previous cached data from disk // Remove previous cached data from disk
async function deleteOldCaches() { async function deleteOldCaches() {
for (const key of await caches.keys()) { for (const key of await caches.keys()) {
if (key !== CACHE) await caches.delete(key); if (key !== CACHE) await caches.delete(key);
} }
} }
event.waitUntil(deleteOldCaches()); event.waitUntil(deleteOldCaches());
}); });
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
// ignore POST requests, etc // ignore POST requests, etc
if (event.request.method !== 'GET') return; if (event.request.method !== 'GET') return;
async function respond() { async function respond() {
const url = new URL(event.request.url); const url = new URL(event.request.url);
const cache = await caches.open(CACHE); const cache = await caches.open(CACHE);
// `build`/`files` can always be served from the cache // `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) { if (ASSETS.includes(url.pathname)) {
return cache.match(url.pathname); return cache.match(url.pathname);
} }
// for everything else, try the network first, but // for everything else, try the network first, but
// fall back to the cache if we're offline // fall back to the cache if we're offline
try { try {
const response = await fetch(event.request); const response = await fetch(event.request);
if (response.status === 200) { if (response.status === 200) {
cache.put(event.request, response.clone()); cache.put(event.request, response.clone());
} }
return response; return response;
} catch { } catch {
return cache.match(event.request); return cache.match(event.request);
} }
} }
event.respondWith(respond()); event.respondWith(respond());
}); });

View File

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