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.OnRecordAfterUpdateSuccess("integrations").BindFunc(createUpdateIntegrationSuccessHandler())
app.OnRecordsListRequest("feed").BindFunc(listFeedHandler())
app.OnRecordsListRequest("feed", "profile_feed").BindFunc(listFeedHandler())
app.OnRecordCreateRequest().BindFunc(sanitizeHTML())
app.OnRecordUpdateRequest().BindFunc(sanitizeHTML())
@@ -905,19 +905,35 @@ func listFeedHandler() func(e *core.RecordsListRequestEvent) error {
for _, r := range e.Records {
var item *core.Record
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):
item, err = e.App.FindRecordById("trails", r.GetString("item"))
item, err = e.App.FindRecordById("trails", itemId)
case string(util.ListFeed):
item, err = e.App.FindRecordById("lists", r.GetString("item"))
item, err = e.App.FindRecordById("lists", itemId)
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 {
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})
}
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 dropdownElement: HTMLUListElement;
let dropdownElement: HTMLUListElement | undefined = $state();
let dropdownToggleElement: HTMLDivElement;
export async function toggleMenu(e: MouseEvent) {
@@ -30,7 +30,7 @@
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,34 +11,48 @@
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 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 },
})}
{/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={feedItem.expand.author?.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${feedItem.expand.author?.preferred_username}&backgroundType=gradientLinear`}
src={author?.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${author?.preferred_username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<div>
<span class="font-semibold"
>{feedItem.expand.author?.preferred_username}</span
>
<span class="font-semibold">{author?.preferred_username}</span>
<p class="text-sm text-gray-500 mb-3">
{feedItem.expand.author?.preferred_username}@{feedItem.expand
.author?.domain}
{author?.preferred_username}@{author?.domain}
</p>
</div>
<div class="basis-full"></div>
@@ -47,11 +62,24 @@
})}
</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>
<div class="flex flex-wrap mt-1 gap-x-4 gap-y-2 text-sm text-gray-500">
{#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-left-right mr-2"></i>{formatDistance(
feedItem.expand.item.distance,
@@ -68,16 +96,25 @@
)}</span
>
<span
><i class="fa fa-arrow-trend-down mr-2"></i>{formatElevation(
><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-6"
: 'grid-cols-1'} mt-4"
>
{#each photos.slice(0, 3) as photo, i}
{#if isVideoURL(photo)}
@@ -97,8 +134,7 @@
{:else}
<img
class="object-cover h-full max-h-80 w-full"
class:row-span-2={i == 0 &&
photos.length > 2}
class:row-span-2={i == 0 && photos.length > 2}
src={getFileURL(
{
collectionId: "trails",
@@ -116,7 +152,10 @@
<p class="text-sm whitespace-pre-wrap mt-6">
{formatHTMLAsText(
!fullDescription
? feedItem.expand.item.description?.substring(0, 100)
? feedItem.expand.item.description?.substring(
0,
100,
)
: feedItem.expand.item.description,
)}
{#if (feedItem.expand.item.description?.length ?? 0) > 100 && !fullDescription}
@@ -134,4 +173,23 @@
</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"
>
{#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}
</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(() => {
await tick();
onupdate?.(filter, selection);
}, 500);
}
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",

View File

@@ -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}`}
>
{#if f.expand?.item}
<FeedCard feedItem={f}></FeedCard>
</a>
{/if}
{/each}
</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 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>

View File

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