hammerhead integration added (#628)

* hammerhead integration added

* typo corrected

* start date added, optimize some translations

* new option to send trail to hammerhead

* fix elevation information (was in cm)

* skip empty tracks on import

* update docs

* remove manual user-id input

* fix merge issues

* remove user-id from documentation

* fix hammerhead logo for light theme

* fix hammerhead logo for light theme

---------

Co-authored-by: Christian Beutel <>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
This commit is contained in:
slothful-vassal
2026-03-20 13:58:34 +01:00
committed by GitHub
parent 5a05573e83
commit 369e71fcec
20 changed files with 1839 additions and 51 deletions

View File

@@ -0,0 +1,15 @@
<svg id="hammerhead" data-name="hammerhead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150">
<defs>
<style>
.cls-1 {
fill: none;
}
</style>
</defs>
<g id="Layer_3" data-name="Layer 3">
<path id="Layer_3-2" data-name="Layer 3" class="cls-1" d="M0,.2H150v150H0Z" transform="translate(0 -0.2)" />
</g>
<path
d="M145.64,74.71a8.05,8.05,0,0,1-2.55,5.87q-6.36,6.33-12.71,12.69l-49,49a8.16,8.16,0,0,1-11.74,0Q38.91,111.4,8,80.68a8.27,8.27,0,0,1-2.63-5.74,8,8,0,0,1,2.48-6.06L22.08,54.69Q45.72,31.07,69.35,7.43a8.51,8.51,0,0,1,5.31-2.76,7.92,7.92,0,0,1,6.67,2.42L94.72,20.47q24.11,24.09,48.21,48.18A8.36,8.36,0,0,1,145.64,74.71ZM88.88,39.61c0,7.58.05,15.13,0,22.7A2,2,0,0,1,87,64.12c-7.94,0-15.89,0-23.83,0a2,2,0,0,1-2-1.9c0-7.54,0-15.07,0-22.62h-18a2.39,2.39,0,0,0-2.7,2.7v64.5a2.35,2.35,0,0,0,2.65,2.65c6-.06,12,.13,18-.08,0-7.41,0-14.81,0-22.23A2,2,0,0,1,63.35,85q11.79,0,23.57,0a2,2,0,0,1,2,2c0,7.49,0,15,0,22.49,6.06.14,12.12,0,18.18.06a2.46,2.46,0,0,0,2.55-2.67q0-32.25,0-64.51a2.53,2.53,0,0,0-2.71-2.7C100.88,39.64,94.92,39.61,88.88,39.61ZM66.81,90.77c0,7.55,0,15.09,0,22.63A1.9,1.9,0,0,1,65,115.22c-4.74,0-9.48,0-14.22,0l-.09.15C58.53,123,66.13,130.91,74,138.56a2.51,2.51,0,0,0,3.29-.13c7.73-7.66,15.35-15.43,23.13-23l-.09-.17c-5.07,0-10.14,0-15.2,0a2,2,0,0,1-1.83-1.8c0-7.54,0-15.09,0-22.64ZM50.87,33.87c4.66,0,9.24,0,13.89,0a2,2,0,0,1,2,2q0,11.21,0,22.4H83.24q0-11,0-22a2.45,2.45,0,0,1,.45-1.66,2.19,2.19,0,0,1,1.87-.74c4.84,0,9.68.11,14.5-.07-7.59-7.62-15-15.07-22.6-22.64a2.6,2.6,0,0,0-2.27-.88,2.92,2.92,0,0,0-1.7,1C65.9,18.86,58.48,26.28,50.87,33.87Zm64.53,66.32c8-7.76,15.87-15.85,23.83-23.72a2.36,2.36,0,0,0,0-3.52C131.3,65,123.43,57,115.4,49.15Zm-80.76-50c-7.72,7.42-15.18,15.17-22.8,22.71a2.36,2.36,0,0,0,0,3.64c7.62,7.51,15,15.26,22.76,22.63Z"
transform="translate(0 -0.2)" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,15 @@
<svg id="hammerhead" data-name="hammerhead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150">
<defs>
<style>
.cls-1 {
fill: none;
}
</style>
</defs>
<g id="Layer_3" data-name="Layer 3">
<path id="Layer_3-2" data-name="Layer 3" class="cls-1" d="M0,.2H150v150H0Z" transform="translate(0 -0.2)" />
</g>
<path fill="white"
d="M145.64,74.71a8.05,8.05,0,0,1-2.55,5.87q-6.36,6.33-12.71,12.69l-49,49a8.16,8.16,0,0,1-11.74,0Q38.91,111.4,8,80.68a8.27,8.27,0,0,1-2.63-5.74,8,8,0,0,1,2.48-6.06L22.08,54.69Q45.72,31.07,69.35,7.43a8.51,8.51,0,0,1,5.31-2.76,7.92,7.92,0,0,1,6.67,2.42L94.72,20.47q24.11,24.09,48.21,48.18A8.36,8.36,0,0,1,145.64,74.71ZM88.88,39.61c0,7.58.05,15.13,0,22.7A2,2,0,0,1,87,64.12c-7.94,0-15.89,0-23.83,0a2,2,0,0,1-2-1.9c0-7.54,0-15.07,0-22.62h-18a2.39,2.39,0,0,0-2.7,2.7v64.5a2.35,2.35,0,0,0,2.65,2.65c6-.06,12,.13,18-.08,0-7.41,0-14.81,0-22.23A2,2,0,0,1,63.35,85q11.79,0,23.57,0a2,2,0,0,1,2,2c0,7.49,0,15,0,22.49,6.06.14,12.12,0,18.18.06a2.46,2.46,0,0,0,2.55-2.67q0-32.25,0-64.51a2.53,2.53,0,0,0-2.71-2.7C100.88,39.64,94.92,39.61,88.88,39.61ZM66.81,90.77c0,7.55,0,15.09,0,22.63A1.9,1.9,0,0,1,65,115.22c-4.74,0-9.48,0-14.22,0l-.09.15C58.53,123,66.13,130.91,74,138.56a2.51,2.51,0,0,0,3.29-.13c7.73-7.66,15.35-15.43,23.13-23l-.09-.17c-5.07,0-10.14,0-15.2,0a2,2,0,0,1-1.83-1.8c0-7.54,0-15.09,0-22.64ZM50.87,33.87c4.66,0,9.24,0,13.89,0a2,2,0,0,1,2,2q0,11.21,0,22.4H83.24q0-11,0-22a2.45,2.45,0,0,1,.45-1.66,2.19,2.19,0,0,1,1.87-.74c4.84,0,9.68.11,14.5-.07-7.59-7.62-15-15.07-22.6-22.64a2.6,2.6,0,0,0-2.27-.88,2.92,2.92,0,0,0-1.7,1C65.9,18.86,58.48,26.28,50.87,33.87Zm64.53,66.32c8-7.76,15.87-15.85,23.83-23.72a2.36,2.36,0,0,0,0-3.52C131.3,65,123.43,57,115.4,49.15Zm-80.76-50c-7.72,7.42-15.18,15.17-22.8,22.71a2.36,2.36,0,0,0,0,3.64c7.62,7.51,15,15.26,22.76,22.63Z"
transform="translate(0 -0.2)" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import Datepicker from "$lib/components/base/datepicker.svelte";
import Modal from "$lib/components/base/modal.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
import { HammerheadSchema } from "$lib/models/api/integration_schema";
import type {
Integration,
HammerheadIntegration,
} from "$lib/models/integration";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n";
interface Props {
integration?: Integration;
onsave?: (hammerheadIntegration: HammerheadIntegration) => void;
}
let { integration, onsave }: Props = $props();
let modal: Modal;
export function openModal() {
errors.set({});
modal.openModal();
}
const getInitialFormValues = () => ({
email: integration?.hammerhead?.email ?? "",
password: integration?.hammerhead?.password ?? "",
completed: integration?.hammerhead?.completed ?? true,
planned: integration?.hammerhead?.planned ?? true,
active: integration?.hammerhead?.active ?? false,
after: integration?.hammerhead?.after,
});
const {
form,
errors,
data: formData,
} = createForm({
initialValues: getInitialFormValues(),
extend: validator({
schema: HammerheadSchema,
}),
onSubmit: async (form) => {
form.active = integration?.hammerhead?.active ?? form.active;
onsave?.(form);
modal.closeModal();
},
});
function clearAfterDate() {
($formData as any).after = undefined;
}
</script>
<Modal
id="hammerhead-settings-modal"
size="md:max-w-lg"
title={"Hammerhead " + $_("settings")}
bind:this={modal}
>
{#snippet content()}
<form id="hammerhead-settings-form" class="space-y-2" use:form>
<TextField
label={$_("email")}
placeholder="user@example.com"
name="email"
error={$errors.email}
></TextField>
<TextField
label={$_("password")}
placeholder={integration?.hammerhead ? `(${$_("unchanged")})` : ""}
name="password"
type="password"
error={$errors.password}
></TextField>
<div class="flex flex-wrap gap-x-4">
<Toggle name="planned" label={$_("planned-tours", { values: { n: 2 } })}
></Toggle>
<Toggle
name="completed"
label={$_("completed-tours", { values: { n: 2 } })}
></Toggle>
</div>
<p
class="text-xs text-gray-500 max-w-lg pt-4 pb-1 border-t border-input-border"
>
{$_("hammerhead-integration-after-date-hint")}
</p>
<div class="flex items-end relative gap-x-2">
<Datepicker
error={$errors.after}
label={$_("ignore-trails-before-date")}
bind:value={$formData.after}
></Datepicker>
<button
class="btn-icon mb-[10px]"
type="button"
onclick={clearAfterDate}
aria-label="Clear 'after' date"
><i class="fa fa-close"></i></button
>
</div>
</form>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
form="hammerhead-settings-form"
type="submit"
name="save">{$_("save")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -70,7 +70,7 @@
<Modal
id="strava-settings-modal"
size="md:min-w-lg"
title={"strava " + $_("settings")}
title={"Strava " + $_("settings")}
bind:this={modal}
>
{#snippet content()}
@@ -114,7 +114,7 @@
<div class="flex items-end relative gap-x-2 pt-2 border-t border-input-border">
<Datepicker
error={$errors.after}
label={$_("after")}
label={$_("ignore-trails-before-date")}
bind:value={$formData.after}
></Datepicker>
<button

View File

@@ -3,7 +3,11 @@
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 {
integrations,
integrations_index,
uploadGpx,
} from "$lib/stores/integration_store";
import {
lists_add_trail,
lists_index,
@@ -17,12 +21,14 @@
import { trail2gpx } from "$lib/util/gpx_util";
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
import JSZip from "jszip";
import type { Snippet } from "svelte";
import { onMount, type Snippet } from "svelte";
import { _ } from "svelte-i18n";
import { get } from "svelte/store";
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 TrailSendModal from "./trail_send_modal.svelte";
import TrailShareModal from "./trail_share_modal.svelte";
interface Props {
@@ -40,8 +46,56 @@
let listSelectModal: ListSearchModal;
let trailExportModal: TrailExportModal;
let trailShareModal: TrailShareModal;
let trailSendModal: TrailSendModal;
const hammerheadIntegration = $derived(
$integrations.find((integration) =>
Boolean(integration.hammerhead?.active)
)
);
let lists: List[] = $state([]);
let integrationsLoading = false;
let integrationsLoadedForUser: string | undefined;
onMount(() => {
const unsubscribe = currentUser.subscribe(async (user) => {
if (!user) {
integrationsLoadedForUser = undefined;
return;
}
const existing = get(integrations);
if (
existing.length &&
existing[0]?.user === user.id
) {
integrationsLoadedForUser = user.id;
return;
}
if (
integrationsLoading ||
integrationsLoadedForUser === user.id
) {
return;
}
integrationsLoading = true;
try {
await integrations_index();
integrationsLoadedForUser = user.id;
} catch (error) {
console.error("Failed to load integrations", error);
} finally {
integrationsLoading = false;
}
});
return () => {
unsubscribe();
};
});
let loading: boolean = $state(false);
@@ -212,6 +266,15 @@
},
]
: []),
...(!isMultiselectMode() && hammerheadIntegration && canExport()
? [
{
text: $_("send-to"),
value: "send-to",
icon: "upload",
},
]
: []),
];
}
@@ -336,6 +399,51 @@
updateTrailsVisibility();
} else if (ddVal == "delete") {
confirmModal.openModal();
} else if (item.value == "send-to") {
trailSendModal.openModal();
}
}
async function uploadToHammerhead() {
if (!hammerheadIntegration || !hasTrail()) {
console.error("No Hammerhead integration found.");
return;
}
for (const uTrail of trails!) {
try {
if (uTrail.gpx) {
const gpxData = await trail2gpx(uTrail, $currentUser);
const formData = new FormData();
const gpxFile = new File(
[gpxData],
`${uTrail.name || "trail"}.gpx`,
{ type: "application/gpx+xml" },
);
formData.append("file", gpxFile);
await uploadGpx("hammerhead", gpxFile);
show_toast({
type: "success",
icon: "check",
text: $_("uploaded-trail-to-hammerhead"),
});
} else {
show_toast({
type: "error",
icon: "close",
text: $_("trail-has-no-gpx"),
});
}
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: $_("error-uploading-trail-to-hammerhead"),
});
}
}
}
@@ -617,3 +725,11 @@
onsave={handleShareUpdate}
bind:this={trailShareModal}
></TrailShareModal>
<TrailSendModal
bind:this={trailSendModal}
onsend={async (settings) => {
if (settings.integrationName === "hammerhead") {
await uploadToHammerhead();
}
}}
></TrailSendModal>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import { _ } from "svelte-i18n";
import hammerheadLogoWhite from "$lib/assets/svgs/logos/hammerhead_white.svg";
import hammerheadLogoDark from "$lib/assets/svgs/logos/hammerhead_dark.svg";
import { theme } from "$lib/stores/theme_store";
interface Props {
title?: string;
onsend?: (settings: typeof sendSettings) => void;
}
let { title = $_("send-to"), onsend: onexport }: Props = $props();
let modal: Modal;
export function openModal() {
modal.openModal();
}
const sendSettings: {
integrationName: string;
} = $state({
integrationName: "hammerhead",
});
function sendTrail() {
onexport?.(sendSettings);
modal.closeModal();
}
</script>
<Modal id="send-modal" {title} size="md:min-w-sm" bind:this={modal}>
{#snippet content()}
<div>
<button class="btn-secondary" onclick={sendTrail}>
<img class="h-20" src={$theme == "light" ? hammerheadLogoDark : hammerheadLogoWhite} alt="integration logo"/>
</button>
</div>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -149,6 +149,7 @@
"error-exporting-trail": "Fehler beim Exportieren der Route",
"error-generating-token": "",
"error-liking-trail": "Error liking trail",
"error-logging-in-to-hammerhead": "Fehler bei der Anmeldung bei Hammerhead",
"error-logging-in-to-komoot": "Fehler bei der Anmeldung bei komoot",
"error-posting-comment": "Fehler beim Posten des Kommentars",
"error-printing-map": "Fehler beim Drucken der Karte",
@@ -157,7 +158,10 @@
"error-saving-trail": "Fehler beim Speichern der Route",
"error-setting-up-integration": "Fehler beim Einrichten der {provider}-Integration",
"error-updating-password": "Fehler beim Aktualisieren des Passworts",
"error-updating-strava-integration": "Fehler bei Aktualisierung der komoot-Integration",
"error-updating-hammerhead-integration": "Fehler bei Aktualisierung der Hammerhead-Integration",
"error-updating-komoot-integration": "Fehler bei Aktualisierung der komoot-Integration",
"error-updating-strava-integration": "Fehler bei Aktualisierung der Strava-Integration",
"error-uploading-trail-to-hammerhead": "Fehler beim Hochladen der Route zu Hammerhead",
"est-duration": "Gesch. Dauer",
"everyone-with-the-link": "Jeder mit dem Link",
"expiration": "",
@@ -196,6 +200,7 @@
"get-started": "Los gehts",
"grid": "Gitter",
"grocery-store": "Lebensmittelgeschäft",
"hammerhead-integration-after-date-hint": "Wenn Ihr Hammerhead Konto bereits mit anderen Trail-Datenbanken wie komoot oder Strava synchronisiert ist, kann die zusätzliche Synchronisierung Ihrer Hammerhead-Daten zu Duplikaten führen. Um dies zu vermeiden, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
"heading": "Überschrift",
"height": "Höhe",
"help": "Hilfe",
@@ -211,11 +216,13 @@
"hut": "Hütte",
"hybrid": "Hybrid",
"icon": "Icon",
"ignore-trails-before-date": "Routen vor diesem Datum ignorieren",
"imperial": "Imperial",
"import": "Importieren",
"import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...",
"include-description": "Beschreibung übernehmen",
"include-waypoints": "Wegpunkte einbeziehen",
"integration-description-hammerhead": "Synchronisiert Deine Hammerhead-Touren regelmäßig mit wanderer.",
"integration-description-komoot": "Synchronisiert Deine komoot-Touren regelmäßig mit wanderer.",
"integration-description-strava": "Synchronisiert Deine Strava-Routen und -Aktivitäten regelmäßig mit wanderer.",
"integration-disabled": "Integration deaktiviert",
@@ -372,6 +379,7 @@
"search-trails": "Route suchen",
"select-list": "Liste auswählen",
"selected": "ausgewählt",
"send-to": "Senden an...",
"set-private": "Verbergen",
"set-public": "Veröffentlichen",
"settings": "Einstellungen",
@@ -417,7 +425,7 @@
"statistics": "Statistiken",
"stop-drawing": "Zeichnen beenden",
"stop-editing": "Bearbeiten beenden",
"strava-integration-after-date-hint": "Wenn in deinem Konto sehr viele Aktivitäten gespeichert sind, kann es aufgrund von API-Abfragelimits bei Strava vorkommen, dass nicht alle Aktivitäten auf einmal synchronisiert werden können. Um dieses Problem zu begrenzen, kannst du unten ein „Danach“-Datum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
"strava-integration-after-date-hint": "Wenn Ihr Konto eine große Anzahl von Aktivitäten enthält, kann es vorkommen, dass Sie aufgrund der API-Restriktionen von Strava nicht alle Aktivitäten auf einmal synchronisieren können. Um dieses Problem zu umgehen, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
"subway-stop": "U-Bahn Eingang",
"summit": "Gipfel",
"summit-book": "Gipfelbuch",
@@ -429,6 +437,7 @@
"top-speed": "Höchstgeschwindigkeit",
"tourism": "Tourismus",
"trail": "{n, plural, =1 {Route} other {Routen}}",
"trail-has-no-gpx": "Diese Route besitzt keine GPX Daten.",
"trail-copied-successfully": "Route erfolgreich kopiert",
"trail-not-in-list": "Trail gehört zu keiner Liste.",
"trail-not-shared": "Mit niemandem geteilt",
@@ -442,6 +451,7 @@
"upload-gpx": "GPX hochladen",
"upload-new-file": "Neue Datei hochladen",
"uploaded": "hochgeladen",
"uploaded-trail-to-hammerhead": "Route erfolgreich zu Hammerhead hochgeladen",
"use-hills": "Hügel einbeziehen",
"use-roads": "Nutze Straßen",
"username": "Nutzername",

View File

@@ -149,6 +149,7 @@
"error-exporting-trail": "Error exporting trail",
"error-generating-token": "Error generating token",
"error-liking-trail": "Error liking trail",
"error-logging-in-to-hammerhead": "Error logging in to Hammerhead",
"error-logging-in-to-komoot": "Error logging in to komoot",
"error-posting-comment": "Error posting comment",
"error-printing-map": "Error printing map",
@@ -157,7 +158,10 @@
"error-saving-trail": "Error saving trail",
"error-setting-up-integration": "Error setting up {provider} integration",
"error-updating-password": "Error updating password",
"error-updating-strava-integration": "Error updating komoot integration",
"error-updating-hammerhead-integration": "Error updating Hammerhead integration",
"error-updating-komoot-integration": "Error updating komoot integration",
"error-updating-strava-integration": "Error updating Strava integration",
"error-uploading-trail-to-hammerhead": "Error uploading trail to Hammerhead",
"est-duration": "Est. duration",
"everyone-with-the-link": "Everyone with the link",
"expiration": "Expiration",
@@ -196,6 +200,7 @@
"get-started": "Get started",
"grid": "Grid",
"grocery-store": "Grocery store",
"hammerhead-integration-after-date-hint": "If your hammerhead account is already synced with other trail databases, such as komoot or Strava, start syncing your Hammerhead data may result in duplicates. To avoid this, you can set an start date below, meaning only activities recorded after this date will be synced.",
"heading": "Heading",
"height": "Height",
"help": "Help",
@@ -211,13 +216,15 @@
"hut": "Hut",
"hybrid": "Hybrid",
"icon": "Icon",
"ignore-trails-before-date": "Ignore trails before this date",
"imperial": "Imperial",
"import": "Import",
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
"include-description": "Include description",
"include-waypoints": "Include waypoints",
"integration-description-hammerhead": "Syncs your Hammerhead tours with wanderer in regular intervals.",
"integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
"integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.",
"integration-description-strava": "Syncs your Strava routes & activities with wanderer in regular intervals.",
"integration-disabled": "integration disabled",
"integration-enabled": "integration enabled",
"integration-privacy-hint-original": "Imported trails will maintain the same visibility they have on the external platform. For example, if the original trail was public, it will be public in wanderer, even if trails are private by default according to your privacy settings.",
@@ -372,6 +379,7 @@
"search-trails": "Search trails",
"select-list": "Select List",
"selected": "selected",
"send-to": "Send to...",
"set-private": "Set private",
"set-public": "Set public",
"settings": "Settings",
@@ -417,7 +425,7 @@
"statistics": "Statistics",
"stop-drawing": "Stop drawing",
"stop-editing": "Stop editing",
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an start date below so that only activities that were recorded after this date are synced.",
"subway-stop": "Subway entrance",
"summit": "Summit",
"summit-book": "Summit Book",
@@ -429,6 +437,7 @@
"top-speed": "Top Speed",
"tourism": "Tourism",
"trail": "{n, plural, =1 {Trail} other {Trails}}",
"trail-has-no-gpx": "This trail has no GPX data.",
"trail-copied-successfully": "trail copied successfully",
"trail-not-in-list": "Trail is not in any list",
"trail-not-shared": "Not shared with anyone",
@@ -442,6 +451,7 @@
"upload-gpx": "Upload GPX",
"upload-new-file": "Upload new file",
"uploaded": "uploaded",
"uploaded-trail-to-hammerhead": "Successfully uploaded trail to Hammerhead",
"use-hills": "Use hills",
"use-roads": "Use Roads",
"username": "Username",

View File

@@ -20,16 +20,27 @@ const KomootSchema = z.object({
privacy: z.enum(["original", "settings"])
})
const HammerheadSchema = z.object({
email: z.string().email(),
password: z.string(),
completed: z.boolean(),
planned: z.boolean(),
active: z.boolean(),
after: z.string().date().optional(),
})
const IntegrationCreateSchema = z.object({
user: z.string().length(15),
strava: StravaSchema.optional(),
komoot: KomootSchema.optional()
komoot: KomootSchema.optional(),
hammerhead: HammerheadSchema.optional(),
}) satisfies ZodType<Integration>
const IntegrationUpdateSchema = z.object({
strava: StravaSchema.optional().nullable(),
komoot: KomootSchema.optional().nullable()
komoot: KomootSchema.optional().nullable(),
hammerhead: HammerheadSchema.optional().nullable(),
}) satisfies ZodType<Partial<Integration>>
export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema };
export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema, HammerheadSchema };

