finishes feed

This commit is contained in:
Christian Beutel
2025-07-12 16:36:40 +02:00
parent e8c7e7721a
commit 03bf5aa5ad
33 changed files with 800 additions and 232 deletions

View File

@@ -21,16 +21,16 @@
let isOpen = $state(false);
let dropdownElement: HTMLUListElement;
let dropdownElement: HTMLUListElement | undefined = $state();
let dropdownToggleElement: HTMLDivElement;
export async function toggleMenu(e: MouseEvent) {
export async function toggleMenu(e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
isOpen = !isOpen;
if (isOpen) {
if (isOpen && dropdownElement) {
await tick();
const toggleRect = dropdownToggleElement.getBoundingClientRect();
@@ -81,7 +81,8 @@
isOpen = false;
}
function handleItemClick(e: Event, item: { text: string; value: any }) {
function handleItemClick(e: MouseEvent, item: { text: string; value: any }) {
e.preventDefault();
e.stopPropagation();
onchange?.(item);
closeMenu();
@@ -129,7 +130,7 @@
<li
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
role="presentation"
onmousedown={(e) => handleItemClick(e, item)}
onclick={(e) => handleItemClick(e, item)}
>
{#if item.icon}
<i class="fa fa-{item.icon} mr-3"></i>

View File

@@ -30,6 +30,8 @@
const dropdownItems = [
{ text: $_("profile"), value: "profile", icon: "user" },
{ text: $_("my-trails"), value: "trails", icon: "route" },
{ text: $_("settings"), value: "settings", icon: "cog" },
{ text: $_("logout"), value: "logout", icon: "right-from-bracket" },
];
@@ -97,6 +99,8 @@
function handleDropdownClick(item: { text: string; value: any }) {
if (item.value == "profile") {
goto(`/profile/@${$currentUser?.username?.toLowerCase()}`);
} else if (item.value == "trails") {
goto(`/profile/@${$currentUser?.username?.toLowerCase()}/trails`);
} else if (item.value == "logout") {
logout();
window.location.href = "/";
@@ -185,7 +189,11 @@
</div>
</Drawer>
<nav class="flex justify-between items-center p-6 {page.url.pathname === '/' ? 'sticky top-0 z-10 bg-background' : ''}">
<nav
class="flex justify-between items-center p-6 {page.url.pathname === '/'
? 'sticky top-0 z-10 bg-background'
: ''}"
>
<a href="/">
{#if $theme == "light"}
<LogoText></LogoText>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { FeedItem } from "$lib/models/feed";
import type { List } from "$lib/models/list";
import type { Trail } from "$lib/models/trail";
import { getFileURL, isVideoURL } from "$lib/util/file_util";
import {
@@ -10,128 +11,185 @@
formatTimeSince,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import TrailDropdown from "../trail/trail_dropdown.svelte";
interface Props {
feedItem: FeedItem;
}
let { feedItem }: Props = $props();
let fullDescription = $state(false)
let fullDescription = $state(false);
const timeSince = formatTimeSince(new Date(feedItem.created ?? ""));
const timeSince = $derived(formatTimeSince(new Date(feedItem.created ?? "")));
const photos = (feedItem.expand.item as Trail).photos
const photos = $derived((feedItem.expand.item as Trail).photos);
const location = $derived((feedItem.expand.item as Trail).location);
const trails = $derived((feedItem.expand.item as List).trails);
const author = $derived(feedItem.expand.item.expand?.author);
</script>
<div class="feed-card p-6 rounded-xl border border-input-border">
<div class="feed-card-header flex gap-x-4 items-start">
<img
class="rounded-full w-10 aspect-square overflow-hidden shrink-0"
src={feedItem.expand.author?.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${feedItem.expand.author?.preferred_username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<div>
<span class="font-semibold"
>{feedItem.expand.author?.preferred_username}</span
>
<p class="text-sm text-gray-500 mb-3">
{feedItem.expand.author?.preferred_username}@{feedItem.expand
.author?.domain}
</p>
</div>
<div class="basis-full"></div>
<p class="text-xs text-gray-500 shrink-0">
{$_(`n-${timeSince.unit}-ago`, {
values: { n: timeSince.value },
<div class="feed-card px-6 py-4 rounded-xl border border-input-border">
<p class="mb-2 text-gray-500 text-sm">
{#if feedItem.type === "trail"}
<i class="fa fa-route mr-2"></i>{$_("trail", { values: { n: 1 } })}
{:else if feedItem.type === "list"}
<i class="fa fa-layer-group mr-2"></i>{$_("list", {
values: { n: 1 },
})}
</p>
</div>
<div class="feed-card-body">
<h3 class="text-2xl font-semibold !mt-2">
{feedItem.expand.item.name}
</h3>
<div class="flex flex-wrap mt-1 gap-x-4 gap-y-2 text-sm text-gray-500">
<span
><i class="fa fa-left-right mr-2"></i>{formatDistance(
feedItem.expand.item.distance,
)}</span
{/if}
</p>
<a href="/profile/{author?.preferred_username}@{author?.domain}">
<div class="feed-card-header flex gap-x-4 items-start">
<img
class="rounded-full w-10 aspect-square overflow-hidden shrink-0"
src={author?.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${author?.preferred_username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<div>
<span class="font-semibold">{author?.preferred_username}</span>
<p class="text-sm text-gray-500 mb-3">
{author?.preferred_username}@{author?.domain}
</p>
</div>
<div class="basis-full"></div>
<p class="text-xs text-gray-500 shrink-0">
{$_(`n-${timeSince.unit}-ago`, {
values: { n: timeSince.value },
})}
</p>
</div>
</a>
<a
class="block"
href={(feedItem.type === "trail" ? "/trail/view/" : "/lists/") +
`@${author?.preferred_username}@${author?.domain}/${feedItem.item}`}
>
<div class="feed-card-body">
<h3 class="text-2xl font-semibold !mt-2">
{feedItem.expand.item.name}
</h3>
{#if location}
<h5 class="my-1">
<i class="fa fa-location-dot mr-3"></i>{location}
</h5>
{/if}
<div
class="flex flex-wrap mt-1 gap-x-4 gap-y-2 text-sm text-gray-500 mb-2"
>
<span
><i class="fa fa-clock mr-2"></i>{formatTimeHHMM(
feedItem.expand.item.duration,
)}</span
<span
><i class="fa fa-left-right mr-2"></i>{formatDistance(
feedItem.expand.item.distance,
)}</span
>
<span
><i class="fa fa-clock mr-2"></i>{formatTimeHHMM(
feedItem.expand.item.duration,
)}</span
>
<span
><i class="fa fa-arrow-trend-up mr-2"></i>{formatElevation(
feedItem.expand.item.elevation_gain,
)}</span
>
<span
><i class="fa fa-arrow-trend-down mr-2"
></i>{formatElevation(
feedItem.expand.item.elevation_loss,
)}</span
>
</div>
{#if trails}
<p class="text-sm text-gray-500">
{trails.length}
{$_("trail", {
values: { n: trails.length },
})}
</p>
{/if}
{#if photos?.length}
<div
class="grid gap-[1px] {photos.length > 1
? 'grid-cols-[8fr_5fr]'
: 'grid-cols-1'} mt-4"
>
{#each photos.slice(0, 3) as photo, i}
{#if isVideoURL(photo)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="object-cover h-full max-h-80 w-full"
autoplay
loop
src={getFileURL(
{
collectionId: "trails",
id: feedItem.item,
},
photo,
)}
></video>
{:else}
<img
class="object-cover h-full max-h-80 w-full"
class:row-span-2={i == 0 && photos.length > 2}
src={getFileURL(
{
collectionId: "trails",
id: feedItem.item,
},
photo,
)}
alt=""
/>
{/if}
{/each}
</div>
{/if}
{#if feedItem.expand.item.description?.length}
<p class="text-sm whitespace-pre-wrap mt-6">
{formatHTMLAsText(
!fullDescription
? feedItem.expand.item.description?.substring(
0,
100,
)
: feedItem.expand.item.description,
)}
{#if (feedItem.expand.item.description?.length ?? 0) > 100 && !fullDescription}
<button
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
fullDescription = true;
}}
>
... <span class="underline">{$_("read-more")}</span
></button
>
{/if}
</p>
{/if}
</div>
</a>
{#if feedItem.type == "trail"}
<div class="feed-card-actions flex items-center justify-end mt-4">
<TrailDropdown
trails={new Set<Trail>([feedItem.expand.item as Trail])}
mode="overview"
>
<span
><i class="fa fa-arrow-trend-up mr-2"></i>{formatElevation(
feedItem.expand.item.elevation_gain,
)}</span
>
<span
><i class="fa fa-arrow-trend-down mr-2"></i>{formatElevation(
feedItem.expand.item.elevation_loss,
)}</span
{#snippet toggle({ toggleMenu: openDropdown })}
<button
class="btn-icon"
onclick={openDropdown}
aria-label="Trail actions"
type="button"
><i class="fa fa-ellipsis-vertical"></i></button
>
{/snippet}</TrailDropdown
>
</div>
{#if photos?.length}
<div
class="grid gap-[1px] {photos.length > 1
? 'grid-cols-[8fr_5fr]'
: 'grid-cols-1'} mt-6"
>
{#each photos.slice(0, 3) as photo, i}
{#if isVideoURL(photo)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="object-cover h-full max-h-80 w-full"
autoplay
loop
src={getFileURL(
{
collectionId: "trails",
id: feedItem.item,
},
photo,
)}
></video>
{:else}
<img
class="object-cover h-full max-h-80 w-full"
class:row-span-2={i == 0 &&
photos.length > 2}
src={getFileURL(
{
collectionId: "trails",
id: feedItem.item,
},
photo,
)}
alt=""
/>
{/if}
{/each}
</div>
{/if}
{#if feedItem.expand.item.description?.length}
<p class="text-sm whitespace-pre-wrap mt-6">
{formatHTMLAsText(
!fullDescription
? feedItem.expand.item.description?.substring(0, 100)
: feedItem.expand.item.description,
)}
{#if (feedItem.expand.item.description?.length ?? 0) > 100 && !fullDescription}
<button
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
fullDescription = true;
}}
>
... <span class="underline">{$_("read-more")}</span
></button
>
{/if}
</p>
{/if}
</div>
{/if}
</div>

View File

@@ -21,14 +21,17 @@
import TrailExportModal from "./trail_export_modal.svelte";
import TrailShareModal from "./trail_share_modal.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
import type { Snippet } from "svelte";
interface Props {
trails?: Set<Trail> | undefined;
mode: "overview" | "map" | "list" | "multi-select";
onconfirm?: (resetSelection?: boolean) => void;
toggle?: Snippet<[any]>;
onDelete?: () => void;
onShare?: () => void;
}
let { trails, mode, onconfirm }: Props = $props();
let { trails, mode, toggle, onDelete, onShare }: Props = $props();
let confirmModal: ConfirmModal;
let listSelectModal: ListSelectModal;
@@ -302,7 +305,7 @@
await doDeleteTrail(dTrail);
}
onconfirm?.(true);
onDelete?.();
}
}
@@ -315,7 +318,7 @@
}
async function handleShareUpdate() {
onconfirm?.();
onShare?.();
}
async function handleListSelection(list: List) {
@@ -390,9 +393,14 @@
}
</script>
<Dropdown items={dropdownItems()} onchange={(item) => handleDropdownClick(item)}
>{#snippet children({ toggleMenu: openDropdown })}
{#if mode == "multi-select"}
<Dropdown
items={dropdownItems()}
onchange={(item) => handleDropdownClick(item)}
>
{#snippet children({ toggleMenu: openDropdown })}
{#if toggle}{@render toggle({
toggleMenu: openDropdown,
})}{:else if mode == "multi-select"}
<button
aria-label="Open dropdown"
class="btn-primary flex-shrink-0 !font-medium"
@@ -400,7 +408,8 @@
>
<span
>{trails?.size}
{$_("selected")} <i class="fa fa-caret-down ml-1"></i></span
{$_("selected")}
<i class="fa fa-caret-down ml-1"></i></span
>
</button>
{:else}

View File

@@ -403,6 +403,10 @@
<LikeButton {trail}></LikeButton>
<TrailDropdown
trails={new Set<Trail>([trail])}
onDelete={() =>
history.length
? history.back()
: goto("/trails")}
{mode}
></TrailDropdown>
</div>

View File

@@ -9,7 +9,7 @@
import TrailTable from "./trail_table.svelte";
import SkeletonCard from "../base/skeleton_card.svelte";
import SkeletonListItem from "../base/skeleton_list_item.svelte";
import { onMount } from "svelte";
import { onMount, tick } from "svelte";
import TrailDropdown from "$lib/components/trail/trail_dropdown.svelte";
interface Props {
@@ -191,15 +191,13 @@
else hoveredTrail = undefined;
}
function handleTrailsEditDone(resetSelection: boolean = false) {
async function handleTrailsEditDone(resetSelection: boolean = false) {
if (resetSelection) {
selection?.clear();
hoveredTrail = undefined;
}
setTimeout(() => {
onupdate?.(filter, selection);
}, 500);
await tick();
onupdate?.(filter, selection);
}
function handleMouseEnter(trail: Trail) {
@@ -224,7 +222,8 @@
<TrailDropdown
trails={selection}
mode={"multi-select"}
onconfirm={handleTrailsEditDone}
onDelete={() => handleTrailsEditDone(true)}
onShare={() => handleTrailsEditDone(false)}
/>
</div>
{/if}

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "Muss mindestens {n} Zeichen lang sein",
"must-be-at-most-n-characters-long": "Darf höchstens {n} Zeichen lang sein",
"my-account": "Mein Konto",
"my-trails": "",
"n-days-ago": "vor {n} Tagen",
"n-hours-ago": "vor {n} Stunden",
"n-minutes-ago": "vor {n} Minuten",

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "Must be at least {n} characters long",
"must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "My Account",
"my-trails": "My trails",
"n-days-ago": "{n} days ago",
"n-hours-ago": "{n} hours ago",
"n-minutes-ago": "{n} minutes ago",

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "Tiene que tener por lo menos {n} caracteres",
"must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "Mi cuenta",
"my-trails": "",
"n-days-ago": "hace {n} días",
"n-hours-ago": "hace {n} horas",
"n-minutes-ago": "hace {n} minutos",

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "Must be at least {n} characters long",
"must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "My Account",
"my-trails": "",
"n-days-ago": "{n} days ago",
"n-hours-ago": "{n} hours ago",
"n-minutes-ago": "{n} minutes ago",

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "Doit être composé d'au moins {n} caractères",
"must-be-at-most-n-characters-long": "Doit être au maximum de {n} caractères",
"my-account": "Mon profil",
"my-trails": "",
"n-days-ago": "il y a {n} jours",
"n-hours-ago": "il y a {n} heures",
"n-minutes-ago": "il y a {n} minutes",

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "Legalább {n} karakter hosszúnak kell lennie",
"must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "My Account",
"my-trails": "",
"n-days-ago": "{n} days ago",
"n-hours-ago": "{n} hours ago",
"n-minutes-ago": "{n} minutes ago",

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "Deve essere lungo almeno {n} caratteri",
"must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "Il mio account",
"my-trails": "",
"n-days-ago": "{n} giorni fa",
"n-hours-ago": "{n} ore fa",
"n-minutes-ago": "{n} minuti fa",

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "Minimaal {n} tekens",
"must-be-at-most-n-characters-long": "Mag maximaal {n} tekens lang zijn.",
"my-account": "Mijn Account",
"my-trails": "",
"n-days-ago": "{n} dagen geleden",
"n-hours-ago": "{n} uuren geleden",
"n-minutes-ago": "{n} minuten geleden",

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "Długość musi wynosić przynajmniej {n} znaków",
"must-be-at-most-n-characters-long": "Musi mieć długość co najwyżej {n} znaków",
"my-account": "Moje konto",
"my-trails": "",
"n-days-ago": "{n} dni temu",
"n-hours-ago": "{n} godzin temu",
"n-minutes-ago": "{n} minut temu",

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "Deve ter pelo menos {n} caracteres",
"must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "A minha conta",
"my-trails": "",
"n-days-ago": "{n} dias atrás",
"n-hours-ago": "{n} horas atrás",
"n-minutes-ago": "{n} minutos atrás",

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "Минимум {n} символов",
"must-be-at-most-n-characters-long": "Максимум {n} символов",
"my-account": "Мой аккаунт",
"my-trails": "",
"n-days-ago": "{n} дней назад",
"n-hours-ago": "{n} часов назад",
"n-minutes-ago": "{n} минут назад",

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "长度至少 {n} 字符",
"must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "我的账户",
"my-trails": "",
"n-days-ago": "{n} 天前",
"n-hours-ago": "{n} 小时前",
"n-minutes-ago": "{n} 分钟前",

View File

@@ -1,21 +1,18 @@
import type { Actor } from "./activitypub/actor";
import type { List } from "./list";
import type { SummitLog } from "./summit_log";
import type { Trail } from "./trail";
interface FeedItem {
id: string;
actor: string;
author: string;
item: string;
type: "trail" | "list" | "summit_log"
expand: {
actor?: Actor,
author?: Actor,
item: Trail | List
}
created: string;
}
export { type FeedItem }
export { type FeedItem };

View File

@@ -7,7 +7,6 @@ let feed: FeedItem[] = []
export async function feed_index(page: number, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f(`/api/v1/feed?` + new URLSearchParams({
expand: "author",
page: page.toString(),
perPage: perPage.toString(),
sort: '-created'

View File

@@ -1,6 +1,6 @@
import type { FeedItem } from "$lib/models/feed";
import type { ListFilter } from "$lib/models/list";
import type { SummitLog, SummitLogFilter } from "$lib/models/summit_log";
import type { TimelineItem } from "$lib/models/timeline";
import { defaultTrailSearchAttributes, Trail, type TrailFilter, type TrailSearchResult } from "$lib/models/trail";
import { APIError } from "$lib/util/api_util";
import type { Hits } from "meilisearch";
@@ -10,7 +10,7 @@ import type { ListSearchResult } from "./search_store";
import { buildFilterText } from "./summit_log_store";
import { searchResultToTrailList } from "./trail_store";
let timeline: TimelineItem[] = []
let feed: FeedItem[] = []
export async function profile_show(handle: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
@@ -58,8 +58,8 @@ export async function profile_lists_index(handle: string, filter: ListFilter, pa
}
export async function profile_timeline_index(handle: string, page: number, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f(`/api/v1/profile/${handle}/timeline?` + new URLSearchParams({
export async function profile_feed_index(handle: string, page: number, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f(`/api/v1/profile/${handle}/feed?` + new URLSearchParams({
page: page.toString(),
perPage: perPage.toString(),
sort: '-created'
@@ -71,13 +71,13 @@ export async function profile_timeline_index(handle: string, page: number, perPa
throw new APIError(r.status, response.message, response.detail)
}
const fetchedTimeline: ListResult<TimelineItem> = await r.json()
const fetchedFeed: ListResult<FeedItem> = await r.json()
const result = page > 1 ? [...timeline, ...fetchedTimeline.items] : fetchedTimeline.items
const result = page > 1 ? [...feed, ...fetchedFeed.items] : fetchedFeed.items
timeline = result;
feed = result;
return { ...fetchedTimeline, items: result };
return { ...fetchedFeed, items: result };
}

View File

@@ -29,6 +29,7 @@ export enum Collection {
list_share = "list_share",
lists = "lists",
notifications = "notifications",
profile_feed = "profile_feed",
settings = "settings",
summit_logs = "summit_logs",
trail_like = "trail_like",
@@ -36,7 +37,6 @@ export enum Collection {
trails = "trails",
tags = "tags",
waypoints = "waypoints",
timeline = "timeline",
trails_bounding_box = "trails_bounding_box",
trails_filter = "trails_filter",
users_anonymous = "users_anonymous",