finishes feed
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -403,6 +403,10 @@
|
||||
<LikeButton {trail}></LikeButton>
|
||||
<TrailDropdown
|
||||
trails={new Set<Trail>([trail])}
|
||||
onDelete={() =>
|
||||
history.length
|
||||
? history.back()
|
||||
: goto("/trails")}
|
||||
{mode}
|
||||
></TrailDropdown>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} минут назад",
|
||||
|
||||
@@ -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} 分钟前",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 };
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -139,11 +139,12 @@
|
||||
<svelte:window onscroll={onScroll} />
|
||||
|
||||
<section
|
||||
class="hero grid grid-cols-1 lg:grid-cols-2 md:px-8 md:gap-8"
|
||||
class="hero grid grid-cols-1 lg:grid-cols-2 md:px-8 gap-4 md:gap-8"
|
||||
style="min-height: calc(100vh - 112px)"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col justify-center gap-8 max-w-md mx-8 sm:mx-auto mt-0 lg:-mt-24 md:mt-24 max-h-screen md:sticky top-0"
|
||||
class="flex flex-col justify-center gap-8 max-w-md mx-8 sm:mx-auto mt-0 lg:sticky"
|
||||
style="max-height: calc(100vh - 112px); top: 112px;"
|
||||
>
|
||||
<h2 class="text-5xl sm:text-6xl md:text-7xl font-bold">
|
||||
{$_("welcome_to")} <span class="-tracking-[0.075em]">wanderer</span>
|
||||
@@ -172,16 +173,8 @@
|
||||
<EmptyStateFeed></EmptyStateFeed>
|
||||
{/if}
|
||||
{#each feed.items as f}
|
||||
{#if f.expand.item}
|
||||
<a
|
||||
class="block"
|
||||
href={(f.type === "trail"
|
||||
? "/trail/view/"
|
||||
: "/lists/") +
|
||||
`@${f.expand.author?.preferred_username}@${f.expand.author?.domain}/${f.item}`}
|
||||
>
|
||||
<FeedCard feedItem={f}></FeedCard>
|
||||
</a>
|
||||
{#if f.expand?.item}
|
||||
<FeedCard feedItem={f}></FeedCard>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
52
web/src/routes/api/v1/profile/[handle]/feed/+server.ts
Normal file
52
web/src/routes/api/v1/profile/[handle]/feed/+server.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { RecordListOptionsSchema } from '$lib/models/api/base_schema';
|
||||
import { type FeedItem } from '$lib/models/feed';
|
||||
import type { Trail } from '$lib/models/trail';
|
||||
import { Collection, handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
import { ClientResponseError, type ListResult } from 'pocketbase';
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
const handle = event.params.handle;
|
||||
if (!handle) {
|
||||
return error(400, { message: "Bad request" })
|
||||
}
|
||||
|
||||
try {
|
||||
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, });
|
||||
|
||||
const searchParams = Object.fromEntries(event.url.searchParams);
|
||||
const safeSearchParams = RecordListOptionsSchema.parse(searchParams);
|
||||
|
||||
let feed: ListResult<FeedItem>;
|
||||
if (actor.isLocal) {
|
||||
feed = await event.locals.pb.collection(Collection.profile_feed)
|
||||
.getList<FeedItem>(safeSearchParams.page, safeSearchParams.perPage, { ...safeSearchParams, filter: `actor='${actor.id}'` })
|
||||
} else {
|
||||
const origin = new URL(actor.iri).origin
|
||||
const feedURL = `${origin}/api/v1/profile/${actor.preferred_username}/feed?` + event.url.searchParams
|
||||
|
||||
const response = await event.fetch(feedURL, { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json()
|
||||
throw new ClientResponseError({ status: response.status, response: errorResponse });
|
||||
}
|
||||
feed = await response.json()
|
||||
|
||||
feed.items.forEach(f => {
|
||||
if (f.type == "trail") {
|
||||
const trail = f.expand.item as Trail
|
||||
trail.photos = trail.photos.map(p =>
|
||||
`${origin}/api/v1/files/trails/${f.item}/${p}`
|
||||
)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return json(feed)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Actor } from '$lib/models/activitypub/actor';
|
||||
import { RecordListOptionsSchema } from '$lib/models/api/base_schema';
|
||||
import { type TimelineItem } from '$lib/models/timeline';
|
||||
import { Collection, handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
import { ClientResponseError, type ListResult } from 'pocketbase';
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
const handle = event.params.handle;
|
||||
if (!handle) {
|
||||
return error(400, { message: "Bad request" })
|
||||
}
|
||||
|
||||
try {
|
||||
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, });
|
||||
|
||||
const searchParams = Object.fromEntries(event.url.searchParams);
|
||||
const safeSearchParams = RecordListOptionsSchema.parse(searchParams);
|
||||
|
||||
let timeline: ListResult<TimelineItem>;
|
||||
if (actor.isLocal) {
|
||||
timeline = await event.locals.pb.collection(Collection.timeline)
|
||||
.getList<TimelineItem>(safeSearchParams.page, safeSearchParams.perPage, { ...safeSearchParams, filter: `author='${actor.iri}'` })
|
||||
} else {
|
||||
const origin = new URL(actor.iri).origin
|
||||
const timelineURL = `${origin}/api/v1/profile/${actor.preferred_username}/timeline?` + event.url.searchParams
|
||||
|
||||
const response = await event.fetch(timelineURL, { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json()
|
||||
throw new ClientResponseError({ status: response.status, response: errorResponse });
|
||||
}
|
||||
timeline = await response.json()
|
||||
|
||||
timeline.items.forEach(i => {
|
||||
i.photos = i. photos.map(p =>
|
||||
`${origin}/api/v1/files/${i.type}s/${i.id}/${p}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return json(timeline)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,28 @@
|
||||
import { page } from "$app/state";
|
||||
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
|
||||
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
|
||||
import ActivityCard from "$lib/components/profile/activity_card.svelte";
|
||||
import FeedCard from "$lib/components/profile/feed_card.svelte";
|
||||
import type { FeedItem } from "$lib/models/feed.js";
|
||||
|
||||
import type { TimelineItem } from "$lib/models/timeline.js";
|
||||
import { profile_timeline_index } from "$lib/stores/profile_store.js";
|
||||
import { profile_feed_index } from "$lib/stores/profile_store.js";
|
||||
import { theme } from "$lib/stores/theme_store.js";
|
||||
import { getFileURL } from "$lib/util/file_util.js";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let timeline = $state(data.timeline);
|
||||
$effect(() => {
|
||||
data.feed;
|
||||
feed = data.feed
|
||||
})
|
||||
|
||||
let feed = $state(data.feed);
|
||||
|
||||
let loading: boolean = false;
|
||||
|
||||
let pagination = $derived({
|
||||
page: timeline.page,
|
||||
totalPages: timeline.totalItems,
|
||||
page: feed.page,
|
||||
totalPages: feed.totalItems,
|
||||
});
|
||||
|
||||
async function onListScroll(e: Event) {
|
||||
@@ -37,19 +42,19 @@
|
||||
|
||||
async function loadNextPage() {
|
||||
pagination.page += 1;
|
||||
timeline = await profile_timeline_index(
|
||||
page.params.handle,
|
||||
pagination.page,
|
||||
);
|
||||
feed = await profile_feed_index(page.params.handle, pagination.page);
|
||||
}
|
||||
|
||||
function handleTimeLineItemClick(item: TimelineItem) {
|
||||
if (item.trail_iri.length) {
|
||||
function handleFeedItemClick(f: FeedItem) {
|
||||
const author = f.expand.item.expand?.author;
|
||||
if (f.type == "trail") {
|
||||
goto(
|
||||
`/trail/view/@${item.trail_author_username}@${item.trail_author_domain}/${item.trail_id}`,
|
||||
`/trail/view/@${author?.preferred_username}@${author?.domain}/${f.item}`,
|
||||
);
|
||||
} else {
|
||||
goto(`/trail/view/${page.params.handle}/${item.trail_id}`);
|
||||
goto(
|
||||
`/lists/@${author?.preferred_username}@${author?.domain}/${f.item}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -130,24 +135,24 @@
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-xl font-semibold">Timeline</h4>
|
||||
{#if !timeline.items?.length && data.isOwnProfile}
|
||||
{#if !feed.items?.length && data.isOwnProfile}
|
||||
<a class="btn-primary inline-block" href="/trails/edit/new"
|
||||
>+ {$_("new-trail")}</a
|
||||
>
|
||||
{:else if !timeline.items?.length}
|
||||
{:else if !feed.items?.length}
|
||||
<p class="w-full text-center text-gray-500 text-sm">
|
||||
{$_("empty-activities", {
|
||||
values: { username: data.profile.preferredUsername },
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
{#each timeline.items as item}
|
||||
{#each feed.items as item}
|
||||
<div
|
||||
class="py-1 cursor-pointer"
|
||||
role="presentation"
|
||||
onclick={() => handleTimeLineItemClick(item)}
|
||||
onclick={() => handleFeedItemClick(item)}
|
||||
>
|
||||
<ActivityCard activity={item} actor={data.actor}></ActivityCard>
|
||||
<FeedCard feedItem={item}></FeedCard>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ListFilter } from "$lib/models/list";
|
||||
import { profile_lists_index, profile_timeline_index } from "$lib/stores/profile_store";
|
||||
import { profile_lists_index, profile_feed_index } from "$lib/stores/profile_store";
|
||||
import { error, type Load } from "@sveltejs/kit";
|
||||
|
||||
export const load: Load = async ({ params, fetch, parent }) => {
|
||||
@@ -19,10 +19,10 @@ export const load: Load = async ({ params, fetch, parent }) => {
|
||||
|
||||
try {
|
||||
const lists = await profile_lists_index(params.handle, filter, 1, 6, fetch)
|
||||
const timeline = await profile_timeline_index(params.handle, 1, 10, fetch);
|
||||
return { lists: lists.items, timeline }
|
||||
const feed = await profile_feed_index(params.handle, 1, 10, fetch);
|
||||
return { lists: lists.items, feed }
|
||||
} catch(e) {
|
||||
return {lists: [], timeline: {items: [], page: 1, perPage: 1, totalItems: 0, totalPages: 0}}
|
||||
return {lists: [], feed: {items: [], page: 1, perPage: 1, totalItems: 0, totalPages: 0}}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user