Improve the timeline UI and spacing

This commit is contained in:
dtonon 2026-04-07 12:36:37 +01:00
parent 7c6bded69e
commit fa56d0252d
2 changed files with 71 additions and 27 deletions

View file

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

View file

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