View File

@@ -23,16 +23,26 @@ export interface KomootIntegration extends BaseIntegration {
privacy: "original" | "settings"
}
export interface HammerheadIntegration extends BaseIntegration {
email: string,
password: string,
completed: boolean,
planned: boolean,
after?: string
}
export class Integration {
id?: string;
user: string;
strava?: StravaIntegration | null;
komoot?: KomootIntegration | null
komoot?: KomootIntegration | null;
hammerhead?: HammerheadIntegration | null;
constructor(user: string, strava?: StravaIntegration, komoot?: KomootIntegration) {
constructor(user: string, strava?: StravaIntegration, komoot?: KomootIntegration, hammerhead?: HammerheadIntegration) {
this.user = user;
this.strava = strava;
this.komoot = komoot;
this.hammerhead = hammerhead;
}
}

View File

@@ -47,6 +47,21 @@ export async function integrations_create(integration: Integration) {
return model;
}
export async function uploadGpx(integrationName: string, file: File) {
const formData = new FormData();
formData.append('file', file);
let r = await fetch(`/api/v1/integration/${integrationName}/upload`, {
method: 'POST',
body: formData,
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
}
export async function integrations_update(integration: Integration, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f('/api/v1/integration/' + integration.id, {
method: 'POST',

View File

@@ -0,0 +1,13 @@
import { handleError } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) {
try {
const r = await event.locals.pb.send("/integration/hammerhead/login", {
method: "GET",
});
return json(r);
} catch (e: any) {
return handleError(e)
}
}

View File

@@ -0,0 +1,22 @@
import { handleError } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function POST(event: RequestEvent) {
try {
const formData = await event.request.formData();
const file = formData.get("file");
if (!(file instanceof Blob)) {
return json({ message: "missing_file" }, { status: 400 });
}
const r = await event.locals.pb.send("/integration/hammerhead/upload", {
method: "POST",
body: formData,
fetch: event.fetch,
});
return json(r);
} catch (e: any) {
return handleError(e)
}
}

View File

@@ -1,12 +1,14 @@
<script lang="ts">
import { page } from "$app/state";
import HammerheadSettingsModal from "$lib/components/settings/integrations/hammerhead_settings_modal.svelte";
import IntegrationCard from "$lib/components/settings/integrations/integration_card.svelte";
import KomootSettingsModal from "$lib/components/settings/integrations/komoot_settings_modal.svelte";
import StravaSettingsModal from "$lib/components/settings/integrations/strava_settings_modal.svelte";
import {
Integration,
type HammerheadIntegration,
type KomootIntegration,
type StravaIntegration
type StravaIntegration,
} from "$lib/models/integration.js";
import {
integrations_create,
@@ -15,6 +17,9 @@
import { show_toast } from "$lib/stores/toast_store.svelte.js";
import { untrack } from "svelte";
import { _ } from "svelte-i18n";
import hammerheadLogoWhite from "$lib/assets/svgs/logos/hammerhead_white.svg";
import hammerheadLogoDark from "$lib/assets/svgs/logos/hammerhead_dark.svg";
import { theme } from "$lib/stores/theme_store";
let { data } = $props();
@@ -33,9 +38,14 @@
untrack(() => data.integration?.komoot?.active ?? false),
);
let hammerheadSettingsModal: HammerheadSettingsModal;
let hammerheadToggleValue: boolean = $state(
untrack(() => data.integration?.hammerhead?.active ?? false),
);
async function onSettingsSave(
form: StravaIntegration | KomootIntegration,
key: "strava" | "komoot",
form: StravaIntegration | KomootIntegration | HammerheadIntegration,
key: "strava" | "komoot" | "hammerhead",
) {
try {
if (integration) {
@@ -49,6 +59,13 @@
integration = await integrations_create(newIntegration);
}
if (key == "komoot" || key == "hammerhead") {
let verified = await verifyLogin(key);
if (!verified) {
return;
}
}
show_toast({
text: $_("settings-saved"),
icon: "check",
@@ -104,7 +121,7 @@
}
show_toast({
text: "strava " + $_("integration-disabled"),
text: "Strava " + $_("integration-disabled"),
icon: "check",
type: "success",
});
@@ -116,23 +133,11 @@
return;
}
if (value) {
try {
const r = await fetch("/api/v1/integration/komoot/login", {
method: "GET",
});
if (!r.ok) {
throw Error();
}
} catch (e) {
komootToggleValue = false;
show_toast({
text: $_("error-logging-in-to-komoot"),
icon: "close",
type: "error",
});
let verified = await verifyLogin("komoot");
if (!verified) {
return;
}
integration.komoot.active = true;
} else {
integration.komoot.active = false;
@@ -141,7 +146,7 @@
integration = await integrations_update(integration);
} catch (e) {
show_toast({
text: $_("error-updating-strava-integration"),
text: $_("error-updating-komoot-integration"),
icon: "close",
type: "error",
});
@@ -155,6 +160,65 @@
type: "success",
});
}
async function verifyLogin(integrationName: string): Promise<boolean> {
try {
const r = await fetch(
`/api/v1/integration/${integrationName}/login`,
{
method: "GET",
},
);
if (!r.ok) {
throw Error();
}
} catch (e) {
hammerheadToggleValue = false;
show_toast({
text: $_(`error-logging-in-to-${integrationName}`),
icon: "close",
type: "error",
});
return false;
}
return true;
}
async function onHammerheadToggle(value: boolean) {
if (!integration?.hammerhead) {
return;
}
if (value) {
let verified = await verifyLogin("hammerhead");
if (!verified) {
return;
}
integration.hammerhead.active = true;
} else {
integration.hammerhead.active = false;
}
try {
integration = await integrations_update(integration);
} catch (e) {
show_toast({
text: $_("error-updating-hammerhead-integration"),
icon: "close",
type: "error",
});
return;
}
show_toast({
text:
"Hammerhead " +
$_(`integration-${value ? "enabled" : "disabled"}`),
icon: "check",
type: "success",
});
}
</script>
<svelte:head>
@@ -167,7 +231,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<IntegrationCard
img="https://upload.wikimedia.org/wikipedia/commons/c/cb/Strava_Logo.svg"
title="strava"
title="Strava"
description={$_("integration-description-strava")}
disabled={!integration?.strava}
active={stravaToggleValue}
@@ -183,6 +247,15 @@
onclick={() => komootSettingsModal.openModal()}
ontoggle={onKomootToggle}
></IntegrationCard>
<IntegrationCard
img={$theme == "light" ? hammerheadLogoDark : hammerheadLogoWhite}
title="Hammerhead"
description={$_("integration-description-hammerhead")}
disabled={!integration?.hammerhead}
bind:active={hammerheadToggleValue}
onclick={() => hammerheadSettingsModal.openModal()}
ontoggle={onHammerheadToggle}
></IntegrationCard>
</div>
<StravaSettingsModal
@@ -196,3 +269,9 @@
{integration}
onsave={(form) => onSettingsSave(form, "komoot")}
></KomootSettingsModal>
<HammerheadSettingsModal
bind:this={hammerheadSettingsModal}
{integration}
onsave={(form) => onSettingsSave(form, "hammerhead")}
></HammerheadSettingsModal>