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:
112
web/src/lib/components/trail/like_button.svelte
Normal file
112
web/src/lib/components/trail/like_button.svelte
Normal 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>
|
||||
@@ -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%;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user