new trail view options: create copy & change visibility (#571)

* change visibility of multiple trails in trails view (multi-select)

* error handling

* create copy option added

* wait for meilisearch when updating trail

* remove toggles from dropdown

* change public/private logic

* adds loading spinner for bulk operations

* fixes trail duplicate

* fixes tags delete on visiblility change

* fix conflict resolving issue

* edit labels & german translation

---------

Co-authored-by: Christian Beutel <>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
This commit is contained in:
slothful-vassal
2026-03-01 18:18:09 +01:00
committed by GitHub
parent 2b9d5305a8
commit f2396ae574
21 changed files with 335 additions and 54 deletions

View File

@@ -346,11 +346,11 @@ func IndexTrails(app core.App, trails []*core.Record, client meilisearch.Service
func UpdateTrail(app core.App, r *core.Record, author *core.Record, client meilisearch.ServiceManager) error {
errs := app.ExpandRecord(r, []string{"tags"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand tags: %v", errs)
return fmt.Errorf("meilisearch update trail: failed to expand tags: %v", errs)
}
errs = app.ExpandRecord(r, []string{"category"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand category: %v", errs)
return fmt.Errorf("meilisearch update trail: failed to expand category: %v", errs)
}
doc, err := documentFromTrailRecord(app, r, author, false)
@@ -359,10 +359,18 @@ func UpdateTrail(app core.App, r *core.Record, author *core.Record, client meili
}
documents := []map[string]interface{}{doc}
if _, err := client.Index("trails").UpdateDocuments(documents); err != nil {
task, err := client.Index("trails").UpdateDocuments(documents)
if err != nil {
return err
}
interval := 500 * time.Millisecond
_, err = client.WaitForTask(task.TaskUID, interval)
if err != nil {
return fmt.Errorf("meilisearch update trail: error waiting for task completion: %v", err)
}
return nil
}

View File

@@ -24,7 +24,7 @@
let dropdownElement: HTMLUListElement | undefined = $state();
let dropdownToggleElement: HTMLDivElement;
export async function toggleMenu(e: MouseEvent) {
export async function toggleMenu(e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
@@ -81,7 +81,10 @@
isOpen = false;
}
function handleItemClick(e: MouseEvent, item: { text: string; value: any }) {
function handleItemClick(
e: MouseEvent,
item: { text: string; value: any },
) {
e.preventDefault();
e.stopPropagation();
onchange?.(item);

View File

@@ -1,28 +1,29 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import type { List } from "$lib/models/list";
import type { Trail } from "$lib/models/trail";
import { categories } from "$lib/stores/category_store.js";
import {
lists_add_trail,
lists_index,
lists_remove_trail,
} from "$lib/stores/list_store";
import { show_toast } from "$lib/stores/toast_store.svelte";
import { trails_delete } from "$lib/stores/trail_store";
import { trails_delete, trails_update } from "$lib/stores/trail_store";
import { currentUser } from "$lib/stores/user_store";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
import { getFileURL, saveAs } from "$lib/util/file_util";
import { trail2gpx } from "$lib/util/gpx_util";
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
import JSZip from "jszip";
import type { Snippet } from "svelte";
import { _ } from "svelte-i18n";
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import ConfirmModal from "../confirm_modal.svelte";
import ListSearchModal from "../list/list_search_modal.svelte";
import TrailExportModal from "./trail_export_modal.svelte";
import TrailShareModal from "./trail_share_modal.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
import type { Snippet } from "svelte";
import { page } from "$app/state";
interface Props {
trails?: Set<Trail> | undefined;
@@ -30,9 +31,10 @@
toggle?: Snippet<[any]>;
onDelete?: () => void;
onShare?: () => void;
onUpdate?: () => void;
}
let { trails, mode, toggle, onDelete, onShare }: Props = $props();
let { trails, mode, toggle, onDelete, onShare, onUpdate }: Props = $props();
let confirmModal: ConfirmModal;
let listSelectModal: ListSearchModal;
@@ -41,6 +43,8 @@
let lists: List[] = $state([]);
let loading: boolean = $state(false);
function allowEdit(): boolean {
return (
hasTrail() &&
@@ -53,6 +57,61 @@
);
}
function majorityOfSelectedTrailsArePublic(): boolean {
if (trails === undefined || trails.size === 0) return false;
if (!Boolean($currentUser)) return false;
let publicCount = 0;
for (const cTrail of trails) {
if (cTrail.expand?.author === undefined) return false;
if (
cTrail.expand!.author!.id !== $currentUser?.actor &&
!cTrail.expand?.trail_share_via_trail?.some(
(s) => s.permission == "edit",
)
) {
return false;
}
if (cTrail.public) {
publicCount += 1;
}
}
return publicCount >= trails.size / 2;
}
function allowCopy(): boolean {
if ((trails?.size ?? 0) > 1) return false;
return !isMultiselectMode();
}
function allowPublish(): boolean {
if (mode !== "multi-select") return false;
if (trails === undefined || trails.size === 0) return false;
if (!Boolean($currentUser)) {
return false;
}
for (const cTrail of trails) {
if (cTrail.expand?.author === undefined) return false;
if (
cTrail.expand!.author!.id !== $currentUser?.actor &&
!cTrail.expand?.trail_share_via_trail?.some(
(s) => s.permission == "edit",
)
) {
return false;
}
}
return true;
}
function dropdownItems(): DropdownItem[] {
return [
...(!isMultiselectMode()
@@ -71,7 +130,13 @@
]
: []),
...(!isMultiselectMode()
? [{ text: $_("directions"), value: "direction", icon: "car" }]
? [
{
text: $_("directions"),
value: "direction",
icon: "car",
},
]
: []),
...(canExport()
? [
@@ -83,25 +148,69 @@
]
: []),
...(!isMultiselectMode()
? [{ text: $_("print"), value: "print", icon: "print" }]
? [
{
text: $_("print"),
value: "print",
icon: "print",
},
]
: []),
...(!isFromCurrentUser()
? []
: [
{
text: $_("add-to-list"),
value: "list",
value: "print",
icon: "bookmark",
},
]),
...(isMultiselectMode() || !isFromCurrentUser()
? []
: [{ text: $_("share"), value: "share", icon: "share" }]),
: [
{
text: $_("share"),
value: "share",
icon: "share",
},
]),
...(allowCopy()
? [
{
text: $_("duplicate"),
value: "copy",
icon: "copy",
},
]
: []),
...(allowPublish()
? [
{
text: `${majorityOfSelectedTrailsArePublic() ? $_("set-private") : $_("set-public")}`,
value: "publish",
icon: majorityOfSelectedTrailsArePublic()
? "lock"
: "globe",
},
]
: []),
...(allowEdit()
? [{ text: $_("edit"), value: "edit", icon: "pen" }]
? [
{
text: $_("edit"),
value: "edit",
icon: "pen",
},
]
: []),
...(allowDelete()
? [{ text: $_("delete"), value: "delete", icon: "trash" }]
? [
{
text: $_("delete"),
value: "delete",
icon: "trash",
},
]
: []),
];
}
@@ -174,19 +283,20 @@
return;
}
const handle = page.params.handle ?? handleFromRecordWithIRI(trail())
const handle = page.params.handle ?? handleFromRecordWithIRI(trail());
if (item.value == "show") {
const ddVal = item.value as string;
if (ddVal == "show") {
if (hasTrail()) {
const url = mode == "overview" || mode == "multi-select"
const url =
mode == "overview" || mode == "multi-select"
? `/map/trail/${handle}/${trailId()}`
: `/trail/view/${handle}/${trailId()}`
goto(
url + '?' + page.url.searchParams
);
: `/trail/view/${handle}/${trailId()}`;
goto(url + "?" + page.url.searchParams);
}
} else if (item.value == "list") {
} else if (ddVal == "list") {
lists = (
await lists_index(
{ q: "", author: $currentUser?.actor ?? "" },
@@ -195,7 +305,7 @@
)
).items;
listSelectModal.openModal();
} else if (item.value == "direction") {
} else if (ddVal == "direction") {
if (hasTrail()) {
window
.open(
@@ -204,23 +314,72 @@
)
?.focus();
}
} else if (item.value == "print") {
} else if (ddVal == "print") {
if (hasTrail()) {
goto(`/map/trail/${handle}/${trailId()}/print?${page.url.searchParams}`);
goto(
`/map/trail/${handle}/${trailId()}/print?${page.url.searchParams}`,
);
}
} else if (item.value == "share") {
} else if (ddVal == "share") {
trailShareModal.openModal();
} else if (item.value == "download") {
} else if (ddVal == "download") {
trailExportModal.openModal();
} else if (item.value == "edit") {
} else if (ddVal == "edit") {
if (hasTrail()) {
goto(`/trail/edit/${trailId()}`);
}
} else if (item.value == "delete") {
} else if (ddVal == "copy") {
if (hasTrail()) {
goto("/trail/edit/new?orig=" + trail()?.id);
}
} else if (ddVal == "publish") {
updateTrailsVisibility();
} else if (ddVal == "delete") {
confirmModal.openModal();
}
}
async function updateTrailsVisibility() {
const newVisibility = !majorityOfSelectedTrailsArePublic();
loading = true;
for (const cTrail of trails ?? []) {
if (!cTrail) continue;
if (!cTrail.expand?.author?.id) continue;
const origTrail: Trail = {
...cTrail,
author: cTrail.expand!.author!.id,
};
const updatedTrail: Trail = {
...origTrail,
public: newVisibility,
};
try {
await trails_update(
origTrail,
updatedTrail,
undefined,
undefined,
["tags", "category"],
);
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: `${$_("error-saving-trail")}: ${cTrail.name}`,
});
}
}
loading = false;
onUpdate?.();
}
async function exportTrails(exportSettings: {
fileFormat: "gpx" | "json";
photos: boolean;
@@ -308,9 +467,12 @@
async function deleteTrails() {
if (hasTrail()) {
loading = true;
for (const dTrail of trails!) {
await doDeleteTrail(dTrail);
}
loading = false;
onDelete?.();
}
@@ -407,7 +569,11 @@
{#snippet children({ toggleMenu: openDropdown })}
{#if toggle}{@render toggle({
toggleMenu: openDropdown,
})}{:else if mode == "multi-select"}
})}
{:else if loading}
<div class:w-16={isMultiselectMode()}></div>
<div class="spinner light:spinner-dark"></div>
{:else if mode == "multi-select"}
<button
aria-label="Open dropdown"
class="btn-primary shrink-0 font-medium!"

View File

@@ -282,7 +282,7 @@
async function handleTrailsEditDone(resetSelection: boolean = false) {
if (resetSelection) {
selection?.clear();
selection = new Set<Trail>();
hoveredTrail = undefined;
}
await tick();
@@ -319,6 +319,7 @@
mode={"multi-select"}
onDelete={() => handleTrailsEditDone(true)}
onShare={() => handleTrailsEditDone(false)}
onUpdate={() => handleTrailsEditDone(true)}
/>
</div>
{/if}

View File

@@ -21,6 +21,7 @@
"alphabetical": "Alphabetisch",
"already-account": "Du hast bereits ein Konto?",
"altitude": "Höhe",
"amenity": "",
"ammenity": "Einrichtung",
"api-documentation": "API Dokumentation",
"apply-user-settings": "",
@@ -113,7 +114,7 @@
"download": "Herunterladen",
"draw-a-route": "Route zeichnen",
"driving": "Auto",
"duplicate": "Duplikat",
"duplicate": "Duplizieren",
"duration": "Dauer",
"dutch": "Niederländisch",
"easy": "Einfach",
@@ -136,6 +137,7 @@
"enable-auto-routing": "Auto-Routing aktivieren",
"english": "Englisch",
"entry": "Eintrag",
"error-copying-trail": "Fehler beim Kopieren der Route",
"error-creating-user": "Fehler beim Erstellen des Nutzers",
"error-disabling-strava-integration": "Fehler beim Deaktivieren der Stravaintegration",
"error-during-login": "Fehler beim Login",
@@ -358,6 +360,8 @@
"search-trails": "Route suchen",
"select-list": "Liste auswählen",
"selected": "ausgewählt",
"set-private": "Verbergen",
"set-public": "Veröffentlichen",
"settings": "Einstellungen",
"settings-notification-comment-mention": "Jemand hat dich in einem Kommentar erwähnt",
"settings-notification-list-create": "Ein Benutzer, dem Du folgst, hat eine Liste erstellt",
@@ -413,6 +417,7 @@
"top-speed": "Höchstgeschwindigkeit",
"tourism": "Tourismus",
"trail": "{n, plural, =1 {Route} other {Routen}}",
"trail-copied-successfully": "Route erfolgreich kopiert",
"trail-not-in-list": "Trail gehört zu keiner Liste.",
"trail-not-shared": "Mit niemandem geteilt",
"trail-saved-successfully": "Route gespeichert",

View File

@@ -22,6 +22,7 @@
"already-account": "Already have an account?",
"altitude": "Altitude",
"amenity": "Amenity",
"ammenity": "",
"api-documentation": "API Documentation",
"apply-user-settings": "Apply user settings",
"attraction": "Attraction",
@@ -136,6 +137,7 @@
"enable-auto-routing": "Enable auto-routing",
"english": "English",
"entry": "Entry",
"error-copying-trail": "Error copying trail",
"error-creating-user": "Error creating user",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-during-login": "Error during login",
@@ -358,6 +360,8 @@
"search-trails": "Search trails",
"select-list": "Select List",
"selected": "selected",
"set-private": "Set private",
"set-public": "Set public",
"settings": "Settings",
"settings-notification-comment-mention": "Someone mentioned you in a comment",
"settings-notification-list-create": "A user who you follow has created a list",
@@ -413,6 +417,7 @@
"top-speed": "Top Speed",
"tourism": "Tourism",
"trail": "{n, plural, =1 {Trail} other {Trails}}",
"trail-copied-successfully": "trail copied successfully",
"trail-not-in-list": "Trail is not in any list",
"trail-not-shared": "Not shared with anyone",
"trail-saved-successfully": "Trail saved successfully",
@@ -430,6 +435,7 @@
"username": "Username",
"view": "View",
"viewpoint": "Viewpoint",
"visibilty": "Visibility",
"visibilty-status": "Visibility status",
"walking-speed": "Walking speed",
"water": "Water",

View File

@@ -21,8 +21,10 @@
"alphabetical": "Alfabético",
"already-account": "¿Ya tienes una cuenta?",
"altitude": "Altitud",
"amenity": "",
"ammenity": "Servicios",
"api-documentation": "Documentación API",
"apply-user-settings": "",
"attraction": "Atracción",
"author": "Autor",
"avatar": "Avatar",
@@ -134,6 +136,7 @@
"enable-auto-routing": "Habilitar auto-ruta",
"english": "Inglés",
"entry": "Entrada",
"error-copying-trail": "",
"error-creating-user": "Error creando el usuario",
"error-disabling-strava-integration": "Error al desactivar la integración de strava",
"error-during-login": "Error durante el acceso",
@@ -356,6 +359,8 @@
"search-trails": "Buscar ruta",
"select-list": "Seleccionar Lista",
"selected": "seleccionado",
"set-private": "",
"set-public": "",
"settings": "Configuración",
"settings-notification-comment-mention": "Alguien te mencionó en un comentario",
"settings-notification-list-create": "Un usuario al que sigues ha creado una nueva lista",
@@ -411,6 +416,7 @@
"top-speed": "Velocidad máxima",
"tourism": "Turismo",
"trail": "{n, plural, one {}=1 {Ruta} other {Rutas}}",
"trail-copied-successfully": "",
"trail-not-in-list": "",
"trail-not-shared": "No compartida con nadie",
"trail-saved-successfully": "Ruta guardada con éxito",
@@ -428,6 +434,7 @@
"username": "Nombre de usuario",
"view": "Ver",
"viewpoint": "Mirador",
"visibilty": "Visibilidad",
"visibilty-status": "Estado de visibilidad",
"walking-speed": "Velocidad al caminar",
"water": "Agua",

View File

@@ -22,6 +22,7 @@
"already-account": "Baduzu kontua lehendik?",
"altitude": "Altuera",
"amenity": "Altimetria",
"ammenity": "",
"api-documentation": "API dokumentazioa",
"apply-user-settings": "",
"attraction": "Erakarmena",
@@ -135,6 +136,7 @@
"enable-auto-routing": "Aktibatu bideratze automatikoa",
"english": "Ingelesa",
"entry": "Sarrera",
"error-copying-trail": "",
"error-creating-user": "Errorea erabiltzailea sortzen",
"error-disabling-strava-integration": "Errorea stravarekin integrazioa desaktibatzean",
"error-during-login": "Errorea sartzean",
@@ -357,6 +359,8 @@
"search-trails": "Bilatu ibilaldiak",
"select-list": "Aukeratu zerrenda",
"selected": "aukeratuta",
"set-private": "",
"set-public": "",
"settings": "Ezarpenak",
"settings-notification-comment-mention": "Norbaitek iruzkin baten aipatu zaitu",
"settings-notification-list-create": "Jarraitzen duzun erabiltzaile batek zerrenda bat sortu du",
@@ -412,6 +416,7 @@
"top-speed": "Gehienezko abiadura",
"tourism": "Turismoa",
"trail": "{n, plural, one {}=1 {ibilbide} other {ibilbide}}",
"trail-copied-successfully": "",
"trail-not-in-list": "",
"trail-not-shared": "Inorekin partekatu gabe",
"trail-saved-successfully": "Ibilbidea ondo gorde da",

View File

@@ -21,6 +21,7 @@
"alphabetical": "Alphabétique",
"already-account": "Déjà un compte ?",
"altitude": "Altitude",
"amenity": "",
"ammenity": "Aménagement",
"api-documentation": "Documentation API",
"apply-user-settings": "",
@@ -135,6 +136,7 @@
"enable-auto-routing": "Activer le routage automatique",
"english": "Anglais",
"entry": "Entrée",
"error-copying-trail": "",
"error-creating-user": "Erreur durant la création de l'utilisateur",
"error-disabling-strava-integration": "Erreur lors de la désactivation de l'intégration Strava",
"error-during-login": "Erreur durant la connexion",
@@ -357,6 +359,8 @@
"search-trails": "Chercher un itinéraire",
"select-list": "Liste de choix",
"selected": "sélectionné(s)",
"set-private": "",
"set-public": "",
"settings": "Paramètres",
"settings-notification-comment-mention": "Quelquun vous a mentionné dans un commentaire",
"settings-notification-list-create": "Un utilisateur que vous suivez à créé une nouvelle liste",
@@ -412,6 +416,7 @@
"top-speed": "Vitesse maximale",
"tourism": "Tourisme",
"trail": "{n, plural, =1 {Itinéraire} other {Itinéraires}}",
"trail-copied-successfully": "",
"trail-not-in-list": "",
"trail-not-shared": "L'itinéraire n'a pas été partagé",
"trail-saved-successfully": "Itinéraire enregistrée",
@@ -429,6 +434,7 @@
"username": "Nom d'utilisateur",
"view": "Afficher",
"viewpoint": "Point de vue",
"visibilty": "Visibilité",
"visibilty-status": "État de visibilité",
"walking-speed": "Vitesse de marche",
"water": "Eau",

View File

@@ -22,6 +22,7 @@
"already-account": "Már rendelkezik fiókkal?",
"altitude": "Magasság",
"amenity": "Amenity",
"ammenity": "",
"api-documentation": "API Dokumentáció",
"apply-user-settings": "",
"attraction": "Attraction",
@@ -135,6 +136,7 @@
"enable-auto-routing": "Enable auto-routing",
"english": "Angol",
"entry": "Bejegyzés",
"error-copying-trail": "",
"error-creating-user": "Hiba felhasználó hozzáadása közben",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-during-login": "Hiba bejelentkezés közben",
@@ -357,6 +359,8 @@
"search-trails": "Nyomvonalak keresése",
"select-list": "Lista kiválasztása",
"selected": "selected",
"set-private": "",
"set-public": "",
"settings": "Beállítások",
"settings-notification-comment-mention": "Someone mentioned you in a comment",
"settings-notification-list-create": "A user who you follow has created a list",
@@ -412,6 +416,7 @@
"top-speed": "Top Speed",
"tourism": "Tourism",
"trail": "{n, plural, =1 {Útvonal} other {Útvonalak}}",
"trail-copied-successfully": "",
"trail-not-in-list": "",
"trail-not-shared": "Not shared with anyone",
"trail-saved-successfully": "Trail saved successfully",

View File

@@ -22,6 +22,7 @@
"already-account": "Hai già un account?",
"altitude": "Altitudine",
"amenity": "Amenity",
"ammenity": "",
"api-documentation": "Documentazione API",
"apply-user-settings": "",
"attraction": "Attraction",
@@ -135,6 +136,7 @@
"enable-auto-routing": "Enable auto-routing",
"english": "Inglese",
"entry": "Voce",
"error-copying-trail": "",
"error-creating-user": "Errore nella creazione dell'utente",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-during-login": "Errore durante il login",
@@ -357,6 +359,8 @@
"search-trails": "Cerca percorsi",
"select-list": "Seleziona lista",
"selected": "selected",
"set-private": "",
"set-public": "",
"settings": "Impostazioni",
"settings-notification-comment-mention": "Someone mentioned you in a comment",
"settings-notification-list-create": "Un utente che segui ha creato una lista",
@@ -412,6 +416,7 @@
"top-speed": "Top Speed",
"tourism": "Tourism",
"trail": "{n, plural, =1 {Percorso} other {Percorsi}}",
"trail-copied-successfully": "",
"trail-not-in-list": "",
"trail-not-shared": "Percorso non condiviso con nessuno",
"trail-saved-successfully": "Percorso salvato con successo",

View File

@@ -136,6 +136,7 @@
"enable-auto-routing": "Automatische routering inschakelen",
"english": "Engels",
"entry": "Item",
"error-copying-trail": "",
"error-creating-user": "Fout bij aanmaken gebruiker",
"error-disabling-strava-integration": "Fout bij het uitschakelen van Strava-integratie",
"error-during-login": "Het inloggen is mislukt",
@@ -358,6 +359,8 @@
"search-trails": "Zoek routes",
"select-list": "Kies een lijst",
"selected": "geselecteerd",
"set-private": "",
"set-public": "",
"settings": "Instellingen",
"settings-notification-comment-mention": "Iemand vermeldt je in een reactie",
"settings-notification-list-create": "Een gebruiker die je volgt, heeft een lijst gecreëerd",
@@ -413,6 +416,7 @@
"top-speed": "Topsnelheid",
"tourism": "Toerisme",
"trail": "{n, plural, =1 {Route} other {Routes}}",
"trail-copied-successfully": "",
"trail-not-in-list": "",
"trail-not-shared": "Niet gedeeld met iemand",
"trail-saved-successfully": "Route succesvol bewaard",
@@ -430,6 +434,7 @@
"username": "Gebruikersnaam",
"view": "Weergave",
"viewpoint": "Uitzichtpunt",
"visibilty": "Zichtbaarheid",
"visibilty-status": "Zichtbaarheid status",
"walking-speed": "Wandelsnelheid",
"water": "Water",

View File

@@ -22,6 +22,7 @@
"already-account": "Czy masz już konto?",
"altitude": "Wysokość",
"amenity": "Amenity",
"ammenity": "",
"api-documentation": "Dokumentacja API",
"apply-user-settings": "",
"attraction": "Attraction",
@@ -135,6 +136,7 @@
"enable-auto-routing": "Włącz auto-trasowanie",
"english": "Angielski",
"entry": "Pozycja",
"error-copying-trail": "",
"error-creating-user": "Błąd tworzenia użytkownika",
"error-disabling-strava-integration": "Błąd przy wyłączaniu integracji strava",
"error-during-login": "Błąd podczas logowania",
@@ -357,6 +359,8 @@
"search-trails": "Szukaj szlaków",
"select-list": "Wybierz Listę",
"selected": "selected",
"set-private": "",
"set-public": "",
"settings": "Ustawienia",
"settings-notification-comment-mention": "Ktoś wspomniał o tobie w komentarzu",
"settings-notification-list-create": "Użytkownik, którego obserwujesz, utworzył listę",
@@ -412,6 +416,7 @@
"top-speed": "Maksymalna prędkość",
"tourism": "Tourism",
"trail": "{n, plural, one {Szlak} few {Szlaki} many {Szlaków}=1 {Szlak} other {Szlaki}}",
"trail-copied-successfully": "",
"trail-not-in-list": "",
"trail-not-shared": "Szlak nie udostępniony",
"trail-saved-successfully": "Szlak pomyślnie zapisany",

View File

@@ -22,6 +22,7 @@
"already-account": "Já tem uma conta?",
"altitude": "Altitude",
"amenity": "Amenity",
"ammenity": "",
"api-documentation": "Documentação da API",
"apply-user-settings": "",
"attraction": "Attraction",
@@ -135,6 +136,7 @@
"enable-auto-routing": "Enable auto-routing",
"english": "Inglês",
"entry": "Entrada",
"error-copying-trail": "",
"error-creating-user": "Erro ao criar utilizador",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-during-login": "Erro durante o login",
@@ -357,6 +359,8 @@
"search-trails": "Procurar trilhos",
"select-list": "Selecionar lista",
"selected": "selected",
"set-private": "",
"set-public": "",
"settings": "Definições",
"settings-notification-comment-mention": "Someone mentioned you in a comment",
"settings-notification-list-create": "A user who you follow has created a list",
@@ -412,6 +416,7 @@
"top-speed": "Top Speed",
"tourism": "Tourism",
"trail": "{n, plural, =1 {Percurso} other {Percursos}}",
"trail-copied-successfully": "",
"trail-not-in-list": "",
"trail-not-shared": "Não partilhado com ninguém",
"trail-saved-successfully": "Percurso gravado com sucesso",

View File

@@ -22,6 +22,7 @@
"already-account": "Уже есть аккаунт?",
"altitude": "Высота",
"amenity": "Amenity",
"ammenity": "",
"api-documentation": "Документация API",
"apply-user-settings": "",
"attraction": "Attraction",
@@ -135,6 +136,7 @@
"enable-auto-routing": "Авто-маршрутизация",
"english": "Английский",
"entry": "Запись",
"error-copying-trail": "",
"error-creating-user": "Ошибка создания пользователя",
"error-disabling-strava-integration": "Ошибка отключения Strava",
"error-during-login": "Ошибка входа",
@@ -357,6 +359,8 @@
"search-trails": "Поиск треков",
"select-list": "Выбрать список",
"selected": "selected",
"set-private": "",
"set-public": "",
"settings": "Настройки",
"settings-notification-comment-mention": "Кто-то упомянул вас в комментариях",
"settings-notification-list-create": "Пользователь, на которого вы подписаны, создал список",
@@ -412,6 +416,7 @@
"top-speed": "Макс. скорость",
"tourism": "Туризм",
"trail": "{n, plural, =1 {Трек} other {Треки}}",
"trail-copied-successfully": "",
"trail-not-in-list": "",
"trail-not-shared": "Нет общего доступа",
"trail-saved-successfully": "Трек сохранён",

View File

@@ -22,6 +22,7 @@
"already-account": "已注册账户?",
"altitude": "海拔",
"amenity": "友好性",
"ammenity": "",
"api-documentation": "API 文档",
"apply-user-settings": "",
"attraction": "景点",
@@ -135,6 +136,7 @@
"enable-auto-routing": "启用自动路由",
"english": "英语",
"entry": "日程",
"error-copying-trail": "",
"error-creating-user": "创建用户错误",
"error-disabling-strava-integration": "禁用strava集成时出错",
"error-during-login": "登录错误",
@@ -357,6 +359,8 @@
"search-trails": "搜索路线",
"select-list": "选择列表",
"selected": "已选",
"set-private": "",
"set-public": "",
"settings": "设置",
"settings-notification-comment-mention": "有人在评论中提到您",
"settings-notification-list-create": "您关注的用户创建了一个列表",
@@ -412,6 +416,7 @@
"top-speed": "最高速度",
"tourism": "旅游",
"trail": "{n, plural, =1 {路线} other {路线}}",
"trail-copied-successfully": "",
"trail-not-in-list": "",
"trail-not-shared": "未与任何人分享",
"trail-saved-successfully": "路线保存成功",

View File

@@ -1,3 +1,4 @@
import cryptoRandomString from "crypto-random-string";
import type { Actor } from "./activitypub/actor";
import type { Category } from "./category";
import type { Comment } from "./comment";
@@ -6,7 +7,7 @@ import type { SummitLog } from "./summit_log";
import type { Tag } from "./tag";
import type { TrailLike } from "./trail_like";
import type { TrailShare } from "./trail_share";
import type { Waypoint } from "./waypoint";
import { Waypoint } from "./waypoint";
class Trail {
id?: string;
@@ -64,12 +65,13 @@ class Trail {
thumbnail?: number,
photos?: string[],
gpx?: string,
gpx_data?: string,
category?: Category,
waypoints?: Waypoint[],
summit_logs?: SummitLog[],
comments?: Comment[],
shares?: TrailShare[],
tags?: string[],
tags?: Tag[],
description?: string
created?: string
}
@@ -89,7 +91,7 @@ class Trail {
this.lon = params?.lon;
this.thumbnail = params?.thumbnail ?? 0;
this.photos = params?.photos ?? [];
this.tags = []
this.tags = [];
this.gpx = params?.gpx;
this.like_count = 0
this.expand = {
@@ -97,12 +99,39 @@ class Trail {
waypoints_via_trail: params?.waypoints ?? [],
summit_logs_via_trail: params?.summit_logs ?? [],
comments_via_trail: params?.comments ?? [],
trail_share_via_trail: params?.shares ?? []
trail_share_via_trail: params?.shares ?? [],
gpx_data: params?.gpx_data,
tags: params?.tags
}
this.description = params?.description ?? "";
this.created = params?.created;
this.author = "000000000000000"
}
static from(orig: Trail): Trail {
return new Trail(orig.name, {
date: orig.date,
description: orig.description,
difficulty: orig.difficulty,
distance: orig.distance,
duration: orig.duration,
elevation_gain: orig.elevation_gain,
elevation_loss: orig.elevation_loss,
lat: orig.lat,
lon: orig.lon,
location: orig.location,
public: orig.public,
tags: orig.expand?.tags,
category: orig.expand?.category,
gpx_data: orig.expand?.gpx_data,
waypoints: orig.expand?.waypoints_via_trail?.map(wp => new Waypoint(wp.lat, wp.lon, {
id: cryptoRandomString({ length: 15 }),
description: wp.description,
icon: wp.icon,
name: wp.name,
})),
})
}
}
interface TrailFilter {

View File

@@ -238,7 +238,7 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl
}
export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: File[], gpx?: File | Blob | null) {
export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: File[], gpx?: File | Blob | null, exclude?: (keyof Trail)[]) {
newTrail.author = oldTrail.author
const waypointUpdates = compareObjectArrays<Waypoint>(oldTrail.expand?.waypoints_via_trail ?? [], newTrail.expand?.waypoints_via_trail ?? []);
@@ -295,7 +295,7 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
newTrail.tags = newTrail.tags.filter(t => t != tag.id);
}
const formData = objectToFormData(newTrail, ["expand"])
const formData = objectToFormData(newTrail, ["expand", ...(exclude ?? [])])
if (gpx) {
formData.append("gpx", gpx);

View File

@@ -272,6 +272,7 @@
clearUndoRedoStack();
if ($formData.expand!.gpx_data) {
$formData.id ??= cryptoRandomString({ length: 15 });
const gpx = GPX.parse($formData.expand!.gpx_data);
if (!(gpx instanceof Error)) {
if (gpx.rte && !gpx.trk) {

View File

@@ -8,7 +8,7 @@ import { get } from "svelte/store";
export const load: Load = async ({ params, fetch, url }) => {
const user = get(currentUser)
if (!params.id) {
return error(400, "Bad Request")
}
@@ -17,7 +17,14 @@ export const load: Load = async ({ params, fetch, url }) => {
let trail: Trail;
if (params.id === "new") {
trail = new Trail("", { category: categories[0] });
// duplicate trail
if (url.searchParams.has("orig")) {
const originalId = url.searchParams.get("orig")!;
const originalTrail = await trails_show(originalId, undefined, undefined, true, fetch);
trail = Trail.from(originalTrail)
} else {
trail = new Trail("", { category: categories[0] });
}
} else {
trail = await trails_show(params.id, undefined, url.searchParams.get("share") ?? undefined, true, fetch);
}

View File

@@ -81,11 +81,11 @@
localStorage.removeItem(TRAIL_LIST_FILTER_STORAGE_KEY);
});
async function handleFilterUpdate() {
async function handleFilterUpdate(resetPagination: boolean = true) {
loading = true;
persistFilter();
await paginate(1, pagination.items, false);
await paginate(resetPagination ? 1 : pagination.page, pagination.items);
loading = false;
}
@@ -96,12 +96,14 @@
try {
await doPaginate(newPage, items);
} catch (err: any) {
let apiError : APIError = err;
if (apiError.status == 413) { // content too large
let apiError: APIError = err;
if (apiError.status == 413) {
// content too large
let newItems = 10;
if (items == 12 || items == 24 || items == 48 || items == 96) { // cards view
if (items == 12 || items == 24 || items == 48 || items == 96) {
// cards view
if (items > 96) {
newItems = 96;
} else if (items > 48) {
@@ -122,11 +124,11 @@
newItems = 10;
}
}
await doPaginate(newPage, newItems);
}
}
page.url.searchParams.set("page", newPage.toString());
goto(`?${page.url.searchParams.toString()}`, { keepFocus: true, noScroll: !scrollToTop });
}
@@ -153,14 +155,14 @@
categories={page.data.categories}
bind:filter
{filterExpanded}
onupdate={handleFilterUpdate}
onupdate={() => handleFilterUpdate()}
></TrailFilterPanel>
<TrailList
bind:filter
{loading}
{trails}
{pagination}
onupdate={handleFilterUpdate}
onupdate={() => handleFilterUpdate(false)}
onpagination={paginate}
></TrailList>
</main>