Federation (#327)

* initial commit federation

* more federation

* more federation

* more federation

* completes follow, accept, undo

* add trail create activity

* process trail create activity

* more trail create activity

* adds missing endpoints

* adds update and delete activity

* adds comment activities

* adds activities back

* adds summit logs activities

* deletes follow counts table

* fixes migrations

* fixes migrations

* fixes migrations

* fixes migrations

* adds remote profiles

* ctd

* ctd

* we are getting closer...

* adds public summit logs

* fixes federated trails in lists

* adds remote lists

* adds list activites

* fixes lists

* adds notifications

* adds iri redirect

* fixes comments and summitlogs

* fixes follows and profiles

* adds asynchronous send

* fixes list search

* adds encryption key

* bug fixes

* fixes activity signing

* removes custon activity object types

* adds html editor

* fixes html editor

* fixes display issues on mastodon

* adds federated sharing

* finishes announcements

* fixes small summit log issues

* adds trail likes

* finalizes likes

* fixes images for komoot

* add disable federation option

* adds private profiles

* updates docs

* adds federated comments

* adds trail and comments actvitiypub routes

* adds mentions to editor

* adds mentions to trails, comments, summit logs

* updates theme

* updates theme

* update docs

* update docs

* updates docs

* fixes various frontend problems

* fixes activitypub follows api

* updates docs

* updates docs

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
Flomp
2025-06-21 10:09:22 +02:00
committed by GitHub
parent 742a6d9d30
commit 9276690d60
356 changed files with 57150 additions and 35470 deletions

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import type { Trail } from "$lib/models/trail";
import { TrailLike } from "$lib/models/trail_like";
import { show_toast } from "$lib/stores/toast_store.svelte";
import {
trail_like_create,
trail_like_delete,
} from "$lib/stores/trail_like_store";
import { currentUser } from "$lib/stores/user_store";
import confetti from "canvas-confetti";
import { _ } from "svelte-i18n";
interface Props {
trail: Trail;
}
let likeButton: HTMLButtonElement;
let { trail }: Props = $props();
let likeCount = $state(trail.like_count);
let loading = $state(false);
async function like(e: Event) {
e.stopPropagation();
e.preventDefault();
if (!$currentUser || !trail.id) {
return;
}
loading = true;
try {
if (trailLike) {
await trail_like_delete(trailLike);
likeCount -= 1;
trailLike = undefined;
} else {
trailLike = await trail_like_create(
new TrailLike($currentUser.actor, trail.id),
);
likeCount += 1;
const rect = likeButton.getBoundingClientRect();
const x = (rect.left + rect.width / 2) / window.innerWidth;
const y = (rect.top + rect.height / 2) / window.innerHeight;
confetti({
particleCount: 60,
startVelocity: 20,
spread: 40,
origin: { x, y },
colors: ["#fb2c36"],
scalar: 1.2,
});
}
} catch (e) {
show_toast({
icon: "close",
text: $_("error-liking-trail"),
type: "error",
});
} finally {
loading = false;
}
}
let trailLike = $state(
trail.expand?.trail_like_via_trail?.find(
(l) => l.actor === $currentUser?.actor,
),
);
</script>
<button
class="like-button rounded-full w-12 py-2 bg-background {$currentUser ? 'hover:bg-secondary-hover' : ''} transition-colors relative"
aria-label="like"
type="button"
bind:this={likeButton}
disabled={!$currentUser}
onclick={like}
data-title={$_("likes")}
>
<i
class="{trailLike ? 'fa' : 'fa-regular'} fa-heart text-3xl"
class:text-red-500={trailLike !== undefined}
class:loading
></i>
<p class="font-semibold">{likeCount}</p>
</button>
<style>
.like-button > i.loading {
animation: heartbeat 1s infinite;
}
@keyframes heartbeat {
0%,
100% {
transform: scale(1);
}
20% {
transform: scale(1.1);
}
40% {
transform: scale(0.95);
}
60% {
transform: scale(1.05);
}
}
</style>

View File

@@ -13,12 +13,10 @@
createPopupFromTrail,
FontawesomeMarker,
} from "$lib/util/maplibre_util";
import { polylineToGeoJSON } from "$lib/util/polyline_util";
import type { ElevationProfileControl } from "$lib/vendor/maplibre-elevation-profile/elevationprofile-control";
import { FullscreenControl } from "$lib/vendor/maplibre-fullscreen/fullscreen-control";
import MaplibreGraticule from "$lib/vendor/maplibre-graticule/maplibre-graticule";
import { StyleSwitcherControl } from "$lib/vendor/maplibre-style-switcher/style-switcher-control";
import { T } from "@threlte/core";
import type { Feature, FeatureCollection, GeoJSON } from "geojson";
import * as M from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
@@ -1135,6 +1133,17 @@
});
function handleKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement;
const isInputField =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable;
if (isInputField) {
return;
}
if (e.key == "m") {
if (trails.length === 1) {
removeCaretLayer();
@@ -1144,6 +1153,17 @@
}
function handleKeyup(e: KeyboardEvent) {
const target = e.target as HTMLElement;
const isInputField =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable;
if (isInputField) {
return;
}
if (e.key == "m") {
if (trails.length === 1) {
addTrailLayer(trails[0], trails[0].id!, 0, data[0]);
@@ -1160,7 +1180,10 @@
<svelte:window on:keydown={handleKeydown} on:keyup={handleKeyup} />
<div id="map" bind:this={mapContainer}></div>
<style>
<style lang="postcss">
@reference "tailwindcss";
@reference "../../../css/app.css";
#map {
width: 100%;
height: 100%;

View File

@@ -30,7 +30,11 @@
let thumbnail = $derived(
trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
? getFileURL(
trail,
trail.photos.at(trail.thumbnail ?? 0) ?? trail.photos[0],
"600x0"
)
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark,
@@ -71,6 +75,20 @@
/>
{/if}
</div>
{#if $currentUser && trail.like_count > 0}
<div
class="flex absolute items-center justify-center top-4 left-4 bg-background w-8 h-8 rounded-full"
>
<span class="tooltip" data-title={$_("likes")}>
<i class="fa fa-heart"></i>
</span>
<div
class="absolute pointer-events-none left-5 -top-1 text-xs rounded-full bg-menu-background px-1 text-center"
>
{trail.like_count}
</div>
</div>
{/if}
{#if (trail.public || trailIsShared) && $currentUser}
<div
class="flex absolute top-4 right-4 {trail.public && trailIsShared
@@ -111,20 +129,20 @@
{$_("by")}
<img
class="rounded-full w-5 aspect-square mx-1 inline"
src={getFileURL(
trail.expand.author,
trail.expand.author.avatar,
) ||
src={trail.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
{trail.expand.author.username}
{trail.expand.author.username}{trail.expand.author.isLocal
? ""
: "@" + trail.expand.author.domain}
</p>
{/if}
{#if trail.expand?.tags?.length}
<div class="flex flex-wrap gap-1 mb-3">
{#each trail.expand?.tags ?? [] as t}
<Chip text={t.name} closable={false} primary={false}></Chip>
<Chip text={t.name} closable={false} primary={false}
></Chip>
{/each}
</div>
{:else if trail.tags?.length}

View File

@@ -23,10 +23,11 @@
interface Props {
trail: Trail;
handle: string;
mode: "overview" | "map" | "list";
}
let { trail, mode }: Props = $props();
let { trail, handle, mode }: Props = $props();
let confirmModal: ConfirmModal;
let listSelectModal: ListSelectModal;
@@ -35,8 +36,10 @@
let lists: List[] = $state([]);
const isOwned: boolean = trail.author == $currentUser?.actor;
const allowEdit =
trail.author == $currentUser?.id ||
isOwned ||
trail.expand?.trail_share_via_trail?.some(
(s) => s.permission == "edit",
);
@@ -61,16 +64,16 @@
]
: []),
{ text: $_("print"), value: "print", icon: "print" },
...(trail.author != $currentUser?.id
? []
: [{ text: $_("add-to-list"), value: "list", icon: "bookmark" }]),
...(trail.author != $currentUser?.id
? []
: [{ text: $_("share"), value: "share", icon: "share" }]),
...(isOwned
? [{ text: $_("add-to-list"), value: "list", icon: "bookmark" }]
: []),
...(isOwned
? [{ text: $_("share"), value: "share", icon: "share" }]
: []),
...(allowEdit
? [{ text: $_("edit"), value: "edit", icon: "pen" }]
: []),
...(trail.author == $currentUser?.id
...(isOwned
? [{ text: $_("delete"), value: "delete", icon: "trash" }]
: []),
];
@@ -79,13 +82,13 @@
if (item.value == "show") {
goto(
mode == "overview"
? `/map/trail/${trail.id!}`
: `/trail/view/${trail.id!}`,
? `/map/trail/${handle}/${trail.id!}`
: `/trail/view/${handle}/${trail.id!}`,
);
} else if (item.value == "list") {
lists = (
await lists_index(
{ q: "", author: $currentUser?.id ?? "" },
{ q: "", author: $currentUser?.actor ?? "" },
1,
-1,
)
@@ -99,7 +102,7 @@
)
?.focus();
} else if (item.value == "print") {
goto(`/map/trail/${trail.id}/print`);
goto(`/map/trail/${handle}/${trail.id}/print`);
} else if (item.value == "share") {
trailShareModal.openModal();
} else if (item.value == "download") {
@@ -155,7 +158,7 @@
}
if (exportSettings.summitLog) {
let summitLogString = "";
for (const summitLog of trail.expand?.summit_logs ?? []) {
for (const summitLog of trail.expand?.summit_logs_via_trail ?? []) {
summitLogString += `${summitLog.date},${summitLog.text}\n`;
}
zip.file(

View File

@@ -37,7 +37,7 @@
}
</script>
<Modal id="export-modal" {title} size="max-w-sm" bind:this={modal}>
<Modal id="export-modal" {title} size="md:min-w-sm" bind:this={modal}>
{#snippet content()}
<div>
<div class="mb-3">

View File

@@ -8,6 +8,7 @@
import { getIconForLocation } from "$lib/util/icon_util";
import { _ } from "svelte-i18n";
import { slide } from "svelte/transition";
import ActorSearch from "../actor_search.svelte";
import Combobox, { type ComboboxItem } from "../base/combobox.svelte";
import Datepicker from "../base/datepicker.svelte";
import DoubleSlider from "../base/double_slider.svelte";
@@ -17,7 +18,6 @@
import Search, { type SearchItem } from "../base/search.svelte";
import type { SelectItem } from "../base/select.svelte";
import Slider from "../base/slider.svelte";
import UserSearch from "../user_search.svelte";
interface Props {
categories: Category[];
@@ -39,7 +39,7 @@
let categorySelectItems = $derived(
categories.map((c) => ({
value: c.id,
value: c.name,
text: c.name,
})),
);
@@ -62,7 +62,7 @@
let tagItems: ComboboxItem[] = $state([]);
async function update() {
async function update() {
onupdate?.(filter);
}
@@ -87,6 +87,11 @@
update();
}
function setLikedFilter(e: Event) {
filter.liked = (e.target as HTMLInputElement).checked;
update();
}
function setCompletedFilter(item: RadioItem) {
switch (item.value) {
case "no_preference":
@@ -217,7 +222,7 @@
<hr class="my-4 border-separator" />
{#if $currentUser}
<UserSearch
<ActorSearch
onclick={(item) => setAuthorFilter(item)}
onclear={() => {
filter.author = "";
@@ -225,23 +230,7 @@
}}
clearAfterSelect={false}
label={$_("author")}
></UserSearch>
<hr class="my-4 border-separator" />
<p class="text-sm font-medium">{$_("visibilty-status")}</p>
<div class="flex items-center mt-2 mb-4">
<input
id="private-checkbox"
type="checkbox"
checked={filter.private}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
onchange={setPrivateFilter}
/>
<label for="private-checkbox" class="ms-2 text-sm"
>{$_("private")}</label
>
</div>
></ActorSearch>
<div class="flex items-center my-4">
<input
id="public-checkbox"
@@ -370,7 +359,18 @@
onchange={update}
></Datepicker>
</div>
<hr class="my-4 border-separator" />
<p class="text-sm font-medium pb-4">{$_("like-status")}</p>
<input
id="liked-checkbox"
type="checkbox"
checked={filter.liked}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
onchange={setLikedFilter}
/>
<label for="liked-checkbox" class="ms-2 text-sm"
>{$_("liked")}</label
>
<hr class="my-4 border-separator" />
<p class="text-sm font-medium pb-4">{$_("completion-status")}</p>
<RadioGroup

View File

@@ -42,9 +42,25 @@
import SummitLogTable from "../summit_log/summit_log_table.svelte";
import MapWithElevationMaplibre from "./map_with_elevation_maplibre.svelte";
import TrailTimeline from "./trail_timeline.svelte";
import {
summit_logs_create,
summit_logs_delete,
summit_logs_index,
summit_logs_update,
summitLog,
summitLogs,
} from "$lib/stores/summit_log_store";
import { SummitLog } from "$lib/models/summit_log";
import SummitLogModal from "../summit_log/summit_log_modal.svelte";
import SkeletonTable from "../base/skeleton_table.svelte";
import ConfirmModal from "../confirm_modal.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
import LikeButton from "./like_button.svelte";
import Editor from "../base/editor.svelte";
interface Props {
initTrail: Trail;
handle: string;
mode?: "overview" | "map" | "list";
markers?: M.Marker[];
activeTab?: number;
@@ -52,11 +68,15 @@
let {
initTrail,
handle,
mode = "map",
markers = [],
activeTab = 0,
}: Props = $props();
let summitLogModal: SummitLogModal;
let confirmModal: ConfirmModal;
let trail = $state(initTrail);
const tabs = [
@@ -72,15 +92,17 @@
let newComment: Comment = $state({
text: "",
rating: 0,
author: "",
trail: trail.id ?? "",
trail: handle + "/" + (trail.id ?? ""),
});
let commentsLoading: boolean = $state(activeTab == 2);
let commentCreateLoading: boolean = $state(false);
let commentDeleteLoading: boolean = false;
let summitLogsLoading: boolean = $state(activeTab == 0);
let summitLogCreateLoading: boolean = $state(false);
let fullDescription: boolean = $state(false);
onMount(async () => {});
@@ -100,13 +122,23 @@
}
async function toggleMapFullScreen() {
goto(`/map/trail/${trail.id!}`);
goto(`/map/trail/${handle}/${trail.id!}`);
}
async function fetchComments() {
commentsLoading = true;
await comments_index(trail);
commentsLoading = false;
const trailId = trail.iri ? trail.iri : trail.id!;
try {
await comments_index(trailId, handle);
} catch (e) {
show_toast({
type: "error",
icon: "close",
text: "Error loading comments.",
});
} finally {
commentsLoading = false;
}
}
async function createComment() {
@@ -114,15 +146,12 @@
return;
}
commentCreateLoading = true;
newComment.author = $currentUser.id;
newComment.author = $currentUser.actor;
newComment.trail = trail.id;
try {
const c = await comments_create(newComment);
newComment.text = "";
c.expand = {
author: { ...$currentUser!, private: false },
};
const newCommentList = [c, ...$comments];
comments.set(newCommentList);
@@ -167,6 +196,70 @@
fetchComments();
}
});
$effect(() => {
if (activeTab == 0) {
fetchSummitLog();
}
});
async function fetchSummitLog() {
summitLogsLoading = true;
const trailId = trail.iri ? trail.iri : trail.id;
try {
await summit_logs_index({ trail: trailId, category: [] }, handle);
} catch (e) {
show_toast({
type: "error",
icon: "close",
text: "Error loading summit logs.",
});
} finally {
summitLogsLoading = false;
}
}
function beforeSummitLogModalOpen(currentSummitLog?: SummitLog) {
currentSummitLog ??= new SummitLog(
new Date().toISOString().split("T")[0],
);
summitLog.set(currentSummitLog);
summitLogModal.openModal();
}
async function saveSummitLog(log: SummitLog) {
summitLogCreateLoading = true;
if (log.id) {
let oldLogIndex = $summitLogs.findIndex((l) => l.id === log.id);
if (oldLogIndex < 0) {
return;
}
const updatedLog = await summit_logs_update(
$summitLogs[oldLogIndex],
log,
);
$summitLogs[oldLogIndex] = updatedLog;
} else {
log.trail = trail.id;
const newLog = await summit_logs_create(log);
summitLogs.set([...$summitLogs, newLog]);
}
summitLogCreateLoading = false;
}
function beforeConfirmModalOpen(currentSummitLog: SummitLog) {
summitLog.set(currentSummitLog);
confirmModal.openModal();
}
async function deleteSummitLog() {
await summit_logs_delete($summitLog);
const newSummitLogList = $summitLogs.filter(
(l) => l.id !== $summitLog.id,
);
summitLogs.set(newSummitLogList);
}
</script>
<div
@@ -283,22 +376,13 @@
{$_("by")}
<img
class="rounded-full w-8 aspect-square mx-1 inline"
src={getFileURL(
trail.expand.author,
trail.expand.author.avatar,
) ||
src={trail.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
{#if !trail.expand.author.private}
<a
class="underline"
href="/profile/{trail.expand.author.id}"
>{trail.expand.author.username}</a
>
{:else}
<span>{trail.expand.author.username}</span>
{/if}
<a class="underline" href="/profile/{handle}"
>{handleFromRecordWithIRI(trail)}</a
>
</p>
{/if}
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-2 mr-8">
@@ -314,8 +398,11 @@
</h3>
</div>
</div>
{#if ($currentUser && $currentUser.id == trail.author) || trail.expand?.trail_share_via_trail?.length || trail.public}
<TrailDropdown {trail} {mode}></TrailDropdown>
{#if ($currentUser && $currentUser.actor == trail.author) || trail.expand?.trail_share_via_trail?.length || trail.public}
<div class="flex flex-col items-center gap-y-2">
<LikeButton {trail}></LikeButton>
<TrailDropdown {trail} {mode} {handle}></TrailDropdown>
</div>
{/if}
</div>
</section>
@@ -386,8 +473,10 @@
{$_("description")}
</h4>
{#if trail.description?.length}
<article class="text-justify whitespace-pre-line text-sm">
{!fullDescription
<article
class="text-justify whitespace-pre-line text-sm prose dark:prose-invert"
>
{@html !fullDescription
? trail.description?.substring(0, 300)
: trail.description}
{#if (trail.description?.length ?? 0) > 300 && !fullDescription}
@@ -422,17 +511,39 @@
onmouseleave={closeMarkerPopup}
></TrailTimeline>
<div class="mb-6 mt-12">
<div class="mb-6 mt-12 flex justify-between flex-wrap gap-y-4">
<Tabs {tabs} bind:activeTab></Tabs>
{#if activeTab == 0 && mode != "list"}
<Button
secondary
type="button"
loading={summitLogCreateLoading}
disabled={summitLogCreateLoading}
onclick={() => beforeSummitLogModalOpen()}
><i class="fa fa-plus mr-2"></i>{$_(
"add-entry",
)}</Button
>
{/if}
</div>
{#if activeTab == 0}
<div class="overflow-x-auto">
<SummitLogTable
summitLogs={trail.expand?.summit_logs}
showAuthor
showRoute
showPhotos
></SummitLogTable>
<div
class="overflow-x-auto overflow-y-clip pb-3 scroll-x-only min-h-[175px]"
>
{#if summitLogsLoading}
<SkeletonTable></SkeletonTable>
{:else}
<SummitLogTable
{handle}
summitLogs={$summitLogs}
showAuthor
showRoute
showPhotos
showMenu
onedit={(log) => beforeSummitLogModalOpen(log)}
ondelete={(log) => beforeConfirmModalOpen(log)}
></SummitLogTable>
{/if}
</div>
{/if}
{#if activeTab == 1}
@@ -483,15 +594,15 @@
$currentUser,
$currentUser.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${$currentUser.username}&backgroundType=gradientLinear`}
`https://api.dicebear.com/7.x/initials/svg?seed=${$currentUser.username?.toLowerCase()}&backgroundType=gradientLinear`}
alt="avatar"
/>
<div class="basis-full">
<Textarea
<Editor
bind:value={newComment.text}
rows={2}
extraClasses="min-h-24 max-w-full"
placeholder="Add comment..."
></Textarea>
></Editor>
</div>
</div>
<div class="flex justify-end mt-3">
@@ -521,7 +632,7 @@
<CommentCard
{comment}
mode={comment.author ==
$currentUser?.id
$currentUser?.actor
? "edit"
: "show"}
ondelete={deleteComment}
@@ -557,6 +668,16 @@
</section>
</div>
<SummitLogModal bind:this={summitLogModal} onsave={(log) => saveSummitLog(log)}
></SummitLogModal>
<ConfirmModal
id="confirm-summit-log-delete-modal"
text={$_("delete-summit-log-confirm")}
bind:this={confirmModal}
onconfirm={deleteSummitLog}
></ConfirmModal>
<style>
.trail-info-panel img {
object-fit: cover;

View File

@@ -49,6 +49,7 @@
{ text: $_("difficulty"), value: "difficulty" },
{ text: $_("elevation-gain"), value: "elevation_gain" },
{ text: $_("elevation-loss"), value: "elevation_loss" },
{ text: $_("likes"), value: "like_count" },
{ text: $_("creation-date"), value: "created" },
{ text: $_("date"), value: "date" },
];
@@ -98,7 +99,7 @@
onupdate?.(filter);
}
function handleSortUpdate(sort: any) {
function handleSortUpdate(sort: any) {
if (!filter) {
return;
}
@@ -190,7 +191,9 @@
<a
class="max-w-full flex-1"
class:basis-full={selectedDisplayOption === "list"}
href="/trail/view/{trail.id}"
href="/trail/view/@{trail.author}{trail.domain
? `@${trail.domain}`
: ''}/{trail.id}"
>
{#if selectedDisplayOption === "cards"}
<TrailCard fullWidth={fullWidthCards} {trail}

View File

@@ -8,10 +8,12 @@
import {
formatDistance,
formatElevation,
formatHTMLAsText,
formatTimeHHMM,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import ShareInfo from "../share_info.svelte";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
interface Props {
trail: Trail;
@@ -22,7 +24,11 @@
let thumbnail = $derived(
trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
? getFileURL(
trail,
trail.photos.at(trail.thumbnail ?? 0) ?? trail.photos[0],
"600x0",
)
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark,
@@ -50,18 +56,32 @@
{/if}
</div>
<div class="min-w-0 basis-full">
<div class="flex items-center gap-4">
<div class="flex items-center justify-between">
<h4 class="font-semibold text-lg">
{trail.name}
</h4>
{#if trail.public && $currentUser}
<span class="tooltip ml-3" data-title={$_("public")}>
<i class="fa fa-globe"></i>
</span>
{/if}
{#if trail.expand?.trail_share_via_trail?.length}
<ShareInfo type="trail" subject={trail}></ShareInfo>
{/if}
<div class="flex items-center shrink-0 gap-3">
{#if trail.public && $currentUser}
<span class="tooltip" data-title={$_("public")}>
<i class="fa fa-globe"></i>
</span>
{/if}
{#if trail.expand?.trail_share_via_trail?.length}
<ShareInfo type="trail" subject={trail}></ShareInfo>
{/if}
{#if $currentUser && trail.like_count > 0}
<div class="flex items-start gap-x-[2px]">
<span class="tooltip" data-title={$_("likes")}>
<i class="fa fa-heart"></i>
</span>
<div
class="pointer-events-none left-4 -top-1 text-xs rounded-full bg-content text-content-inverse px-1 text-center"
>
{trail.like_count}
</div>
</div>
{/if}
</div>
</div>
{#if trail.date}
<p class="text-xs text-gray-500 mb-3">
@@ -78,14 +98,11 @@
{$_("by")}
<img
class="rounded-full w-5 aspect-square mx-1 inline"
src={getFileURL(
trail.expand.author,
trail.expand.author.avatar,
) ||
src={trail.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
{trail.expand.author.username}
{handleFromRecordWithIRI(trail)}
</p>
{/if}
<div class="flex flex-wrap gap-x-8">
@@ -123,7 +140,7 @@
<p
class="mt-3 text-sm whitespace-nowrap min-w-0 max-w-full overflow-hidden text-ellipsis"
>
{trail.description}
{formatHTMLAsText(trail.description ?? "")}
</p>
{/if}
</div>

View File

@@ -9,12 +9,11 @@
trail_share_index,
trail_share_update,
} from "$lib/stores/trail_share_store";
import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
import ActorSearch from "../actor_search.svelte";
import Button from "../base/button.svelte";
import type { SelectItem } from "../base/select.svelte";
import Select from "../base/select.svelte";
import UserSearch from "../user_search.svelte";
interface Props {
trail: Trail;
@@ -25,6 +24,8 @@
let modal: Modal;
let displayShareError = $state(false);
export function openModal() {
openShareModalLocal();
}
@@ -55,7 +56,12 @@
}
async function shareTrail(item: SelectItem) {
const share = new TrailShare(item.value.id, trail.id!, "view");
if (!item.value.isLocal && !trail.public) {
displayShareError = true;
return;
}
displayShareError = false;
const share = new TrailShare(item.value.iri, trail.id!, "view");
await trail_share_create(share);
fetchShares();
}
@@ -83,13 +89,23 @@
<Modal
id="share-modal"
title={$_("share-this-trail")}
size="max-w-sm overflow-visible"
size="md:min-w-sm overflow-visible"
bind:this={modal}
>
{#snippet content()}
<div>
<UserSearch includeSelf={false} onclick={(item) => shareTrail(item)}
></UserSearch>
{#if displayShareError}
<p class="p-4 bg-red-100 rounded-xl mb-4 text-sm text-gray-500">
<i class="fa fa-warning mr-2"></i>
{$_("object-share-error", {
values: { object: $_("trail", { values: { n: 1 } }) },
})}
</p>
{/if}
<ActorSearch
includeSelf={false}
onclick={(item) => shareTrail(item)}
></ActorSearch>
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
{#if $shares.length == 0}
@@ -102,22 +118,26 @@
<div class="flex items-center gap-x-2 p-2">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(
share.expand.user,
share.expand.user.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
src={share.expand.actor.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.actor.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p>{share.expand.user.username}</p>
<p>
{`@${share.expand.actor.username}${share.expand.actor.isLocal ? "" : "@" + share.expand.actor.domain}`}
</p>
<span
class="basis-full text-sm text-center text-gray-500"
class="basis-full text-sm text-gray-500 text-end"
>{$_("can")}</span
>
<div class="shrink-0">
<div
class="shrink-0"
class:tooltip={!share.expand.actor.isLocal}
data-title={$_("remote-users-cannot-edit")}
>
<Select
value={share.permission}
items={permissionSelectItems}
disabled={!share.expand.actor.isLocal}
onchange={(value) =>
updateSharePermission(share, value)}
></Select>

View File

@@ -15,7 +15,7 @@
tableHeader: SelectItem[];
trails?: Trail[] | null;
filter?: TrailFilter | null;
onsort?: (value: any) => void
onsort?: (value: any) => void;
}
let { tableHeader, trails = null, filter = null, onsort }: Props = $props();
@@ -71,7 +71,12 @@
{#each trails as trail}
<tr
class="border-t border-input-border cursor-pointer hover:bg-secondary-hover transition-colors"
onclick={() => goto(`/trail/view/${trail.id}`)}
onclick={() =>
goto(
`/trail/view/@${trail.author}${
trail.domain ? `@${trail.domain}` : ""
}/${trail.id}`,
)}
>
<td
class="flex justify-between items-center text-sm relative"
@@ -83,12 +88,9 @@
{#if trail.expand && trail.expand.author}
<div class="author-icon">
<img
title={`${trail.public ? $_("public") + " " : ""}${$_("by")} ${trail.expand.author.username}`}
title={`${trail.public ? $_("public") + " " : ""}${$_("by")} ${trail.expand.author.username}${trail.expand.author.isLocal ? "" : "@" + trail.expand.author.domain}`}
class="rounded-full w-5 aspect-square mx-1 inline"
src={getFileURL(
trail.expand.author,
trail.expand.author.avatar,
) ||
src={trail.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
@@ -122,6 +124,9 @@
<td class="p-4 text-sm">
{formatElevation(trail.elevation_gain)}
</td>
<td class="p-4 text-sm">
{trail.like_count}
</td>
<td class="p-4 text-sm">
{#if trail.created}
{new Date(trail.created).toLocaleDateString(