Improve the timeline UI and spacing
This commit is contained in:
parent
7c6bded69e
commit
fa56d0252d
2 changed files with 71 additions and 27 deletions
|
|
@ -6,9 +6,15 @@
|
||||||
posts: PostLike[];
|
posts: PostLike[];
|
||||||
postEls: (HTMLElement | null)[];
|
postEls: (HTMLElement | null)[];
|
||||||
topOffset: number;
|
topOffset: number;
|
||||||
|
visible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { posts, postEls, topOffset }: Props = $props();
|
let {
|
||||||
|
posts,
|
||||||
|
postEls,
|
||||||
|
topOffset,
|
||||||
|
visible = $bindable(false),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let scrollEl = $state<HTMLElement | null>(null);
|
let scrollEl = $state<HTMLElement | null>(null);
|
||||||
let trackEl = $state<HTMLElement | null>(null);
|
let trackEl = $state<HTMLElement | null>(null);
|
||||||
|
|
@ -23,6 +29,9 @@
|
||||||
let dragStartScrollTop = 0;
|
let dragStartScrollTop = 0;
|
||||||
|
|
||||||
const isOverflowing = $derived(scrollHeight > clientHeight);
|
const isOverflowing = $derived(scrollHeight > clientHeight);
|
||||||
|
$effect(() => {
|
||||||
|
visible = isOverflowing;
|
||||||
|
});
|
||||||
const maxScroll = $derived(Math.max(scrollHeight - clientHeight, 1));
|
const maxScroll = $derived(Math.max(scrollHeight - clientHeight, 1));
|
||||||
const thumbHeight = $derived(
|
const thumbHeight = $derived(
|
||||||
Math.max((clientHeight / scrollHeight) * trackHeight, 28),
|
Math.max((clientHeight / scrollHeight) * trackHeight, 28),
|
||||||
|
|
@ -60,11 +69,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(ts: number) {
|
function formatDate(ts: number) {
|
||||||
return new Date(ts * 1000).toLocaleDateString("en-US", {
|
const date = new Date(ts * 1000);
|
||||||
|
const monthDay = date.toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
|
||||||
});
|
});
|
||||||
|
const year = date.toLocaleDateString("en-US", { year: "numeric" });
|
||||||
|
return `${monthDay}\n${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function update() {
|
function update() {
|
||||||
|
|
@ -131,11 +142,15 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="sticky self-start flex-shrink-0 flex flex-col items-end select-none pt-1"
|
class="sticky self-start flex-shrink-0 flex flex-col items-end select-none pt-1"
|
||||||
style="top: calc({topOffset}px - 1.5rem); height: 50vh; width: 72px; visibility: {isOverflowing ? 'visible' : 'hidden'}; pointer-events: {isOverflowing ? 'auto' : 'none'};"
|
style="top: calc({topOffset}px - 1.5rem); height: 50vh; width: 72px; display: {isOverflowing
|
||||||
|
? 'flex'
|
||||||
|
: 'none'};"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<!-- First post date -->
|
<!-- First post date -->
|
||||||
<div class="text-xs text-gray-300 mb-1 text-right leading-tight">
|
<div
|
||||||
|
class="text-xs text-gray-300 mb-1 text-right leading-tight whitespace-pre"
|
||||||
|
>
|
||||||
{firstPost ? formatDate(firstPost.createdAt) : ""}
|
{firstPost ? formatDate(firstPost.createdAt) : ""}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -174,7 +189,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Last post date -->
|
<!-- Last post date -->
|
||||||
<div class="text-xs text-gray-300 mt-1 text-right leading-tight">
|
<div
|
||||||
|
class="text-xs text-gray-300 mt-1 text-right leading-tight whitespace-pre"
|
||||||
|
>
|
||||||
{lastPost ? formatDate(lastPost.createdAt) : ""}
|
{lastPost ? formatDate(lastPost.createdAt) : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { threadDetailStore, loadThread, sendReply, type PostData } from "$lib/thread.svelte";
|
import {
|
||||||
|
threadDetailStore,
|
||||||
|
loadThread,
|
||||||
|
sendReply,
|
||||||
|
type PostData,
|
||||||
|
} from "$lib/thread.svelte";
|
||||||
import { auth, login } from "$lib/auth.svelte";
|
import { auth, login } from "$lib/auth.svelte";
|
||||||
import Reactions from "$lib/components/Reactions.svelte";
|
import Reactions from "$lib/components/Reactions.svelte";
|
||||||
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
||||||
|
|
@ -9,7 +14,10 @@
|
||||||
|
|
||||||
type Author = { pubkey: string; name: string; picture?: string };
|
type Author = { pubkey: string; name: string; picture?: string };
|
||||||
|
|
||||||
function resolveAuthor(pubkey: string, profiles: Record<string, NostrUser>): Author {
|
function resolveAuthor(
|
||||||
|
pubkey: string,
|
||||||
|
profiles: Record<string, NostrUser>,
|
||||||
|
): Author {
|
||||||
const user = profiles[pubkey];
|
const user = profiles[pubkey];
|
||||||
return {
|
return {
|
||||||
pubkey,
|
pubkey,
|
||||||
|
|
@ -47,13 +55,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let opTopOffset = $state(0);
|
let opTopOffset = $state(0);
|
||||||
|
let scrubberVisible = $state(false);
|
||||||
|
|
||||||
const detail = $derived(threadDetailStore.detail);
|
const detail = $derived(threadDetailStore.detail);
|
||||||
const profiles = $derived(threadDetailStore.profiles);
|
const profiles = $derived(threadDetailStore.profiles);
|
||||||
|
|
||||||
const allPosts = $derived(
|
const allPosts = $derived(detail ? [detail.op, ...detail.replies] : []);
|
||||||
detail ? [detail.op, ...detail.replies] : []
|
|
||||||
);
|
|
||||||
const postEls = $derived([opEl, ...replyEls]);
|
const postEls = $derived([opEl, ...replyEls]);
|
||||||
|
|
||||||
// Reload when navigating between threads
|
// Reload when navigating between threads
|
||||||
|
|
@ -65,7 +72,9 @@
|
||||||
const main = document.querySelector("main");
|
const main = document.querySelector("main");
|
||||||
if (!main) return;
|
if (!main) return;
|
||||||
main.classList.add("no-scrollbar");
|
main.classList.add("no-scrollbar");
|
||||||
const onScroll = () => { isScrolled = main.scrollTop > 0; };
|
const onScroll = () => {
|
||||||
|
isScrolled = main.scrollTop > 0;
|
||||||
|
};
|
||||||
main.addEventListener("scroll", onScroll, { passive: true });
|
main.addEventListener("scroll", onScroll, { passive: true });
|
||||||
return () => {
|
return () => {
|
||||||
main.classList.remove("no-scrollbar");
|
main.classList.remove("no-scrollbar");
|
||||||
|
|
@ -88,9 +97,15 @@
|
||||||
|
|
||||||
{#snippet avatar(author: Author)}
|
{#snippet avatar(author: Author)}
|
||||||
{#if author.picture}
|
{#if author.picture}
|
||||||
<img src={author.picture} alt="" class="w-12 h-12 rounded-full object-cover" />
|
<img
|
||||||
|
src={author.picture}
|
||||||
|
alt=""
|
||||||
|
class="w-12 h-12 rounded-full object-cover"
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="w-12 h-12 flex items-center justify-center rounded-full bg-gray-200 text-lg font-semibold text-gray-500">
|
<span
|
||||||
|
class="w-12 h-12 flex items-center justify-center rounded-full bg-gray-200 text-lg font-semibold text-gray-500"
|
||||||
|
>
|
||||||
{author.name[0].toUpperCase()}
|
{author.name[0].toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -105,7 +120,9 @@
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-baseline justify-between mb-2">
|
<div class="flex items-baseline justify-between mb-2">
|
||||||
<span class="font-medium text-gray-600">{author.name}</span>
|
<span class="font-medium text-gray-600">{author.name}</span>
|
||||||
<span class="text-sm text-gray-400 ml-4 flex-shrink-0">{formatDate(p.createdAt)}</span>
|
<span class="text-sm text-gray-400 ml-4 flex-shrink-0"
|
||||||
|
>{formatDate(p.createdAt)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="prose leading-5 max-w-none text-gray-700">
|
<div class="prose leading-5 max-w-none text-gray-700">
|
||||||
{#each p.content.split("\n\n") as para}
|
{#each p.content.split("\n\n") as para}
|
||||||
|
|
@ -114,7 +131,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-3">
|
<div class="flex items-center justify-between mt-3">
|
||||||
<Reactions reactions={[]} zaps={0} />
|
<Reactions reactions={[]} zaps={0} />
|
||||||
<div class="flex items-center gap-4 text-sm text-gray-400 flex-shrink-0">
|
<div
|
||||||
|
class="flex items-center gap-4 text-sm text-gray-400 flex-shrink-0"
|
||||||
|
>
|
||||||
<button class="hover:text-brand transition-colors">Quote</button>
|
<button class="hover:text-brand transition-colors">Quote</button>
|
||||||
<button class="hover:text-brand transition-colors">React</button>
|
<button class="hover:text-brand transition-colors">React</button>
|
||||||
<button class="hover:text-amber-500 transition-colors">⚡ Zap</button>
|
<button class="hover:text-amber-500 transition-colors">⚡ Zap</button>
|
||||||
|
|
@ -126,21 +145,25 @@
|
||||||
|
|
||||||
{#if detail}
|
{#if detail}
|
||||||
<div class="flex gap-6 items-start">
|
<div class="flex gap-6 items-start">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0" class:pr-18={!scrubberVisible}>
|
||||||
<div class="sticky -top-6 bg-white z-10 pb-4 -mx-10 px-10 pt-6 -mt-6 relative">
|
<div
|
||||||
|
class="sticky -top-6 bg-white z-10 pb-4 -mx-10 px-10 pt-6 -mt-6 relative"
|
||||||
|
>
|
||||||
<h1 class="text-[1.65rem] text-brand leading-7">{detail.title}</h1>
|
<h1 class="text-[1.65rem] text-brand leading-7">{detail.title}</h1>
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 right-0 h-8 pointer-events-none transition-opacity duration-200"
|
class="absolute left-0 right-0 h-8 pointer-events-none transition-opacity duration-200"
|
||||||
style="top: 100%; opacity: {isScrolled ? 1 : 0}; background: linear-gradient(to bottom, white, transparent);"
|
style="top: 100%; opacity: {isScrolled
|
||||||
|
? 1
|
||||||
|
: 0}; background: linear-gradient(to bottom, white, transparent);"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pt-6 pb-6 border-b border-gray-100" bind:this={opEl}>
|
<div class="pt-6 pb-6" bind:this={opEl}>
|
||||||
{@render post(detail.op, () => {})}
|
{@render post(detail.op, () => {})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if detail.replies.length > 0}
|
{#if detail.replies.length > 0}
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100 border-t border-gray-100">
|
||||||
{#each detail.replies as reply, i}
|
{#each detail.replies as reply, i}
|
||||||
<div class="py-6" bind:this={replyEls[i]}>
|
<div class="py-6" bind:this={replyEls[i]}>
|
||||||
{@render post(reply, () => {})}
|
{@render post(reply, () => {})}
|
||||||
|
|
@ -149,10 +172,12 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mt-8 border-t border-gray-200 pt-6">
|
<div class="mt-8 border-t border-gray-200 pt-6 pl-18">
|
||||||
{#if auth.user}
|
{#if auth.user}
|
||||||
{#if replyError}
|
{#if replyError}
|
||||||
<div class="mb-4 rounded bg-red-50 px-4 py-3 text-sm text-red-700 border border-red-200">
|
<div
|
||||||
|
class="mb-4 rounded bg-red-50 px-4 py-3 text-sm text-red-700 border border-red-200"
|
||||||
|
>
|
||||||
{replyError}
|
{replyError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -173,15 +198,17 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm text-gray-500">
|
<p class=" text-gray-500 text-center">
|
||||||
To participate and reply,
|
To participate and reply, please
|
||||||
<button onclick={login} class="text-brand hover:underline">login now</button>
|
<button onclick={login} class="text-brand hover:underline"
|
||||||
|
>login now</button
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ThreadScrubber posts={allPosts} {postEls} topOffset={opTopOffset} />
|
<ThreadScrubber posts={allPosts} {postEls} topOffset={opTopOffset} bind:visible={scrubberVisible} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="py-12 text-center text-gray-400">Loading…</div>
|
<div class="py-12 text-center text-gray-400">Loading…</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue