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,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
>