Feature/trail table view (#138)
* add "Table" * add trails table view * style table header * add author image, adjust table layout * tooltip for author image * i18n for author tooltip * trail sorting by duration and author * remove sort by elevation loss, add sort by duration instead * improve table layout * fix meilisearch migration * revert removing elevation_loss from sortOptions * add badge for public trails * hide public badge on small screens * adds share info button to trail_table
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/dbx"
|
||||
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
client := meilisearch.New(os.Getenv("MEILI_URL"), meilisearch.WithAPIKey(os.Getenv("MEILI_MASTER_KEY")))
|
||||
|
||||
m.Register(func(db dbx.Builder) error {
|
||||
_, err := client.Index("trails").UpdateSortableAttributes(&[]string{
|
||||
"created", "date", "difficulty", "distance", "elevation_gain", "elevation_loss", "name", "duration", "author",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}, func(db dbx.Builder) error {
|
||||
_, err := client.Index("trails").UpdateSortableAttributes(&[]string{
|
||||
"created", "date", "difficulty", "distance", "elevation_gain", "elevation_loss", "name",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import EmptyStateSearch from "../empty_states/empty_state_search.svelte";
|
||||
import TrailCard from "./trail_card.svelte";
|
||||
import TrailListItem from "./trail_list_item.svelte";
|
||||
import TrailTable from "./trail_table.svelte";
|
||||
import SkeletonCard from "../base/skeleton_card.svelte";
|
||||
import SkeletonListItem from "../base/skeleton_list_item.svelte";
|
||||
|
||||
@@ -22,6 +23,7 @@
|
||||
const displayOptions: SelectItem[] = [
|
||||
{ text: $_("card", { values: { n: 2 } }), value: "cards" },
|
||||
{ text: $_("list", { values: { n: 1 } }), value: "list" },
|
||||
{ text: $_("table"), value: "table" },
|
||||
];
|
||||
|
||||
let selectedDisplayOption = displayOptions[0].value;
|
||||
@@ -29,13 +31,14 @@
|
||||
let dispatch = createEventDispatcher();
|
||||
|
||||
const sortOptions: SelectItem[] = [
|
||||
{ text: $_("alphabetical"), value: "name" },
|
||||
{ text: $_("creation-date"), value: "created" },
|
||||
{ text: $_("date"), value: "date" },
|
||||
{ text: $_("difficulty"), value: "difficulty" },
|
||||
{ text: $_("name"), value: "name" },
|
||||
{ text: $_("distance"), value: "distance" },
|
||||
{ text: $_("duration"), value: "duration" },
|
||||
{ text: $_("difficulty"), value: "difficulty" },
|
||||
{ text: $_("elevation-gain"), value: "elevation_gain" },
|
||||
{ text: $_("elevation-loss"), value: "elevation_loss" },
|
||||
{ text: $_("creation-date"), value: "created" },
|
||||
{ text: $_("date"), value: "date" },
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
@@ -81,6 +84,19 @@
|
||||
localStorage.setItem("sort_order", filter.sortOrder);
|
||||
dispatch("update", filter);
|
||||
}
|
||||
|
||||
function handleSortUpdate(sort: any) {
|
||||
if (!filter) {
|
||||
return;
|
||||
}
|
||||
if (sort.detail === filter.sort) {
|
||||
setSortOrder();
|
||||
} else {
|
||||
filter.sort = sort.detail;
|
||||
filter.sortOrder = "+";
|
||||
setSort();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-0">
|
||||
@@ -94,21 +110,23 @@
|
||||
</div>
|
||||
{#if filter}
|
||||
<div class="shrink-0">
|
||||
<p class="text-sm text-gray-500 pb-2">{$_("sort")}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
bind:value={filter.sort}
|
||||
items={sortOptions}
|
||||
on:change={setSort}
|
||||
></Select>
|
||||
<button
|
||||
id="sort-order-btn"
|
||||
class="btn-icon"
|
||||
class:rotated={filter.sortOrder == "-"}
|
||||
on:click={() => setSortOrder()}
|
||||
><i class="fa fa-arrow-up"></i></button
|
||||
>
|
||||
</div>
|
||||
{#if selectedDisplayOption !== "table"}
|
||||
<p class="text-sm text-gray-500 pb-2">{$_("sort")}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
bind:value={filter.sort}
|
||||
items={sortOptions}
|
||||
on:change={setSort}
|
||||
></Select>
|
||||
<button
|
||||
id="sort-order-btn"
|
||||
class="btn-icon"
|
||||
class:rotated={filter.sortOrder == "-"}
|
||||
on:click={() => setSortOrder()}
|
||||
><i class="fa fa-arrow-up"></i></button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="shrink-0">
|
||||
@@ -124,35 +142,51 @@
|
||||
|
||||
<div id="trails" class="flex items-start flex-wrap gap-8 py-8 max-w-full">
|
||||
{#if loading}
|
||||
{#each { length: 12 } as _, index}
|
||||
{#if selectedDisplayOption === "cards"}
|
||||
{#if selectedDisplayOption === "table"}
|
||||
<TrailTable trails={null} tableHeader={sortOptions}
|
||||
></TrailTable>
|
||||
{:else}
|
||||
{#each { length: 12 } as _, index}
|
||||
{#if selectedDisplayOption === "cards"}
|
||||
<div class="flex-1">
|
||||
<SkeletonCard></SkeletonCard>
|
||||
</div>
|
||||
{:else}
|
||||
<SkeletonListItem></SkeletonListItem>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else if selectedDisplayOption === "list"}
|
||||
<SkeletonListItem></SkeletonListItem>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
{#if trails.length == 0}
|
||||
<div class="flex flex-col basis-full items-center">
|
||||
<EmptyStateSearch width={356}></EmptyStateSearch>
|
||||
</div>
|
||||
{/if}
|
||||
{#each trails as trail}
|
||||
<a
|
||||
class="max-w-full flex-1"
|
||||
class:basis-full={selectedDisplayOption === "list"}
|
||||
href="/trail/view/{trail.id}"
|
||||
data-sveltekit-preload-data="off"
|
||||
>
|
||||
{#if selectedDisplayOption === "cards"}
|
||||
<TrailCard {trail}></TrailCard>
|
||||
{:else}
|
||||
<TrailListItem {trail}></TrailListItem>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{#if selectedDisplayOption === "table"}
|
||||
<TrailTable
|
||||
{trails}
|
||||
tableHeader={sortOptions.filter(
|
||||
(option) => option.value !== "elevation_loss",
|
||||
)}
|
||||
{filter}
|
||||
on:sort={(sort) => handleSortUpdate(sort)}
|
||||
></TrailTable>
|
||||
{:else}
|
||||
{#each trails as trail}
|
||||
<a
|
||||
class="max-w-full flex-1"
|
||||
class:basis-full={selectedDisplayOption === "list"}
|
||||
href="/trail/view/{trail.id}"
|
||||
data-sveltekit-preload-data="off"
|
||||
>
|
||||
{#if selectedDisplayOption === "cards"}
|
||||
<TrailCard {trail}></TrailCard>
|
||||
{:else}
|
||||
<TrailListItem {trail}></TrailListItem>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<Pagination
|
||||
|
||||
181
web/src/lib/components/trail/trail_table.svelte
Normal file
181
web/src/lib/components/trail/trail_table.svelte
Normal file
@@ -0,0 +1,181 @@
|
||||
<script lang="ts">
|
||||
import type { Trail, TrailFilter } from "$lib/models/trail";
|
||||
import {
|
||||
formatDistance,
|
||||
formatElevation,
|
||||
formatTimeHHMM,
|
||||
} from "$lib/util/format_util";
|
||||
import { _ } from "svelte-i18n";
|
||||
import type { SelectItem } from "../base/select.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import ShareInfo from "../share_info.svelte";
|
||||
|
||||
export let tableHeader: SelectItem[];
|
||||
export let trails: Trail[] | null = null;
|
||||
export let filter: TrailFilter | null = null;
|
||||
|
||||
let dispatch = createEventDispatcher();
|
||||
|
||||
function getColumnWidth(columnValue: string): string {
|
||||
switch (columnValue) {
|
||||
case "name":
|
||||
return "w-[25%]";
|
||||
case "distance":
|
||||
return "w-[15%]";
|
||||
case "duration":
|
||||
case "difficulty":
|
||||
return "w-[11%]";
|
||||
case "elevation_gain":
|
||||
case "created":
|
||||
case "date":
|
||||
return "w-[5%]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="table-container w-full border border-input-border rounded-xl overflow-x-scroll"
|
||||
>
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-secondary-hover">
|
||||
{#each tableHeader as column}
|
||||
<th
|
||||
class="p-4 text-left text-sm font-medium {getColumnWidth(
|
||||
column.value,
|
||||
)}"
|
||||
on:click={() => dispatch("sort", column.value)}
|
||||
>
|
||||
<div class="cursor-pointer">
|
||||
{column.text}
|
||||
{#if filter && filter.sort === column.value}
|
||||
<i
|
||||
id="sort-order-btn"
|
||||
class="fa fa-arrow-up"
|
||||
class:rotated={filter.sortOrder == "-"}
|
||||
></i>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if trails}
|
||||
{#each trails as trail}
|
||||
<tr
|
||||
class="border-t border-input-border cursor-pointer hover:bg-secondary-hover transition-colors"
|
||||
on:click={() => goto(`/trail/view/${trail.id}`)}
|
||||
>
|
||||
<td
|
||||
class="flex justify-between items-center text-sm relative"
|
||||
>
|
||||
<div class="p-4 w-[75%]" title={trail.name}>
|
||||
{trail.name}
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
{#if trail.expand && trail.expand.author}
|
||||
<div class="author-icon">
|
||||
<img
|
||||
title={`${trail.public ? $_("public") + " " : ""}${$_("by")} ${trail.expand.author.username}`}
|
||||
class="rounded-full w-5 aspect-square mx-1 inline"
|
||||
src={getFileURL(
|
||||
trail.expand.author,
|
||||
trail.expand.author.avatar,
|
||||
) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-x-1">
|
||||
{#if trail.public}
|
||||
<div
|
||||
class="public-icon tooltip"
|
||||
data-title={$_("public")}
|
||||
>
|
||||
<i class="fa fa-globe"></i>
|
||||
</div>
|
||||
{/if}
|
||||
{#if trail.expand?.trail_share_via_trail?.length}
|
||||
<ShareInfo type="trail" subject={trail}
|
||||
></ShareInfo>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4 text-sm">
|
||||
{formatDistance(trail.distance)}
|
||||
</td>
|
||||
<td class="p-4 text-sm">
|
||||
{formatTimeHHMM(trail.duration)}
|
||||
</td>
|
||||
<td class="p-4 text-sm">
|
||||
{trail.difficulty}
|
||||
</td>
|
||||
<td class="p-4 text-sm">
|
||||
{formatElevation(trail.elevation_gain)}
|
||||
</td>
|
||||
<td class="p-4 text-sm">
|
||||
{#if trail.created}
|
||||
{new Date(trail.created).toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="p-4 text-sm">
|
||||
{#if trail.date}
|
||||
{new Date(trail.date).toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
)}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each { length: 12 } as _, index}
|
||||
<tr
|
||||
class="border-t border-input-border cursor-pointer bg-secondary-hover transition-colors animate-pulse"
|
||||
>
|
||||
{#each { length: tableHeader.length } as _, index}
|
||||
<td class="p-4 text-sm"
|
||||
><div
|
||||
class="h-4 bg-menu-item-background-focus rounded w-3/4"
|
||||
></div></td
|
||||
>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.table-container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@container (max-width: 660px) {
|
||||
th:nth-last-child(-n + 2),
|
||||
td:nth-last-child(-n + 2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -213,6 +213,7 @@
|
||||
"speed": "Geschwindigkeit",
|
||||
"stop-drawing": "Zeichnen beenden",
|
||||
"summit-book": "Gipfelbuch",
|
||||
"table": "Tabelle",
|
||||
"text": "Text",
|
||||
"tilesets": "Tilesets",
|
||||
"trail": "{n, plural, =1 {Route} other {Routen}}",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"speed": "Speed",
|
||||
"stop-drawing": "Stop drawing",
|
||||
"summit-book": "Summit Book",
|
||||
"table": "Table",
|
||||
"text": "Text",
|
||||
"tilesets": "Custom tilesets",
|
||||
"trail": "{n, plural, =1 {Trail} other {Trails}}",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"speed": "",
|
||||
"stop-drawing": "",
|
||||
"summit-book": "Livre du sommet",
|
||||
"table": "Tableau",
|
||||
"text": "Texte",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Itinéraire} other {Itinéraires}}",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"speed": "",
|
||||
"stop-drawing": "",
|
||||
"summit-book": "Csúcspont könyv",
|
||||
"table": "Táblázat",
|
||||
"text": "Szöveg",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Útvonal} other {Útvonalak}}",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"speed": "Velocità",
|
||||
"stop-drawing": "Smettere di disegnare",
|
||||
"summit-book": "Libro di vetta",
|
||||
"table": "Tavolo",
|
||||
"text": "Testo",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Percorso} other {Percorsi}}",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"speed": "",
|
||||
"stop-drawing": "",
|
||||
"summit-book": "Bergtopboek",
|
||||
"table": "Tabel",
|
||||
"text": "Tekst",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Wandelroute} other {Wandelroutes}}",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"speed": "",
|
||||
"stop-drawing": "",
|
||||
"summit-book": "Logbook",
|
||||
"table": "Tabela",
|
||||
"text": "Tekst",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Szlak} other {Szlaki}}",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"speed": "",
|
||||
"stop-drawing": "",
|
||||
"summit-book": "Livro da cimeira",
|
||||
"table": "Tabela",
|
||||
"text": "Texto",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Percurso} other {Percursos}}",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"speed": "",
|
||||
"stop-drawing": "",
|
||||
"summit-book": "详细日程",
|
||||
"table": "表格",
|
||||
"text": "文本",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {路线} other {路线}}",
|
||||
|
||||
Reference in New Issue
Block a user