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

@@ -120,7 +120,7 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa
app.OnRecordUpdate("integrations").BindFunc(updateIntegrationHandler()) app.OnRecordUpdate("integrations").BindFunc(updateIntegrationHandler())
app.OnRecordAfterUpdateSuccess("integrations").BindFunc(createUpdateIntegrationSuccessHandler()) app.OnRecordAfterUpdateSuccess("integrations").BindFunc(createUpdateIntegrationSuccessHandler())
app.OnRecordsListRequest("feed").BindFunc(listFeedHandler()) app.OnRecordsListRequest("feed", "profile_feed").BindFunc(listFeedHandler())
app.OnRecordCreateRequest().BindFunc(sanitizeHTML()) app.OnRecordCreateRequest().BindFunc(sanitizeHTML())
app.OnRecordUpdateRequest().BindFunc(sanitizeHTML()) app.OnRecordUpdateRequest().BindFunc(sanitizeHTML())
@@ -905,19 +905,35 @@ func listFeedHandler() func(e *core.RecordsListRequestEvent) error {
for _, r := range e.Records { for _, r := range e.Records {
var item *core.Record var item *core.Record
var err error var err error
switch r.GetString("type") {
typ := r.GetString("type")
typ = strings.Trim(typ, "\"")
itemId := r.GetString("item")
itemId = strings.Trim(itemId, "\"")
switch typ {
case string(util.TrailFeed): case string(util.TrailFeed):
item, err = e.App.FindRecordById("trails", r.GetString("item")) item, err = e.App.FindRecordById("trails", itemId)
case string(util.ListFeed): case string(util.ListFeed):
item, err = e.App.FindRecordById("lists", r.GetString("item")) item, err = e.App.FindRecordById("lists", itemId)
case string(util.SummitLogFeed): case string(util.SummitLogFeed):
item, err = e.App.FindRecordById("summit_logs", r.GetString("item")) item, err = e.App.FindRecordById("summit_logs", itemId)
} }
if err != nil { if err != nil {
continue continue
} }
if item == nil {
continue
}
errs := e.App.ExpandRecord(item, []string{"author"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
}
r.MergeExpand(map[string]any{"item": item}) r.MergeExpand(map[string]any{"item": item})
} }
return e.Next() return e.Next()

View File

@@ -0,0 +1,106 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
jsonData := `{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3208210256",
"max": 0,
"min": 0,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"hidden": false,
"id": "json1148540665",
"maxSize": 1,
"name": "actor",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3182418120",
"maxSize": 1,
"name": "author",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json521872670",
"maxSize": 1,
"name": "item",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json2363381545",
"maxSize": 1,
"name": "type",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json2990389176",
"maxSize": 1,
"name": "created",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}
],
"id": "pbc_1973704172",
"indexes": [],
"listRule": "",
"name": "profile_feed",
"system": false,
"type": "view",
"updateRule": null,
"viewQuery": "SELECT\n (ROW_NUMBER() OVER ()) as id,\n actor,\n author,\n item,\n type,\n created\nFROM\n (\n SELECT\n author as actor,\n author,\n id as item,\n \"list\" as type,\n created\n FROM\n lists\n UNION\n SELECT\n author as actor,\n author,\n id as item,\n \"trail\" as type,\n created\n FROM trails\n )\nORDER BY created desc;",
"viewRule": null
}`
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_1973704172")
if err != nil {
return err
}
return app.Delete(collection)
})
}

View File

