adds profile page

This commit is contained in:
Christian Beutel
2024-12-08 21:05:35 +01:00
parent 2d376331eb
commit 254e053bd9
26 changed files with 1243 additions and 838 deletions

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import type { SelectItem } from "$lib/components/base/select.svelte";
import { _ } from "svelte-i18n";
const settingsLinks: SelectItem[] = [
{ text: $_("profile"), value: "/settings/profile" },
{ text: $_("my-account"), value: "/settings/account" },
{
text: $_("language") + " & " + $_("units"),
value: "/settings/language",
},
{ text: $_("map"), value: "/settings/map" },
{ text: `${$_("import")}/${$_("export")}`, value: "/settings/export" },
{
text: $_("help"),
value: "https://wanderer.to/getting-started/installation",
},
];
$: activeIndex = settingsLinks.findIndex(
(l) => l.value === $page.url.pathname,
);
function handleItemClick(item: SelectItem) {
if (item.value != settingsLinks.at(-1)?.value) {
goto(item.value);
} else {
window.open(item.value, "_blank");
}
}
</script>
<div
class="grid grid-cols-1 md:grid-cols-[256px_1fr] max-w-6xl mx-4 md:mx-auto gap-x-12 items-start"
>
<menu class="p-4 border border-input-border rounded-xl mb-4">
{#each settingsLinks as item, i}
<li
class="menu-item flex items-center px-4 py-3 my-1 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors rounded-md"
class:bg-menu-item-background-hover={i == activeIndex}
role="presentation"
on:mousedown|stopPropagation={() => handleItemClick(item)}
>
{item.text}
</li>
{/each}
</menu>
<div class="settings-content">
<slot></slot>
</div>
</div>

View File

@@ -3,14 +3,12 @@
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, {
import {
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,
@@ -19,24 +17,11 @@
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";
import { _ } 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" },
@@ -79,28 +64,6 @@
}));
}
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 handleLanguageSelection(
value: "en" | "de" | "fr" | "hu" | "nl" | "pl" | "pt",
) {
locale.set(value);
await settings_update({
id: settings!.id,
language: value,
});
}
async function deleteAccount() {
await users_delete($currentUser!);
logout();
@@ -139,90 +102,39 @@
});
}
}
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
<h2 class="text-2xl font-semibold">{$_("my-account")}</h2>
<hr class="mt-4 mb-6 border-input-border" />
<div class="space-y-6">
<h4 class="text-xl font-medium">{$_("login-details")}</h4>
<button class="btn-secondary block" on:click={openEmailModal}
>{$_("change-email")}</button
>
<button class="btn-secondary" on:click={openPasswordModal}
>{$_("change-password")}</button
>
<div class="space-y-4">
<h4 class="text-xl text-red-400 font-medium">{$_("danger-zone")}</h4>
<button class="btn-danger" on:click={openConfirmModal}
>{$_("delete-account")}</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>
<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>
</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>
{/if}
</main>
<ConfirmModal
text={$_("account-delete-confirm")}
bind:openModal={openConfirmModal}

View File

@@ -1,254 +0,0 @@
<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 = "";
let terrainURL: string = "";
let hillshadingURL: string = "";
onMount(() => {
citySearchQuery = settings?.location?.name ?? "";
selectedLanguage = settings?.language || "en";
selectedMapFocus = settings?.mapFocus ?? "trails";
terrainURL = settings?.terrain?.terrain ?? "";
hillshadingURL = settings?.terrain?.hillshading ?? "";
});
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,
});
}
async function handleTerrainAdd() {
await settings_update({
id: settings!.id,
terrain: { terrain: terrainURL, hillshading: hillshadingURL },
});
}
$: terrainSaveEnabled =
terrainURL !== settings?.terrain?.terrain ||
hillshadingURL !== settings?.terrain?.hillshading;
</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')}..."
clearAfterSelect={false}
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={$_("name")}
></TextField>
<div class="flex items-center basis-full gap-2">
<div class="flex-grow">
<TextField
label="URL"
bind:value={customTilesetURL}
placeholder="https://.../style.json"
></TextField>
</div>
<button
class="btn-icon mt-6"
on:click={handleTilesetAdd}
><i class="fa fa-plus"></i></button
>
</div>
</div>
<h3 class="text-2xl font-semibold">{$_("Terrain")}</h3>
<div class="flex items-center gap-2">
<div class="basis-full">
<TextField
label="Terrain URL"
bind:value={terrainURL}
placeholder="https://.../tiles.json"
></TextField>
</div>
<div class="basis-full">
<TextField
label="Hillshading URL"
bind:value={hillshadingURL}
placeholder="https://.../tiles.json"
></TextField>
</div>
<button
disabled={!terrainSaveEnabled}
class="btn-icon mt-6"
class:hover:!bg-background={!terrainSaveEnabled}
on:click={handleTerrainAdd}
class:text-gray-500={!terrainSaveEnabled}
><i class="fa fa-save"></i></button
>
</div>
</div>
</Settings>
{/if}
</main>

View File

@@ -1,6 +1,5 @@
<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 {
@@ -59,11 +58,9 @@
uploading = true;
let progress = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
uploadProgress.set(progress += 100 / (files.length * 2));
uploadProgress.set((progress += 100 / (files.length * 2)));
await trails_upload(file);
} catch (e) {
errorsThrown += 1;
@@ -73,7 +70,7 @@
text: `Error uploading file: ${file.name}`,
});
} finally {
uploadProgress.set(progress += 100 / (files.length * 2));
uploadProgress.set((progress += 100 / (files.length * 2)));
}
}
@@ -162,40 +159,38 @@
<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 p-4 {uploading
? ''
: '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"
class:bg-menu-item-background-hover={uploading}
class:border-2={offerUpload && !uploading}
style="--progress: {$uploadProgress}%"
on:click={openFileBrowser}
on:dragover={handleDragOver}
on:dragleave={handleDragLeave}
on:drop={handleDrop}
>
{$_("import-hint")}
</button>
<div class="space-y-6">
<h3 class="text-2xl font-semibold">{$_("import")}</h3>
<hr class="mt-4 mb-6 border-input-border" />
<button
class="drop-area relative h-64 w-full p-4 {uploading
? ''
: '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"
class:bg-menu-item-background-hover={uploading}
class:border-2={offerUpload && !uploading}
style="--progress: {$uploadProgress}%"
on:click={openFileBrowser}
on:dragover={handleDragOver}
on:dragleave={handleDragLeave}
on:drop={handleDrop}
>
{$_("import-hint")}
</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>
<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>
<hr class="mt-4 mb-6 border-input-border" />
<Button secondary={true} on:click={() => openExportModal()}
>{$_("export-all-trails")}</Button
>
</div>
<TrailExportModal
bind:openModal={openExportModal}

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { page } from "$app/stores";
import RadioGroup, {
type RadioItem,
} from "$lib/components/base/radio_group.svelte";
import Select, {
type SelectItem,
} from "$lib/components/base/select.svelte";
import { settings_update } from "$lib/stores/settings_store";
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 units: RadioItem[] = [
{ text: $_("metric"), value: "metric" },
{ text: $_("imperial"), value: "imperial" },
];
let selectedLanguage = "en";
async function handleLanguageSelection(
value: "en" | "de" | "fr" | "hu" | "nl" | "pl" | "pt",
) {
locale.set(value);
await settings_update({
id: settings!.id,
language: value,
});
}
async function handleUnitSelection(e: RadioItem) {
await settings_update({
id: settings!.id,
unit: e.value as "imperial" | "metric",
});
}
</script>
<svelte:head>
<title>{$_("settings")} | wanderer</title>
</svelte:head>
<h2 class="text-2xl font-semibold">{$_("language")} & {$_("units")}</h2>
<hr class="mt-4 mb-6 border-input-border" />
<h4 class="text-xl font-medium mb-2">{$_("language")}</h4>
<Select
items={languages}
bind:value={selectedLanguage}
on:change={(e) => handleLanguageSelection(e.detail)}
></Select>
<h4 class="text-xl font-medium mt-6 mb-2">{$_("units")}</h4>
<RadioGroup
name="unit"
items={units}
selected={settings?.unit == "metric" ? 0 : 1}
on:change={(e) => handleUnitSelection(e.detail)}
></RadioGroup>

View File

@@ -0,0 +1,208 @@
<script lang="ts">
import { page } from "$app/stores";
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_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";
$: settings = $page.data.settings;
const mapFocus: SelectItem[] = [
{ text: $_("trail", { values: { n: 2 } }), value: "trails" },
{ text: $_("location"), value: "location" },
];
let selectedLanguage = "en";
let selectedMapFocus = "trails";
let searchDropdownItems: SearchItem[] = [];
let citySearchQuery: string = "";
let customTilesetName: string = "";
let customTilesetURL: string = "";
let terrainURL: string = "";
let hillshadingURL: string = "";
onMount(() => {
citySearchQuery = settings?.location?.name ?? "";
selectedLanguage = settings?.language || "en";
selectedMapFocus = settings?.mapFocus ?? "trails";
terrainURL = settings?.terrain?.terrain ?? "";
hillshadingURL = settings?.terrain?.hillshading ?? "";
});
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 handleMapFocusSelection(value: "trails" | "location") {
await settings_update({
id: settings!.id,
mapFocus: 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,
});
}
async function handleTerrainAdd() {
await settings_update({
id: settings!.id,
terrain: { terrain: terrainURL, hillshading: hillshadingURL },
});
}
$: terrainSaveEnabled =
terrainURL !== settings?.terrain?.terrain ||
hillshadingURL !== settings?.terrain?.hillshading;
</script>
<svelte:head>
<title>{$_("settings")} | wanderer</title>
</svelte:head>
<h2 class="text-2xl font-semibold">{$_("map")}</h2>
<hr class="mt-4 mb-6 border-input-border" />
{#if $currentUser}
<div class="space-y-12">
<div>
<h4 class="text-xl font-medium mb-2">{$_("focus-map-on")}</h4>
<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')}..."
clearAfterSelect={false}
bind:value={citySearchQuery}
on:update={(e) => searchCities(e.detail)}
on:click={(e) => handleSearchClick(e.detail)}
></Search>
</div>
{/if}
</div>
<div>
<h4 class="text-xl font-medium mb-2">{$_("tilesets")}</h4>
{#each settings.tilesets ?? [] as tileset, i}
<div
class="flex items-center justify-between px-4 py-2 border border-input-border rounded-xl mb-2"
>
<div>
<p>{tileset.name}</p>
<p class="text-sm text-gray-500">{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={$_("name")}
></TextField>
<div class="flex items-center basis-full gap-2">
<div class="flex-grow">
<TextField
label="URL"
bind:value={customTilesetURL}
placeholder="https://.../style.json"
></TextField>
</div>
<button class="btn-icon mt-6" on:click={handleTilesetAdd}
><i class="fa fa-plus"></i></button
>
</div>
</div>
</div>
<div>
<h4 class="text-xl font-medium mb-2">{$_("Terrain")}</h4>
<div class="flex items-center gap-2">
<div class="basis-full">
<TextField
label="Terrain URL"
bind:value={terrainURL}
placeholder="https://.../tiles.json"
></TextField>
</div>
<div class="basis-full">
<TextField
label="Hillshading URL"
bind:value={hillshadingURL}
placeholder="https://.../tiles.json"
></TextField>
</div>
<button
disabled={!terrainSaveEnabled}
class="btn-icon mt-6"
class:hover:!bg-background={!terrainSaveEnabled}
on:click={handleTerrainAdd}
class:text-gray-500={!terrainSaveEnabled}
><i class="fa fa-save"></i></button
>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { page } from "$app/stores";
import Button from "$lib/components/base/button.svelte";
import type { SelectItem } from "$lib/components/base/select.svelte";
import Select from "$lib/components/base/select.svelte";
import Textarea from "$lib/components/base/textarea.svelte";
import type { Category } from "$lib/models/category.js";
import { settings_update } from "$lib/stores/settings_store";
import { show_toast } from "$lib/stores/toast_store";
import { currentUser, users_update } from "$lib/stores/user_store";
import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
export let data;
let selectedCategory =
$page.data.settings?.category || data.categories[0].id;
let bio = $currentUser?.bio ?? "";
const categoryItems: SelectItem[] = data.categories.map((c: Category) => ({
text: $_(c.name),
value: c.id,
}));
function openFileBrowser() {
document.getElementById("avatarInput")!.click();
}
async function handleAvatarSelection() {
if (!$currentUser) {
return;
}
const files = (
document.getElementById("avatarInput") as HTMLInputElement
).files;
if (!files || files.length == 0) {
return;
}
await users_update($currentUser!, files[0]);
}
async function handleBioSave() {
if (!$currentUser) {
return;
}
try {
$currentUser.bio = bio;
await users_update($currentUser);
} catch (e) {
show_toast({
type: "error",
icon: "close",
text: "Error saving bio",
});
console.error(e);
}
}
async function handleCategorySelection(value: string) {
await settings_update({
id: $page.data.settings!.id,
category: value,
});
}
</script>
<svelte:head>
<title>{$_("settings")} | wanderer</title>
</svelte:head>
{#if $currentUser}
<h2 class="text-2xl font-semibold">{$_("profile")}</h2>
<hr class="mt-4 mb-6 border-input-border" />
<div class="space-y-6">
<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-xl font-semibold">{$currentUser.username}</h4>
<h5 class="font-medium">{$currentUser.email}</h5>
</div>
</div>
<div>
<h4 class="text-xl font-medium">Bio</h4>
<Textarea bind:value={bio} rows={5}></Textarea>
<div class="mt-3">
<Button
on:click={() => handleBioSave()}
primary
disabled={$currentUser.bio === bio}>{$_("save")}</Button
>
</div>
</div>
<div>
<h4 class="text-xl font-medium mb-2">{$_("favourite-sport")}</h4>
<Select
items={categoryItems}
bind:value={selectedCategory}
on:change={(e) => handleCategorySelection(e.detail)}
></Select>
</div>
</div>
{/if}