merge trails function added (#627)
* merge trails function added * fix conflict resolve issues * avoid incorrect authorship when merging trail comments * require edit access for all selected trails before merge * fix likes checkbox binding in trail merge modal * improve error handling * require editable target and deletable sources for merge * avoid duplicate trail fetch during merge * prevent duplicate likes when merging trails * check photo download responses during trail merge * add merge completion toast notifications * cleanup * small fixes * move merge code to backend, option to merge single trail selection with similar one, automatic merge for integrations add as option, maintenance page to find and merge similar trails added * fix build error * fix * docu, beautifying, refactoring --------- Co-authored-by: Flomp <Flomp@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
text: string;
|
||||
value: any;
|
||||
icon?: string;
|
||||
separator?: boolean;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -130,16 +131,22 @@
|
||||
bind:this={dropdownElement}
|
||||
>
|
||||
{#each items as item}
|
||||
<li
|
||||
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||
role="presentation"
|
||||
onclick={(e) => handleItemClick(e, item)}
|
||||
>
|
||||
{#if item.icon}
|
||||
<i class="fa fa-{item.icon} mr-3"></i>
|
||||
{/if}
|
||||
<span class="whitespace-nowrap">{item.text}</span>
|
||||
</li>
|
||||
{#if item.separator}
|
||||
<li class="px-3 py-2" role="separator" aria-hidden="true">
|
||||
<div class="border-t border-input-border"></div>
|
||||
</li>
|
||||
{:else}
|
||||
<li
|
||||
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||
role="presentation"
|
||||
onclick={(e) => handleItemClick(e, item as { text: string; value: any })}
|
||||
>
|
||||
{#if item.icon}
|
||||
<i class="fa fa-{item.icon} mr-3"></i>
|
||||
{/if}
|
||||
<span class="whitespace-nowrap">{item.text}</span>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.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";
|
||||
@@ -33,6 +34,9 @@
|
||||
planned: integration?.hammerhead?.planned ?? true,
|
||||
active: integration?.hammerhead?.active ?? false,
|
||||
after: integration?.hammerhead?.after,
|
||||
merge: integration?.hammerhead?.merge ?? {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -104,6 +108,8 @@
|
||||
><i class="fa fa-close"></i></button
|
||||
>
|
||||
</div>
|
||||
|
||||
<IntegrationMergeSettings prefix="merge" />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
let { prefix }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="pt-4 border-t border-input-border space-y-2">
|
||||
<Toggle
|
||||
name={`${prefix}.enabled`}
|
||||
label={$_("integration-auto-merge-label")}
|
||||
/>
|
||||
<p class="text-xs text-gray-500 max-w-lg">
|
||||
{$_("integration-auto-merge-hint")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
|
||||
import Select, {
|
||||
type SelectItem,
|
||||
} from "$lib/components/base/select.svelte";
|
||||
@@ -43,6 +44,9 @@
|
||||
planned: integration?.komoot?.planned ?? true,
|
||||
active: integration?.komoot?.active ?? false,
|
||||
privacy: integration?.komoot?.privacy ?? "original",
|
||||
merge: integration?.komoot?.merge ?? {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -106,6 +110,8 @@
|
||||
{$_("integration-privacy-hint-user")}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<IntegrationMergeSettings prefix="merge" />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import type { SelectItem } from "$lib/components/base/select.svelte";
|
||||
import Select from "$lib/components/base/select.svelte";
|
||||
@@ -43,7 +44,10 @@
|
||||
activities: integration?.strava?.activities ?? true,
|
||||
active: integration?.strava?.active ?? false,
|
||||
after: integration?.strava?.after,
|
||||
privacy: integration?.komoot?.privacy ?? "original",
|
||||
privacy: integration?.strava?.privacy ?? "original",
|
||||
merge: integration?.strava?.merge ?? {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -130,6 +134,8 @@
|
||||
>
|
||||
{$_("strava-integration-after-date-hint")}
|
||||
</p>
|
||||
|
||||
<IntegrationMergeSettings prefix="merge" />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
lists_remove_trail,
|
||||
} from "$lib/stores/list_store";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
||||
import { trails_delete, trails_update } from "$lib/stores/trail_store";
|
||||
import {
|
||||
trails_delete,
|
||||
trails_show,
|
||||
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";
|
||||
@@ -30,6 +34,21 @@
|
||||
import TrailExportModal from "./trail_export_modal.svelte";
|
||||
import TrailSendModal from "./trail_send_modal.svelte";
|
||||
import TrailShareModal from "./trail_share_modal.svelte";
|
||||
import {
|
||||
mergeStore,
|
||||
processMergeQueue,
|
||||
type Merge,
|
||||
} from "$lib/stores/trail_merge_store.svelte";
|
||||
import TrailMergeModal from "./trail_merge_modal.svelte";
|
||||
import type { MergeSelection, MergeSettings } from "./trail_merge_modal.svelte";
|
||||
import MergeDialog from "$lib/components/trail/trail_merge_dialog.svelte";
|
||||
import { trail_merge } from "$lib/stores/trail_merge_api";
|
||||
|
||||
export interface MergeResult {
|
||||
targetTrail: Trail;
|
||||
deletedTrailIds: string[];
|
||||
successfulMergeCount: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
trails?: Set<Trail> | undefined;
|
||||
@@ -38,14 +57,16 @@
|
||||
onDelete?: () => void;
|
||||
onShare?: () => void;
|
||||
onUpdate?: () => void;
|
||||
onMerge?: (result: MergeResult) => void;
|
||||
}
|
||||
|
||||
let { trails, mode, toggle, onDelete, onShare, onUpdate }: Props = $props();
|
||||
let { trails, mode, toggle, onDelete, onShare, onUpdate, onMerge }: Props = $props();
|
||||
|
||||
let confirmModal: ConfirmModal;
|
||||
let listSelectModal: ListSearchModal;
|
||||
let trailExportModal: TrailExportModal;
|
||||
let trailShareModal: TrailShareModal;
|
||||
let trailMergeModal: TrailMergeModal;
|
||||
let trailSendModal: TrailSendModal;
|
||||
|
||||
const hammerheadIntegration = $derived(
|
||||
@@ -99,18 +120,75 @@
|
||||
|
||||
let loading: boolean = $state(false);
|
||||
|
||||
function canEditTrail(candidate: Trail | undefined): boolean {
|
||||
if (!candidate || !$currentUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
candidate.expand?.author?.id === $currentUser.actor ||
|
||||
Boolean(
|
||||
candidate.expand?.trail_share_via_trail?.some(
|
||||
(s) =>
|
||||
s.permission == "edit" &&
|
||||
s.actor == $currentUser.actor,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getMergeTarget(): Trail | undefined {
|
||||
if (!trails || trails.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const candidate of trails) {
|
||||
if (
|
||||
candidate.expand?.summit_logs_via_trail &&
|
||||
candidate.expand.summit_logs_via_trail.length > 0
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return [...trails][0];
|
||||
}
|
||||
|
||||
function allowEdit(): boolean {
|
||||
return (
|
||||
hasTrail() &&
|
||||
!isMultiselectMode() &&
|
||||
Boolean($currentUser) &&
|
||||
(trail()!.expand?.author?.id === $currentUser?.actor ||
|
||||
trail()!.expand?.trail_share_via_trail?.some(
|
||||
(s) => s.permission == "edit",
|
||||
))!
|
||||
canEditTrail(trail())
|
||||
);
|
||||
}
|
||||
|
||||
function allowMerge(): boolean {
|
||||
if (!hasTrail() || !isMultiselectMode() || !Boolean($currentUser)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetTrail = getMergeTarget();
|
||||
if (!canEditTrail(targetTrail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const cTrail of trails ?? []) {
|
||||
if (cTrail.id === targetTrail?.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!allowDeleteTrail(cTrail)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function allowFindSimilarTrails(): boolean {
|
||||
return hasTrail() && !isMultiselectMode() && Boolean($currentUser) && hasGpx() && isFromCurrentUser(trail());
|
||||
}
|
||||
|
||||
function majorityOfSelectedTrailsArePublic(): boolean {
|
||||
if (trails === undefined || trails.size === 0) return false;
|
||||
|
||||
@@ -119,13 +197,7 @@
|
||||
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",
|
||||
)
|
||||
) {
|
||||
if (!canEditTrail(cTrail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -152,13 +224,7 @@
|
||||
}
|
||||
|
||||
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",
|
||||
)
|
||||
) {
|
||||
if (!canEditTrail(cTrail)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -167,8 +233,70 @@
|
||||
}
|
||||
|
||||
function dropdownItems(): DropdownItem[] {
|
||||
const separator = (value: string): DropdownItem => ({
|
||||
text: "",
|
||||
value,
|
||||
separator: true,
|
||||
});
|
||||
const allowListManagement = isFromCurrentUser();
|
||||
const allowShareSingleTrail = !isMultiselectMode() && isFromCurrentUser();
|
||||
const allowSingleOutput = canExport() || (!isMultiselectMode() && hammerheadIntegration && canExport());
|
||||
|
||||
if (isMultiselectMode()) {
|
||||
return [
|
||||
...(allowMerge()
|
||||
? [{ text: $_("link"), value: "merge", icon: "link" }]
|
||||
: []),
|
||||
...(allowMerge() && (canExport() || allowListManagement || allowPublish() || allowDelete())
|
||||
? [separator("sep-multi-actions")]
|
||||
: []),
|
||||
...(canExport()
|
||||
? [
|
||||
{
|
||||
text: $_("export"),
|
||||
value: "download",
|
||||
icon: "download",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!allowListManagement
|
||||
? []
|
||||
: [
|
||||
{
|
||||
text: $_("add-to-list"),
|
||||
value: "list",
|
||||
icon: "bookmark",
|
||||
},
|
||||
]),
|
||||
...(((canExport() || allowListManagement) && (allowPublish() || allowDelete()))
|
||||
? [separator("sep-multi-visibility")]
|
||||
: []),
|
||||
...(allowPublish()
|
||||
? [
|
||||
{
|
||||
text: `${majorityOfSelectedTrailsArePublic() ? $_("set-private") : $_("set-public")}`,
|
||||
value: "publish",
|
||||
icon: majorityOfSelectedTrailsArePublic()
|
||||
? "lock"
|
||||
: "globe",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(allowDelete()
|
||||
? [
|
||||
separator("sep-multi-danger"),
|
||||
{
|
||||
text: $_("delete"),
|
||||
value: "delete",
|
||||
icon: "trash",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...(!isMultiselectMode()
|
||||
...(hasTrail()
|
||||
? [
|
||||
mode == "overview" || mode == "multi-select"
|
||||
? {
|
||||
@@ -181,10 +309,6 @@
|
||||
value: "show",
|
||||
icon: "table-columns",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isMultiselectMode()
|
||||
? [
|
||||
{
|
||||
text: $_("directions"),
|
||||
value: "direction",
|
||||
@@ -192,6 +316,69 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...((hasTrail() && (allowEdit() || allowFindSimilarTrails() || allowCopy()))
|
||||
? [separator("sep-single-edit")]
|
||||
: []),
|
||||
...(allowEdit()
|
||||
? [
|
||||
{
|
||||
text: $_("edit"),
|
||||
value: "edit",
|
||||
icon: "pen",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(allowFindSimilarTrails()
|
||||
? [{
|
||||
text: $_("find-similar-trails"),
|
||||
value: "find-similar-trails",
|
||||
icon: "link",
|
||||
}]
|
||||
: []),
|
||||
...(allowCopy()
|
||||
? [
|
||||
{
|
||||
text: $_("duplicate"),
|
||||
value: "copy",
|
||||
icon: "copy",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...((allowListManagement || allowShareSingleTrail || allowPublish())
|
||||
? [separator("sep-single-organize")]
|
||||
: []),
|
||||
...(!allowListManagement
|
||||
? []
|
||||
: [
|
||||
{
|
||||
text: $_("add-to-list"),
|
||||
value: "list",
|
||||
icon: "bookmark",
|
||||
},
|
||||
]),
|
||||
...(!allowShareSingleTrail
|
||||
? []
|
||||
: [
|
||||
{
|
||||
text: $_("share"),
|
||||
value: "share",
|
||||
icon: "share",
|
||||
},
|
||||
]),
|
||||
...(allowPublish()
|
||||
? [
|
||||
{
|
||||
text: `${majorityOfSelectedTrailsArePublic() ? $_("set-private") : $_("set-public")}`,
|
||||
value: "publish",
|
||||
icon: majorityOfSelectedTrailsArePublic()
|
||||
? "lock"
|
||||
: "globe",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...((allowSingleOutput || allowDelete())
|
||||
? [separator("sep-single-output")]
|
||||
: []),
|
||||
...(canExport()
|
||||
? [
|
||||
{
|
||||
@@ -201,6 +388,15 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isMultiselectMode() && hammerheadIntegration && canExport()
|
||||
? [
|
||||
{
|
||||
text: $_("send-to"),
|
||||
value: "send-to",
|
||||
icon: "upload",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isMultiselectMode()
|
||||
? [
|
||||
{
|
||||
@@ -210,55 +406,9 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isFromCurrentUser()
|
||||
? []
|
||||
: [
|
||||
{
|
||||
text: $_("add-to-list"),
|
||||
value: "list",
|
||||
icon: "bookmark",
|
||||
},
|
||||
]),
|
||||
...(isMultiselectMode() || !isFromCurrentUser()
|
||||
? []
|
||||
: [
|
||||
{
|
||||
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",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(allowDelete()
|
||||
? [
|
||||
separator("sep-single-danger"),
|
||||
{
|
||||
text: $_("delete"),
|
||||
value: "delete",
|
||||
@@ -266,15 +416,6 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isMultiselectMode() && hammerheadIntegration && canExport()
|
||||
? [
|
||||
{
|
||||
text: $_("send-to"),
|
||||
value: "send-to",
|
||||
icon: "upload",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -399,11 +540,69 @@
|
||||
updateTrailsVisibility();
|
||||
} else if (ddVal == "delete") {
|
||||
confirmModal.openModal();
|
||||
} else if (item.value == "merge") {
|
||||
await trailMergeModal.openModal(Array.from(trails ?? []));
|
||||
} else if (item.value == "find-similar-trails") {
|
||||
if (trail()) {
|
||||
await trailMergeModal.openSimilarTrailsModal(trail()!);
|
||||
}
|
||||
} else if (item.value == "send-to") {
|
||||
trailSendModal.openModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function mergeTrails(settings: MergeSettings, selection: MergeSelection) {
|
||||
let trailTarget = selection.targetTrail;
|
||||
if (!trailTarget.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trailTarget.expand) {
|
||||
trailTarget = await trails_show(trailTarget.id);
|
||||
}
|
||||
|
||||
for (const t of selection.sourceTrails) {
|
||||
if (t.id === trailTarget.id) continue;
|
||||
|
||||
const u: Merge = {
|
||||
trailTarget: trailTarget,
|
||||
trailSource: t,
|
||||
progress: 0,
|
||||
status: "enqueued",
|
||||
settings: settings,
|
||||
function: trails_merge_backend
|
||||
};
|
||||
mergeStore.enqueuedMerges.push(u);
|
||||
}
|
||||
|
||||
const completedBeforeRun = mergeStore.completedMerges.length;
|
||||
await processMergeQueue();
|
||||
const completedThisRun = mergeStore.completedMerges.slice(completedBeforeRun);
|
||||
const successfulMerges = completedThisRun.filter(
|
||||
(merge) => merge.status === "success",
|
||||
);
|
||||
const successfulThisRun = successfulMerges.length;
|
||||
|
||||
if (settings.delete && successfulThisRun > 0) {
|
||||
onMerge?.({
|
||||
targetTrail: trailTarget,
|
||||
deletedTrailIds: successfulMerges
|
||||
.map((merge) => merge.trailSource.id)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
successfulMergeCount: successfulThisRun,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function trails_merge_backend(trailTarget: Trail, trailSource: Trail, settings: MergeSettings, onProgress?: (progress: number) => void) {
|
||||
if (!trailTarget.id || !trailSource.id) {
|
||||
throw new Error($_("error-merging-trail"));
|
||||
}
|
||||
|
||||
onProgress?.(0.2);
|
||||
await trail_merge(trailSource.id, trailTarget.id, settings);
|
||||
onProgress?.(1);
|
||||
}
|
||||
async function uploadToHammerhead() {
|
||||
if (!hammerheadIntegration || !hasTrail()) {
|
||||
console.error("No Hammerhead integration found.");
|
||||
@@ -725,6 +924,12 @@
|
||||
onsave={handleShareUpdate}
|
||||
bind:this={trailShareModal}
|
||||
></TrailShareModal>
|
||||
<TrailMergeModal
|
||||
bind:this={trailMergeModal}
|
||||
onmerge={(settings, selection) => mergeTrails(settings, selection)}
|
||||
></TrailMergeModal>
|
||||
|
||||
<MergeDialog/>
|
||||
<TrailSendModal
|
||||
bind:this={trailSendModal}
|
||||
onsend={async (settings) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import Tabs from "$lib/components/base/tabs.svelte";
|
||||
import TrailDropdown from "$lib/components/trail/trail_dropdown.svelte";
|
||||
import TrailDropdown, { type MergeResult } from "$lib/components/trail/trail_dropdown.svelte";
|
||||
import { Comment } from "$lib/models/comment";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
|
||||
@@ -269,6 +270,21 @@
|
||||
summitLogs.set(newSummitLogList);
|
||||
}
|
||||
|
||||
function handleTrailMerge(result: MergeResult) {
|
||||
if (!trail.id || !result.deletedTrailIds.includes(trail.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetHandle = handleFromRecordWithIRI(result.targetTrail);
|
||||
const targetPath =
|
||||
mode === "map"
|
||||
? `/map/trail/${targetHandle}/${result.targetTrail.id}`
|
||||
: `/trail/view/${targetHandle}/${result.targetTrail.id}`;
|
||||
|
||||
const search = page.url.searchParams.toString();
|
||||
goto(search ? `${targetPath}?${search}` : targetPath);
|
||||
}
|
||||
|
||||
async function markTrailAsCompleted() {
|
||||
trail.completed = true;
|
||||
const updatedTrail: Trail = { ...trail };
|
||||
@@ -430,6 +446,7 @@
|
||||
trails={new Set<Trail>([trail])}
|
||||
onDelete={() =>
|
||||
history.length ? history.back() : goto("/trails")}
|
||||
onMerge={handleTrailMerge}
|
||||
{mode}
|
||||
></TrailDropdown>
|
||||
</div>
|
||||
|
||||
@@ -298,6 +298,15 @@
|
||||
else hoveredTrail = undefined;
|
||||
}
|
||||
|
||||
async function handleTrailsMergeDone(resetSelection: boolean = false) {
|
||||
if (resetSelection) {
|
||||
selection?.clear();
|
||||
hoveredTrail = undefined;
|
||||
}
|
||||
await tick();
|
||||
onupdate?.(filter, selection);
|
||||
}
|
||||
|
||||
async function handleTrailsEditDone(resetSelection: boolean = false) {
|
||||
if (resetSelection) {
|
||||
selection = new Set<Trail>();
|
||||
@@ -346,6 +355,7 @@
|
||||
mode={"multi-select"}
|
||||
onDelete={() => handleTrailsEditDone(true)}
|
||||
onShare={() => handleTrailsEditDone(false)}
|
||||
onMerge={() => handleTrailsMergeDone(true)}
|
||||
onUpdate={() => handleTrailsEditDone(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
177
web/src/lib/components/trail/trail_merge_dialog.svelte
Normal file
177
web/src/lib/components/trail/trail_merge_dialog.svelte
Normal file
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
processMergeQueue,
|
||||
mergeStore,
|
||||
type Merge,
|
||||
} from "$lib/stores/trail_merge_store.svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
let minimized: boolean = $state(false);
|
||||
|
||||
let visibleMerges = $derived(
|
||||
mergeStore.enqueuedMerges
|
||||
.concat(mergeStore.completedMerges)
|
||||
.sort((a, b) =>
|
||||
(a.trailSource.name || a.trailSource.id || "").localeCompare(
|
||||
b.trailSource.name || b.trailSource.id || "",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
let remaining = $derived(mergeStore.enqueuedMerges.length);
|
||||
|
||||
let successfulMerges = $derived(
|
||||
mergeStore.completedMerges.reduce(
|
||||
(sum, u) => (sum += u.status == "success" ? 1 : 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
let errorMerges = $derived(
|
||||
mergeStore.completedMerges.reduce(
|
||||
(sum, u) => (sum += u.status == "error" ? 1 : 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
function dismissMerge(u: Merge) {
|
||||
const index = mergeStore.completedMerges.indexOf(u);
|
||||
mergeStore.completedMerges.splice(index, 1);
|
||||
}
|
||||
|
||||
function dismissAllCompleted() {
|
||||
mergeStore.completedMerges = [];
|
||||
}
|
||||
|
||||
function cancelMerge(u: Merge) {
|
||||
const index = mergeStore.enqueuedMerges.indexOf(u);
|
||||
u.status = "cancelled";
|
||||
mergeStore.enqueuedMerges.splice(index, 1);
|
||||
mergeStore.completedMerges.push(u);
|
||||
}
|
||||
|
||||
function reMerge(u: Merge) {
|
||||
const index = mergeStore.completedMerges.indexOf(u);
|
||||
u.status = "enqueued";
|
||||
u.progress = 0;
|
||||
u.error = undefined;
|
||||
mergeStore.completedMerges.splice(index, 1);
|
||||
mergeStore.enqueuedMerges.push(u);
|
||||
processMergeQueue(undefined);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visibleMerges.length}
|
||||
<div
|
||||
class="fixed bottom-4 right-4 z-10 p-4 bg-background rounded-xl border border-input-border shadow-xl"
|
||||
class:cursor-pointer={minimized}
|
||||
in:slide
|
||||
out:slide
|
||||
role="presentation"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
minimized = false;
|
||||
}}
|
||||
>
|
||||
<div class="flex gap-x-2 items-start justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{$_("trail-merge-summary", {
|
||||
values: {
|
||||
remaining,
|
||||
processed: mergeStore.completedMerges.length,
|
||||
total:
|
||||
mergeStore.enqueuedMerges.length +
|
||||
mergeStore.completedMerges.length,
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
{$_("trail-merge-result-summary", {
|
||||
values: {
|
||||
success: successfulMerges,
|
||||
errors: errorMerges,
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<button title={$_('clear-all')} aria-label={$_('clear-all')} onclick={dismissAllCompleted}
|
||||
><i class="fa fa-ban"></i></button
|
||||
>
|
||||
<button
|
||||
aria-label={$_("minimize")}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
minimized = true;
|
||||
}}><i class="fa fa-minus"></i></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-96 max-w-72 mt-4 overflow-y-auto space-y-2"
|
||||
class:hidden={minimized}
|
||||
>
|
||||
{#each visibleMerges as u}
|
||||
<div class="bg-menu-item-background-hover rounded-lg py-2 px-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 shrink-0">
|
||||
{#if u.status === "enqueued" || u.status == "merging"}
|
||||
<div class="spinner spinner-small"></div>
|
||||
{:else}
|
||||
<i
|
||||
class={{
|
||||
fa: true,
|
||||
"fa-circle-exclamation text-red-400":
|
||||
u.status == "error",
|
||||
"fa-circle-check text-emerald-400":
|
||||
u.status == "success",
|
||||
"fa-ban text-gray-500":
|
||||
u.status == "cancelled",
|
||||
}}
|
||||
></i>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs basis-full min-w-0 break-all mr-2">
|
||||
{u.trailSource.name}
|
||||
</p>
|
||||
{#if u.status == "error" || u.status == "cancelled"}
|
||||
<button
|
||||
aria-label={$_("trail-merge-retry")}
|
||||
onclick={() => reMerge(u)}
|
||||
><i class="fa fa-redo text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
{#if u.status == "enqueued"}
|
||||
<button
|
||||
aria-label={$_("trail-merge-cancel")}
|
||||
onclick={() => cancelMerge(u)}
|
||||
><i class="fa fa-stop text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
{#if u.status != "enqueued" && u.status != "merging"}
|
||||
<button
|
||||
aria-label={$_("dismiss")}
|
||||
onclick={() => dismissMerge(u)}
|
||||
><i class="fa fa-close text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if u.status == "merging"}
|
||||
<div
|
||||
class="progress-bar my-1 rounded-md"
|
||||
style="height:2px; width:{u.progress * 100}%; background-color:#3549bb;transition: width 0.5s ease-in-out;"
|
||||
></div>
|
||||
{:else if u.error}
|
||||
<p class="text-red-400 text-xs">
|
||||
{u.error}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
514
web/src/lib/components/trail/trail_merge_modal.svelte
Normal file
514
web/src/lib/components/trail/trail_merge_modal.svelte
Normal file
@@ -0,0 +1,514 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import Select, { type SelectItem } from "$lib/components/base/select.svelte";
|
||||
import { trails_show } from "$lib/stores/trail_store";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import {
|
||||
trail_merge_suggest_auto,
|
||||
trail_merge_suggest_manual,
|
||||
type TrailMergeSuggestCandidate,
|
||||
} from "$lib/stores/trail_merge_api";
|
||||
import { translateTrailMergeError } from "$lib/stores/trail_merge_i18n";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
|
||||
export interface MergeSelection {
|
||||
targetTrail: Trail;
|
||||
sourceTrails: Trail[];
|
||||
}
|
||||
|
||||
export interface OpenMergeModalOptions {
|
||||
preferredTargetTrailId?: string;
|
||||
fixedTargetTrailId?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
onmerge?: (settings: MergeSettings, selection: MergeSelection) => void;
|
||||
}
|
||||
|
||||
let { title = $_("link-as-summit-log"), onmerge: onmerge }: Props = $props();
|
||||
|
||||
const STORAGE_KEY_SETTINGS = "trail_merge_settings";
|
||||
const STORAGE_KEY_REMEMBER = "trail_merge_remember";
|
||||
|
||||
let modal: Modal;
|
||||
let loading = $state(false);
|
||||
let candidateWarnings: Record<string, string[]> = $state({});
|
||||
let candidateReasons: Record<string, string> = $state({});
|
||||
let selectableTargets: Trail[] = $state([]);
|
||||
let targetTrailId = $state("");
|
||||
let modalTitle = $state("");
|
||||
let autoDiscoveryMode = $state(false);
|
||||
let fixedTargetSelection = $state(false);
|
||||
let mergeTrailsSelection: Trail[] = $state([]);
|
||||
let autoDiscoverySourceTrail: Trail | undefined = $state();
|
||||
let autoDiscoveryCandidateTrails: Trail[] = $state([]);
|
||||
|
||||
const defaultSettings: MergeSettings = {
|
||||
summitLog: true,
|
||||
photos: true,
|
||||
comments: true,
|
||||
delete: true,
|
||||
tags: true,
|
||||
likes: true,
|
||||
};
|
||||
|
||||
let rememberSettings = $state(false);
|
||||
|
||||
function applySettings(next: MergeSettings) {
|
||||
settings.summitLog = next.summitLog;
|
||||
settings.photos = next.photos;
|
||||
settings.comments = next.comments;
|
||||
settings.delete = next.delete;
|
||||
settings.tags = next.tags;
|
||||
settings.likes = next.likes;
|
||||
}
|
||||
|
||||
function loadStoredSettings() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
applySettings(defaultSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
rememberSettings = localStorage.getItem(STORAGE_KEY_REMEMBER) === "true";
|
||||
if (!rememberSettings) {
|
||||
applySettings(defaultSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(STORAGE_KEY_SETTINGS);
|
||||
if (!raw) {
|
||||
applySettings(defaultSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<MergeSettings>;
|
||||
applySettings({
|
||||
summitLog: parsed.summitLog ?? defaultSettings.summitLog,
|
||||
photos: parsed.photos ?? defaultSettings.photos,
|
||||
comments: parsed.comments ?? defaultSettings.comments,
|
||||
delete: parsed.delete ?? defaultSettings.delete,
|
||||
tags: parsed.tags ?? defaultSettings.tags,
|
||||
likes: parsed.likes ?? defaultSettings.likes,
|
||||
});
|
||||
} catch {
|
||||
applySettings(defaultSettings);
|
||||
}
|
||||
}
|
||||
|
||||
function persistSettingsPreference() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY_REMEMBER, rememberSettings ? "true" : "false");
|
||||
if (!rememberSettings) {
|
||||
localStorage.removeItem(STORAGE_KEY_SETTINGS);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_SETTINGS,
|
||||
JSON.stringify({
|
||||
summitLog: settings.summitLog,
|
||||
photos: settings.photos,
|
||||
comments: settings.comments,
|
||||
delete: settings.delete,
|
||||
tags: settings.tags,
|
||||
likes: settings.likes,
|
||||
} satisfies MergeSettings),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadAutoDiscoveryTargets(
|
||||
sourceTrail: Trail,
|
||||
): Promise<{
|
||||
targetTrailId: string;
|
||||
warnings: Record<string, string[]>;
|
||||
reasons: Record<string, string>;
|
||||
selectableTargets: Trail[];
|
||||
}> {
|
||||
if (!sourceTrail.id) {
|
||||
throw new Error($_("trail_merge_missing_source_trail_id"));
|
||||
}
|
||||
|
||||
const response = await trail_merge_suggest_auto(sourceTrail.id);
|
||||
const selectableCandidates = response.candidates.filter(
|
||||
(candidate) => candidate.selectable,
|
||||
);
|
||||
|
||||
const loadedTargets = await Promise.all(
|
||||
selectableCandidates.map(async (candidate) => {
|
||||
const trail = await trails_show(candidate.trailId);
|
||||
return { candidate, trail };
|
||||
}),
|
||||
);
|
||||
|
||||
const warnings: Record<string, string[]> = {};
|
||||
const reasons: Record<string, string> = {};
|
||||
|
||||
for (const { candidate } of loadedTargets) {
|
||||
warnings[candidate.trailId] = candidate.warnings;
|
||||
reasons[candidate.trailId] = candidate.reason;
|
||||
}
|
||||
|
||||
const targets = loadedTargets.map(({ trail }) => trail);
|
||||
|
||||
return {
|
||||
targetTrailId:
|
||||
targets.find((trail) => trail.id === response.targetTrailId)?.id ??
|
||||
targets[0]?.id ??
|
||||
"",
|
||||
warnings,
|
||||
reasons,
|
||||
selectableTargets: targets,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openModal(trails: Trail[] = [], options: OpenMergeModalOptions = {}) {
|
||||
loading = true;
|
||||
modalTitle = title;
|
||||
autoDiscoveryMode = false;
|
||||
fixedTargetSelection = false;
|
||||
mergeTrailsSelection = trails;
|
||||
autoDiscoverySourceTrail = undefined;
|
||||
autoDiscoveryCandidateTrails = [];
|
||||
candidateWarnings = {};
|
||||
candidateReasons = {};
|
||||
selectableTargets = [];
|
||||
targetTrailId = "";
|
||||
|
||||
loadStoredSettings();
|
||||
modal.openModal();
|
||||
|
||||
if (options.fixedTargetTrailId) {
|
||||
const fixedTargetTrail = trails.find((trail) => trail.id === options.fixedTargetTrailId);
|
||||
if (!fixedTargetTrail?.id) {
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: $_("trail-merge-no-editable-target"),
|
||||
});
|
||||
modal.closeModal();
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
fixedTargetSelection = true;
|
||||
selectableTargets = [fixedTargetTrail];
|
||||
targetTrailId = fixedTargetTrail.id;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await trail_merge_suggest_manual(
|
||||
trails.map((trail) => trail.id!).filter(Boolean),
|
||||
);
|
||||
|
||||
const nextWarnings: Record<string, string[]> = {};
|
||||
const nextReasons: Record<string, string> = {};
|
||||
|
||||
const candidatesById = new Map<string, TrailMergeSuggestCandidate>(
|
||||
response.candidates.map((candidate) => [candidate.trailId, candidate]),
|
||||
);
|
||||
|
||||
const nextSelectableTargets = trails.filter((trail) => {
|
||||
const candidate = candidatesById.get(trail.id!);
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nextWarnings[trail.id!] = candidate.warnings;
|
||||
nextReasons[trail.id!] = candidate.reason;
|
||||
return candidate.selectable;
|
||||
});
|
||||
|
||||
candidateWarnings = nextWarnings;
|
||||
candidateReasons = nextReasons;
|
||||
selectableTargets = nextSelectableTargets;
|
||||
|
||||
if (selectableTargets.length === 0) {
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: $_("trail-merge-no-editable-target"),
|
||||
});
|
||||
modal.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
targetTrailId =
|
||||
selectableTargets.find((trail) => trail.id === options.preferredTargetTrailId)?.id
|
||||
?? selectableTargets.find((trail) => trail.id === response.targetTrailId)?.id
|
||||
?? selectableTargets[0]?.id
|
||||
?? "";
|
||||
} catch (error) {
|
||||
console.error("Failed to suggest trail merge target", error);
|
||||
const text =
|
||||
error instanceof APIError
|
||||
? translateTrailMergeError(error.message)
|
||||
: $_("trail-merge-suggest-error");
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text,
|
||||
});
|
||||
modal.closeModal();
|
||||
return;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openSimilarTrailsModal(sourceTrail: Trail) {
|
||||
loading = true;
|
||||
modalTitle = $_("find-similar-trails");
|
||||
autoDiscoveryMode = true;
|
||||
mergeTrailsSelection = [sourceTrail];
|
||||
autoDiscoverySourceTrail = sourceTrail;
|
||||
candidateWarnings = {};
|
||||
candidateReasons = {};
|
||||
selectableTargets = [];
|
||||
autoDiscoveryCandidateTrails = [];
|
||||
targetTrailId = "";
|
||||
|
||||
loadStoredSettings();
|
||||
modal.openModal();
|
||||
|
||||
try {
|
||||
const result = await loadAutoDiscoveryTargets(sourceTrail);
|
||||
candidateWarnings = result.warnings;
|
||||
candidateReasons = result.reasons;
|
||||
autoDiscoveryCandidateTrails = result.selectableTargets;
|
||||
selectableTargets = [sourceTrail, ...result.selectableTargets];
|
||||
candidateWarnings[sourceTrail.id!] = [];
|
||||
candidateReasons[sourceTrail.id!] = "selected_trail";
|
||||
targetTrailId = result.targetTrailId || sourceTrail.id || "";
|
||||
|
||||
if (autoDiscoveryCandidateTrails.length === 0) {
|
||||
show_toast({
|
||||
type: "warning",
|
||||
icon: "triangle-exclamation",
|
||||
text: $_("trail-merge-no-similar-trails"),
|
||||
});
|
||||
modal.closeModal();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to find similar trails", error);
|
||||
const text =
|
||||
error instanceof APIError
|
||||
? translateTrailMergeError(error.message)
|
||||
: $_("trail-merge-suggest-error");
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text,
|
||||
});
|
||||
modal.closeModal();
|
||||
return;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MergeSettings {
|
||||
summitLog: boolean;
|
||||
photos: boolean;
|
||||
comments: boolean;
|
||||
delete: boolean;
|
||||
tags: boolean;
|
||||
likes: boolean;
|
||||
}
|
||||
|
||||
const settings: MergeSettings = $state({
|
||||
...defaultSettings,
|
||||
});
|
||||
|
||||
function mergeTrail() {
|
||||
if (!targetTrailId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTrail = selectableTargets.find((trail) => trail.id === targetTrailId);
|
||||
if (!targetTrail) {
|
||||
return;
|
||||
}
|
||||
|
||||
let sourceTrails = mergeTrailsSelection.filter((trail) => trail.id !== targetTrail.id);
|
||||
if (autoDiscoveryMode && autoDiscoverySourceTrail) {
|
||||
sourceTrails =
|
||||
targetTrail.id === autoDiscoverySourceTrail.id
|
||||
? autoDiscoveryCandidateTrails.filter((trail) => trail.id !== targetTrail.id)
|
||||
: [autoDiscoverySourceTrail];
|
||||
}
|
||||
|
||||
if (sourceTrails.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
persistSettingsPreference();
|
||||
onmerge?.(settings, {
|
||||
targetTrail,
|
||||
sourceTrails,
|
||||
});
|
||||
modal.closeModal();
|
||||
}
|
||||
|
||||
let warnings = $derived(targetTrailId ? (candidateWarnings[targetTrailId] ?? []) : []);
|
||||
let selectedReason = $derived(targetTrailId ? candidateReasons[targetTrailId] : "");
|
||||
let targetTrailItems = $derived.by((): SelectItem[] =>
|
||||
selectableTargets.map((trail) => ({
|
||||
text: trail.name ?? "",
|
||||
value: trail.id ?? "",
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Modal id="merge-modal" title={modalTitle} size="min-w-md" bind:this={modal}>
|
||||
{#snippet content()}
|
||||
<div>
|
||||
{#if loading}
|
||||
<div class="py-6 flex flex-col items-center justify-center gap-3">
|
||||
<div class="spinner light:spinner-dark"></div>
|
||||
<p class="text-sm text-gray-500">
|
||||
{autoDiscoveryMode
|
||||
? $_("trail-merge-loading-similar-trails")
|
||||
: $_("trail-merge-loading-targets")}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-4">
|
||||
{#if !fixedTargetSelection}
|
||||
<h4 class="font-semibold mb-2">{$_("trail-merge-target")}</h4>
|
||||
<Select
|
||||
name="trail-merge-target"
|
||||
items={targetTrailItems}
|
||||
bind:value={targetTrailId}
|
||||
disabled={selectableTargets.length <= 1}
|
||||
/>
|
||||
{/if}
|
||||
{#if autoDiscoveryMode}
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
{$_("trail-merge-similar-trails-found", {
|
||||
values: { n: autoDiscoveryCandidateTrails.length },
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
{#if selectedReason}
|
||||
<p class="text-sm text-gray-500 mt-2">{$_(`trail-merge-reason-${selectedReason}`)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if warnings.length > 0}
|
||||
<div class="mb-4 rounded-xl border border-yellow-500/40 bg-yellow-500/10 p-3">
|
||||
<h4 class="font-semibold mb-2">{$_("trail-merge-warnings-title")}</h4>
|
||||
<ul class="text-sm space-y-1">
|
||||
{#each warnings as warning}
|
||||
<li>{$_(`trail-merge-warning-${warning}`)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h4 class="font-semibold mb-2">{$_("copy-include-elements")}</h4>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-summit-log-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={settings.summitLog}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-summit-log-checkbox" class="ms-2 text-sm"
|
||||
>{$_("summit-log", { values: { n: 2 } })}</label
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-photos-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={settings.photos}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-photos-checkbox" class="ms-2 text-sm"
|
||||
>{$_("photos")}</label
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-comments-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={settings.comments}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-comments-checkbox" class="ms-2 text-sm"
|
||||
>{$_("comment", { values: { n: 2 } })}</label
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-tags-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={settings.tags}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-tags-checkbox" class="ms-2 text-sm"
|
||||
>{$_("tags")}</label
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-likes-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={settings.likes}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-likes-checkbox" class="ms-2 text-sm"
|
||||
>{$_("likes")}</label
|
||||
>
|
||||
</div>
|
||||
<h4 class="font-semibold mt-4 mb-2">{$_("linked-trails")}</h4>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-trail-delete-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={settings.delete}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-trail-delete-checkbox" class="ms-2 text-sm"
|
||||
>{$_("delete-linked-trails")}</label
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label for="remember-merge-settings-checkbox" class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
id="remember-merge-settings-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={rememberSettings}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<span>{$_("trail-merge-remember-settings-short")}</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
disabled={loading || !targetTrailId}
|
||||
onclick={mergeTrail}
|
||||
name="save">{$_("link")}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
Reference in New Issue
Block a user