@@ -0,0 +1,57 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_1973704172")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"viewQuery": "SELECT\n (ROW_NUMBER() OVER ()) as id,\n actor,\n item,\n type,\n created\nFROM\n (\n SELECT\n author as actor,\n id as item,\n 'list' as type,\n created\n FROM\n lists\n UNION\n SELECT\n author as actor,\n id as item,\n 'trail' as type,\n created\n FROM trails\n )\nORDER BY created desc;"
}`), &collection); err != nil {
return err
}
// remove field
collection.Fields.RemoveById("json3182418120")
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_1973704172")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"viewQuery": "SELECT\n (ROW_NUMBER() OVER ()) as id,\n actor,\n author,\n item,\n type,\n created\nFROM\n (\n SELECT\n author as actor,\n author,\n id as item,\n \"list\" as type,\n created\n FROM\n lists\n UNION\n SELECT\n author as actor,\n author,\n id as item,\n \"trail\" as type,\n created\n FROM trails\n )\nORDER BY created desc;"
}`), &collection); err != nil {
return err
}
// add field
if err := collection.Fields.AddMarshaledJSONAt(2, []byte(`{
"hidden": false,
"id": "json3182418120",
"maxSize": 1,
"name": "author",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}`)); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@@ -0,0 +1,44 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_72164123")
if err != nil {
return err
}
// remove field
collection.Fields.RemoveById("relation3182418120")
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_72164123")
if err != nil {
return err
}
// add field
if err := collection.Fields.AddMarshaledJSONAt(2, []byte(`{
"cascadeDelete": true,
"collectionId": "pbc_1295301207",
"hidden": false,
"id": "relation3182418120",
"maxSelect": 1,
"minSelect": 0,
"name": "author",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
}`)); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@@ -0,0 +1,216 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_468398817")
if err != nil {
return err
}
return app.Delete(collection)
}, func(app core.App) error {
jsonData := `{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3208210256",
"max": 0,
"min": 0,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"hidden": false,
"id": "json2310347867",
"maxSize": 1,
"name": "trail_id",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3184124860",
"maxSize": 1,
"name": "trail_author_username",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json2887874732",
"maxSize": 1,
"name": "trail_author_domain",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json113557190",
"maxSize": 1,
"name": "trail_iri",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json2862495610",
"maxSize": 1,
"name": "date",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json1579384326",
"maxSize": 1,
"name": "name",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json1843675174",
"maxSize": 1,
"name": "description",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3275261007",
"maxSize": 1,
"name": "gpx",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3182418120",
"maxSize": 1,
"name": "author",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json142008537",
"maxSize": 1,
"name": "photos",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json479369857",
"maxSize": 1,
"name": "distance",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json2254405824",
"maxSize": 1,
"name": "duration",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3015100073",
"maxSize": 1,
"name": "elevation_gain",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json3171089056",
"maxSize": 1,
"name": "elevation_loss",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json2990389176",
"maxSize": 1,
"name": "created",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json2363381545",
"maxSize": 1,
"name": "type",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}
],
"id": "pbc_468398817",
"indexes": [],
"listRule": "@collection.trails.id ?= trail_id && @collection.trails.public ?= true",
"name": "timeline",
"system": false,
"type": "view",
"updateRule": null,
"viewQuery": "SELECT\n id,\n trail_id,\n trail_author_username,\n trail_author_domain,\n trail_iri,\n date,\n name,\n description,\n gpx,\n author,\n photos,\n distance,\n duration,\n elevation_gain,\n elevation_loss,\n created,\n type\nFROM\n (\n SELECT\n summit_logs.id,\n summit_logs.trail as trail_id,\n tapa.preferred_username as trail_author_username,\n tapa.domain as trail_author_domain,\n trails.iri as trail_iri,\n summit_logs.date,\n trails.name,\n text as description,\n summit_logs.gpx,\n sapa.iri as author,\n summit_logs.photos,\n summit_logs.distance,\n summit_logs.duration,\n summit_logs.elevation_gain,\n summit_logs.elevation_loss,\n summit_logs.created,\n \"summit_log\" as type\n FROM\n summit_logs\n JOIN trails ON summit_logs.trail = trails.id\n JOIN activitypub_actors sapa ON sapa.id = summit_logs.author\n JOIN activitypub_actors tapa ON tapa.id = trails.author\n UNION\n SELECT\n trails.id,\n trails.id as trail_id,\n activitypub_actors.preferred_username as trail_author_username,\n activitypub_actors.domain as trail_author_domain,\n trails.iri as trail_iri,\n date,\n trails.name,\n description,\n gpx,\n activitypub_actors.iri as author,\n photos,\n distance,\n duration,\n elevation_gain,\n elevation_loss,\n trails.created,\n \"trail\" as type\n FROM\n trails\n JOIN activitypub_actors ON activitypub_actors.id = trails.author\n )\nORDER BY\n created DESC;\n",
"viewRule": "@collection.trails.id ?= trail_id && @collection.trails.public ?= true"
}`
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@@ -0,0 +1,40 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_1973704172")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"listRule": "actor.user.settings_via_user.privacy.account != 'private' || actor.user = @request.auth.id"
}`), &collection); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_1973704172")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"listRule": ""
}`), &collection); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@@ -21,7 +21,7 @@
let isOpen = $state(false); let isOpen = $state(false);
let dropdownElement: HTMLUListElement; let dropdownElement: HTMLUListElement | undefined = $state();
let dropdownToggleElement: HTMLDivElement; let dropdownToggleElement: HTMLDivElement;
export async function toggleMenu(e: MouseEvent) { export async function toggleMenu(e: MouseEvent) {
@@ -30,7 +30,7 @@
isOpen = !isOpen; isOpen = !isOpen;
if (isOpen) { if (isOpen && dropdownElement) {
await tick(); await tick();
const toggleRect = dropdownToggleElement.getBoundingClientRect(); const toggleRect = dropdownToggleElement.getBoundingClientRect();
@@ -81,7 +81,8 @@
isOpen = false; isOpen = false;
} }
function handleItemClick(e: Event, item: { text: string; value: any }) { function handleItemClick(e: MouseEvent, item: { text: string; value: any }) {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onchange?.(item); onchange?.(item);
closeMenu(); closeMenu();
@@ -129,7 +130,7 @@
<li <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" 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" role="presentation"
onmousedown={(e) => handleItemClick(e, item)} onclick={(e) => handleItemClick(e, item)}
> >
{#if item.icon} {#if item.icon}
<i class="fa fa-{item.icon} mr-3"></i> <i class="fa fa-{item.icon} mr-3"></i>

View File

@@ -30,6 +30,8 @@
const dropdownItems = [ const dropdownItems = [
{ text: $_("profile"), value: "profile", icon: "user" }, { text: $_("profile"), value: "profile", icon: "user" },
{ text: $_("my-trails"), value: "trails", icon: "route" },
{ text: $_("settings"), value: "settings", icon: "cog" }, { text: $_("settings"), value: "settings", icon: "cog" },
{ text: $_("logout"), value: "logout", icon: "right-from-bracket" }, { text: $_("logout"), value: "logout", icon: "right-from-bracket" },
]; ];
@@ -97,6 +99,8 @@
function handleDropdownClick(item: { text: string; value: any }) { function handleDropdownClick(item: { text: string; value: any }) {
if (item.value == "profile") { if (item.value == "profile") {
goto(`/profile/@${$currentUser?.username?.toLowerCase()}`); goto(`/profile/@${$currentUser?.username?.toLowerCase()}`);
} else if (item.value == "trails") {
goto(`/profile/@${$currentUser?.username?.toLowerCase()}/trails`);
} else if (item.value == "logout") { } else if (item.value == "logout") {
logout(); logout();
window.location.href = "/"; window.location.href = "/";
@@ -185,7 +189,11 @@
</div> </div>
</Drawer> </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="/"> <a href="/">
{#if $theme == "light"} {#if $theme == "light"}
<LogoText></LogoText> <LogoText></LogoText>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { FeedItem } from "$lib/models/feed"; import type { FeedItem } from "$lib/models/feed";
import type { List } from "$lib/models/list";
import type { Trail } from "$lib/models/trail"; import type { Trail } from "$lib/models/trail";
import { getFileURL, isVideoURL } from "$lib/util/file_util"; import { getFileURL, isVideoURL } from "$lib/util/file_util";
import { import {
@@ -10,128 +11,185 @@
formatTimeSince, formatTimeSince,
} from "$lib/util/format_util"; } from "$lib/util/format_util";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import TrailDropdown from "../trail/trail_dropdown.svelte";
interface Props { interface Props {
feedItem: FeedItem; feedItem: FeedItem;
} }
let { feedItem }: Props = $props(); 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> </script>
<div class="feed-card p-6 rounded-xl border border-input-border"> <div class="feed-card px-6 py-4 rounded-xl border border-input-border">
<div class="feed-card-header flex gap-x-4 items-start"> <p class="mb-2 text-gray-500 text-sm">
<img {#if feedItem.type === "trail"}
class="rounded-full w-10 aspect-square overflow-hidden shrink-0" <i class="fa fa-route mr-2"></i>{$_("trail", { values: { n: 1 } })}
src={feedItem.expand.author?.icon || {:else if feedItem.type === "list"}
`https://api.dicebear.com/7.x/initials/svg?seed=${feedItem.expand.author?.preferred_username}&backgroundType=gradientLinear`} <i class="fa fa-layer-group mr-2"></i>{$_("list", {
alt="avatar" values: { n: 1 },
/>
<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 },
})} })}
</p> {/if}
</div> </p>
<div class="feed-card-body">
<h3 class="text-2xl font-semibold !mt-2"> <a href="/profile/{author?.preferred_username}@{author?.domain}">
{feedItem.expand.item.name} <div class="feed-card-header flex gap-x-4 items-start">
</h3> <img
<div class="flex flex-wrap mt-1 gap-x-4 gap-y-2 text-sm text-gray-500"> class="rounded-full w-10 aspect-square overflow-hidden shrink-0"
<span src={author?.icon ||
><i class="fa fa-left-right mr-2"></i>{formatDistance( `https://api.dicebear.com/7.x/initials/svg?seed=${author?.preferred_username}&backgroundType=gradientLinear`}
feedItem.expand.item.distance, alt="avatar"
)}</span />
<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 <span
><i class="fa fa-clock mr-2"></i>{formatTimeHHMM( ><i class="fa fa-left-right mr-2"></i>{formatDistance(
feedItem.expand.item.duration, feedItem.expand.item.distance,
)}</span )}</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 {#snippet toggle({ toggleMenu: openDropdown })}
><i class="fa fa-arrow-trend-up mr-2"></i>{formatElevation( <button
feedItem.expand.item.elevation_gain, class="btn-icon"
)}</span onclick={openDropdown}
> aria-label="Trail actions"
<span type="button"
><i class="fa fa-arrow-trend-down mr-2"></i>{formatElevation( ><i class="fa fa-ellipsis-vertical"></i></button
feedItem.expand.item.elevation_loss, >
)}</span {/snippet}</TrailDropdown
> >
</div> </div>
{#if photos?.length} {/if}
<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>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "Muss mindestens {n} Zeichen lang sein", "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", "must-be-at-most-n-characters-long": "Darf höchstens {n} Zeichen lang sein",
"my-account": "Mein Konto", "my-account": "Mein Konto",
"my-trails": "",
"n-days-ago": "vor {n} Tagen", "n-days-ago": "vor {n} Tagen",
"n-hours-ago": "vor {n} Stunden", "n-hours-ago": "vor {n} Stunden",
"n-minutes-ago": "vor {n} Minuten", "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-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", "must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "My Account", "my-account": "My Account",
"my-trails": "My trails",
"n-days-ago": "{n} days ago", "n-days-ago": "{n} days ago",
"n-hours-ago": "{n} hours ago", "n-hours-ago": "{n} hours ago",
"n-minutes-ago": "{n} minutes 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-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", "must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "Mi cuenta", "my-account": "Mi cuenta",
"my-trails": "",
"n-days-ago": "hace {n} días", "n-days-ago": "hace {n} días",
"n-hours-ago": "hace {n} horas", "n-hours-ago": "hace {n} horas",
"n-minutes-ago": "hace {n} minutos", "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-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", "must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "My Account", "my-account": "My Account",
"my-trails": "",
"n-days-ago": "{n} days ago", "n-days-ago": "{n} days ago",
"n-hours-ago": "{n} hours ago", "n-hours-ago": "{n} hours ago",
"n-minutes-ago": "{n} minutes 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-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", "must-be-at-most-n-characters-long": "Doit être au maximum de {n} caractères",
"my-account": "Mon profil", "my-account": "Mon profil",
"my-trails": "",
"n-days-ago": "il y a {n} jours", "n-days-ago": "il y a {n} jours",
"n-hours-ago": "il y a {n} heures", "n-hours-ago": "il y a {n} heures",
"n-minutes-ago": "il y a {n} minutes", "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-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", "must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "My Account", "my-account": "My Account",
"my-trails": "",
"n-days-ago": "{n} days ago", "n-days-ago": "{n} days ago",
"n-hours-ago": "{n} hours ago", "n-hours-ago": "{n} hours ago",
"n-minutes-ago": "{n} minutes 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-least-n-characters-long": "Deve essere lungo almeno {n} caratteri",
"must-be-at-most-n-characters-long": "Must be at most {n} characters long", "must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "Il mio account", "my-account": "Il mio account",
"my-trails": "",
"n-days-ago": "{n} giorni fa", "n-days-ago": "{n} giorni fa",
"n-hours-ago": "{n} ore fa", "n-hours-ago": "{n} ore fa",
"n-minutes-ago": "{n} minuti 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-least-n-characters-long": "Minimaal {n} tekens",
"must-be-at-most-n-characters-long": "Mag maximaal {n} tekens lang zijn.", "must-be-at-most-n-characters-long": "Mag maximaal {n} tekens lang zijn.",
"my-account": "Mijn Account", "my-account": "Mijn Account",
"my-trails": "",
"n-days-ago": "{n} dagen geleden", "n-days-ago": "{n} dagen geleden",
"n-hours-ago": "{n} uuren geleden", "n-hours-ago": "{n} uuren geleden",
"n-minutes-ago": "{n} minuten 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-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", "must-be-at-most-n-characters-long": "Musi mieć długość co najwyżej {n} znaków",
"my-account": "Moje konto", "my-account": "Moje konto",
"my-trails": "",
"n-days-ago": "{n} dni temu", "n-days-ago": "{n} dni temu",
"n-hours-ago": "{n} godzin temu", "n-hours-ago": "{n} godzin temu",
"n-minutes-ago": "{n} minut 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-least-n-characters-long": "Deve ter pelo menos {n} caracteres",
"must-be-at-most-n-characters-long": "Must be at most {n} characters long", "must-be-at-most-n-characters-long": "Must be at most {n} characters long",
"my-account": "A minha conta", "my-account": "A minha conta",
"my-trails": "",
"n-days-ago": "{n} dias atrás", "n-days-ago": "{n} dias atrás",
"n-hours-ago": "{n} horas atrás", "n-hours-ago": "{n} horas atrás",
"n-minutes-ago": "{n} minutos 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-least-n-characters-long": "Минимум {n} символов",
"must-be-at-most-n-characters-long": "Максимум {n} символов", "must-be-at-most-n-characters-long": "Максимум {n} символов",
"my-account": "Мой аккаунт", "my-account": "Мой аккаунт",
"my-trails": "",
"n-days-ago": "{n} дней назад", "n-days-ago": "{n} дней назад",
"n-hours-ago": "{n} часов назад", "n-hours-ago": "{n} часов назад",
"n-minutes-ago": "{n} минут назад", "n-minutes-ago": "{n} минут назад",

View File

@@ -218,6 +218,7 @@
"must-be-at-least-n-characters-long": "长度至少 {n} 字符", "must-be-at-least-n-characters-long": "长度至少 {n} 字符",
"must-be-at-most-n-characters-long": "Must be at most {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} 天前", "n-days-ago": "{n} 天前",
"n-hours-ago": "{n} 小时前", "n-hours-ago": "{n} 小时前",
"n-minutes-ago": "{n} 分钟前", "n-minutes-ago": "{n} 分钟前",

View File

@@ -1,21 +1,18 @@
import type { Actor } from "./activitypub/actor"; import type { Actor } from "./activitypub/actor";
import type { List } from "./list"; import type { List } from "./list";
import type { SummitLog } from "./summit_log";
import type { Trail } from "./trail"; import type { Trail } from "./trail";
interface FeedItem { interface FeedItem {
id: string; id: string;
actor: string; actor: string;
author: string;
item: string; item: string;
type: "trail" | "list" | "summit_log" type: "trail" | "list" | "summit_log"
expand: { expand: {
actor?: Actor, actor?: Actor,
author?: Actor,
item: Trail | List item: Trail | List
} }
created: string; 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) { 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({ let r = await f(`/api/v1/feed?` + new URLSearchParams({
expand: "author",
page: page.toString(), page: page.toString(),
perPage: perPage.toString(), perPage: perPage.toString(),
sort: '-created' sort: '-created'

View File

@@ -1,6 +1,6 @@
import type { FeedItem } from "$lib/models/feed";
import type { ListFilter } from "$lib/models/list"; import type { ListFilter } from "$lib/models/list";
import type { SummitLog, SummitLogFilter } from "$lib/models/summit_log"; 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 { defaultTrailSearchAttributes, Trail, type TrailFilter, type TrailSearchResult } from "$lib/models/trail";
import { APIError } from "$lib/util/api_util"; import { APIError } from "$lib/util/api_util";
import type { Hits } from "meilisearch"; import type { Hits } from "meilisearch";
@@ -10,7 +10,7 @@ import type { ListSearchResult } from "./search_store";
import { buildFilterText } from "./summit_log_store"; import { buildFilterText } from "./summit_log_store";
import { searchResultToTrailList } from "./trail_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) { 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) { 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}/timeline?` + new URLSearchParams({ let r = await f(`/api/v1/profile/${handle}/feed?` + new URLSearchParams({
page: page.toString(), page: page.toString(),
perPage: perPage.toString(), perPage: perPage.toString(),
sort: '-created' 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) 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", list_share = "list_share",
lists = "lists", lists = "lists",
notifications = "notifications", notifications = "notifications",
profile_feed = "profile_feed",
settings = "settings", settings = "settings",
summit_logs = "summit_logs", summit_logs = "summit_logs",
trail_like = "trail_like", trail_like = "trail_like",
@@ -36,7 +37,6 @@ export enum Collection {
trails = "trails", trails = "trails",
tags = "tags", tags = "tags",
waypoints = "waypoints", waypoints = "waypoints",
timeline = "timeline",
trails_bounding_box = "trails_bounding_box", trails_bounding_box = "trails_bounding_box",
trails_filter = "trails_filter", trails_filter = "trails_filter",
users_anonymous = "users_anonymous", users_anonymous = "users_anonymous",

View File

@@ -139,11 +139,12 @@
<svelte:window onscroll={onScroll} /> <svelte:window onscroll={onScroll} />
<section <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)" style="min-height: calc(100vh - 112px)"
> >
<div <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"> <h2 class="text-5xl sm:text-6xl md:text-7xl font-bold">
{$_("welcome_to")} <span class="-tracking-[0.075em]">wanderer</span> {$_("welcome_to")} <span class="-tracking-[0.075em]">wanderer</span>
@@ -172,16 +173,8 @@
<EmptyStateFeed></EmptyStateFeed> <EmptyStateFeed></EmptyStateFeed>
{/if} {/if}
{#each feed.items as f} {#each feed.items as f}
{#if f.expand.item} {#if f.expand?.item}
<a <FeedCard feedItem={f}></FeedCard>
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} {/if}
{/each} {/each}
</div> </div>

View 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)
}
}

View File

@@ -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)
}
}

View File

@@ -3,23 +3,28 @@
import { page } from "$app/state"; import { page } from "$app/state";
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg"; 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 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_feed_index } from "$lib/stores/profile_store.js";
import { profile_timeline_index } from "$lib/stores/profile_store.js";
import { theme } from "$lib/stores/theme_store.js"; import { theme } from "$lib/stores/theme_store.js";
import { getFileURL } from "$lib/util/file_util.js"; import { getFileURL } from "$lib/util/file_util.js";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
let { data } = $props(); let { data } = $props();
let timeline = $state(data.timeline); $effect(() => {
data.feed;
feed = data.feed
})
let feed = $state(data.feed);
let loading: boolean = false; let loading: boolean = false;
let pagination = $derived({ let pagination = $derived({
page: timeline.page, page: feed.page,
totalPages: timeline.totalItems, totalPages: feed.totalItems,
}); });
async function onListScroll(e: Event) { async function onListScroll(e: Event) {
@@ -37,19 +42,19 @@
async function loadNextPage() { async function loadNextPage() {
pagination.page += 1; pagination.page += 1;
timeline = await profile_timeline_index( feed = await profile_feed_index(page.params.handle, pagination.page);
page.params.handle,
pagination.page,
);
} }
function handleTimeLineItemClick(item: TimelineItem) { function handleFeedItemClick(f: FeedItem) {
if (item.trail_iri.length) { const author = f.expand.item.expand?.author;
if (f.type == "trail") {
goto( goto(
`/trail/view/@${item.trail_author_username}@${item.trail_author_domain}/${item.trail_id}`, `/trail/view/@${author?.preferred_username}@${author?.domain}/${f.item}`,
); );
} else { } else {
goto(`/trail/view/${page.params.handle}/${item.trail_id}`); goto(
`/lists/@${author?.preferred_username}@${author?.domain}/${f.item}`,
);
} }
} }
</script> </script>
@@ -130,24 +135,24 @@
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<h4 class="text-xl font-semibold">Timeline</h4> <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" <a class="btn-primary inline-block" href="/trails/edit/new"
>+ {$_("new-trail")}</a >+ {$_("new-trail")}</a
> >
{:else if !timeline.items?.length} {:else if !feed.items?.length}
<p class="w-full text-center text-gray-500 text-sm"> <p class="w-full text-center text-gray-500 text-sm">
{$_("empty-activities", { {$_("empty-activities", {
values: { username: data.profile.preferredUsername }, values: { username: data.profile.preferredUsername },
})} })}
</p> </p>
{/if} {/if}
{#each timeline.items as item} {#each feed.items as item}
<div <div
class="py-1 cursor-pointer" class="py-1 cursor-pointer"
role="presentation" role="presentation"
onclick={() => handleTimeLineItemClick(item)} onclick={() => handleFeedItemClick(item)}
> >
<ActivityCard activity={item} actor={data.actor}></ActivityCard> <FeedCard feedItem={item}></FeedCard>
</div> </div>
{/each} {/each}
</div> </div>

View File

@@ -1,5 +1,5 @@
import type { ListFilter } from "$lib/models/list"; 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"; import { error, type Load } from "@sveltejs/kit";
export const load: Load = async ({ params, fetch, parent }) => { export const load: Load = async ({ params, fetch, parent }) => {
@@ -19,10 +19,10 @@ export const load: Load = async ({ params, fetch, parent }) => {
try { try {
const lists = await profile_lists_index(params.handle, filter, 1, 6, fetch) const lists = await profile_lists_index(params.handle, filter, 1, 6, fetch)
const timeline = await profile_timeline_index(params.handle, 1, 10, fetch); const feed = await profile_feed_index(params.handle, 1, 10, fetch);
return { lists: lists.items, timeline } return { lists: lists.items, feed }
} catch(e) { } 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}}
} }