adds docs
This commit is contained in:
@@ -1,3 +1,12 @@
|
||||
# v0.9.0
|
||||
## Features
|
||||
- Complete visual overhaul of the list page
|
||||
- Lists can now be shared with other users
|
||||
|
||||
## Bug fixes
|
||||
- Fixes a bug that caused categories not to load properly on page reload
|
||||
|
||||
|
||||
# v0.8.2
|
||||
## Features
|
||||
- The pagination is now available at top and bottom
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 6.6 MiB |
BIN
docs/src/assets/guides/wanderer_lists_edit.png
Normal file
BIN
docs/src/assets/guides/wanderer_lists_edit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 MiB |
391
docs/src/content/docs/api-reference/list-share.md
Normal file
391
docs/src/content/docs/api-reference/list-share.md
Normal file
@@ -0,0 +1,391 @@
|
||||
---
|
||||
title: list-share
|
||||
---
|
||||
|
||||
## GET show
|
||||
|
||||
GET /list-share/{id}
|
||||
|
||||
### Params
|
||||
|
||||
|Name|Location|Type|Required|Description|
|
||||
|---|---|---|---|---|
|
||||
|id|path|string| yes |none|
|
||||
|
||||
> Response Examples
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"collectionId": "string",
|
||||
"collectionName": "string",
|
||||
"created": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"permission": "view",
|
||||
"list": "string",
|
||||
"updated": "2019-08-24T14:15:22Z",
|
||||
"user": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
|HTTP Status Code |Meaning|Description|Data schema|
|
||||
|---|---|---|---|
|
||||
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
|
||||
|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|none|Inline|
|
||||
|
||||
### Responses Data Schema
|
||||
|
||||
HTTP Status Code **200**
|
||||
|
||||
|Name|Type|Required|Restrictions|Title|description|
|
||||
|---|---|---|---|---|---|
|
||||
|» collectionId|string|true|none||none|
|
||||
|» collectionName|string|true|none||none|
|
||||
|» created|string(date-time)|true|none||none|
|
||||
|» id|string|true|none||none|
|
||||
|» permission|string|true|none||none|
|
||||
|» list|string|true|none||none|
|
||||
|» updated|string(date-time)|true|none||none|
|
||||
|» user|string|true|none||none|
|
||||
|
||||
#### Enum
|
||||
|
||||
|Name|Value|
|
||||
|---|---|
|
||||
|permission|view|
|
||||
|permission|edit|
|
||||
|
||||
HTTP Status Code **404**
|
||||
|
||||
|Name|Type|Required|Restrictions|Title|description|
|
||||
|---|---|---|---|---|---|
|
||||
|» url|string|true|none||none|
|
||||
|» status|integer|true|none||none|
|
||||
|» response|object|true|none||none|
|
||||
|»» code|integer|true|none||none|
|
||||
|»» message|string|true|none||none|
|
||||
|»» data|object|true|none||none|
|
||||
|» isAbort|boolean|true|none||none|
|
||||
|» originalError|object|true|none||none|
|
||||
|»» url|string|true|none||none|
|
||||
|»» status|integer|true|none||none|
|
||||
|»» data|object|true|none||none|
|
||||
|»»» code|integer|true|none||none|
|
||||
|»»» message|string|true|none||none|
|
||||
|»»» data|object|true|none||none|
|
||||
|» name|string|true|none||none|
|
||||
|
||||
## POST update
|
||||
|
||||
POST /list-share/{id}
|
||||
|
||||
> Body Parameters
|
||||
|
||||
```json
|
||||
{
|
||||
"list": "string",
|
||||
"user": "string",
|
||||
"permission": "view"
|
||||
}
|
||||
```
|
||||
|
||||
### Params
|
||||
|
||||
|Name|Location|Type|Required|Description|
|
||||
|---|---|---|---|---|
|
||||
|id|path|string| yes |none|
|
||||
|Content-Type|header|string| yes |none|
|
||||
|body|body|object| no |none|
|
||||
|» list|body|string| yes |none|
|
||||
|» user|body|string| yes |none|
|
||||
|» permission|body|string| yes |none|
|
||||
|
||||
#### Enum
|
||||
|
||||
|Name|Value|
|
||||
|---|---|
|
||||
|» permission|view|
|
||||
|» permission|edit|
|
||||
|
||||
> Response Examples
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"collectionId": "string",
|
||||
"collectionName": "string",
|
||||
"created": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"permission": "view",
|
||||
"list": "string",
|
||||
"updated": "2019-08-24T14:15:22Z",
|
||||
"user": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
|HTTP Status Code |Meaning|Description|Data schema|
|
||||
|---|---|---|---|
|
||||
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
|
||||
|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|none|Inline|
|
||||
|
||||
### Responses Data Schema
|
||||
|
||||
HTTP Status Code **200**
|
||||
|
||||
|Name|Type|Required|Restrictions|Title|description|
|
||||
|---|---|---|---|---|---|
|
||||
|» collectionId|string|true|none||none|
|
||||
|» collectionName|string|true|none||none|
|
||||
|» created|string(date-time)|true|none||none|
|
||||
|» id|string|true|none||none|
|
||||
|» permission|string|true|none||none|
|
||||
|» list|string|true|none||none|
|
||||
|» updated|string(date-time)|true|none||none|
|
||||
|» user|string|true|none||none|
|
||||
|
||||
#### Enum
|
||||
|
||||
|Name|Value|
|
||||
|---|---|
|
||||
|permission|view|
|
||||
|permission|edit|
|
||||
|
||||
HTTP Status Code **404**
|
||||
|
||||
|Name|Type|Required|Restrictions|Title|description|
|
||||
|---|---|---|---|---|---|
|
||||
|» url|string|true|none||none|
|
||||
|» status|integer|true|none||none|
|
||||
|» response|object|true|none||none|
|
||||
|»» code|integer|true|none||none|
|
||||
|»» message|string|true|none||none|
|
||||
|»» data|object|true|none||none|
|
||||
|» isAbort|boolean|true|none||none|
|
||||
|» originalError|object|true|none||none|
|
||||
|»» url|string|true|none||none|
|
||||
|»» status|integer|true|none||none|
|
||||
|»» data|object|true|none||none|
|
||||
|»»» code|integer|true|none||none|
|
||||
|»»» message|string|true|none||none|
|
||||
|»»» data|object|true|none||none|
|
||||
|» name|string|true|none||none|
|
||||
|
||||
## GET list
|
||||
|
||||
GET /list-share
|
||||
|
||||
> Response Examples
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"collectionId": "string",
|
||||
"collectionName": "string",
|
||||
"created": "string",
|
||||
"id": "string",
|
||||
"list": "string",
|
||||
"permission": "string",
|
||||
"updated": "string",
|
||||
"user": "string",
|
||||
"expand": {
|
||||
"user": {
|
||||
"avatar": "string",
|
||||
"collectionId": "string",
|
||||
"collectionName": "string",
|
||||
"id": "string",
|
||||
"username": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
|HTTP Status Code |Meaning|Description|Data schema|
|
||||
|---|---|---|---|
|
||||
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
|
||||
|
||||
### Responses Data Schema
|
||||
|
||||
HTTP Status Code **200**
|
||||
|
||||
|Name|Type|Required|Restrictions|Title|description|
|
||||
|---|---|---|---|---|---|
|
||||
|» collectionId|string|false|none||none|
|
||||
|» collectionName|string|false|none||none|
|
||||
|» created|string|false|none||none|
|
||||
|» id|string|false|none||none|
|
||||
|» list|string|false|none||none|
|
||||
|» permission|string|false|none||none|
|
||||
|» updated|string|false|none||none|
|
||||
|» user|string|false|none||none|
|
||||
|» expand|object|false|none||none|
|
||||
|»» user|object|true|none||none|
|
||||
|»»» avatar|string|true|none||none|
|
||||
|»»» collectionId|string|true|none||none|
|
||||
|»»» collectionName|string|true|none||none|
|
||||
|»»» id|string|true|none||none|
|
||||
|»»» username|string|true|none||none|
|
||||
|
||||
## DELETE delete
|
||||
|
||||
DELETE /list-share/{id}
|
||||
|
||||
### Params
|
||||
|
||||
|Name|Location|Type|Required|Description|
|
||||
|---|---|---|---|---|
|
||||
|id|path|string| yes |none|
|
||||
|
||||
> Response Examples
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"acknowledged": true
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
|HTTP Status Code |Meaning|Description|Data schema|
|
||||
|---|---|---|---|
|
||||
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
|
||||
|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|none|Inline|
|
||||
|
||||
### Responses Data Schema
|
||||
|
||||
HTTP Status Code **200**
|
||||
|
||||
|Name|Type|Required|Restrictions|Title|description|
|
||||
|---|---|---|---|---|---|
|
||||
|» acknowledged|boolean|true|none||none|
|
||||
|
||||
HTTP Status Code **404**
|
||||
|
||||
|Name|Type|Required|Restrictions|Title|description|
|
||||
|---|---|---|---|---|---|
|
||||
|» url|string|true|none||none|
|
||||
|» status|integer|true|none||none|
|
||||
|» response|object|true|none||none|
|
||||
|»» code|integer|true|none||none|
|
||||
|»» message|string|true|none||none|
|
||||
|»» data|object|true|none||none|
|
||||
|» isAbort|boolean|true|none||none|
|
||||
|» originalError|object|true|none||none|
|
||||
|»» url|string|true|none||none|
|
||||
|»» status|integer|true|none||none|
|
||||
|»» data|object|true|none||none|
|
||||
|»»» code|integer|true|none||none|
|
||||
|»»» message|string|true|none||none|
|
||||
|»»» data|object|true|none||none|
|
||||
|» name|string|true|none||none|
|
||||
|
||||
## PUT create
|
||||
|
||||
PUT /list-share
|
||||
|
||||
> Body Parameters
|
||||
|
||||
```json
|
||||
{
|
||||
"list": "string",
|
||||
"user": "string",
|
||||
"permission": "view"
|
||||
}
|
||||
```
|
||||
|
||||
### Params
|
||||
|
||||
|Name|Location|Type|Required|Description|
|
||||
|---|---|---|---|---|
|
||||
|body|body|object| no |none|
|
||||
|» list|body|string| yes |none|
|
||||
|» user|body|string| yes |none|
|
||||
|» permission|body|string| yes |none|
|
||||
|
||||
#### Enum
|
||||
|
||||
|Name|Value|
|
||||
|---|---|
|
||||
|» permission|view|
|
||||
|» permission|edit|
|
||||
|
||||
> Response Examples
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"collectionId": "string",
|
||||
"collectionName": "string",
|
||||
"created": "2019-08-24T14:15:22Z",
|
||||
"id": "string",
|
||||
"permission": "view",
|
||||
"list": "string",
|
||||
"updated": "2019-08-24T14:15:22Z",
|
||||
"user": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
|HTTP Status Code |Meaning|Description|Data schema|
|
||||
|---|---|---|---|
|
||||
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
|
||||
|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|none|Inline|
|
||||
|
||||
### Responses Data Schema
|
||||
|
||||
HTTP Status Code **200**
|
||||
|
||||
|Name|Type|Required|Restrictions|Title|description|
|
||||
|---|---|---|---|---|---|
|
||||
|» collectionId|string|true|none||none|
|
||||
|» collectionName|string|true|none||none|
|
||||
|» created|string(date-time)|true|none||none|
|
||||
|» id|string|true|none||none|
|
||||
|» permission|string|true|none||none|
|
||||
|» list|string|true|none||none|
|
||||
|» updated|string(date-time)|true|none||none|
|
||||
|» user|string|true|none||none|
|
||||
|
||||
#### Enum
|
||||
|
||||
|Name|Value|
|
||||
|---|---|
|
||||
|permission|view|
|
||||
|permission|edit|
|
||||
|
||||
HTTP Status Code **400**
|
||||
|
||||
|Name|Type|Required|Restrictions|Title|description|
|
||||
|---|---|---|---|---|---|
|
||||
|» url|string|true|none||none|
|
||||
|» status|integer|true|none||none|
|
||||
|» response|object|true|none||none|
|
||||
|»» code|integer|true|none||none|
|
||||
|»» message|string|true|none||none|
|
||||
|»» data|object|true|none||none|
|
||||
|» isAbort|boolean|true|none||none|
|
||||
|» originalError|object|true|none||none|
|
||||
|»» url|string|true|none||none|
|
||||
|»» status|integer|true|none||none|
|
||||
|»» data|object|true|none||none|
|
||||
|»»» code|integer|true|none||none|
|
||||
|»»» message|string|true|none||none|
|
||||
|»»» data|object|true|none||none|
|
||||
|» name|string|true|none||none|
|
||||
|
||||
# Data Schema
|
||||
|
||||
@@ -3,18 +3,27 @@ title: Lists
|
||||
description: What are lists and how can we use them?
|
||||
---
|
||||
|
||||
Lists allow you to further organise your trails by bundling them in one place. Over at `/lists` you can create, update, and delete your custom lists. You can view all trails in a list either on a map or in a simple list.
|
||||
Lists allow you to further organise your trails by bundling them in one place. Over at `/lists` you can create, update, share and delete your custom lists.
|
||||
|
||||

|
||||
|
||||
## Create a list
|
||||
|
||||
Simply click the `+ Create new list` button and give your list a name. Optionally, you can also add an avatar and a short description.
|
||||
Simply click the <button class="h-10 text-white rounded-lg px-4 py-2 mx-2 bg-primary font-semibold transition-all hover:bg-primary-hover focus:ring-4 ring-zinc-400 leading-none">+ New List</button> button. You will be redirected to the editor where you can give your list a name and add one or more trails to it. Optionally, you can also add an avatar and a short description.
|
||||
|
||||
## Add a trail
|
||||
To add a trail to the list simply search for it and click on the search suggestion. After adding a trail you can change its position in the list using the up- and down arrows.
|
||||
|
||||
To add a trail to a list head over to `/trails` and click on the trail you want to add. From the ⋮ menu select "Add to list" and select the list you want to add the trail to. To remove the trail simply repeat this process.
|
||||
Once you are done creating your list simply click the <button class="h-10 text-white rounded-lg px-4 py-2 mx-2 bg-primary font-semibold transition-all hover:bg-primary-hover focus:ring-4 ring-zinc-400 leading-none">Save List</button> button at the bottom.
|
||||
|
||||

|
||||
|
||||
## Share a list
|
||||
You can also share a list to allow other users access it. To do so select the list in the overview and click "Share" in the ⋮ menu. Everything else works analogous to <a href="/guides/share-trails#share-a-trail">sharing a trail</a>. Depending on the permission you set for the shared list a user can simply view the list or also modify its contents.
|
||||
|
||||
:::caution
|
||||
Sharing a list with another user will also automatically share all trails contained in that list with the user. Unsharing a list will _not_ automatically unshare all trails contained in the list.
|
||||
:::
|
||||
|
||||
## Edit & delete a list
|
||||
|
||||
To edit or delete a list simply select the respective entry from the ⋮ menu.
|
||||
To edit or delete a list first select it from the overview. Then you can select the respective entry from the ⋮ menu.
|
||||
@@ -6,7 +6,7 @@
|
||||
export let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
|
||||
export let title: string = "Confirm Deletion";
|
||||
export let title: string = $_('confirm-deletion');
|
||||
export let text: string;
|
||||
export let action: string = "delete";
|
||||
|
||||
|
||||
@@ -6,43 +6,28 @@
|
||||
formatElevation,
|
||||
formatTimeHHMM,
|
||||
} from "$lib/util/format_util";
|
||||
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import ShareInfo from "../share_info.svelte";
|
||||
import { currentUser } from "$lib/stores/user_store";
|
||||
export let list: List;
|
||||
export let active: boolean = false;
|
||||
|
||||
$: cumulativeDistance = list.expand?.trails.reduce(
|
||||
$: cumulativeDistance = list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.distance!,
|
||||
0,
|
||||
);
|
||||
|
||||
$: cumulativeElevationGain = list.expand?.trails.reduce(
|
||||
$: cumulativeElevationGain = list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.elevation_gain!,
|
||||
0,
|
||||
);
|
||||
|
||||
$: cumulativeDuration = list.expand?.trails.reduce(
|
||||
$: cumulativeDuration = list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.duration!,
|
||||
0,
|
||||
);
|
||||
|
||||
$: listIsShared = (list.expand?.list_share_via_list?.length ?? 0) > 0;
|
||||
|
||||
$: allowEdit =
|
||||
list.author == $currentUser?.id ||
|
||||
list.expand?.list_share_via_list?.some((s) => s.permission == "edit");
|
||||
|
||||
$: dropdownItems = [
|
||||
...(list.author == $currentUser?.id
|
||||
? [{ text: $_("share"), value: "share" }]
|
||||
: []),
|
||||
...(allowEdit ? [{ text: $_("edit"), value: "edit" }] : []),
|
||||
...(list.author == $currentUser?.id
|
||||
? [{ text: $_("delete"), value: "delete" }]
|
||||
: []),
|
||||
];
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -72,9 +57,6 @@
|
||||
{#if listIsShared}
|
||||
<ShareInfo type="list" subject={list}></ShareInfo>
|
||||
{/if}
|
||||
{#if dropdownItems.length}
|
||||
<Dropdown items={dropdownItems} on:change></Dropdown>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex mt-1 gap-x-4 text-sm text-gray-500 whitespace-nowrap flex-wrap">
|
||||
<span
|
||||
@@ -94,9 +76,9 @@
|
||||
>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mb-2">
|
||||
{list.expand?.trails.length ?? 0}
|
||||
{list.expand?.trails?.length ?? 0}
|
||||
{$_("trail", {
|
||||
values: { n: list.expand?.trails.length ?? 0 },
|
||||
values: { n: list.expand?.trails?.length ?? 0 },
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { browser } from "$app/environment";
|
||||
import { listSchema, type List } from "$lib/models/list";
|
||||
import { list } from "$lib/stores/list_store";
|
||||
import { createForm } from "$lib/vendor/svelte-form-lib/index";
|
||||
import { util } from "$lib/vendor/svelte-form-lib/util";
|
||||
import { _ } from "svelte-i18n";
|
||||
import Modal from "../base/modal.svelte";
|
||||
import TextField from "../base/text_field.svelte";
|
||||
import Textarea from "../base/textarea.svelte";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
export let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let previewURL = "";
|
||||
|
||||
const { form, errors, handleChange, handleSubmit } = createForm<List>({
|
||||
initialValues: $list,
|
||||
validationSchema: listSchema,
|
||||
onSubmit: async (submittedList) => {
|
||||
dispatch("save", { list: submittedList, avatar: (document.getElementById("avatar") as HTMLInputElement).files![0] });
|
||||
(document.getElementById("avatar") as HTMLInputElement).value = "";
|
||||
closeModal!();
|
||||
},
|
||||
});
|
||||
|
||||
function openAvatarBrowser() {
|
||||
document.getElementById("avatar")!.click();
|
||||
}
|
||||
|
||||
function handleAvatarSelection() {
|
||||
const files = (document.getElementById("avatar") as HTMLInputElement)
|
||||
.files;
|
||||
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewURL = URL.createObjectURL(files[0]);
|
||||
}
|
||||
$: if (browser) {
|
||||
form.set(util.cloneDeep($list));
|
||||
previewURL = getFileURL($list, $list.avatar) ?? "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="list-modal"
|
||||
title={$form.id ? $_("edit-list") : $_("new-list")}
|
||||
let:openModal
|
||||
bind:openModal
|
||||
bind:closeModal
|
||||
>
|
||||
<slot {openModal} />
|
||||
<form
|
||||
id="list-form"
|
||||
slot="content"
|
||||
class="modal-content space-y-4"
|
||||
on:submit={handleSubmit}
|
||||
>
|
||||
<label for="avatar" class="text-sm font-medium block"> {$_('avatar')} </label>
|
||||
<input
|
||||
name="avatar"
|
||||
type="file"
|
||||
id="avatar"
|
||||
accept="image/*"
|
||||
style="display: none;"
|
||||
on:change={handleAvatarSelection}
|
||||
/>
|
||||
<div class="flex items-center gap-4">
|
||||
{#if previewURL.length > 0}
|
||||
<img
|
||||
class="w-32 aspect-square rounded-full object-cover"
|
||||
alt="avatar"
|
||||
src={previewURL}
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
class="btn-secondary"
|
||||
type="button"
|
||||
on:click={openAvatarBrowser}>{$_('change')}...</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
name="name"
|
||||
label={$_("name")}
|
||||
bind:value={$form.name}
|
||||
error={$errors.name}
|
||||
on:change={handleChange}
|
||||
></TextField>
|
||||
|
||||
<Textarea
|
||||
name="description"
|
||||
label={$_("description")}
|
||||
bind:value={$form.description}
|
||||
error={$errors.description}
|
||||
on:change={handleChange}
|
||||
></Textarea>
|
||||
</form>
|
||||
<div slot="footer" class="flex items-center gap-4">
|
||||
<button class="btn-secondary" on:click={closeModal}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button class="btn-primary" type="submit" form="list-form" name="save"
|
||||
>{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
</Modal>
|
||||
156
web/src/lib/components/list/list_panel.svelte
Normal file
156
web/src/lib/components/list/list_panel.svelte
Normal file
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import type { List } from "$lib/models/list";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import {
|
||||
formatDistance,
|
||||
formatElevation,
|
||||
formatTimeHHMM,
|
||||
} from "$lib/util/format_util";
|
||||
import { _ } from "svelte-i18n";
|
||||
import TrailListItem from "../trail/trail_list_item.svelte";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { currentUser } from "$lib/stores/user_store";
|
||||
import Dropdown from "../base/dropdown.svelte";
|
||||
import ShareInfo from "../share_info.svelte";
|
||||
|
||||
export let list: List;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
$: cumulativeDistance = list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.distance!,
|
||||
0,
|
||||
);
|
||||
|
||||
$: cumulativeElevationGain = list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.elevation_gain!,
|
||||
0,
|
||||
);
|
||||
|
||||
$: cumulativeDuration = list.expand?.trails?.reduce(
|
||||
(s, b) => s + b.duration!,
|
||||
0,
|
||||
);
|
||||
|
||||
$: allowEdit =
|
||||
list.author == $currentUser?.id ||
|
||||
list.expand?.list_share_via_list?.some((s) => s.permission == "edit");
|
||||
|
||||
$: dropdownItems = [
|
||||
...(list.author == $currentUser?.id
|
||||
? [{ text: $_("share"), value: "share", icon: "share" }]
|
||||
: []),
|
||||
...(allowEdit
|
||||
? [{ text: $_("edit"), value: "edit", icon: "pen" }]
|
||||
: []),
|
||||
...(list.author == $currentUser?.id
|
||||
? [{ text: $_("delete"), value: "delete", icon: "trash" }]
|
||||
: []),
|
||||
];
|
||||
|
||||
$: listIsShared = (list.expand?.list_share_via_list?.length ?? 0) > 0;
|
||||
|
||||
let fullDescription: boolean = false;
|
||||
|
||||
function handleTrailSelect(trail: Trail, index: number) {
|
||||
dispatch("click", { trail, index });
|
||||
}
|
||||
|
||||
function handleTrailMouseEnter(trail: Trail, index: number) {
|
||||
dispatch("mouseenter", { trail, index });
|
||||
}
|
||||
|
||||
function handleTrailMouseLeave(trail: Trail, index: number) {
|
||||
dispatch("mouseleave", { trail, index });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
{#if listIsShared}
|
||||
<div class="absolute top-8 right-8 bg-white rounded-full w-8 py-1 text-center">
|
||||
<ShareInfo type="list" subject={list}></ShareInfo>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if dropdownItems.length}
|
||||
<div class="absolute bottom-8 right-8">
|
||||
<Dropdown
|
||||
items={dropdownItems}
|
||||
on:change
|
||||
let:toggleMenu={openDropdown}
|
||||
><button
|
||||
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
|
||||
on:click={openDropdown}
|
||||
>
|
||||
<i class="fa fa-ellipsis-vertical"></i>
|
||||
</button></Dropdown
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if list.avatar}
|
||||
<img
|
||||
class="w-full object-cover"
|
||||
src={getFileURL(list, list.avatar)}
|
||||
alt="avatar"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex w-full shrink-0 items-center justify-center min-h-72">
|
||||
<i class="fa fa-table-list text-5xl"></i>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="p-4 md:p-6">
|
||||
<h4 class="text-2xl font-semibold mb-4">{list.name}</h4>
|
||||
|
||||
<hr />
|
||||
<div class="flex my-4 gap-x-4 font-semibold whitespace-nowrap flex-wrap justify-around">
|
||||
<span
|
||||
><i class="fa fa-left-right mr-2"></i>{formatDistance(
|
||||
cumulativeDistance,
|
||||
)}</span
|
||||
>
|
||||
<span
|
||||
><i class="fa fa-up-down mr-2"></i>{formatElevation(
|
||||
cumulativeElevationGain,
|
||||
)}</span
|
||||
>
|
||||
<span
|
||||
><i class="fa fa-clock mr-2"></i>{formatTimeHHMM(
|
||||
cumulativeDuration,
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
<hr class="mb-4" />
|
||||
<p
|
||||
class="text-gray-500 whitespace-pre-wrap {fullDescription
|
||||
? ''
|
||||
: 'max-h-24 overflow-hidden text-ellipsis'}"
|
||||
>
|
||||
{!fullDescription
|
||||
? list.description?.substring(0, 100)
|
||||
: list.description}
|
||||
{#if (list.description?.length ?? 0) > 100 && !fullDescription}
|
||||
<button on:click={() => (fullDescription = true)}>
|
||||
... <span class="underline">{$_("read-more")}</span></button
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
<h5 class="text-xl font-semibold my-4">
|
||||
{list.trails?.length ?? 0}
|
||||
{$_("trail", { values: { n: list.trails?.length ?? 0 } })}
|
||||
</h5>
|
||||
<div class="space-y-2">
|
||||
{#each list.expand?.trails ?? [] as trail, i}
|
||||
<div
|
||||
role="presentation"
|
||||
on:click={() => handleTrailSelect(trail, i)}
|
||||
on:mouseenter={() => handleTrailMouseEnter(trail, i)}
|
||||
on:mouseleave={() => handleTrailMouseLeave(trail, i)}
|
||||
>
|
||||
<TrailListItem {trail} showDescription={false}></TrailListItem>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,6 +20,7 @@
|
||||
export let map: any | null = null;
|
||||
export let options: any = {};
|
||||
export let activeTrailIndex: number | null = 0;
|
||||
export let markers: any[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -28,7 +29,9 @@
|
||||
|
||||
let selectedMetric: "altitude" | "slope" | "speed" | false = "altitude";
|
||||
|
||||
$: gpxData = trails.map((t) => t.expand.gpx_data);
|
||||
$: gpxData = trails.map((t) => {
|
||||
return { id: t.id, gpx: t.expand.gpx_data };
|
||||
});
|
||||
$: if (
|
||||
gpxData &&
|
||||
gpxGroup &&
|
||||
@@ -216,15 +219,20 @@
|
||||
|
||||
gpxGroup.on("selection_changed", ({ polyline }: { polyline: any }) => {
|
||||
markerLayerGroup.clearLayers();
|
||||
|
||||
activeTrailIndex = null;
|
||||
markers = [];
|
||||
|
||||
if (polyline._selected) {
|
||||
activeTrailIndex = polyline.options.index;
|
||||
activeTrailIndex = trails.findIndex(
|
||||
(t) => t.id ==
|
||||
polyline.options.id,
|
||||
);
|
||||
|
||||
for (const waypoint of trails.at(activeTrailIndex!)?.expand
|
||||
.waypoints ?? []) {
|
||||
const marker = createMarkerFromWaypoint(L, waypoint);
|
||||
marker.addTo(markerLayerGroup!);
|
||||
markers.push(marker);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -237,8 +245,8 @@
|
||||
gpxGroup.on("route_loaded", ({ route }: { route: any }) => {
|
||||
const trail = trails.find(
|
||||
(t) =>
|
||||
gpxGroup?._hashCode(t.expand.gpx_data) ==
|
||||
route.options.hash,
|
||||
t.id ==
|
||||
route.options.id,
|
||||
);
|
||||
|
||||
if (!trail) {
|
||||
@@ -275,16 +283,28 @@
|
||||
gpxGroup.addTo(map);
|
||||
});
|
||||
|
||||
export function selectTrail(index: number) {
|
||||
gpxGroup.select(index);
|
||||
export function highlightTrail(id: string) {
|
||||
gpxGroup?.highlightTrack(id);
|
||||
}
|
||||
|
||||
export function openPopup(index: number) {
|
||||
gpxGroup.openPopup(index);
|
||||
export function unHighlightTrail(id: string) {
|
||||
gpxGroup?.unHighlightTrack(id);
|
||||
}
|
||||
|
||||
export function closePopup(index: number) {
|
||||
gpxGroup.closePopup(index);
|
||||
export function selectTrail(id: string) {
|
||||
gpxGroup?.select(id);
|
||||
}
|
||||
|
||||
export function resetSelection() {
|
||||
gpxGroup?.resetSelection();
|
||||
}
|
||||
|
||||
export function openPopup(id: string) {
|
||||
gpxGroup?.openPopup(id);
|
||||
}
|
||||
|
||||
export function closePopup(id: string) {
|
||||
gpxGroup?.closePopup(id);
|
||||
}
|
||||
|
||||
function switchHotline(metric: "altitude" | "slope" | "speed" | false) {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
import { pb } from "$lib/pocketbase";
|
||||
|
||||
export let trail: Trail;
|
||||
export let mode: "overview" | "map";
|
||||
export let mode: "overview" | "map" | "list";
|
||||
|
||||
let openConfirmModal: () => void;
|
||||
let openListSelectModal: () => void;
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
formatElevation,
|
||||
formatTimeHHMM,
|
||||
} from "$lib/util/format_util";
|
||||
import { createMarkerFromWaypoint, endIcon, startIcon } from "$lib/util/leaflet_util";
|
||||
import {
|
||||
createMarkerFromWaypoint,
|
||||
endIcon,
|
||||
startIcon,
|
||||
} from "$lib/util/leaflet_util";
|
||||
import "$lib/vendor/leaflet-elevation/src/index.css";
|
||||
import type { Icon, Map, Marker } from "leaflet";
|
||||
import "leaflet.awesome-markers/dist/leaflet.awesome-markers.css";
|
||||
@@ -36,7 +40,7 @@
|
||||
import ShareInfo from "../share_info.svelte";
|
||||
|
||||
export let trail: Trail;
|
||||
export let mode: "overview" | "map" = "map";
|
||||
export let mode: "overview" | "map" | "list" = "map";
|
||||
export let markers: Marker[] = [];
|
||||
|
||||
const tabs = [
|
||||
@@ -116,6 +120,10 @@
|
||||
markers[i].openPopup();
|
||||
}
|
||||
|
||||
function closeMarkerPopup(i: number) {
|
||||
markers[i].closePopup();
|
||||
}
|
||||
|
||||
async function toggleMapFullScreen() {
|
||||
goto(`/map/trail/${trail.id!}`);
|
||||
}
|
||||
@@ -160,12 +168,19 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="trail-info-panel mx-auto border border-input-border rounded-3xl h-full"
|
||||
class="trail-info-panel mx-auto {mode == 'list'
|
||||
? ''
|
||||
: 'border border-input-border rounded-3xl'} h-full"
|
||||
style="max-width: min(100%, 64rem);"
|
||||
>
|
||||
<div class="trail-info-panel-header">
|
||||
<section class="relative h-80">
|
||||
<img class="w-full h-80 rounded-t-3xl" src={thumbnail} alt="" />
|
||||
<img
|
||||
class="w-full h-80 "
|
||||
class:rounded-t-3xl={mode !== "list"}
|
||||
src={thumbnail}
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
class="absolute bottom-0 w-full h-1/2 bg-gradient-to-b from-transparent to-black opacity-50"
|
||||
></div>
|
||||
@@ -186,7 +201,8 @@
|
||||
</span>
|
||||
{/if}
|
||||
{#if trailIsShared}
|
||||
<ShareInfo type="trail" subject={trail} large={true}></ShareInfo>
|
||||
<ShareInfo type="trail" subject={trail} large={true}
|
||||
></ShareInfo>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -286,7 +302,7 @@
|
||||
{#if activeTab == 1}
|
||||
<ul>
|
||||
{#each trail.expand.waypoints ?? [] as waypoint, i}
|
||||
<li on:mouseenter={() => openMarkerPopup(i)}>
|
||||
<li on:mouseenter={() => openMarkerPopup(i)} on:mouseleave={() => closeMarkerPopup(i)}>
|
||||
<WaypointCard {waypoint}></WaypointCard>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
export let trail: Trail;
|
||||
|
||||
export let showDescription: boolean = true;
|
||||
|
||||
$: thumbnail = trail.photos.length
|
||||
? getFileURL(trail, trail.photos[trail.thumbnail])
|
||||
: "/imgs/default_thumbnail.webp";
|
||||
@@ -51,9 +53,7 @@
|
||||
<h5><i class="fa fa-location-dot mr-3"></i>{trail.location}</h5>
|
||||
{/if}
|
||||
<h5>
|
||||
<i class="fa fa-gauge mr-3"></i>{$_(
|
||||
trail.difficulty ?? "?",
|
||||
)}
|
||||
<i class="fa fa-gauge mr-3"></i>{$_(trail.difficulty ?? "?")}
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
@@ -74,10 +74,12 @@
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
class="mt-3 text-sm whitespace-nowrap min-w-0 max-w-full overflow-hidden text-ellipsis"
|
||||
>
|
||||
{trail.description}
|
||||
</p>
|
||||
{#if showDescription}
|
||||
<p
|
||||
class="mt-3 text-sm whitespace-nowrap min-w-0 max-w-full overflow-hidden text-ellipsis"
|
||||
>
|
||||
{trail.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"completed": "Abgeschlossen",
|
||||
"completion-status": "Abschlussstatus",
|
||||
"confirm": "",
|
||||
"confirm-deletion": "",
|
||||
"confirm-share": "",
|
||||
"contribute": "Mitwirken",
|
||||
"copy-link": "Link kopieren",
|
||||
@@ -155,6 +156,7 @@
|
||||
"profile": "Profil",
|
||||
"public": "Öffentlich",
|
||||
"radius": "Radius",
|
||||
"read-more": "",
|
||||
"register": "Registrieren",
|
||||
"removed-trail-from": "Route entfernt aus",
|
||||
"required": "Pflichtfeld",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"completed": "Completed",
|
||||
"completion-status": "Completion Status",
|
||||
"confirm": "Confirm",
|
||||
"confirm-deletion": "Confirm Deletion",
|
||||
"confirm-share": "Confirm share",
|
||||
"contribute": "Contribute",
|
||||
"copy-link": "Copy Link",
|
||||
@@ -155,6 +156,7 @@
|
||||
"profile": "Profile",
|
||||
"public": "Public",
|
||||
"radius": "Radius",
|
||||
"read-more": "Read more",
|
||||
"register": "Register",
|
||||
"removed-trail-from": "Removed trail from",
|
||||
"required": "Required",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"completed": "Compléter",
|
||||
"completion-status": "L'état d'achèvement",
|
||||
"confirm": "",
|
||||
"confirm-deletion": "",
|
||||
"confirm-share": "",
|
||||
"contribute": "Contribuer",
|
||||
"copy-link": "",
|
||||
@@ -155,6 +156,7 @@
|
||||
"profile": "Profile",
|
||||
"public": "Publique",
|
||||
"radius": "Rayon",
|
||||
"read-more": "",
|
||||
"register": "S'enregistrer",
|
||||
"removed-trail-from": "Enlever l'itinéraire de",
|
||||
"required": "Requis",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"completed": "Teljesítve",
|
||||
"completion-status": "Befejezés állapota",
|
||||
"confirm": "",
|
||||
"confirm-deletion": "",
|
||||
"confirm-share": "",
|
||||
"contribute": "Hozzájárulás",
|
||||
"copy-link": "",
|
||||
@@ -155,6 +156,7 @@
|
||||
"profile": "Profil",
|
||||
"public": "Publikus",
|
||||
"radius": "Átmérő",
|
||||
"read-more": "",
|
||||
"register": "Regisztráció",
|
||||
"removed-trail-from": "Eltávolított nyomvonal a",
|
||||
"required": "Kötelező",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"completed": "Completato",
|
||||
"completion-status": "Stato di completamento",
|
||||
"confirm": "",
|
||||
"confirm-deletion": "",
|
||||
"confirm-share": "",
|
||||
"contribute": "Contribuisci",
|
||||
"copy-link": "Copia link",
|
||||
@@ -155,6 +156,7 @@
|
||||
"profile": "Profilo",
|
||||
"public": "Pubblico",
|
||||
"radius": "Raggio",
|
||||
"read-more": "",
|
||||
"register": "Registrati",
|
||||
"removed-trail-from": "Percorso rimosso da",
|
||||
"required": "Obbligatorio",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"completed": "Voltooid",
|
||||
"completion-status": "Voltooiingsstatus",
|
||||
"confirm": "",
|
||||
"confirm-deletion": "",
|
||||
"confirm-share": "",
|
||||
"contribute": "Bijdragen",
|
||||
"copy-link": "",
|
||||
@@ -155,6 +156,7 @@
|
||||
"profile": "Profiel",
|
||||
"public": "Openbaar",
|
||||
"radius": "Straal",
|
||||
"read-more": "",
|
||||
"register": "Registreren",
|
||||
"removed-trail-from": "De wandelroute is verwijderd van",
|
||||
"required": "Verplicht",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"completed": "Zakończono",
|
||||
"completion-status": "Stan ukończenia",
|
||||
"confirm": "",
|
||||
"confirm-deletion": "",
|
||||
"confirm-share": "",
|
||||
"contribute": "Kontrybuuj",
|
||||
"copy-link": "",
|
||||
@@ -155,6 +156,7 @@
|
||||
"profile": "Profil",
|
||||
"public": "Publiczny",
|
||||
"radius": "Promień",
|
||||
"read-more": "",
|
||||
"register": "Zarejestruj",
|
||||
"removed-trail-from": "Usunięto ścieżkę z",
|
||||
"required": "Wymagane",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"completed": "Completada",
|
||||
"completion-status": "Status de conclusão",
|
||||
"confirm": "",
|
||||
"confirm-deletion": "",
|
||||
"confirm-share": "",
|
||||
"contribute": "Contribuir",
|
||||
"copy-link": "",
|
||||
@@ -155,6 +156,7 @@
|
||||
"profile": "Perfil",
|
||||
"public": "Público",
|
||||
"radius": "Raio",
|
||||
"read-more": "",
|
||||
"register": "Registo",
|
||||
"removed-trail-from": "Trilha removida de",
|
||||
"required": "Obrigatório",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"completed": "已完成",
|
||||
"completion-status": "完成状态",
|
||||
"confirm": "",
|
||||
"confirm-deletion": "",
|
||||
"confirm-share": "",
|
||||
"contribute": "贡献",
|
||||
"copy-link": "",
|
||||
@@ -155,6 +156,7 @@
|
||||
"profile": "个人资料",
|
||||
"public": "公开",
|
||||
"radius": "半径",
|
||||
"read-more": "",
|
||||
"register": "注册",
|
||||
"removed-trail-from": "路线已删除自",
|
||||
"required": "必填",
|
||||
|
||||
@@ -9,7 +9,7 @@ export class List {
|
||||
avatar?: string;
|
||||
trails?: string[];
|
||||
expand?: {
|
||||
trails: Trail[]
|
||||
trails?: Trail[]
|
||||
list_share_via_list?: ListShare[]
|
||||
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { writable, type Writable } from "svelte/store";
|
||||
import { fetchGPX } from "./trail_store";
|
||||
|
||||
export const lists: Writable<List[]> = writable([])
|
||||
export const list: Writable<List> = writable(new List("", []))
|
||||
|
||||
export const list: Writable<List | null> = writable(null)
|
||||
export const listTrail: Writable<Trail | null> = writable(null);
|
||||
|
||||
export async function lists_index(filter?: ListFilter, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
const r = await f('/api/v1/list?' + new URLSearchParams({
|
||||
@@ -26,7 +26,7 @@ export async function lists_index(filter?: ListFilter, f: (url: RequestInfo | UR
|
||||
lists.set(fetchedLists);
|
||||
|
||||
if (fetchedLists.length > 0) {
|
||||
list.set(fetchedLists[0])
|
||||
// list.set(fetchedLists[0])
|
||||
}
|
||||
|
||||
return fetchedLists;
|
||||
@@ -39,7 +39,7 @@ export async function lists_show(id: string, f: (url: RequestInfo | URL, config?
|
||||
})
|
||||
const response = await r.json()
|
||||
|
||||
for (const trail of response.expand.trails) {
|
||||
for (const trail of response.expand?.trails ?? []) {
|
||||
const gpxData: string = await fetchGPX(trail);
|
||||
trail.expand.gpx_data = gpxData;
|
||||
}
|
||||
|
||||
@@ -140,40 +140,62 @@ export const GpxGroup = L.GpxGroup = L.Class.extend({
|
||||
|
||||
},
|
||||
|
||||
select(index) {
|
||||
if (index > this._routes.length - 1) {
|
||||
return
|
||||
highlightTrack(id) {
|
||||
const route = this._routes.find(r => r.options.id == id)
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setSelection(this._routes[index])
|
||||
route.eachLayer((l) => {
|
||||
this.highlight(route, l)
|
||||
})
|
||||
},
|
||||
|
||||
openPopup(index) {
|
||||
if (index > this._routes.length - 1) {
|
||||
return
|
||||
unHighlightTrack(id) {
|
||||
const route = this._routes.find(r => r.options.id == id)
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._routes[index].openPopup();
|
||||
route.eachLayer((l) => {
|
||||
this.unhighlight(route, l)
|
||||
})
|
||||
},
|
||||
|
||||
closePopup(index) {
|
||||
if (index > this._routes.length - 1) {
|
||||
return
|
||||
select(id) {
|
||||
const route = this._routes.find(r => r.options.id == id)
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
this.setSelection(route)
|
||||
},
|
||||
|
||||
this._routes[index].closePopup();
|
||||
resetSelection() {
|
||||
this.setSelection(this._selected)
|
||||
if (this.options.flyToBounds) {
|
||||
this._map.flyToBounds(this.getBounds(), { duration: 0.25, easeLinearity: 0.25, noMoveStart: true });
|
||||
}
|
||||
},
|
||||
|
||||
openPopup(id) {
|
||||
const route = this._routes.find(r => r.options.id == id)
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
route.openPopup();
|
||||
},
|
||||
|
||||
closePopup(id) {
|
||||
const route = this._routes.find(r => r.options.id == id)
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
route.closePopup();
|
||||
},
|
||||
|
||||
_addTrack: function (track) {
|
||||
if (track instanceof Object) {
|
||||
this._loadGeoJSON(track);
|
||||
} else if (track !== undefined) {
|
||||
this._elevation._parseFromString(track)
|
||||
.then(geojson => this._loadGeoJSON(geojson, this._hashCode(track), track.split('/').pop().split('#')[0].split('?')[0]))
|
||||
}
|
||||
},
|
||||
this._elevation._parseFromString(track.gpx)
|
||||
.then(geojson => this._loadGeoJSON(geojson, track.id, track.gpx.split('/').pop().split('#')[0].split('?')[0]))
|
||||
|
||||
_hashCode: (s) => s.split('').reduce((a, b) => (((a << 5) - a) + b.charCodeAt(0)) | 0, 0),
|
||||
},
|
||||
|
||||
clear: function () {
|
||||
this._elevation.clear()
|
||||
@@ -197,9 +219,9 @@ export const GpxGroup = L.GpxGroup = L.Class.extend({
|
||||
}
|
||||
},
|
||||
|
||||
_loadGeoJSON: function (geojson, hash, fallbackName) {
|
||||
_loadGeoJSON: function (geojson, id, fallbackName) {
|
||||
if (geojson) {
|
||||
geojson.hash = hash
|
||||
geojson.id = id
|
||||
geojson.name = geojson.name || (geojson[0] && geojson[0].properties.name) || fallbackName;
|
||||
this._loadRoute(geojson);
|
||||
}
|
||||
@@ -208,7 +230,7 @@ export const GpxGroup = L.GpxGroup = L.Class.extend({
|
||||
_loadRoute: function (data) {
|
||||
if (!data) return;
|
||||
var line_style = {
|
||||
color: this._hashToColor(data.hash),
|
||||
color: this._stringToColor(data.id),
|
||||
opacity: 0.75,
|
||||
weight: 5,
|
||||
distanceMarkers: this.options.distanceMarkers_options,
|
||||
@@ -220,7 +242,7 @@ export const GpxGroup = L.GpxGroup = L.Class.extend({
|
||||
distanceMarkers: line_style.distanceMarkers,
|
||||
originalStyle: line_style,
|
||||
isGroupLayer: true,
|
||||
hash: data.hash,
|
||||
id: data.id,
|
||||
filter: feature => feature.geometry.type != "Point",
|
||||
});
|
||||
|
||||
@@ -351,23 +373,20 @@ export const GpxGroup = L.GpxGroup = L.Class.extend({
|
||||
}
|
||||
},
|
||||
|
||||
_hashToColor(hash) {
|
||||
// Ensure hash is a positive integer
|
||||
hash = Math.abs(hash);
|
||||
|
||||
_stringToColor(input) {
|
||||
// Define the maximum brightness to ensure the color is not too light
|
||||
const maxBrightness = 250;
|
||||
const maxBrightness = 200;
|
||||
|
||||
// Convert the hash to a color in a reduced RGB range to prevent light and green colors
|
||||
const r = (hash >> 16) & 0xFF; // Extract the red component
|
||||
const g = (hash >> 8) & 0xFF; // Extract the green component
|
||||
const b = hash & 0xFF; // Extract the blue component
|
||||
// Divide the string into 3 parts (5 characters each)
|
||||
const redPart = input.slice(0, 5);
|
||||
const greenPart = input.slice(5, 10);
|
||||
const bluePart = input.slice(10, 15);
|
||||
|
||||
// Adjust the green value to avoid greenish colors
|
||||
// Reducing the green component significantly to avoid strong green shades
|
||||
const green = Math.floor((g / 255) * (maxBrightness * 0.5)); // Reduce green component range
|
||||
const red = Math.floor((r / 255) * maxBrightness);
|
||||
const blue = Math.floor((b / 255) * maxBrightness);
|
||||
// Calculate the red, green, and blue values by summing the char codes of the parts
|
||||
const red = Math.floor((redPart.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 256) * (maxBrightness / 255));
|
||||
const greenRaw = greenPart.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 256;
|
||||
const green = Math.floor((greenRaw * 0.5) * (maxBrightness / 255)); // Reduce green to avoid greenish colors
|
||||
const blue = Math.floor((bluePart.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 256) * (maxBrightness / 255));
|
||||
|
||||
// Format as HEX color
|
||||
return `#${(1 << 24 | red << 16 | green << 8 | blue).toString(16).slice(1).toUpperCase()}`;
|
||||
|
||||
@@ -4,19 +4,18 @@
|
||||
import type { DropdownItem } from "$lib/components/base/dropdown.svelte";
|
||||
import ConfirmModal from "$lib/components/confirm_modal.svelte";
|
||||
import ListCard from "$lib/components/list/list_card.svelte";
|
||||
import ListModal from "$lib/components/list/list_modal.svelte";
|
||||
import ListPanel from "$lib/components/list/list_panel.svelte";
|
||||
import ListShareModal from "$lib/components/list/list_share_modal.svelte";
|
||||
import MapWithElevationMultiple from "$lib/components/trail/map_with_elevation_multiple.svelte";
|
||||
import TrailInfoPanel from "$lib/components/trail/trail_info_panel.svelte";
|
||||
import TrailList from "$lib/components/trail/trail_list.svelte";
|
||||
import { List } from "$lib/models/list";
|
||||
import type { TrailFilter } from "$lib/models/trail";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import {
|
||||
list,
|
||||
lists,
|
||||
lists_create,
|
||||
lists_delete,
|
||||
lists_index,
|
||||
lists_update,
|
||||
} from "$lib/stores/list_store";
|
||||
import { fetchGPX } from "$lib/stores/trail_store";
|
||||
import "$lib/vendor/leaflet-elevation/src/index.css";
|
||||
@@ -27,136 +26,171 @@
|
||||
import { onMount, tick } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
let openListModal: () => void;
|
||||
let openConfirmModal: () => void;
|
||||
let openShareModal: () => void;
|
||||
|
||||
let listToBeDeleted: List | null = null;
|
||||
|
||||
let map: Map;
|
||||
let mapWithElevationMultiple: MapWithElevationMultiple;
|
||||
let markers: any[];
|
||||
let showMap: boolean = true;
|
||||
|
||||
let selectedList: List | null = null;
|
||||
let selectedTrail: Trail | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if ($page.url.searchParams.get("list")) {
|
||||
const listToFocus = $lists.find((l) => l.id == $page.url.searchParams.get("list"));
|
||||
if(listToFocus) {
|
||||
const listToFocus = $lists.find(
|
||||
(l) => l.id == $page.url.searchParams.get("list"),
|
||||
);
|
||||
if (listToFocus) {
|
||||
setCurrentList(listToFocus);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function toggleMap() {
|
||||
showMap = !showMap;
|
||||
if (showMap) {
|
||||
setCurrentList($list);
|
||||
await tick();
|
||||
map.invalidateSize();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveList(e: CustomEvent<{ list: List; avatar?: File }>) {
|
||||
const result = e.detail;
|
||||
if (result.list.id) {
|
||||
await lists_update(result.list, result.avatar);
|
||||
} else {
|
||||
await lists_create(result.list, result.avatar);
|
||||
}
|
||||
await lists_index();
|
||||
}
|
||||
|
||||
async function handleDropdownClick(
|
||||
e: CustomEvent<DropdownItem>,
|
||||
currentList: List,
|
||||
) {
|
||||
async function handleDropdownClick(e: CustomEvent<DropdownItem>) {
|
||||
const item = e.detail;
|
||||
|
||||
if (!selectedList) {
|
||||
return;
|
||||
}
|
||||
if (item.value == "share") {
|
||||
list.set(currentList);
|
||||
await tick();
|
||||
openShareModal();
|
||||
} else if (item.value == "edit") {
|
||||
goto("/lists/edit/" + currentList.id);
|
||||
goto("/lists/edit/" + selectedList.id);
|
||||
} else if (item.value == "delete") {
|
||||
openConfirmModal();
|
||||
listToBeDeleted = currentList;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteList() {
|
||||
if (!listToBeDeleted) {
|
||||
if (!selectedList) {
|
||||
return;
|
||||
}
|
||||
await lists_delete(listToBeDeleted);
|
||||
await lists_delete(selectedList);
|
||||
await lists_index();
|
||||
listToBeDeleted = null;
|
||||
selectedList = null;
|
||||
}
|
||||
|
||||
async function setCurrentList(item: List) {
|
||||
if (item.expand && item.expand.trails.length > 0) {
|
||||
if (
|
||||
item.expand &&
|
||||
item.expand.trails &&
|
||||
item.expand.trails.length > 0
|
||||
) {
|
||||
for (const trail of item.expand.trails) {
|
||||
const gpxData: string = await fetchGPX(trail);
|
||||
trail.expand.gpx_data = gpxData;
|
||||
}
|
||||
}
|
||||
list.set(item);
|
||||
selectedList = item;
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (selectedTrail) {
|
||||
selectedTrail = null;
|
||||
mapWithElevationMultiple.resetSelection();
|
||||
} else if (selectedList) {
|
||||
selectedList = null;
|
||||
map.flyTo([0, 0], 4, {
|
||||
duration: 0.25,
|
||||
easeLinearity: 0.25,
|
||||
noMoveStart: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function selectTrail(trail: Trail) {
|
||||
selectedTrail = trail;
|
||||
mapWithElevationMultiple.selectTrail(trail.id!);
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
function highlightTrail(trail: Trail) {
|
||||
mapWithElevationMultiple.highlightTrail(trail.id!);
|
||||
}
|
||||
|
||||
function unHighlightTrail(trail: Trail) {
|
||||
mapWithElevationMultiple.unHighlightTrail(trail.id!);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("list", { values: { n: 2 } })} | wanderer</title>
|
||||
</svelte:head>
|
||||
<main class="grid grid-cols-1 md:grid-cols-[430px_1fr] gap-4 lg:gap-4 mx-4">
|
||||
<ul
|
||||
class="list-list md:mx-auto rounded-xl border border-input-border max-h-full"
|
||||
<main class="grid grid-cols-1 md:grid-cols-[430px_1fr] gap-4 lg:gap-4 md:mx-4">
|
||||
<div
|
||||
class="list-list md:mx-auto rounded-xl border border-input-border max-h-full w-full order-1 md:order-none"
|
||||
>
|
||||
<div
|
||||
class="flex gap-x-4 items-center justify-between p-4 bg-background z-50 rounded-xl"
|
||||
>
|
||||
<button
|
||||
class="btn-icon"
|
||||
class:btn-disabled={!selectedList}
|
||||
disabled={!selectedList}
|
||||
on:click={back}><i class="fa fa-arrow-left"></i></button
|
||||
>
|
||||
<a class="btn-primary btn-large text-center" href="/lists/edit/new"
|
||||
><i class="fa fa-plus mr-2"></i>{$_("new-list")}</a
|
||||
>
|
||||
<button type="button" class="btn-icon" on:click={toggleMap}
|
||||
<!-- <button type="button" class="btn-icon" on:click={toggleMap}
|
||||
><i class="fa-regular fa-{showMap ? 'rectangle-list' : 'map'}"
|
||||
></i></button
|
||||
>
|
||||
> -->
|
||||
</div>
|
||||
|
||||
<hr class="border-separator mb-2" />
|
||||
<hr class="border-separator" />
|
||||
|
||||
<div class="px-4">
|
||||
{#each $lists as item, i}
|
||||
<li
|
||||
class="list-list-item my-1"
|
||||
on:click={() => setCurrentList(item)}
|
||||
role="presentation"
|
||||
>
|
||||
<ListCard
|
||||
list={item}
|
||||
on:change={(e) => handleDropdownClick(e, item)}
|
||||
active={item.id === $list.id}
|
||||
></ListCard>
|
||||
</li>
|
||||
{/each}
|
||||
<div>
|
||||
{#if !selectedList}
|
||||
<div class="px-4 mt-2">
|
||||
{#each $lists as item, i}
|
||||
<div
|
||||
class="list-list-item my-1"
|
||||
on:click={() => setCurrentList(item)}
|
||||
role="presentation"
|
||||
>
|
||||
<ListCard list={item}></ListCard>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if selectedList && !selectedTrail}
|
||||
<ListPanel
|
||||
list={selectedList}
|
||||
on:mouseenter={(e) => highlightTrail(e.detail.trail)}
|
||||
on:mouseleave={(e) => unHighlightTrail(e.detail.trail)}
|
||||
on:change={(e) => handleDropdownClick(e)}
|
||||
on:click={(e) => selectTrail(e.detail.trail)}
|
||||
></ListPanel>
|
||||
{:else if selectedList && selectedTrail}
|
||||
<TrailInfoPanel trail={selectedTrail} mode="list" {markers}
|
||||
></TrailInfoPanel>
|
||||
{/if}
|
||||
</div>
|
||||
</ul>
|
||||
<div id="trail-map" class="sticky top-[62px]" class:hidden={!showMap}>
|
||||
</div>
|
||||
<div id="trail-map" class="md:sticky md:top-[62px]" class:hidden={!showMap}>
|
||||
<MapWithElevationMultiple
|
||||
trails={$list.expand?.trails ?? []}
|
||||
trails={selectedList?.expand?.trails ?? []}
|
||||
bind:map
|
||||
bind:this={mapWithElevationMultiple}
|
||||
bind:markers
|
||||
options={{ itinerary: true, flyToBounds: true }}
|
||||
></MapWithElevationMultiple>
|
||||
</div>
|
||||
<div class="min-w-0" class:hidden={showMap}>
|
||||
<TrailList trails={$list.expand?.trails ?? []}></TrailList>
|
||||
<TrailList trails={selectedList?.expand?.trails ?? []}></TrailList>
|
||||
</div>
|
||||
|
||||
<ListModal bind:openModal={openListModal} on:save={saveList}></ListModal>
|
||||
<ConfirmModal
|
||||
text={$_("delete-list-confirm")}
|
||||
bind:openModal={openConfirmModal}
|
||||
on:confirm={deleteList}
|
||||
></ConfirmModal>
|
||||
<ListShareModal list={$list} bind:openShareModal></ListShareModal>
|
||||
{#if selectedList}
|
||||
<ListShareModal list={selectedList} bind:openShareModal
|
||||
></ListShareModal>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -126,12 +126,15 @@
|
||||
async function handleSearchClick(item: SearchItem) {
|
||||
const trail = await trails_show(item.value, true);
|
||||
$form.trails?.push(trail.id!);
|
||||
$form.expand!.trails = [...$form.expand!.trails, trail];
|
||||
$form.expand = {
|
||||
trails: [...($form.expand?.trails ?? []), trail],
|
||||
list_share_via_list: $form.expand?.list_share_via_list,
|
||||
};
|
||||
}
|
||||
|
||||
function deleteTrail(trail: Trail) {
|
||||
$form.trails = $form.trails?.filter((id) => id !== trail.id);
|
||||
$form.expand!.trails = $form.expand!.trails.filter(
|
||||
$form.expand!.trails = $form.expand!.trails!.filter(
|
||||
(t) => t.id !== trail.id,
|
||||
);
|
||||
}
|
||||
@@ -174,6 +177,26 @@
|
||||
text: $_("list-saved-successfully"),
|
||||
});
|
||||
}
|
||||
|
||||
function moveTrail(trail: Trail, index: number, direction: 1 | -1) {
|
||||
(document?.activeElement as HTMLElement).blur()
|
||||
|
||||
if (!$form.trails || !$form.expand || !$form.expand.trails) {
|
||||
return;
|
||||
}
|
||||
const previousTrail = $form.expand?.trails?.at(index + direction);
|
||||
const previousTrailId = $form.trails?.at(index + direction);
|
||||
|
||||
if (!previousTrail || !previousTrailId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$form.expand!.trails![index] = previousTrail;
|
||||
$form.expand!.trails![index + direction] = trail;
|
||||
|
||||
$form.trails[index] = previousTrailId;
|
||||
$form.trails[index + direction] = trail.id!;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -181,6 +204,7 @@
|
||||
>{$form.id ? `${$form.name} | ${$_("edit")}` : $_("new-list")} | wanderer</title
|
||||
>
|
||||
</svelte:head>
|
||||
|
||||
<main class="grid grid-cols-1 md:grid-cols-[440px_1fr]">
|
||||
<form
|
||||
id="list-form"
|
||||
@@ -246,14 +270,15 @@
|
||||
placeholder="{$_('search-trails')}..."
|
||||
items={searchDropdownItems}
|
||||
></Search>
|
||||
{#if $form.expand?.trails.length}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
{#if $form.expand?.trails?.length}
|
||||
{#each $form.expand?.trails ?? [] as trail, i}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="flex gap-4 p-4 rounded-xl border border-input-border cursor-pointer hover:bg-secondary-hover transition-colors items-center"
|
||||
class:border-primary={i == activeTrailIndex}
|
||||
on:click={() => map.selectTrail(i)}
|
||||
class:bg-secondary-hover={i == activeTrailIndex}
|
||||
role="presentation"
|
||||
on:mouseenter={() => map.highlightTrail(trail.id ?? "")}
|
||||
on:mouseleave={() => map.unHighlightTrail(trail.id ?? "")}
|
||||
on:click={() => map.selectTrail(trail.id ?? "")}
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<img
|
||||
@@ -268,17 +293,14 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="basis-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-semibold text-lg">
|
||||
{trail.name}
|
||||
</h4>
|
||||
<span class="text-sm"
|
||||
><i class="fa fa-gauge mr-2"></i>{$_(
|
||||
trail.difficulty ?? "?",
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<h4 class="font-semibold text-lg">
|
||||
{trail.name}
|
||||
</h4>
|
||||
<span class="text-sm"
|
||||
><i class="fa fa-gauge mr-2"></i>{$_(
|
||||
trail.difficulty ?? "?",
|
||||
)}</span
|
||||
>
|
||||
<div class="flex mt-1 gap-4 text-sm text-gray-500">
|
||||
<span
|
||||
><i class="fa fa-left-right mr-2"
|
||||
@@ -296,12 +318,34 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon text-red-500"
|
||||
on:click={() => deleteTrail(trail)}
|
||||
><i class="fa fa-trash"></i></button
|
||||
>
|
||||
<div class="basis-0">
|
||||
{#if i > 0}
|
||||
<button
|
||||
on:click|stopPropagation={() =>
|
||||
moveTrail(trail, i, -1)}
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
>
|
||||
<i class="fa fa-chevron-up"></i>
|
||||
</button>
|
||||
{/if}
|
||||
{#if i < $form.expand?.trails?.length - 1}
|
||||
<button
|
||||
on:click|stopPropagation={() =>
|
||||
moveTrail(trail, i, 1)}
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
>
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon text-red-500"
|
||||
on:click={() => deleteTrail(trail)}
|
||||
><i class="fa fa-trash"></i></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
@@ -328,7 +372,7 @@
|
||||
</main>
|
||||
|
||||
<ConfirmModal
|
||||
text={$_('list-share-warning-update')}
|
||||
text={$_("list-share-warning-update")}
|
||||
title={$_("confirm-share")}
|
||||
action="confirm"
|
||||
bind:openModal={openConfirmModal}
|
||||
@@ -337,7 +381,8 @@
|
||||
|
||||
<style>
|
||||
@media only screen and (min-width: 768px) {
|
||||
#trail-map, #list-form {
|
||||
#trail-map,
|
||||
#list-form {
|
||||
height: calc(100vh - 124px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,12 +104,12 @@
|
||||
);
|
||||
}
|
||||
|
||||
function handleTrailCardMouseEnter(index: number) {
|
||||
mapWithElevation.openPopup(index)
|
||||
function handleTrailCardMouseEnter(trail: Trail) {
|
||||
mapWithElevation.openPopup(trail.id!)
|
||||
}
|
||||
|
||||
function handleTrailCardMouseLeave(index: number) {
|
||||
mapWithElevation.closePopup(index)
|
||||
function handleTrailCardMouseLeave(trail: Trail) {
|
||||
mapWithElevation.closePopup(trail.id!)
|
||||
}
|
||||
|
||||
async function handleFilterUpdate(filter: TrailFilter) {
|
||||
@@ -210,8 +210,8 @@
|
||||
<a href="map/trail/{trail.id}">
|
||||
<TrailCard
|
||||
{trail}
|
||||
on:mouseenter={() => handleTrailCardMouseEnter(i)}
|
||||
on:mouseleave={() => handleTrailCardMouseLeave(i)}
|
||||
on:mouseenter={() => handleTrailCardMouseEnter(trail)}
|
||||
on:mouseleave={() => handleTrailCardMouseLeave(trail)}
|
||||
></TrailCard>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user