full settings overhaul
This commit is contained in:
@@ -3,6 +3,6 @@ import { trails_index } from "$lib/stores/trail_store";
|
||||
import type { ServerLoad } from "@sveltejs/kit";
|
||||
|
||||
export const load: ServerLoad = async ({ params, locals, fetch }) => {
|
||||
await trails_index({perPage: 20, random: true, f: fetch})
|
||||
await trails_index(20, true, fetch)
|
||||
await categories_index(fetch)
|
||||
};
|
||||
@@ -10,10 +10,19 @@ export async function GET(event: RequestEvent) {
|
||||
const filter = event.url.searchParams.get("filter") ?? "";
|
||||
|
||||
try {
|
||||
const r = await pb.collection('trails')
|
||||
.getList<Trail>(parseInt(page), parseInt(perPage), { expand: expand ?? "", sort: sort ?? "", filter: filter ?? "" })
|
||||
let r;
|
||||
if (parseInt(perPage) < 0) {
|
||||
r = {
|
||||
items: await pb.collection('trails')
|
||||
.getFullList<Trail>({ expand: expand, sort: sort, filter: filter })
|
||||
}
|
||||
} else {
|
||||
r = await pb.collection('trails')
|
||||
.getList<Trail>(parseInt(page), parseInt(perPage), { expand: expand ?? "", sort: sort ?? "", filter: filter ?? "" })
|
||||
}
|
||||
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
} catch (e: any) {
|
||||
throw error(e.status, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,48 @@
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { trails_create } from "$lib/stores/trail_store";
|
||||
import { gpx2trail } from "$lib/util/gpx_util";
|
||||
import { fromFIT, fromKML, fromTCX, gpx2trail, isFITFile } from "$lib/util/gpx_util";
|
||||
import { error, json, type RequestEvent } from "@sveltejs/kit";
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
const data = await event.request.blob();
|
||||
const gpxString = await data.text();
|
||||
if (!gpxString.length) {
|
||||
const fileBuffer = await data.arrayBuffer();
|
||||
const fileContent = await data.text();
|
||||
let gpxData = ""
|
||||
let gpxFile: Blob;
|
||||
if (isFITFile(fileBuffer)) {
|
||||
gpxData = await fromFIT(fileBuffer);
|
||||
gpxFile = new Blob([gpxData], {
|
||||
type: "application/gpx+xml",
|
||||
});
|
||||
}
|
||||
else if (fileContent.includes("http://www.opengis.net/kml")) {
|
||||
gpxData = fromKML(fileContent);
|
||||
gpxFile = new Blob([gpxData], {
|
||||
type: "application/gpx+xml",
|
||||
});
|
||||
} else if (fileContent.includes("TrainingCenterDatabase")) {
|
||||
gpxData = fromTCX(fileContent);
|
||||
gpxFile = new Blob([gpxData], {
|
||||
type: "application/gpx+xml",
|
||||
});
|
||||
} else {
|
||||
gpxData = fileContent;
|
||||
gpxFile = data
|
||||
}
|
||||
if (!gpxData.length) {
|
||||
throw new ClientResponseError({ status: 400, response: { message: "Empty file" } })
|
||||
}
|
||||
let trail: Trail;
|
||||
try {
|
||||
trail = (await gpx2trail(gpxString)).trail;
|
||||
trail = (await gpx2trail(gpxData)).trail;
|
||||
} catch (e: any) {
|
||||
throw new ClientResponseError({ status: 400, response: { message: "Invalid GPX file" } })
|
||||
throw new ClientResponseError({ status: 400, response: { message: "Invalid file" } })
|
||||
}
|
||||
|
||||
try {
|
||||
trail = await trails_create(trail, [], data, event.fetch);
|
||||
trail = await trails_create(trail, [], gpxFile, event.fetch);
|
||||
} catch (e: any) {
|
||||
console.log(e);
|
||||
|
||||
@@ -28,6 +51,8 @@ export async function PUT(event: RequestEvent) {
|
||||
return json(trail);
|
||||
|
||||
} catch (e: any) {
|
||||
console.log(e);
|
||||
|
||||
throw error(e.status, e)
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,17 @@ export async function GET(event: RequestEvent) {
|
||||
export async function POST(event: RequestEvent) {
|
||||
const data = await event.request.json()
|
||||
try {
|
||||
if (data.email != pb.authStore.model!.email) {
|
||||
const r = await pb.collection('users').requestEmailChange(data.email);
|
||||
pb.authStore.model!.email = data.email;
|
||||
}
|
||||
const r = await pb.collection('users').update<User>(event.params.id as string, data)
|
||||
return json(r);
|
||||
if (data.password) {
|
||||
const r = await pb.collection('users').authWithPassword(data.email ?? data.username!, data.password);
|
||||
return json(r.record)
|
||||
} else {
|
||||
return json(r);
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw error(e.status, e)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</svelte:head>
|
||||
<main class="grid grid-cols-1 md:grid-cols-[458px_1fr] gap-x-1 gap-y-4">
|
||||
<TrailInfoPanel trail={$trail} {markers}></TrailInfoPanel>
|
||||
<div id="trail-details" class=" sticky top-0">
|
||||
<div id="trail-details" class=" sticky top-0 min-h-[600px]">
|
||||
<MapWithElevation trail={$trail} bind:markers></MapWithElevation>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import RadioGroup, {
|
||||
type RadioItem,
|
||||
} from "$lib/components/base/radio_group.svelte";
|
||||
import Search, {
|
||||
type SearchItem,
|
||||
} from "$lib/components/base/search.svelte";
|
||||
import Select, {
|
||||
type SelectItem,
|
||||
} from "$lib/components/base/select.svelte";
|
||||
import ConfirmModal from "$lib/components/confirm_modal.svelte";
|
||||
import { settings_update } from "$lib/stores/settings_store";
|
||||
import {
|
||||
currentUser,
|
||||
logout,
|
||||
users_delete,
|
||||
users_update,
|
||||
} from "$lib/stores/user_store";
|
||||
import { country_codes } from "$lib/util/country_code_util";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { onMount } from "svelte";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
|
||||
const settings = $page.data.settings;
|
||||
|
||||
const languages: SelectItem[] = [
|
||||
{ text: $_("chinese"), value: "zh" },
|
||||
{ text: $_("german"), value: "de" },
|
||||
{ text: $_("english"), value: "en" },
|
||||
{ text: $_("french"), value: "fr" },
|
||||
{ text: $_("hungarian"), value: "hu" },
|
||||
{ text: $_("italian"), value: "it" },
|
||||
{ text: $_("dutch"), value: "nl" },
|
||||
{ text: $_("polish"), value: "pl" },
|
||||
{ text: $_("portuguese"), value: "pt" },
|
||||
];
|
||||
|
||||
const mapFocus: SelectItem[] = [
|
||||
{ text: $_("trail", { values: { n: 2 } }), value: "trails" },
|
||||
{ text: $_("location"), value: "location" },
|
||||
];
|
||||
|
||||
const units: RadioItem[] = [
|
||||
{ text: $_("metric"), value: "metric" },
|
||||
{ text: $_("imperial"), value: "imperial" },
|
||||
];
|
||||
|
||||
let selectedLanguage = "en";
|
||||
let selectedMapFocus = "trails";
|
||||
|
||||
let searchDropdownItems: SearchItem[] = [];
|
||||
let citySearchQuery: string = "";
|
||||
|
||||
let openConfirmModal: () => void;
|
||||
|
||||
onMount(() => {
|
||||
citySearchQuery = settings?.location?.name ?? "";
|
||||
selectedLanguage = settings?.language || "en";
|
||||
selectedMapFocus = settings?.mapFocus ?? "trails";
|
||||
});
|
||||
|
||||
async function searchCities(q: string) {
|
||||
const r = await fetch("/api/v1/search/cities500", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ q: q, options: { limit: 5 } }),
|
||||
});
|
||||
const result = await r.json();
|
||||
searchDropdownItems = result.hits.map((h: Record<string, any>) => ({
|
||||
text: h.name,
|
||||
description: `${h.division ? `${h.division} | ` : ""}${
|
||||
country_codes[h["country code"] as keyof typeof country_codes]
|
||||
}`,
|
||||
value: h,
|
||||
icon: "city",
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSearchClick(item: SearchItem) {
|
||||
citySearchQuery = item.text;
|
||||
await settings_update({
|
||||
id: settings?.id!,
|
||||
location: {
|
||||
name: item.value.name,
|
||||
lat: item.value.lat,
|
||||
lon: item.value.lon,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAvatarSelection() {
|
||||
const files = (
|
||||
document.getElementById("avatarInput") as HTMLInputElement
|
||||
).files;
|
||||
|
||||
if (!files || files.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await users_update($currentUser!, files[0]);
|
||||
}
|
||||
|
||||
async function handleUnitSelection(e: RadioItem) {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
unit: e.value as "imperial" | "metric",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLanguageSelection(
|
||||
value: "en" | "de" | "fr" | "hu" | "nl" | "pl" | "pt",
|
||||
) {
|
||||
locale.set(value);
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
language: value,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMapFocusSelection(value: "trails" | "location") {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
mapFocus: value,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
await users_delete($currentUser!);
|
||||
logout();
|
||||
goto("/");
|
||||
}
|
||||
|
||||
function openFileBrowser() {
|
||||
document.getElementById("avatarInput")!.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("profile")} | wanderer</title>
|
||||
</svelte:head>
|
||||
<main
|
||||
class="grid grid-cols-1 md:grid-cols-[356px_1fr] max-w-4xl mx-4 md:mx-auto gap-x-8 items-start"
|
||||
>
|
||||
{#if $currentUser}
|
||||
<div
|
||||
class="flex flex-col items-center rounded-xl border border-input-border p-4"
|
||||
>
|
||||
<div
|
||||
class="rounded-full w-32 aspect-square overflow-hidden relative group"
|
||||
>
|
||||
<img
|
||||
src={getFileURL($currentUser, $currentUser.avatar) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${$currentUser.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
<button
|
||||
class="absolute top-0 w-32 aspect-square opacity-0 group-hover:opacity-100 flex justify-center items-center bg-black/50 focus:bg-black/60 text-white cursor-pointer transition-opacity"
|
||||
on:click={openFileBrowser}
|
||||
>
|
||||
<i class="fa fa-pen"></i>
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
name="avatar"
|
||||
id="avatarInput"
|
||||
accept="image/*"
|
||||
style="display: none;"
|
||||
on:change={handleAvatarSelection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl mt-4 font-semibold">{$currentUser.username}</h3>
|
||||
<h5 class="font-medium">{$currentUser.email}</h5>
|
||||
</div>
|
||||
<div class="space-y-6 rounded-xl p-4">
|
||||
<h3 class="text-2xl font-semibold">{$_("settings")}</h3>
|
||||
<div>
|
||||
<h5 class="font-medium mb-1">{$_("language")}</h5>
|
||||
<Select
|
||||
items={languages}
|
||||
bind:value={selectedLanguage}
|
||||
on:change={(e) => handleLanguageSelection(e.detail)}
|
||||
></Select>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-medium mb-1">{$_("units")}</h5>
|
||||
<RadioGroup
|
||||
name="unit"
|
||||
items={units}
|
||||
selected={settings?.unit == "metric" ? 0 : 1}
|
||||
on:change={(e) => handleUnitSelection(e.detail)}
|
||||
></RadioGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-medium mb-1">Focus map on</h5>
|
||||
|
||||
<Select
|
||||
items={mapFocus}
|
||||
bind:value={selectedMapFocus}
|
||||
on:change={(e) => handleMapFocusSelection(e.detail)}
|
||||
></Select>
|
||||
{#if selectedMapFocus == "location"}
|
||||
<div class="mt-3">
|
||||
<Search
|
||||
items={searchDropdownItems}
|
||||
placeholder="{$_('search-cities')}..."
|
||||
bind:value={citySearchQuery}
|
||||
on:update={(e) => searchCities(e.detail)}
|
||||
on:click={(e) => handleSearchClick(e.detail)}
|
||||
></Search>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<hr class="border-input-border" />
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-xl text-red-400">{$_("danger-zone")}</h4>
|
||||
<button class="btn-danger" on:click={openConfirmModal}
|
||||
>{$_("delete-account")}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
<ConfirmModal
|
||||
text={$_("account-delete-confirm")}
|
||||
bind:openModal={openConfirmModal}
|
||||
on:confirm={deleteAccount}
|
||||
></ConfirmModal>
|
||||
@@ -4,9 +4,10 @@
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.svelte";
|
||||
import LogoTextTwoLineLight from "$lib/components/logo/logo_text_two_line_light.svelte";
|
||||
import type { User } from "$lib/models/user";
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
import { show_toast } from "$lib/stores/toast_store";
|
||||
import { login, users_create, type User } from "$lib/stores/user_store";
|
||||
import { login, users_create } from "$lib/stores/user_store";
|
||||
import { createForm } from "$lib/vendor/svelte-form-lib";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { object, string } from "yup";
|
||||
|
||||
285
web/src/routes/settings/account/+page.svelte
Normal file
285
web/src/routes/settings/account/+page.svelte
Normal file
@@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { type RadioItem } from "$lib/components/base/radio_group.svelte";
|
||||
import { type SearchItem } from "$lib/components/base/search.svelte";
|
||||
import Select, {
|
||||
type SelectItem,
|
||||
} from "$lib/components/base/select.svelte";
|
||||
import ConfirmModal from "$lib/components/confirm_modal.svelte";
|
||||
import EmailModal from "$lib/components/profile/email_modal.svelte";
|
||||
import PasswordModal from "$lib/components/profile/password_modal.svelte";
|
||||
import Settings from "$lib/components/profile/settings.svelte";
|
||||
import { settings_update } from "$lib/stores/settings_store";
|
||||
import { show_toast } from "$lib/stores/toast_store";
|
||||
import {
|
||||
currentUser,
|
||||
logout,
|
||||
users_delete,
|
||||
users_update,
|
||||
} from "$lib/stores/user_store";
|
||||
import { country_codes } from "$lib/util/country_code_util";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { onMount } from "svelte";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
|
||||
const settings = $page.data.settings;
|
||||
|
||||
const languages: SelectItem[] = [
|
||||
{ text: $_("chinese"), value: "zh" },
|
||||
{ text: $_("german"), value: "de" },
|
||||
{ text: $_("english"), value: "en" },
|
||||
{ text: $_("french"), value: "fr" },
|
||||
{ text: $_("hungarian"), value: "hu" },
|
||||
{ text: $_("italian"), value: "it" },
|
||||
{ text: $_("dutch"), value: "nl" },
|
||||
{ text: $_("polish"), value: "pl" },
|
||||
{ text: $_("portuguese"), value: "pt" },
|
||||
];
|
||||
|
||||
const mapFocus: SelectItem[] = [
|
||||
{ text: $_("trail", { values: { n: 2 } }), value: "trails" },
|
||||
{ text: $_("location"), value: "location" },
|
||||
];
|
||||
|
||||
const units: RadioItem[] = [
|
||||
{ text: $_("metric"), value: "metric" },
|
||||
{ text: $_("imperial"), value: "imperial" },
|
||||
];
|
||||
|
||||
let selectedLanguage = "en";
|
||||
let selectedMapFocus = "trails";
|
||||
|
||||
let searchDropdownItems: SearchItem[] = [];
|
||||
let citySearchQuery: string = "";
|
||||
|
||||
let openConfirmModal: () => void;
|
||||
let openEmailModal: () => void;
|
||||
let openPasswordModal: () => void;
|
||||
|
||||
onMount(() => {
|
||||
citySearchQuery = settings?.location?.name ?? "";
|
||||
selectedLanguage = settings?.language || "en";
|
||||
selectedMapFocus = settings?.mapFocus ?? "trails";
|
||||
});
|
||||
|
||||
async function searchCities(q: string) {
|
||||
const r = await fetch("/api/v1/search/cities500", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ q: q, options: { limit: 5 } }),
|
||||
});
|
||||
const result = await r.json();
|
||||
searchDropdownItems = result.hits.map((h: Record<string, any>) => ({
|
||||
text: h.name,
|
||||
description: `${h.division ? `${h.division} | ` : ""}${
|
||||
country_codes[h["country code"] as keyof typeof country_codes]
|
||||
}`,
|
||||
value: h,
|
||||
icon: "city",
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSearchClick(item: SearchItem) {
|
||||
citySearchQuery = item.text;
|
||||
await settings_update({
|
||||
id: settings?.id!,
|
||||
location: {
|
||||
name: item.value.name,
|
||||
lat: item.value.lat,
|
||||
lon: item.value.lon,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAvatarSelection() {
|
||||
const files = (
|
||||
document.getElementById("avatarInput") as HTMLInputElement
|
||||
).files;
|
||||
|
||||
if (!files || files.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await users_update($currentUser!, files[0]);
|
||||
}
|
||||
|
||||
async function handleUnitSelection(e: RadioItem) {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
unit: e.value as "imperial" | "metric",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLanguageSelection(
|
||||
value: "en" | "de" | "fr" | "hu" | "nl" | "pl" | "pt",
|
||||
) {
|
||||
locale.set(value);
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
language: value,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMapFocusSelection(value: "trails" | "location") {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
mapFocus: value,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
await users_delete($currentUser!);
|
||||
logout();
|
||||
goto("/");
|
||||
}
|
||||
|
||||
async function updateEmail(email: string) {
|
||||
try {
|
||||
await users_update({ ...$currentUser!, email: email });
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: "Error updating email",
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePassword(data: {
|
||||
oldPassword: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
}) {
|
||||
try {
|
||||
await users_update({ ...$currentUser!, ...data });
|
||||
show_toast({
|
||||
text: $_("password-updated"),
|
||||
icon: "check",
|
||||
type: "success",
|
||||
});
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: $_("error-updating-password"),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openFileBrowser() {
|
||||
document.getElementById("avatarInput")!.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("settings")} | wanderer</title>
|
||||
</svelte:head>
|
||||
<main>
|
||||
{#if $currentUser}
|
||||
<Settings selected={0}>
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-2xl font-semibold">{$_("profile")}</h3>
|
||||
|
||||
<div class="flex gap-6 items-center">
|
||||
<div
|
||||
class="rounded-full w-24 aspect-square overflow-hidden relative group"
|
||||
>
|
||||
<img
|
||||
src={getFileURL(
|
||||
$currentUser,
|
||||
$currentUser.avatar,
|
||||
) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${$currentUser.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
<button
|
||||
class="absolute top-0 w-24 aspect-square opacity-0 group-hover:opacity-100 flex justify-center items-center bg-black/50 focus:bg-black/60 text-white cursor-pointer transition-opacity"
|
||||
on:click={openFileBrowser}
|
||||
>
|
||||
<i class="fa fa-pen"></i>
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
name="avatar"
|
||||
id="avatarInput"
|
||||
accept="image/*"
|
||||
style="display: none;"
|
||||
on:change={handleAvatarSelection}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-2xl">{$currentUser.username}</h4>
|
||||
<h5 class="font-medium">{$currentUser.email}</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-semibold">{$_("login-details")}</h3>
|
||||
<button class="btn-secondary block" on:click={openEmailModal}
|
||||
>{$_("change-email")}</button
|
||||
>
|
||||
<button class="btn-secondary" on:click={openPasswordModal}
|
||||
>{$_("change-password")}</button
|
||||
>
|
||||
|
||||
<h3 class="text-2xl font-semibold">{$_("language")}</h3>
|
||||
<div class="block">
|
||||
<Select
|
||||
items={languages}
|
||||
bind:value={selectedLanguage}
|
||||
on:change={(e) => handleLanguageSelection(e.detail)}
|
||||
></Select>
|
||||
</div>
|
||||
<!--<div>
|
||||
<h5 class="font-medium mb-1">{$_("units")}</h5>
|
||||
<RadioGroup
|
||||
name="unit"
|
||||
items={units}
|
||||
selected={settings?.unit == "metric" ? 0 : 1}
|
||||
on:change={(e) => handleUnitSelection(e.detail)}
|
||||
></RadioGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-medium mb-1">Focus map on</h5>
|
||||
|
||||
<Select
|
||||
items={mapFocus}
|
||||
bind:value={selectedMapFocus}
|
||||
on:change={(e) => handleMapFocusSelection(e.detail)}
|
||||
></Select>
|
||||
{#if selectedMapFocus == "location"}
|
||||
<div class="mt-3">
|
||||
<Search
|
||||
items={searchDropdownItems}
|
||||
placeholder="{$_('search-cities')}..."
|
||||
bind:value={citySearchQuery}
|
||||
on:update={(e) => searchCities(e.detail)}
|
||||
on:click={(e) => handleSearchClick(e.detail)}
|
||||
></Search>
|
||||
</div>
|
||||
{/if}
|
||||
</div> -->
|
||||
<hr class="border-input-border" />
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-xl text-red-400">{$_("danger-zone")}</h4>
|
||||
<button class="btn-danger" on:click={openConfirmModal}
|
||||
>{$_("delete-account")}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<EmailModal
|
||||
email={$currentUser.email}
|
||||
on:save={(e) => updateEmail(e.detail)}
|
||||
bind:openModal={openEmailModal}
|
||||
></EmailModal>
|
||||
<PasswordModal
|
||||
on:save={(e) => updatePassword(e.detail)}
|
||||
bind:openModal={openPasswordModal}
|
||||
></PasswordModal>
|
||||
</Settings>
|
||||
{/if}
|
||||
</main>
|
||||
<ConfirmModal
|
||||
text={$_("account-delete-confirm")}
|
||||
bind:openModal={openConfirmModal}
|
||||
on:confirm={deleteAccount}
|
||||
></ConfirmModal>
|
||||
212
web/src/routes/settings/display/+page.svelte
Normal file
212
web/src/routes/settings/display/+page.svelte
Normal file
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import RadioGroup, {
|
||||
type RadioItem,
|
||||
} from "$lib/components/base/radio_group.svelte";
|
||||
import Search, {
|
||||
type SearchItem,
|
||||
} from "$lib/components/base/search.svelte";
|
||||
import Select, {
|
||||
type SelectItem,
|
||||
} from "$lib/components/base/select.svelte";
|
||||
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import Settings from "$lib/components/profile/settings.svelte";
|
||||
import type { Category } from "$lib/models/category.js";
|
||||
import { settings_update } from "$lib/stores/settings_store";
|
||||
import { currentUser } from "$lib/stores/user_store";
|
||||
import { country_codes } from "$lib/util/country_code_util";
|
||||
import { onMount } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
export let data;
|
||||
|
||||
$: settings = $page.data.settings;
|
||||
|
||||
const mapFocus: SelectItem[] = [
|
||||
{ text: $_("trail", { values: { n: 2 } }), value: "trails" },
|
||||
{ text: $_("location"), value: "location" },
|
||||
];
|
||||
|
||||
const categoryItems: SelectItem[] = data.categories.map((c: Category) => ({
|
||||
text: $_(c.name),
|
||||
value: c.id,
|
||||
}));
|
||||
|
||||
const units: RadioItem[] = [
|
||||
{ text: $_("metric"), value: "metric" },
|
||||
{ text: $_("imperial"), value: "imperial" },
|
||||
];
|
||||
|
||||
let selectedLanguage = "en";
|
||||
let selectedMapFocus = "trails";
|
||||
|
||||
let selectedCategory =
|
||||
$page.data.settings?.category || data.categories[0].id;
|
||||
|
||||
let searchDropdownItems: SearchItem[] = [];
|
||||
let citySearchQuery: string = "";
|
||||
|
||||
let customTilesetName: string = "";
|
||||
let customTilesetURL: string = "";
|
||||
|
||||
onMount(() => {
|
||||
citySearchQuery = settings?.location?.name ?? "";
|
||||
selectedLanguage = settings?.language || "en";
|
||||
selectedMapFocus = settings?.mapFocus ?? "trails";
|
||||
});
|
||||
|
||||
async function searchCities(q: string) {
|
||||
const r = await fetch("/api/v1/search/cities500", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ q: q, options: { limit: 5 } }),
|
||||
});
|
||||
const result = await r.json();
|
||||
searchDropdownItems = result.hits.map((h: Record<string, any>) => ({
|
||||
text: h.name,
|
||||
description: `${h.division ? `${h.division} | ` : ""}${
|
||||
country_codes[h["country code"] as keyof typeof country_codes]
|
||||
}`,
|
||||
value: h,
|
||||
icon: "city",
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSearchClick(item: SearchItem) {
|
||||
citySearchQuery = item.text;
|
||||
await settings_update({
|
||||
id: settings?.id!,
|
||||
location: {
|
||||
name: item.value.name,
|
||||
lat: item.value.lat,
|
||||
lon: item.value.lon,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUnitSelection(e: RadioItem) {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
unit: e.value as "imperial" | "metric",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMapFocusSelection(value: "trails" | "location") {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
mapFocus: value,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCategorySelection(value: string) {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
category: value,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTilesetAdd() {
|
||||
if (!customTilesetName || !customTilesetURL) {
|
||||
return;
|
||||
}
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
tilesets: [
|
||||
...(settings.tilesets ?? []),
|
||||
{ name: customTilesetName, url: customTilesetURL },
|
||||
],
|
||||
});
|
||||
customTilesetName = "";
|
||||
customTilesetURL = "";
|
||||
}
|
||||
|
||||
async function handleTilesetDelete(index: number) {
|
||||
settings.tilesets.splice(index, 1);
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
tilesets: settings.tilesets,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("settings")} | wanderer</title>
|
||||
</svelte:head>
|
||||
<main>
|
||||
{#if $currentUser}
|
||||
<Settings selected={1}>
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-2xl font-semibold">{$_("units")}</h3>
|
||||
<RadioGroup
|
||||
name="unit"
|
||||
items={units}
|
||||
selected={settings?.unit == "metric" ? 0 : 1}
|
||||
on:change={(e) => handleUnitSelection(e.detail)}
|
||||
></RadioGroup>
|
||||
<h3 class="text-2xl font-semibold">{$_("focus-map-on")}</h3>
|
||||
|
||||
<Select
|
||||
items={mapFocus}
|
||||
bind:value={selectedMapFocus}
|
||||
on:change={(e) => handleMapFocusSelection(e.detail)}
|
||||
></Select>
|
||||
{#if selectedMapFocus == "location"}
|
||||
<div class="mt-3">
|
||||
<Search
|
||||
items={searchDropdownItems}
|
||||
placeholder="{$_('search-cities')}..."
|
||||
bind:value={citySearchQuery}
|
||||
on:update={(e) => searchCities(e.detail)}
|
||||
on:click={(e) => handleSearchClick(e.detail)}
|
||||
></Search>
|
||||
</div>
|
||||
{/if}
|
||||
<h3 class="text-2xl font-semibold">{$_("default-category")}</h3>
|
||||
<Select
|
||||
items={categoryItems}
|
||||
bind:value={selectedCategory}
|
||||
on:change={(e) => handleCategorySelection(e.detail)}
|
||||
></Select>
|
||||
<h3 class="text-2xl font-semibold">{$_("tilesets")}</h3>
|
||||
|
||||
{#each settings.tilesets ?? [] as tileset, i}
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-2 border border-input-border rounded-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{tileset.name}</p>
|
||||
<p class=" text-sm">{tileset.url}</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-icon"
|
||||
on:click={() => handleTilesetDelete(i)}
|
||||
><i class="fa fa-trash text-red-500"></i></button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex gap-4 items-center">
|
||||
<TextField
|
||||
label={$_("name")}
|
||||
bind:value={customTilesetName}
|
||||
placeholder="Open Street Maps"
|
||||
></TextField>
|
||||
<div class="flex items-center basis-full gap-2">
|
||||
<div class="flex-grow">
|
||||
<TextField
|
||||
label="URL"
|
||||
bind:value={customTilesetURL}
|
||||
placeholder="https://{'{'}s{'}'}.tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png"
|
||||
></TextField>
|
||||
</div>
|
||||
<button
|
||||
class="btn-icon mt-6"
|
||||
on:click={handleTilesetAdd}
|
||||
><i class="fa fa-plus"></i></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Settings>
|
||||
{/if}
|
||||
</main>
|
||||
8
web/src/routes/settings/display/+page.ts
Normal file
8
web/src/routes/settings/display/+page.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { categories_index } from "$lib/stores/category_store";
|
||||
import { type Load } from "@sveltejs/kit";
|
||||
|
||||
export const load: Load = async ({ params, fetch }) => {
|
||||
const categories = await categories_index(fetch)
|
||||
|
||||
return { categories: categories }
|
||||
};
|
||||
194
web/src/routes/settings/export/+page.svelte
Normal file
194
web/src/routes/settings/export/+page.svelte
Normal file
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import Button from "$lib/components/base/button.svelte";
|
||||
import Settings from "$lib/components/profile/settings.svelte";
|
||||
import TrailExportModal from "$lib/components/trail/trail_export_modal.svelte";
|
||||
import { show_toast } from "$lib/stores/toast_store";
|
||||
import { fetchGPX, trails_index, trails_upload } from "$lib/stores/trail_store";
|
||||
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 { _ } from "svelte-i18n";
|
||||
import { linear } from "svelte/easing";
|
||||
import { tweened } from "svelte/motion";
|
||||
|
||||
const uploadProgress = tweened(0, {
|
||||
duration: 300,
|
||||
easing: linear,
|
||||
});
|
||||
|
||||
let openExportModal: () => void;
|
||||
|
||||
function openFileBrowser() {
|
||||
document.getElementById("file-input")!.click();
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
handleFileSelection(e.dataTransfer?.files);
|
||||
}
|
||||
|
||||
async function handleFileSelection(files?: FileList | null) {
|
||||
if (!files) {
|
||||
files = (document.getElementById("file-input") as HTMLInputElement)
|
||||
.files;
|
||||
}
|
||||
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
let errorsThrown = 0;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
try {
|
||||
await trails_upload(file);
|
||||
} catch (e) {
|
||||
errorsThrown += 1;
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: `Error uploading file: ${file.name}`,
|
||||
});
|
||||
}
|
||||
$uploadProgress += (files.length / i) * 100;
|
||||
setTimeout(() => {}, 3000);
|
||||
}
|
||||
if (errorsThrown == 0) {
|
||||
show_toast({
|
||||
type: "success",
|
||||
icon: "check",
|
||||
text: `${files.length} ${$_("trail", { values: { n: files.length } })} ${$_("uploaded")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function exportTrails(exportSettings: {
|
||||
fileFormat: "gpx" | "json";
|
||||
photos: boolean;
|
||||
summitLog: boolean;
|
||||
}) {
|
||||
try {
|
||||
const trails = await trails_index(-1);
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const trail of trails) {
|
||||
const gpxData: string = await fetchGPX(trail);
|
||||
if (!trail.expand) {
|
||||
trail.expand = {};
|
||||
}
|
||||
trail.expand.gpx_data = gpxData;
|
||||
const trailFolder = zip.folder(`${trail.name}`);
|
||||
let fileData: string = await trail2gpx(trail);
|
||||
if (exportSettings.fileFormat == "json") {
|
||||
fileData = JSON.stringify(
|
||||
gpx(
|
||||
new DOMParser().parseFromString(
|
||||
fileData,
|
||||
"text/xml",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
trailFolder?.file(
|
||||
`${trail.name}.${exportSettings.fileFormat}`,
|
||||
fileData,
|
||||
);
|
||||
if (exportSettings.photos) {
|
||||
const photoFolder = trailFolder?.folder($_("photos"));
|
||||
for (const photo of trail.photos ?? []) {
|
||||
const photoURL = getFileURL(trail, photo);
|
||||
const photoBlob = await fetch(photoURL).then(
|
||||
(response) => response.blob(),
|
||||
);
|
||||
const photoData = new File([photoBlob], photo);
|
||||
photoFolder?.file(photo, photoData, { base64: true });
|
||||
}
|
||||
}
|
||||
if (exportSettings.summitLog) {
|
||||
let summitLogString = "";
|
||||
for (const summitLog of trail.expand?.summit_logs ?? []) {
|
||||
summitLogString += `${summitLog.date},${summitLog.text}\n`;
|
||||
}
|
||||
trailFolder?.file(
|
||||
`${trail.name} - ${$_("summit-book")}.csv`,
|
||||
summitLogString,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await zip.generateAsync({ type: "blob" });
|
||||
saveAs(blob, `wanderer-export-${Date.now()}.zip`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: $_("error-exporting-trail"),
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("settings")} | wanderer</title>
|
||||
</svelte:head>
|
||||
<main>
|
||||
<Settings selected={2}>
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-2xl font-semibold">{$_("import")}</h3>
|
||||
<button
|
||||
class="drop-area relative h-64 w-full border border-content border-dashed rounded-xl flex items-center justify-center text-gray-500 bg-background cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||
style="--progress: {$uploadProgress}%"
|
||||
on:click={openFileBrowser}
|
||||
on:dragover={handleDragOver}
|
||||
on:drop={handleDrop}
|
||||
>
|
||||
Select or drag GPX, FIT, KML or TCX files here...
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="file-input"
|
||||
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
|
||||
multiple={true}
|
||||
style="display: none;"
|
||||
on:change={() => handleFileSelection()}
|
||||
/>
|
||||
<h3 class="text-2xl font-semibold">{$_("export")}</h3>
|
||||
<Button secondary={true} on:click={() => openExportModal()}
|
||||
>{$_("export-all-trails")}</Button
|
||||
>
|
||||
</div>
|
||||
</Settings>
|
||||
</main>
|
||||
|
||||
<TrailExportModal
|
||||
bind:openModal={openExportModal}
|
||||
on:export={(e) => exportTrails(e.detail)}
|
||||
></TrailExportModal>
|
||||
|
||||
<style>
|
||||
.drop-area::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: -4px;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
background-color: transparent;
|
||||
z-index: -100;
|
||||
border-radius: 0.75rem;
|
||||
background-image: conic-gradient(
|
||||
rgba(var(--content)),
|
||||
rgba(var(--content)) var(--progress),
|
||||
transparent var(--progress)
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { PUBLIC_VALHALLA_URL } from "$env/static/public";
|
||||
import Button from "$lib/components/base/button.svelte";
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
@@ -50,18 +51,18 @@
|
||||
formatElevation,
|
||||
formatTimeHHMM,
|
||||
} from "$lib/util/format_util";
|
||||
import { fromFIT, fromKML, fromTCX, gpx2trail } from "$lib/util/gpx_util";
|
||||
import { fromFIT, fromKML, fromTCX, gpx2trail, isFITFile } from "$lib/util/gpx_util";
|
||||
import {
|
||||
createAnchorMarker,
|
||||
createMarkerFromWaypoint,
|
||||
} from "$lib/util/leaflet_util";
|
||||
import { createForm } from "$lib/vendor/svelte-form-lib";
|
||||
import cryptoRandomString from "crypto-random-string";
|
||||
import type { DivIcon, LatLng, LeafletMouseEvent, Map } from "leaflet";
|
||||
import type { DivIcon, LeafletMouseEvent, Map } from "leaflet";
|
||||
import { onMount } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { backInOut } from "svelte/easing";
|
||||
import { fade, scale } from "svelte/transition";
|
||||
import { scale } from "svelte/transition";
|
||||
import { array, number, object, string } from "yup";
|
||||
|
||||
export let data: { trail: Trail };
|
||||
@@ -110,7 +111,10 @@
|
||||
let autoRouting = true;
|
||||
|
||||
const { form, errors, handleChange, handleSubmit } = createForm<Trail>({
|
||||
initialValues: data.trail,
|
||||
initialValues: {
|
||||
...data.trail,
|
||||
category: $page.data.settings?.category || $categories[0].id,
|
||||
},
|
||||
validationSchema: trailSchema,
|
||||
onError: (errors) => {
|
||||
if (errors.name) {
|
||||
@@ -225,9 +229,10 @@
|
||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
||||
|
||||
let gpxData = "";
|
||||
|
||||
if (fileExtension !== "fit") {
|
||||
const fileContent = await file.text();
|
||||
const fileContent = await file.text();
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
if (!isFITFile(fileBuffer)) {
|
||||
if (fileContent.includes("http://www.opengis.net/kml")) {
|
||||
gpxData = fromKML(fileContent);
|
||||
gpxFile = new Blob([gpxData], {
|
||||
@@ -244,11 +249,13 @@
|
||||
gpxFile = file;
|
||||
}
|
||||
} else {
|
||||
const fileContent = await file.arrayBuffer();
|
||||
gpxData = await fromFIT(fileContent);
|
||||
gpxData = await fromFIT(fileBuffer);
|
||||
gpxFile = new Blob([gpxData], {
|
||||
type: "application/gpx+xml",
|
||||
});
|
||||
}
|
||||
|
||||
return gpxData
|
||||
return gpxData;
|
||||
}
|
||||
|
||||
async function handleFileSelection() {
|
||||
@@ -272,7 +279,7 @@
|
||||
const parseResult = await gpx2trail(gpxData);
|
||||
$form = parseResult.trail;
|
||||
$form.expand.gpx_data = gpxData;
|
||||
$form.category = $categories[0].id;
|
||||
$form.category = $page.data.settings.category || $categories[0].id;
|
||||
|
||||
setRoute(parseResult.gpx);
|
||||
initRouteAnchors(parseResult.gpx);
|
||||
@@ -399,7 +406,7 @@
|
||||
savedWaypoint.marker = marker;
|
||||
}
|
||||
|
||||
function beforSummitLogModalOpen() {
|
||||
function beforeSummitLogModalOpen() {
|
||||
summitLog.set(new SummitLog(new Date().toISOString().split("T")[0]));
|
||||
openSummitLogModal();
|
||||
}
|
||||
@@ -810,7 +817,7 @@
|
||||
<button
|
||||
class="btn-secondary"
|
||||
type="button"
|
||||
on:click={beforSummitLogModalOpen}
|
||||
on:click={beforeSummitLogModalOpen}
|
||||
><i class="fa fa-plus mr-2"></i>{$_("add-entry")}</button
|
||||
>
|
||||
{#if $lists.length}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { lists_index } from "$lib/stores/list_store";
|
||||
import { trails_show } from "$lib/stores/trail_store";
|
||||
import { error, type Load } from "@sveltejs/kit";
|
||||
|
||||
export const load: Load = async ({ params, fetch }) => {
|
||||
export const load: Load = async ({ params, fetch, data }) => {
|
||||
if (!params.id) {
|
||||
return error(400, "Bad Request")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user