adds profile page
This commit is contained in:
55
db/migrations/1733580734_updated_users.go
Normal file
55
db/migrations/1733580734_updated_users.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/daos"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
"github.com/pocketbase/pocketbase/models/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(db dbx.Builder) error {
|
||||||
|
dao := daos.New(db);
|
||||||
|
|
||||||
|
collection, err := dao.FindCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// add
|
||||||
|
new_bio := &schema.SchemaField{}
|
||||||
|
if err := json.Unmarshal([]byte(`{
|
||||||
|
"system": false,
|
||||||
|
"id": "pd2cq8sq",
|
||||||
|
"name": "bio",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": null,
|
||||||
|
"max": 10000,
|
||||||
|
"pattern": ""
|
||||||
|
}
|
||||||
|
}`), new_bio); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
collection.Schema.AddField(new_bio)
|
||||||
|
|
||||||
|
return dao.SaveCollection(collection)
|
||||||
|
}, func(db dbx.Builder) error {
|
||||||
|
dao := daos.New(db);
|
||||||
|
|
||||||
|
collection, err := dao.FindCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove
|
||||||
|
collection.Schema.RemoveField("pd2cq8sq")
|
||||||
|
|
||||||
|
return dao.SaveCollection(collection)
|
||||||
|
})
|
||||||
|
}
|
||||||
179
db/migrations/1733580769_updated_users_anonymous.go
Normal file
179
db/migrations/1733580769_updated_users_anonymous.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/daos"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
"github.com/pocketbase/pocketbase/models/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(db dbx.Builder) error {
|
||||||
|
dao := daos.New(db);
|
||||||
|
|
||||||
|
collection, err := dao.FindCollectionByNameOrId("xku110v5a5xbufa")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
options := map[string]any{}
|
||||||
|
if err := json.Unmarshal([]byte(`{
|
||||||
|
"query": "SELECT id, username, avatar, bio, created FROM users"
|
||||||
|
}`), &options); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
collection.SetOptions(options)
|
||||||
|
|
||||||
|
// remove
|
||||||
|
collection.Schema.RemoveField("femc0ok5")
|
||||||
|
|
||||||
|
// remove
|
||||||
|
collection.Schema.RemoveField("aglcodpn")
|
||||||
|
|
||||||
|
// add
|
||||||
|
new_username := &schema.SchemaField{}
|
||||||
|
if err := json.Unmarshal([]byte(`{
|
||||||
|
"system": false,
|
||||||
|
"id": "tkxbpwxe",
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": null,
|
||||||
|
"max": null,
|
||||||
|
"pattern": ""
|
||||||
|
}
|
||||||
|
}`), new_username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
collection.Schema.AddField(new_username)
|
||||||
|
|
||||||
|
// add
|
||||||
|
new_avatar := &schema.SchemaField{}
|
||||||
|
if err := json.Unmarshal([]byte(`{
|
||||||
|
"system": false,
|
||||||
|
"id": "xs5qvkih",
|
||||||
|
"name": "avatar",
|
||||||
|
"type": "file",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"mimeTypes": [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp"
|
||||||
|
],
|
||||||
|
"thumbs": null,
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 5242880,
|
||||||
|
"protected": false
|
||||||
|
}
|
||||||
|
}`), new_avatar); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
collection.Schema.AddField(new_avatar)
|
||||||
|
|
||||||
|
// add
|
||||||
|
new_bio := &schema.SchemaField{}
|
||||||
|
if err := json.Unmarshal([]byte(`{
|
||||||
|
"system": false,
|
||||||
|
"id": "agsa8h5z",
|
||||||
|
"name": "bio",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": null,
|
||||||
|
"max": 10000,
|
||||||
|
"pattern": ""
|
||||||
|
}
|
||||||
|
}`), new_bio); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
collection.Schema.AddField(new_bio)
|
||||||
|
|
||||||
|
return dao.SaveCollection(collection)
|
||||||
|
}, func(db dbx.Builder) error {
|
||||||
|
dao := daos.New(db);
|
||||||
|
|
||||||
|
collection, err := dao.FindCollectionByNameOrId("xku110v5a5xbufa")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
options := map[string]any{}
|
||||||
|
if err := json.Unmarshal([]byte(`{
|
||||||
|
"query": "SELECT id, username, avatar, created FROM users"
|
||||||
|
}`), &options); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
collection.SetOptions(options)
|
||||||
|
|
||||||
|
// add
|
||||||
|
del_username := &schema.SchemaField{}
|
||||||
|
if err := json.Unmarshal([]byte(`{
|
||||||
|
"system": false,
|
||||||
|
"id": "femc0ok5",
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": null,
|
||||||
|
"max": null,
|
||||||
|
"pattern": ""
|
||||||
|
}
|
||||||
|
}`), del_username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
collection.Schema.AddField(del_username)
|
||||||
|
|
||||||
|
// add
|
||||||
|
del_avatar := &schema.SchemaField{}
|
||||||
|
if err := json.Unmarshal([]byte(`{
|
||||||
|
"system": false,
|
||||||
|
"id": "aglcodpn",
|
||||||
|
"name": "avatar",
|
||||||
|
"type": "file",
|
||||||
|
"required": false,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"mimeTypes": [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp"
|
||||||
|
],
|
||||||
|
"thumbs": null,
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 5242880,
|
||||||
|
"protected": false
|
||||||
|
}
|
||||||
|
}`), del_avatar); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
collection.Schema.AddField(del_avatar)
|
||||||
|
|
||||||
|
// remove
|
||||||
|
collection.Schema.RemoveField("tkxbpwxe")
|
||||||
|
|
||||||
|
// remove
|
||||||
|
collection.Schema.RemoveField("xs5qvkih")
|
||||||
|
|
||||||
|
// remove
|
||||||
|
collection.Schema.RemoveField("agsa8h5z")
|
||||||
|
|
||||||
|
return dao.SaveCollection(collection)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
logout();
|
logout();
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
} else if (item.value == "settings") {
|
} else if (item.value == "settings") {
|
||||||
goto("/settings/account");
|
goto("/settings/profile");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
"explore-some-trails": "Erkunden Sie einige Routen",
|
"explore-some-trails": "Erkunden Sie einige Routen",
|
||||||
"export": "Exportieren",
|
"export": "Exportieren",
|
||||||
"export-all-trails": "Alle Routen exportieren",
|
"export-all-trails": "Alle Routen exportieren",
|
||||||
|
"favourite-sport": "",
|
||||||
"features": "Features",
|
"features": "Features",
|
||||||
"file-format": "Dateiformat",
|
"file-format": "Dateiformat",
|
||||||
"file-too-big": "Datei {file} ist zu groß (max. {size})",
|
"file-too-big": "Datei {file} ist zu groß (max. {size})",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
"explore-some-trails": "Explore some trails",
|
"explore-some-trails": "Explore some trails",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"export-all-trails": "Export all trails",
|
"export-all-trails": "Export all trails",
|
||||||
|
"favourite-sport": "Favourite sport",
|
||||||
"features": "Features",
|
"features": "Features",
|
||||||
"file-format": "File format",
|
"file-format": "File format",
|
||||||
"file-too-big": "File {file} is too big (max. {size})",
|
"file-too-big": "File {file} is too big (max. {size})",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
"explore-some-trails": "Explorer certains itinéraires",
|
"explore-some-trails": "Explorer certains itinéraires",
|
||||||
"export": "",
|
"export": "",
|
||||||
"export-all-trails": "",
|
"export-all-trails": "",
|
||||||
|
"favourite-sport": "",
|
||||||
"features": "Fonctionnalités",
|
"features": "Fonctionnalités",
|
||||||
"file-format": "",
|
"file-format": "",
|
||||||
"file-too-big": "",
|
"file-too-big": "",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
"explore-some-trails": "Fedezzen fel néhány ösvényt",
|
"explore-some-trails": "Fedezzen fel néhány ösvényt",
|
||||||
"export": "",
|
"export": "",
|
||||||
"export-all-trails": "",
|
"export-all-trails": "",
|
||||||
|
"favourite-sport": "",
|
||||||
"features": "Jellemzők",
|
"features": "Jellemzők",
|
||||||
"file-format": "",
|
"file-format": "",
|
||||||
"file-too-big": "",
|
"file-too-big": "",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
"explore-some-trails": "Esplora alcuni percorsi",
|
"explore-some-trails": "Esplora alcuni percorsi",
|
||||||
"export": "Esporta",
|
"export": "Esporta",
|
||||||
"export-all-trails": "",
|
"export-all-trails": "",
|
||||||
|
"favourite-sport": "",
|
||||||
"features": "Caratteristiche",
|
"features": "Caratteristiche",
|
||||||
"file-format": "Formato del file",
|
"file-format": "Formato del file",
|
||||||
"file-too-big": "",
|
"file-too-big": "",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
"explore-some-trails": "Verken enkele wandelroutes",
|
"explore-some-trails": "Verken enkele wandelroutes",
|
||||||
"export": "",
|
"export": "",
|
||||||
"export-all-trails": "",
|
"export-all-trails": "",
|
||||||
|
"favourite-sport": "",
|
||||||
"features": "Kenmerken",
|
"features": "Kenmerken",
|
||||||
"file-format": "",
|
"file-format": "",
|
||||||
"file-too-big": "",
|
"file-too-big": "",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
"explore-some-trails": "Eksploruj różne ścieżki",
|
"explore-some-trails": "Eksploruj różne ścieżki",
|
||||||
"export": "",
|
"export": "",
|
||||||
"export-all-trails": "",
|
"export-all-trails": "",
|
||||||
|
"favourite-sport": "",
|
||||||
"features": "Funkcje",
|
"features": "Funkcje",
|
||||||
"file-format": "",
|
"file-format": "",
|
||||||
"file-too-big": "",
|
"file-too-big": "",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
"explore-some-trails": "Explore algumas trilhas",
|
"explore-some-trails": "Explore algumas trilhas",
|
||||||
"export": "",
|
"export": "",
|
||||||
"export-all-trails": "",
|
"export-all-trails": "",
|
||||||
|
"favourite-sport": "",
|
||||||
"features": "Características",
|
"features": "Características",
|
||||||
"file-format": "",
|
"file-format": "",
|
||||||
"file-too-big": "",
|
"file-too-big": "",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
"explore-some-trails": "探索行程",
|
"explore-some-trails": "探索行程",
|
||||||
"export": "",
|
"export": "",
|
||||||
"export-all-trails": "",
|
"export-all-trails": "",
|
||||||
|
"favourite-sport": "",
|
||||||
"features": "特性",
|
"features": "特性",
|
||||||
"file-format": "",
|
"file-format": "",
|
||||||
"file-too-big": "",
|
"file-too-big": "",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type User = {
|
|||||||
email?: string,
|
email?: string,
|
||||||
password: string,
|
password: string,
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
bio?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
created?: string;
|
created?: string;
|
||||||
}
|
}
|
||||||
@@ -14,5 +15,6 @@ export type UserAnonymous = {
|
|||||||
id: string,
|
id: string,
|
||||||
username?: string,
|
username?: string,
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
bio?: string;
|
||||||
created?: string;
|
created?: string;
|
||||||
}
|
}
|
||||||
15
web/src/routes/profile/[id]/+layout.server.ts
Normal file
15
web/src/routes/profile/[id]/+layout.server.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { users_show } from "$lib/stores/user_store";
|
||||||
|
import { error, type ServerLoad } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load: ServerLoad = async ({ params, locals, fetch }) => {
|
||||||
|
|
||||||
|
if(!params.id) {
|
||||||
|
error(404, "Not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await users_show(params.id, fetch);
|
||||||
|
|
||||||
|
const isOwnProfile = params.id === locals.user?.id;
|
||||||
|
|
||||||
|
return { user, isOwnProfile }
|
||||||
|
};
|
||||||
73
web/src/routes/profile/[id]/+layout.svelte
Normal file
73
web/src/routes/profile/[id]/+layout.svelte
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import type { SelectItem } from "$lib/components/base/select.svelte";
|
||||||
|
import { getFileURL } from "$lib/util/file_util";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
const profileLinks: SelectItem[] = [
|
||||||
|
{ text: "Profile", value: `/profile/${$page.params.id}` },
|
||||||
|
{ text: "Trails", value: `/profile/${$page.params.id}/trails` },
|
||||||
|
{ text: "Activities", value: `/profile/${$page.params.id}/activities` },
|
||||||
|
{ text: "Stats", value: `/profile/${$page.params.id}/stats` },
|
||||||
|
];
|
||||||
|
|
||||||
|
$: activeIndex = profileLinks.findIndex(
|
||||||
|
(l) => l.value === $page.url.pathname,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 lg:grid-cols-[356px_minmax(0,_1fr)] gap-4 max-w-6xl mx-auto items-start"
|
||||||
|
>
|
||||||
|
<div class="border border-input-border rounded-xl space-y-8">
|
||||||
|
{#if data.user}
|
||||||
|
<div class="flex items-center gap-x-6 px-6 mt-6">
|
||||||
|
<img
|
||||||
|
class="rounded-full w-16 aspect-square overflow-hidden"
|
||||||
|
src={getFileURL(data.user, data.user.avatar) ||
|
||||||
|
`https://api.dicebear.com/7.x/initials/svg?seed=${data.user.username}&backgroundType=gradientLinear`}
|
||||||
|
alt="avatar"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-2xl font-semibold col-start-2">
|
||||||
|
{data.user.username}
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm">
|
||||||
|
<span class="text-gray-500">Joined:</span>
|
||||||
|
{new Date(data.user.created ?? "").toLocaleDateString(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-x-6 text-sm px-6">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">0</p>
|
||||||
|
<p>Followers</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">0</p>
|
||||||
|
<p>Following</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{#each profileLinks as link, i}
|
||||||
|
<a
|
||||||
|
class="block mx-2 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}
|
||||||
|
href={link.value}>{link.text}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
@@ -1,428 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
|
||||||
import Calendar from "$lib/components/base/calendar.svelte";
|
|
||||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
|
||||||
import MultiSelect from "$lib/components/base/multi_select.svelte";
|
|
||||||
import Select, {
|
|
||||||
type SelectItem,
|
|
||||||
} from "$lib/components/base/select.svelte";
|
|
||||||
import SummitLogTable from "$lib/components/summit_log/summit_log_table.svelte";
|
|
||||||
import { categories } from "$lib/stores/category_store.js";
|
|
||||||
import {
|
|
||||||
summit_logs_index,
|
|
||||||
summitLogs,
|
|
||||||
} from "$lib/stores/summit_log_store";
|
|
||||||
import { currentUser } from "$lib/stores/user_store";
|
|
||||||
import { getFileURL } from "$lib/util/file_util";
|
|
||||||
import {
|
|
||||||
formatDistance,
|
|
||||||
formatElevation,
|
|
||||||
formatSpeed,
|
|
||||||
formatTimeHHMM,
|
|
||||||
} from "$lib/util/format_util";
|
|
||||||
import {
|
|
||||||
ArcElement,
|
|
||||||
BarElement,
|
|
||||||
CategoryScale,
|
|
||||||
Chart as ChartJS,
|
|
||||||
Legend,
|
|
||||||
LinearScale,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
} from "chart.js";
|
|
||||||
import { Bar, Pie } from "svelte-chartjs";
|
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
|
|
||||||
ChartJS.register(
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ArcElement,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
BarElement,
|
|
||||||
);
|
|
||||||
|
|
||||||
const filter = data.filter;
|
|
||||||
|
|
||||||
const categorySelectItems: SelectItem[] = $categories.map((c) => ({
|
|
||||||
value: c.id,
|
|
||||||
text: c.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const barChartSelectItems: SelectItem[] = [
|
|
||||||
{
|
|
||||||
text: $_("distance"),
|
|
||||||
value: "distance",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: $_("duration"),
|
|
||||||
value: "duration",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: $_("elevation-gain"),
|
|
||||||
value: "elevation_gain",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: $_("elevation-loss"),
|
|
||||||
value: "elevation_loss",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let barChartSelectedValue = barChartSelectItems[0].value;
|
|
||||||
|
|
||||||
const categoryColors = [
|
|
||||||
"#fb8500",
|
|
||||||
"#ffb703",
|
|
||||||
"#06d6a0",
|
|
||||||
"#219ebc",
|
|
||||||
"#8ecae6",
|
|
||||||
"#ffafcc",
|
|
||||||
];
|
|
||||||
|
|
||||||
const conversionFactors = {
|
|
||||||
distance: 0.621371,
|
|
||||||
elevation_gain: 3.28084,
|
|
||||||
elevation_loss: 3.28084,
|
|
||||||
};
|
|
||||||
|
|
||||||
$: logCategories = $summitLogs.reduce(
|
|
||||||
(acc, log) => {
|
|
||||||
const cat =
|
|
||||||
log.expand?.trails_via_summit_logs?.at(0)?.expand?.category
|
|
||||||
?.name ?? "unknown";
|
|
||||||
acc[$_(cat)] = (acc[$_(cat)] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, number>,
|
|
||||||
);
|
|
||||||
|
|
||||||
$: categoryLabels = Object.keys(logCategories).sort();
|
|
||||||
$: categoryValues = Object.values(logCategories);
|
|
||||||
$: categoryColorMap = Object.fromEntries(
|
|
||||||
categoryLabels.map((label, index) => [
|
|
||||||
label,
|
|
||||||
categoryColors[index % categoryColors.length],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
$: categoryChartData = {
|
|
||||||
labels: categoryLabels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
data: categoryValues,
|
|
||||||
backgroundColor: categoryColors,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function barChartUnit() {
|
|
||||||
const unit = $page.data.settings?.unit ?? "metric";
|
|
||||||
|
|
||||||
switch (barChartSelectedValue) {
|
|
||||||
case "duration":
|
|
||||||
return "h";
|
|
||||||
case "elevation_gain":
|
|
||||||
case "elevation_loss":
|
|
||||||
return unit == "metric" ? "m" : "ft";
|
|
||||||
case "distance":
|
|
||||||
return unit == "metric" ? "km" : "mi";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: barChartDataByDate = $summitLogs.reduce(
|
|
||||||
(acc, log) => {
|
|
||||||
const date = new Date(log.date).toLocaleDateString(undefined, {
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
acc[date] =
|
|
||||||
(acc[date] || 0) + ((log as any)[barChartSelectedValue] ?? 0);
|
|
||||||
if (barChartSelectedValue === "distance") {
|
|
||||||
acc[date] = acc[date] / 1000;
|
|
||||||
} else if (barChartSelectedValue === "duration") {
|
|
||||||
acc[date] = acc[date] / 60 / 60;
|
|
||||||
}
|
|
||||||
if ($page.data.settings?.unit !== "metric") {
|
|
||||||
acc[date] =
|
|
||||||
acc[date] *
|
|
||||||
(conversionFactors as any)[barChartSelectedValue];
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, number>,
|
|
||||||
);
|
|
||||||
|
|
||||||
$: barChartLabels = Object.keys(barChartDataByDate); // Dates as labels
|
|
||||||
$: barChartValues = Object.values(barChartDataByDate);
|
|
||||||
|
|
||||||
$: barChartData = {
|
|
||||||
labels: barChartLabels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: barChartSelectItems.find(
|
|
||||||
(i) => i.value === barChartSelectedValue,
|
|
||||||
)?.text,
|
|
||||||
data: barChartValues,
|
|
||||||
backgroundColor: "#3388ff",
|
|
||||||
borderRadius: 15,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
$: totalDistance = $summitLogs.reduce(
|
|
||||||
(sum, log) => sum + (log.distance ?? 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
$: totalDuration = $summitLogs.reduce(
|
|
||||||
(sum, log) => sum + (log.duration ?? 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
$: totalElevationGain = $summitLogs.reduce(
|
|
||||||
(sum, log) => sum + (log.elevation_gain ?? 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
$: totalElevationLoss = $summitLogs.reduce(
|
|
||||||
(sum, log) => sum + (log.elevation_loss ?? 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
$: averageSpeed =
|
|
||||||
totalDuration > 0
|
|
||||||
? $summitLogs.reduce(
|
|
||||||
(sum, log) =>
|
|
||||||
sum + (log.distance && log.duration ? log.distance : 0),
|
|
||||||
0,
|
|
||||||
) / totalDuration
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
function updateFilterCategory(categories: SelectItem[]) {
|
|
||||||
filter.category = categories.map((c) => c.value);
|
|
||||||
loadSummitLogs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDateClick(date: Date) {
|
|
||||||
const datePlusN = date;
|
|
||||||
datePlusN.setDate(date.getDate() + 1);
|
|
||||||
filter.startDate = datePlusN.toISOString().slice(0, 10);
|
|
||||||
datePlusN.setDate(date.getDate() + 1);
|
|
||||||
filter.endDate = datePlusN.toISOString().slice(0, 10);
|
|
||||||
loadSummitLogs();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSummitLogs() {
|
|
||||||
const logs = await summit_logs_index($page.params.id, filter);
|
|
||||||
summitLogs.set(logs);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<div class="mx-8 space-y-6">
|
||||||
<title>{$_("profile")} | wanderer</title>
|
<div class="space-y-4">
|
||||||
</svelte:head>
|
<h4 class="text-xl font-semibold">
|
||||||
|
{$_("about")}
|
||||||
<div
|
{data.user.username}
|
||||||
class="grid grid-cols-1 sm:grid-cols-[272px_minmax(0,_1fr)] md:grid-cols-[356px_minmax(0,_1fr)] gap-y-4 max-w-6xl mx-auto"
|
{#if data.isOwnProfile && data.user.bio?.length}
|
||||||
>
|
<a class="ml-4" href="/settings/profile"
|
||||||
{#if data.user}
|
><i class="fa fa-pen text-base"></i></a
|
||||||
<div class="flex items-center gap-x-6 h-full px-4">
|
|
||||||
<img
|
|
||||||
class="rounded-full w-16 aspect-square overflow-hidden"
|
|
||||||
src={getFileURL(data.user, data.user.avatar) ||
|
|
||||||
`https://api.dicebear.com/7.x/initials/svg?seed=${data.user.username}&backgroundType=gradientLinear`}
|
|
||||||
alt="avatar"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-2xl font-semibold col-start-2">
|
|
||||||
{data.user.username}
|
|
||||||
</h4>
|
|
||||||
<p class="text-sm">
|
|
||||||
<span class="text-gray-500">Joined:</span>
|
|
||||||
{new Date(data.user.created ?? "").toLocaleDateString(
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
timeZone: "UTC",
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap md:flex-nowrap col-start-1 sm:col-start-2 gap-x-4 justify-end"
|
|
||||||
>
|
|
||||||
<MultiSelect
|
|
||||||
on:change={(e) => updateFilterCategory(e.detail)}
|
|
||||||
label={$_("categories")}
|
|
||||||
items={categorySelectItems}
|
|
||||||
placeholder={`${$_("filter-categories")}...`}
|
|
||||||
></MultiSelect>
|
|
||||||
<Datepicker
|
|
||||||
on:change={loadSummitLogs}
|
|
||||||
bind:value={filter.startDate}
|
|
||||||
label={$_("after")}
|
|
||||||
></Datepicker>
|
|
||||||
<Datepicker
|
|
||||||
on:change={loadSummitLogs}
|
|
||||||
bind:value={filter.endDate}
|
|
||||||
label={$_("before")}
|
|
||||||
></Datepicker>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-4 grow-0 sm:mr-4">
|
|
||||||
<div class="border border-input-border rounded-xl p-6">
|
|
||||||
<Calendar
|
|
||||||
on:click={(e) => handleDateClick(e.detail)}
|
|
||||||
logs={$summitLogs}
|
|
||||||
colorMap={categoryColorMap}
|
|
||||||
></Calendar>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="border border-input-border rounded-xl p-6 space-y-4"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500 font-semibold text-lg"
|
|
||||||
><i class="fa fa-person-hiking mr-3"></i>{$_(
|
|
||||||
"categories",
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
<Pie
|
|
||||||
data={categoryChartData}
|
|
||||||
options={{
|
|
||||||
responsive: true,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: "bottom",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500 font-semibold text-lg self-start"
|
|
||||||
><i class="fa fa-hashtag mr-3"></i>{$_("activity", {
|
|
||||||
values: { n: 2 },
|
|
||||||
})}</span
|
|
||||||
>
|
|
||||||
<p class="text-3xl font-bold">{$summitLogs.length}</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500 font-semibold text-lg self-start"
|
|
||||||
><i class="fa fa-left-right mr-3"></i>{$_("distance")}</span
|
|
||||||
>
|
|
||||||
<p class="text-3xl font-bold">{formatDistance(totalDistance)}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500 font-semibold text-lg self-start"
|
|
||||||
><i class="fa fa-clock mr-3"></i>{$_("duration")}</span
|
|
||||||
>
|
|
||||||
<p class="text-3xl font-bold">
|
|
||||||
{formatTimeHHMM(totalDuration / 60)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500 font-semibold text-lg self-start"
|
|
||||||
><i class="fa fa-arrow-trend-up mr-3"></i>{$_(
|
|
||||||
"elevation-gain",
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
<p class="text-3xl font-bold">
|
|
||||||
{formatElevation(totalElevationGain)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500 font-semibold text-lg self-start"
|
|
||||||
><i class="fa fa-arrow-trend-down mr-3"></i>{$_(
|
|
||||||
"elevation-loss",
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
<p class="text-3xl font-bold">
|
|
||||||
{formatElevation(totalElevationLoss)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500 font-semibold text-lg self-start"
|
|
||||||
><i class="fa fa-arrow-trend-down mr-3"></i>{$_(
|
|
||||||
"average-speed",
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
<p class="text-3xl font-bold">{formatSpeed(averageSpeed)}</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="h-full sm:col-span-2 lg:col-span-3 space-y-2 border border-input-border rounded-xl p-6"
|
|
||||||
>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500 font-semibold text-lg"
|
|
||||||
><i class="fa fa-calendar mr-3"></i>{$_("activity", {
|
|
||||||
values: { n: 1 },
|
|
||||||
})}</span
|
|
||||||
>
|
>
|
||||||
<Select
|
{/if}
|
||||||
bind:value={barChartSelectedValue}
|
</h4>
|
||||||
items={barChartSelectItems}
|
{#if data.user.bio?.length}
|
||||||
></Select>
|
<p class="whitespace-pre-wrap text-sm">{data.user.bio}</p>
|
||||||
</div>
|
{:else if data.isOwnProfile}
|
||||||
|
<a class="btn-primary inline-block" href="/settings/profile"
|
||||||
<Bar
|
>+ Add Bio</a
|
||||||
data={barChartData}
|
>
|
||||||
options={{
|
{/if}
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: (item) =>
|
|
||||||
`${item.dataset.label}: ${item.formattedValue} ${barChartUnit()}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
ticks: {
|
|
||||||
callback: function (value, index, ticks) {
|
|
||||||
return value + " " + barChartUnit();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
></Bar>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div
|
<h4 class="text-xl font-semibold">
|
||||||
class="col-span-1 sm:col-span-2 border border-input-border rounded-xl p-6 space-y-6"
|
{$_("list", { values: { n: 2 } })}
|
||||||
>
|
</h4>
|
||||||
<span class="text-gray-500 font-semibold text-lg"
|
</div>
|
||||||
><i class="fa fa-table mr-3"></i>{$_("all-activities")}</span
|
<div>
|
||||||
>
|
<h4 class="text-xl font-semibold">Timeline</h4>
|
||||||
<div class=" overflow-x-auto">
|
|
||||||
<SummitLogTable summitLogs={$summitLogs} showCategory showTrail showRoute
|
|
||||||
></SummitLogTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
400
web/src/routes/profile/[id]/stats/+page.svelte
Normal file
400
web/src/routes/profile/[id]/stats/+page.svelte
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import Calendar from "$lib/components/base/calendar.svelte";
|
||||||
|
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||||
|
import MultiSelect from "$lib/components/base/multi_select.svelte";
|
||||||
|
import Select, {
|
||||||
|
type SelectItem,
|
||||||
|
} from "$lib/components/base/select.svelte";
|
||||||
|
import SummitLogTable from "$lib/components/summit_log/summit_log_table.svelte";
|
||||||
|
import { categories } from "$lib/stores/category_store.js";
|
||||||
|
import {
|
||||||
|
summit_logs_index,
|
||||||
|
summitLogs,
|
||||||
|
} from "$lib/stores/summit_log_store";
|
||||||
|
import {
|
||||||
|
formatDistance,
|
||||||
|
formatElevation,
|
||||||
|
formatSpeed,
|
||||||
|
formatTimeHHMM,
|
||||||
|
} from "$lib/util/format_util";
|
||||||
|
import {
|
||||||
|
ArcElement,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
Chart as ChartJS,
|
||||||
|
Legend,
|
||||||
|
LinearScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from "chart.js";
|
||||||
|
import { Bar, Pie } from "svelte-chartjs";
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ArcElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filter = data.filter;
|
||||||
|
|
||||||
|
const categorySelectItems: SelectItem[] = $categories.map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
text: c.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const barChartSelectItems: SelectItem[] = [
|
||||||
|
{
|
||||||
|
text: $_("distance"),
|
||||||
|
value: "distance",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $_("duration"),
|
||||||
|
value: "duration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $_("elevation-gain"),
|
||||||
|
value: "elevation_gain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $_("elevation-loss"),
|
||||||
|
value: "elevation_loss",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let barChartSelectedValue = barChartSelectItems[0].value;
|
||||||
|
|
||||||
|
const categoryColors = [
|
||||||
|
"#fb8500",
|
||||||
|
"#ffb703",
|
||||||
|
"#06d6a0",
|
||||||
|
"#219ebc",
|
||||||
|
"#8ecae6",
|
||||||
|
"#ffafcc",
|
||||||
|
];
|
||||||
|
|
||||||
|
const conversionFactors = {
|
||||||
|
distance: 0.621371,
|
||||||
|
elevation_gain: 3.28084,
|
||||||
|
elevation_loss: 3.28084,
|
||||||
|
};
|
||||||
|
|
||||||
|
$: logCategories = $summitLogs.reduce(
|
||||||
|
(acc, log) => {
|
||||||
|
const cat =
|
||||||
|
log.expand?.trails_via_summit_logs?.at(0)?.expand?.category
|
||||||
|
?.name ?? "unknown";
|
||||||
|
acc[$_(cat)] = (acc[$_(cat)] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
);
|
||||||
|
|
||||||
|
$: categoryLabels = Object.keys(logCategories).sort();
|
||||||
|
$: categoryValues = Object.values(logCategories);
|
||||||
|
$: categoryColorMap = Object.fromEntries(
|
||||||
|
categoryLabels.map((label, index) => [
|
||||||
|
label,
|
||||||
|
categoryColors[index % categoryColors.length],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$: categoryChartData = {
|
||||||
|
labels: categoryLabels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: categoryValues,
|
||||||
|
backgroundColor: categoryColors,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function barChartUnit() {
|
||||||
|
const unit = $page.data.settings?.unit ?? "metric";
|
||||||
|
|
||||||
|
switch (barChartSelectedValue) {
|
||||||
|
case "duration":
|
||||||
|
return "h";
|
||||||
|
case "elevation_gain":
|
||||||
|
case "elevation_loss":
|
||||||
|
return unit == "metric" ? "m" : "ft";
|
||||||
|
case "distance":
|
||||||
|
return unit == "metric" ? "km" : "mi";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: barChartDataByDate = $summitLogs.reduce(
|
||||||
|
(acc, log) => {
|
||||||
|
const date = new Date(log.date).toLocaleDateString(undefined, {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
acc[date] =
|
||||||
|
(acc[date] || 0) + ((log as any)[barChartSelectedValue] ?? 0);
|
||||||
|
if (barChartSelectedValue === "distance") {
|
||||||
|
acc[date] = acc[date] / 1000;
|
||||||
|
} else if (barChartSelectedValue === "duration") {
|
||||||
|
acc[date] = acc[date] / 60 / 60;
|
||||||
|
}
|
||||||
|
if ($page.data.settings?.unit !== "metric") {
|
||||||
|
acc[date] =
|
||||||
|
acc[date] *
|
||||||
|
(conversionFactors as any)[barChartSelectedValue];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
);
|
||||||
|
|
||||||
|
$: barChartLabels = Object.keys(barChartDataByDate); // Dates as labels
|
||||||
|
$: barChartValues = Object.values(barChartDataByDate);
|
||||||
|
|
||||||
|
$: barChartData = {
|
||||||
|
labels: barChartLabels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: barChartSelectItems.find(
|
||||||
|
(i) => i.value === barChartSelectedValue,
|
||||||
|
)?.text,
|
||||||
|
data: barChartValues,
|
||||||
|
backgroundColor: "#3388ff",
|
||||||
|
borderRadius: 15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
$: totalDistance = $summitLogs.reduce(
|
||||||
|
(sum, log) => sum + (log.distance ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
$: totalDuration = $summitLogs.reduce(
|
||||||
|
(sum, log) => sum + (log.duration ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
$: totalElevationGain = $summitLogs.reduce(
|
||||||
|
(sum, log) => sum + (log.elevation_gain ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
$: totalElevationLoss = $summitLogs.reduce(
|
||||||
|
(sum, log) => sum + (log.elevation_loss ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
$: averageSpeed =
|
||||||
|
totalDuration > 0
|
||||||
|
? $summitLogs.reduce(
|
||||||
|
(sum, log) =>
|
||||||
|
sum + (log.distance && log.duration ? log.distance : 0),
|
||||||
|
0,
|
||||||
|
) / totalDuration
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
function updateFilterCategory(categories: SelectItem[]) {
|
||||||
|
filter.category = categories.map((c) => c.value);
|
||||||
|
loadSummitLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDateClick(date: Date) {
|
||||||
|
const datePlusN = date;
|
||||||
|
datePlusN.setDate(date.getDate() + 1);
|
||||||
|
filter.startDate = datePlusN.toISOString().slice(0, 10);
|
||||||
|
datePlusN.setDate(date.getDate() + 1);
|
||||||
|
filter.endDate = datePlusN.toISOString().slice(0, 10);
|
||||||
|
loadSummitLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummitLogs() {
|
||||||
|
const logs = await summit_logs_index($page.params.id, filter);
|
||||||
|
summitLogs.set(logs);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$_("profile")} | wanderer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-[272px_minmax(0,_1fr)] lg:grid-cols-[320px_minmax(0,_1fr)] gap-y-4 max-w-6xl mx-auto">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap md:flex-nowrap col-start-1 sm:col-start-2 gap-x-4 justify-end"
|
||||||
|
>
|
||||||
|
<MultiSelect
|
||||||
|
on:change={(e) => updateFilterCategory(e.detail)}
|
||||||
|
label={$_("categories")}
|
||||||
|
items={categorySelectItems}
|
||||||
|
placeholder={`${$_("filter-categories")}...`}
|
||||||
|
></MultiSelect>
|
||||||
|
<Datepicker
|
||||||
|
on:change={loadSummitLogs}
|
||||||
|
bind:value={filter.startDate}
|
||||||
|
label={$_("after")}
|
||||||
|
></Datepicker>
|
||||||
|
<Datepicker
|
||||||
|
on:change={loadSummitLogs}
|
||||||
|
bind:value={filter.endDate}
|
||||||
|
label={$_("before")}
|
||||||
|
></Datepicker>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4 grow-0 sm:mr-4">
|
||||||
|
<div class="border border-input-border rounded-xl p-6">
|
||||||
|
<Calendar
|
||||||
|
on:click={(e) => handleDateClick(e.detail)}
|
||||||
|
logs={$summitLogs}
|
||||||
|
colorMap={categoryColorMap}
|
||||||
|
></Calendar>
|
||||||
|
</div>
|
||||||
|
<div class="border border-input-border rounded-xl p-6 space-y-4">
|
||||||
|
<span class="text-gray-500 font-semibold text-lg"
|
||||||
|
><i class="fa fa-person-hiking mr-3"></i>{$_(
|
||||||
|
"categories",
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
<Pie
|
||||||
|
data={categoryChartData}
|
||||||
|
options={{
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: "bottom",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<span class="text-gray-500 font-semibold text-lg self-start"
|
||||||
|
><i class="fa fa-hashtag mr-3"></i>{$_("activity", {
|
||||||
|
values: { n: 2 },
|
||||||
|
})}</span
|
||||||
|
>
|
||||||
|
<p class="text-3xl font-bold">{$summitLogs.length}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<span class="text-gray-500 font-semibold text-lg self-start"
|
||||||
|
><i class="fa fa-left-right mr-3"></i>{$_("distance")}</span
|
||||||
|
>
|
||||||
|
<p class="text-3xl font-bold">{formatDistance(totalDistance)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<span class="text-gray-500 font-semibold text-lg self-start"
|
||||||
|
><i class="fa fa-clock mr-3"></i>{$_("duration")}</span
|
||||||
|
>
|
||||||
|
<p class="text-3xl font-bold">
|
||||||
|
{formatTimeHHMM(totalDuration / 60)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<span class="text-gray-500 font-semibold text-lg self-start"
|
||||||
|
><i class="fa fa-arrow-trend-down mr-3"></i>{$_(
|
||||||
|
"average-speed",
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
<p class="text-3xl font-bold">{formatSpeed(averageSpeed)}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<span class="text-gray-500 font-semibold text-lg self-start"
|
||||||
|
><i class="fa fa-arrow-trend-up mr-3"></i>{$_(
|
||||||
|
"elevation-gain",
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
<p class="text-3xl font-bold">
|
||||||
|
{formatElevation(totalElevationGain)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-4 border border-input-border rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<span class="text-gray-500 font-semibold text-lg self-start"
|
||||||
|
><i class="fa fa-arrow-trend-down mr-3"></i>{$_(
|
||||||
|
"elevation-loss",
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
<p class="text-3xl font-bold">
|
||||||
|
{formatElevation(totalElevationLoss)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="h-full sm:col-span-2 space-y-2 border border-input-border rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 font-semibold text-lg"
|
||||||
|
><i class="fa fa-calendar mr-3"></i>{$_("activity", {
|
||||||
|
values: { n: 1 },
|
||||||
|
})}</span
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
bind:value={barChartSelectedValue}
|
||||||
|
items={barChartSelectItems}
|
||||||
|
></Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Bar
|
||||||
|
data={barChartData}
|
||||||
|
options={{
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (item) =>
|
||||||
|
`${item.dataset.label}: ${item.formattedValue} ${barChartUnit()}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
callback: function (value, index, ticks) {
|
||||||
|
return value + " " + barChartUnit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
></Bar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="col-span-1 sm:col-span-2 border border-input-border rounded-xl p-6 space-y-6"
|
||||||
|
>
|
||||||
|
<span class="text-gray-500 font-semibold text-lg"
|
||||||
|
><i class="fa fa-table mr-3"></i>{$_("all-activities")}</span
|
||||||
|
>
|
||||||
|
<div class=" overflow-x-auto">
|
||||||
|
<SummitLogTable
|
||||||
|
summitLogs={$summitLogs}
|
||||||
|
showCategory
|
||||||
|
showTrail
|
||||||
|
showRoute
|
||||||
|
></SummitLogTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -25,7 +25,6 @@ export const load: ServerLoad = async ({ params, locals, fetch }) => {
|
|||||||
category: []
|
category: []
|
||||||
}
|
}
|
||||||
const logs = await summit_logs_index(params.id, filter, fetch);
|
const logs = await summit_logs_index(params.id, filter, fetch);
|
||||||
const user = await users_show(params.id, fetch);
|
|
||||||
|
|
||||||
return { filter, user }
|
return { filter }
|
||||||
};
|
};
|
||||||
@@ -1,18 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { DropdownItem } from "../base/dropdown.svelte";
|
import { page } from "$app/stores";
|
||||||
|
import type { SelectItem } from "$lib/components/base/select.svelte";
|
||||||
import { _ } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
export let selected: number;
|
|
||||||
|
|
||||||
const items: DropdownItem[] = [
|
const settingsLinks: SelectItem[] = [
|
||||||
|
{ text: $_("profile"), value: "/settings/profile" },
|
||||||
{ text: $_("my-account"), value: "/settings/account" },
|
{ text: $_("my-account"), value: "/settings/account" },
|
||||||
{ text: $_("display"), value: "/settings/display" },
|
{
|
||||||
|
text: $_("language") + " & " + $_("units"),
|
||||||
|
value: "/settings/language",
|
||||||
|
},
|
||||||
|
{ text: $_("map"), value: "/settings/map" },
|
||||||
{ text: `${$_("import")}/${$_("export")}`, value: "/settings/export" },
|
{ text: `${$_("import")}/${$_("export")}`, value: "/settings/export" },
|
||||||
{ text: $_('help'), value: "https://wanderer.to" },
|
{
|
||||||
|
text: $_("help"),
|
||||||
|
value: "https://wanderer.to/getting-started/installation",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function handleItemClick(item: DropdownItem) {
|
$: activeIndex = settingsLinks.findIndex(
|
||||||
if (item.text != "Help") {
|
(l) => l.value === $page.url.pathname,
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleItemClick(item: SelectItem) {
|
||||||
|
if (item.value != settingsLinks.at(-1)?.value) {
|
||||||
goto(item.value);
|
goto(item.value);
|
||||||
} else {
|
} else {
|
||||||
window.open(item.value, "_blank");
|
window.open(item.value, "_blank");
|
||||||
@@ -21,13 +33,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-1 md:grid-cols-[256px_1fr] max-w-4xl mx-4 md:mx-auto gap-x-12 items-start"
|
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">
|
<menu class="p-4 border border-input-border rounded-xl mb-4">
|
||||||
{#each items as item, i}
|
{#each settingsLinks as item, i}
|
||||||
<li
|
<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="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 == selected}
|
class:bg-menu-item-background-hover={i == activeIndex}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
on:mousedown|stopPropagation={() => handleItemClick(item)}
|
on:mousedown|stopPropagation={() => handleItemClick(item)}
|
||||||
>
|
>
|
||||||
@@ -3,14 +3,12 @@
|
|||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { type RadioItem } from "$lib/components/base/radio_group.svelte";
|
import { type RadioItem } from "$lib/components/base/radio_group.svelte";
|
||||||
import { type SearchItem } from "$lib/components/base/search.svelte";
|
import { type SearchItem } from "$lib/components/base/search.svelte";
|
||||||
import Select, {
|
import {
|
||||||
type SelectItem,
|
type SelectItem,
|
||||||
} from "$lib/components/base/select.svelte";
|
} from "$lib/components/base/select.svelte";
|
||||||
import ConfirmModal from "$lib/components/confirm_modal.svelte";
|
import ConfirmModal from "$lib/components/confirm_modal.svelte";
|
||||||
import EmailModal from "$lib/components/profile/email_modal.svelte";
|
import EmailModal from "$lib/components/profile/email_modal.svelte";
|
||||||
import PasswordModal from "$lib/components/profile/password_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 { show_toast } from "$lib/stores/toast_store";
|
||||||
import {
|
import {
|
||||||
currentUser,
|
currentUser,
|
||||||
@@ -19,24 +17,11 @@
|
|||||||
users_update,
|
users_update,
|
||||||
} from "$lib/stores/user_store";
|
} from "$lib/stores/user_store";
|
||||||
import { country_codes } from "$lib/util/country_code_util";
|
import { country_codes } from "$lib/util/country_code_util";
|
||||||
import { getFileURL } from "$lib/util/file_util";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { _, locale } from "svelte-i18n";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
const settings = $page.data.settings;
|
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[] = [
|
const mapFocus: SelectItem[] = [
|
||||||
{ text: $_("trail", { values: { n: 2 } }), value: "trails" },
|
{ text: $_("trail", { values: { n: 2 } }), value: "trails" },
|
||||||
{ text: $_("location"), value: "location" },
|
{ 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() {
|
async function deleteAccount() {
|
||||||
await users_delete($currentUser!);
|
await users_delete($currentUser!);
|
||||||
logout();
|
logout();
|
||||||
@@ -139,90 +102,39 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFileBrowser() {
|
|
||||||
document.getElementById("avatarInput")!.click();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_("settings")} | wanderer</title>
|
<title>{$_("settings")} | wanderer</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
<main>
|
|
||||||
{#if $currentUser}
|
{#if $currentUser}
|
||||||
<Settings selected={0}>
|
<h2 class="text-2xl font-semibold">{$_("my-account")}</h2>
|
||||||
<div class="space-y-6">
|
<hr class="mt-4 mb-6 border-input-border" />
|
||||||
<h3 class="text-2xl font-semibold">{$_("profile")}</h3>
|
<div class="space-y-6">
|
||||||
|
<h4 class="text-xl font-medium">{$_("login-details")}</h4>
|
||||||
<div class="flex gap-6 items-center">
|
<button class="btn-secondary block" on:click={openEmailModal}
|
||||||
<div
|
>{$_("change-email")}</button
|
||||||
class="rounded-full w-24 aspect-square overflow-hidden relative group"
|
>
|
||||||
>
|
<button class="btn-secondary" on:click={openPasswordModal}
|
||||||
<img
|
>{$_("change-password")}</button
|
||||||
src={getFileURL(
|
>
|
||||||
$currentUser,
|
<div class="space-y-4">
|
||||||
$currentUser.avatar,
|
<h4 class="text-xl text-red-400 font-medium">{$_("danger-zone")}</h4>
|
||||||
) ||
|
<button class="btn-danger" on:click={openConfirmModal}
|
||||||
`https://api.dicebear.com/7.x/initials/svg?seed=${$currentUser.username}&backgroundType=gradientLinear`}
|
>{$_("delete-account")}</button
|
||||||
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>
|
|
||||||
<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>
|
</div>
|
||||||
<EmailModal
|
</div>
|
||||||
email={$currentUser.email}
|
<EmailModal
|
||||||
on:save={(e) => updateEmail(e.detail)}
|
email={$currentUser.email}
|
||||||
bind:openModal={openEmailModal}
|
on:save={(e) => updateEmail(e.detail)}
|
||||||
></EmailModal>
|
bind:openModal={openEmailModal}
|
||||||
<PasswordModal
|
></EmailModal>
|
||||||
on:save={(e) => updatePassword(e.detail)}
|
<PasswordModal
|
||||||
bind:openModal={openPasswordModal}
|
on:save={(e) => updatePassword(e.detail)}
|
||||||
></PasswordModal>
|
bind:openModal={openPasswordModal}
|
||||||
</Settings>
|
></PasswordModal>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
text={$_("account-delete-confirm")}
|
text={$_("account-delete-confirm")}
|
||||||
bind:openModal={openConfirmModal}
|
bind:openModal={openConfirmModal}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from "$lib/components/base/button.svelte";
|
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 TrailExportModal from "$lib/components/trail/trail_export_modal.svelte";
|
||||||
import { show_toast } from "$lib/stores/toast_store";
|
import { show_toast } from "$lib/stores/toast_store";
|
||||||
import {
|
import {
|
||||||
@@ -59,11 +58,9 @@
|
|||||||
uploading = true;
|
uploading = true;
|
||||||
let progress = 0;
|
let progress = 0;
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
|
||||||
|
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
try {
|
try {
|
||||||
uploadProgress.set(progress += 100 / (files.length * 2));
|
uploadProgress.set((progress += 100 / (files.length * 2)));
|
||||||
await trails_upload(file);
|
await trails_upload(file);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorsThrown += 1;
|
errorsThrown += 1;
|
||||||
@@ -73,7 +70,7 @@
|
|||||||
text: `Error uploading file: ${file.name}`,
|
text: `Error uploading file: ${file.name}`,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
uploadProgress.set(progress += 100 / (files.length * 2));
|
uploadProgress.set((progress += 100 / (files.length * 2)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,40 +159,38 @@
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_("settings")} | wanderer</title>
|
<title>{$_("settings")} | wanderer</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
<main>
|
<div class="space-y-6">
|
||||||
<Settings selected={2}>
|
<h3 class="text-2xl font-semibold">{$_("import")}</h3>
|
||||||
<div class="space-y-6">
|
<hr class="mt-4 mb-6 border-input-border" />
|
||||||
<h3 class="text-2xl font-semibold">{$_("import")}</h3>
|
<button
|
||||||
<button
|
class="drop-area relative h-64 w-full p-4 {uploading
|
||||||
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"
|
||||||
: '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:bg-menu-item-background-hover={uploading}
|
class:border-2={offerUpload && !uploading}
|
||||||
class:border-2={offerUpload && !uploading}
|
style="--progress: {$uploadProgress}%"
|
||||||
style="--progress: {$uploadProgress}%"
|
on:click={openFileBrowser}
|
||||||
on:click={openFileBrowser}
|
on:dragover={handleDragOver}
|
||||||
on:dragover={handleDragOver}
|
on:dragleave={handleDragLeave}
|
||||||
on:dragleave={handleDragLeave}
|
on:drop={handleDrop}
|
||||||
on:drop={handleDrop}
|
>
|
||||||
>
|
{$_("import-hint")}
|
||||||
{$_("import-hint")}
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
id="file-input"
|
id="file-input"
|
||||||
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
|
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
|
||||||
multiple={true}
|
multiple={true}
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
on:change={() => handleFileSelection()}
|
on:change={() => handleFileSelection()}
|
||||||
/>
|
/>
|
||||||
<h3 class="text-2xl font-semibold">{$_("export")}</h3>
|
<h3 class="text-2xl font-semibold">{$_("export")}</h3>
|
||||||
<Button secondary={true} on:click={() => openExportModal()}
|
<hr class="mt-4 mb-6 border-input-border" />
|
||||||
>{$_("export-all-trails")}</Button
|
<Button secondary={true} on:click={() => openExportModal()}
|
||||||
>
|
>{$_("export-all-trails")}</Button
|
||||||
</div>
|
>
|
||||||
</Settings>
|
</div>
|
||||||
</main>
|
|
||||||
|
|
||||||
<TrailExportModal
|
<TrailExportModal
|
||||||
bind:openModal={openExportModal}
|
bind:openModal={openExportModal}
|
||||||
|
|||||||
68
web/src/routes/settings/language/+page.svelte
Normal file
68
web/src/routes/settings/language/+page.svelte
Normal 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>
|
||||||
208
web/src/routes/settings/map/+page.svelte
Normal file
208
web/src/routes/settings/map/+page.svelte
Normal 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}
|
||||||
127
web/src/routes/settings/profile/+page.svelte
Normal file
127
web/src/routes/settings/profile/+page.svelte
Normal 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}
|
||||||
Reference in New Issue
Block a user