Review thread page with a working timeline
This commit is contained in:
parent
f739fd32ae
commit
2e743b41e0
4 changed files with 418 additions and 73 deletions
179
src/lib/components/ThreadScrubber.svelte
Normal file
179
src/lib/components/ThreadScrubber.svelte
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { Post } from "$lib/mock";
|
||||
|
||||
type Props = {
|
||||
posts: Post[];
|
||||
postEls: (HTMLElement | null)[];
|
||||
topOffset: number;
|
||||
};
|
||||
|
||||
let { posts, postEls, topOffset }: Props = $props();
|
||||
|
||||
let scrollEl = $state<HTMLElement | null>(null);
|
||||
let trackEl = $state<HTMLElement | null>(null);
|
||||
|
||||
let scrollTop = $state(0);
|
||||
let scrollHeight = $state(1);
|
||||
let clientHeight = $state(1);
|
||||
let trackHeight = $state(300);
|
||||
|
||||
let isDragging = false;
|
||||
let dragStartY = 0;
|
||||
let dragStartScrollTop = 0;
|
||||
|
||||
const maxScroll = $derived(Math.max(scrollHeight - clientHeight, 1));
|
||||
const thumbHeight = $derived(
|
||||
Math.max((clientHeight / scrollHeight) * trackHeight, 28),
|
||||
);
|
||||
const thumbTop = $derived(
|
||||
(scrollTop / maxScroll) * (trackHeight - thumbHeight),
|
||||
);
|
||||
|
||||
const currentPostIndex = $derived.by(() => {
|
||||
if (!scrollEl || !postEls.length) return 0;
|
||||
const mid = scrollTop + clientHeight / 2;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < postEls.length; i++) {
|
||||
const el = postEls[i];
|
||||
if (!el) continue;
|
||||
const mainTop = scrollEl.getBoundingClientRect().top;
|
||||
const elTop = el.getBoundingClientRect().top - mainTop + scrollTop;
|
||||
if (elTop <= mid) idx = i;
|
||||
else break;
|
||||
}
|
||||
return idx;
|
||||
});
|
||||
|
||||
const currentDate = $derived(
|
||||
posts[currentPostIndex]
|
||||
? formatShortDate(posts[currentPostIndex].createdAt)
|
||||
: "",
|
||||
);
|
||||
|
||||
function formatShortDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function update() {
|
||||
if (!scrollEl) return;
|
||||
scrollTop = scrollEl.scrollTop;
|
||||
scrollHeight = scrollEl.scrollHeight;
|
||||
clientHeight = scrollEl.clientHeight;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
scrollEl = document.querySelector("main");
|
||||
if (!scrollEl) return;
|
||||
update();
|
||||
scrollEl.addEventListener("scroll", update, { passive: true });
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(scrollEl);
|
||||
return () => {
|
||||
scrollEl!.removeEventListener("scroll", update);
|
||||
ro.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!trackEl) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
trackHeight = trackEl!.clientHeight;
|
||||
});
|
||||
ro.observe(trackEl);
|
||||
trackHeight = trackEl.clientHeight;
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
function onThumbPointerDown(e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragging = true;
|
||||
dragStartY = e.clientY;
|
||||
dragStartScrollTop = scrollTop;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function onThumbPointerMove(e: PointerEvent) {
|
||||
if (!isDragging || !scrollEl) return;
|
||||
const dy = e.clientY - dragStartY;
|
||||
const ratio = dy / (trackHeight - thumbHeight);
|
||||
scrollEl.scrollTop = dragStartScrollTop + ratio * maxScroll;
|
||||
}
|
||||
|
||||
function onThumbPointerUp() {
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
function onTrackClick(e: MouseEvent) {
|
||||
if (!scrollEl || !trackEl) return;
|
||||
const rect = trackEl.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top - thumbHeight / 2;
|
||||
const ratio = Math.max(0, Math.min(1, y / (trackHeight - thumbHeight)));
|
||||
scrollEl.scrollTop = ratio * maxScroll;
|
||||
}
|
||||
|
||||
const firstPost = $derived(posts[0]);
|
||||
const lastPost = $derived(posts[posts.length - 1]);
|
||||
</script>
|
||||
|
||||
<div
|
||||
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;"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- First post date -->
|
||||
<div class="text-xs text-gray-300 mb-1 text-right leading-tight">
|
||||
{firstPost ? formatDate(firstPost.createdAt) : ""}
|
||||
</div>
|
||||
|
||||
<!-- Track area -->
|
||||
<div
|
||||
bind:this={trackEl}
|
||||
class="relative flex-1 w-full cursor-pointer"
|
||||
onclick={onTrackClick}
|
||||
role="presentation"
|
||||
>
|
||||
<!-- Track line -->
|
||||
<div
|
||||
class="absolute top-0 bottom-0 rounded-full bg-brand"
|
||||
style="width: 2px; right: 4px;"
|
||||
></div>
|
||||
|
||||
<!-- Thumb -->
|
||||
<div
|
||||
class="absolute rounded-full bg-brand hover:bg-gray-800 transition-colors cursor-grab active:cursor-grabbing touch-none"
|
||||
style="width: 4px; right: 3px; top: {thumbTop}px; height: {thumbHeight}px;"
|
||||
onpointerdown={onThumbPointerDown}
|
||||
onpointermove={onThumbPointerMove}
|
||||
onpointerup={onThumbPointerUp}
|
||||
onpointercancel={onThumbPointerUp}
|
||||
role="presentation"
|
||||
></div>
|
||||
|
||||
<!-- Current date label -->
|
||||
<div
|
||||
class="absolute text-xs text-brand whitespace-nowrap pointer-events-none leading-tight"
|
||||
style="right: 18px; top: {thumbTop +
|
||||
thumbHeight / 2}px; transform: translateY(-50%);"
|
||||
>
|
||||
{currentDate}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last post date -->
|
||||
<div class="text-xs text-gray-300 mt-1 text-right leading-tight">
|
||||
{lastPost ? formatDate(lastPost.createdAt) : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -97,7 +97,7 @@ export const threads: Thread[] = [
|
|||
id: "r1",
|
||||
author: bob,
|
||||
content:
|
||||
"Etiam efficitur ornare odio, id elementum felis interdum sit amet. Nam tincidunt justo quam, eu rhoncus lectus pellentesque id.",
|
||||
"Etiam efficitur ornare odio, id elementum felis interdum sit amet. Nam tincidunt justo quam, eu rhoncus lectus pellentesque id. Vivamus fermentum accumsan lorem, laoreet molestie lectus facilisis in.",
|
||||
createdAt: "2025-01-15T11:00:00Z",
|
||||
reactions: [{ emoji: "👍", count: 5 }],
|
||||
zaps: 210,
|
||||
|
|
@ -106,7 +106,7 @@ export const threads: Thread[] = [
|
|||
id: "r2",
|
||||
author: carol,
|
||||
content:
|
||||
"Maecenas sollicitudin erat eu metus lacinia congue. Nunc sagittis laoreet odio, non molestie eros.",
|
||||
"Maecenas sollicitudin erat eu metus lacinia congue. Nunc sagittis laoreet odio, non molestie eros. Proin vitae ex iaculis, luctus elit in, fermentum turpis. Pellentesque sagittis congue quam erat, egestas a lobortis non.",
|
||||
createdAt: "2025-01-15T12:30:00Z",
|
||||
reactions: [
|
||||
{ emoji: "❤️", count: 2 },
|
||||
|
|
@ -114,6 +114,87 @@ export const threads: Thread[] = [
|
|||
],
|
||||
zaps: 0,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
author: dave,
|
||||
content:
|
||||
"Interesting point. Sed tempus augue sapien laoreet, sed iaculis velit dictum diam. Fusce ullamcorper nulla a purus faucibus, vel hendrerit velit sodales. Nullam porttitor arcu vel diam hendrerit, nec gravida magna tincidunt.",
|
||||
createdAt: "2025-01-16T09:15:00Z",
|
||||
reactions: [{ emoji: "👍", count: 3 }],
|
||||
zaps: 50,
|
||||
},
|
||||
{
|
||||
id: "r4",
|
||||
author: alice,
|
||||
content:
|
||||
"To expand on what Dave said — Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper.",
|
||||
createdAt: "2025-01-17T14:00:00Z",
|
||||
reactions: [
|
||||
{ emoji: "🎉", count: 4 },
|
||||
{ emoji: "👍", count: 2 },
|
||||
],
|
||||
zaps: 320,
|
||||
},
|
||||
{
|
||||
id: "r5",
|
||||
author: bob,
|
||||
content:
|
||||
"Nam rutrum dolor in nulla imperdiet consequat. Sed tincidunt augue a velit consectetur, et cursus lorem auctor. Curabitur tempor felis nec magna blandit, ac posuere lorem varius.",
|
||||
createdAt: "2025-01-18T10:30:00Z",
|
||||
reactions: [],
|
||||
zaps: 0,
|
||||
},
|
||||
{
|
||||
id: "r6",
|
||||
author: carol,
|
||||
content:
|
||||
"Phasellus nec velit quis luctus maximus pellentesque. Nam erat, egestas a lobortis non, molestie ut enim. Quisque sagittis quam dui, nec pulvinar velit aliquam in. Sed volutpat interdum purus at convallis.",
|
||||
createdAt: "2025-01-19T16:45:00Z",
|
||||
reactions: [{ emoji: "❤️", count: 1 }],
|
||||
zaps: 80,
|
||||
},
|
||||
{
|
||||
id: "r7",
|
||||
author: dave,
|
||||
content:
|
||||
"Vivamus fermentum accumsan lorem, laoreet molestie lectus facilisis in. In hac habitasse platea dictumst. Nulla a quam tempor, posuere neque non, fringilla velit.",
|
||||
createdAt: "2025-01-20T11:00:00Z",
|
||||
reactions: [{ emoji: "👍", count: 7 }],
|
||||
zaps: 140,
|
||||
},
|
||||
{
|
||||
id: "r8",
|
||||
author: alice,
|
||||
content:
|
||||
"Mauro dui dolor, sagittis id sem nec, rhoncus consequat duis. Suspendisse dapibus mauris maximus mauris imperdiet. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.",
|
||||
createdAt: "2025-01-21T08:30:00Z",
|
||||
reactions: [
|
||||
{ emoji: "🎉", count: 2 },
|
||||
{ emoji: "👍", count: 6 },
|
||||
],
|
||||
zaps: 450,
|
||||
},
|
||||
{
|
||||
id: "r9",
|
||||
author: bob,
|
||||
content:
|
||||
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
||||
createdAt: "2025-01-22T13:15:00Z",
|
||||
reactions: [{ emoji: "👍", count: 4 }],
|
||||
zaps: 0,
|
||||
},
|
||||
{
|
||||
id: "r10",
|
||||
author: carol,
|
||||
content:
|
||||
"Great discussion everyone. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. This sums it up well I think.",
|
||||
createdAt: "2025-01-23T17:00:00Z",
|
||||
reactions: [
|
||||
{ emoji: "❤️", count: 5 },
|
||||
{ emoji: "🎉", count: 3 },
|
||||
],
|
||||
zaps: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
replyCount: 12,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
<Navbar />
|
||||
<div class="flex flex-1 gap-5 overflow-hidden pt-2">
|
||||
<LeftSidebar {mode} {activeRoom} />
|
||||
<main class="flex-1 overflow-y-auto rounded-t-xl bg-white px-10 py-6">
|
||||
<main class="flex-1 overflow-y-auto rounded-t-xl bg-white px-10 pt-6 pb-20">
|
||||
{@render children()}
|
||||
</main>
|
||||
{#if chatEnabled}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,47 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { threads } from "$lib/mock";
|
||||
import Tag from "$lib/components/Tag.svelte";
|
||||
import Reactions from "$lib/components/Reactions.svelte";
|
||||
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const thread = $derived(threads.find((t) => t.id === page.params.id));
|
||||
|
||||
let opEl = $state<HTMLElement | null>(null);
|
||||
let replyEls = $state<(HTMLElement | null)[]>([]);
|
||||
|
||||
// Gradient: visible only once the user has scrolled
|
||||
let isScrolled = $state(false);
|
||||
|
||||
// Scrubber: top offset = OP's natural distance from main's top border
|
||||
let opTopOffset = $state(0);
|
||||
|
||||
const allPosts = $derived(
|
||||
thread ? [thread.op, ...(thread.op.replies ?? [])] : [],
|
||||
);
|
||||
|
||||
const postEls = $derived([opEl, ...replyEls]);
|
||||
|
||||
onMount(() => {
|
||||
const main = document.querySelector("main");
|
||||
if (!main) return;
|
||||
const onScroll = () => {
|
||||
isScrolled = main.scrollTop > 0;
|
||||
};
|
||||
main.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => main.removeEventListener("scroll", onScroll);
|
||||
});
|
||||
|
||||
// Measure OP's natural distance from main's border top (once after DOM is ready)
|
||||
$effect(() => {
|
||||
if (!opEl) return;
|
||||
const main = document.querySelector("main");
|
||||
if (!main) return;
|
||||
opTopOffset =
|
||||
opEl.getBoundingClientRect().top - main.getBoundingClientRect().top;
|
||||
});
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
|
|
@ -20,72 +56,117 @@
|
|||
</svelte:head>
|
||||
|
||||
{#if thread}
|
||||
<div class="max-w-4xl">
|
||||
<!-- Thread header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<div class="flex gap-6 items-start">
|
||||
<!-- Content column -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Sticky title: -top-6 anchors to the border edge (past the py-6 padding gap) -->
|
||||
<!-- -mt-6 -mx-10 pull the bg flush to main's edges so nothing bleeds through above/sides -->
|
||||
<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">{thread.title}</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#each thread.tags as tag}
|
||||
<Tag label={tag.label} color={tag.color} />
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Gradient: only visible once scrolling starts -->
|
||||
<div
|
||||
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);"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- OP -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<!-- Tags: not sticky, scroll away -->
|
||||
<div class="flex gap-2 pt-3 pb-6">
|
||||
{#each thread.tags as tag}
|
||||
<Tag label={tag.label} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- OP post -->
|
||||
<div bind:this={opEl} class="pb-6 border-b border-gray-100">
|
||||
<div class="flex gap-6 items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
src={thread.op.author.picture}
|
||||
alt={thread.op.author.name}
|
||||
class="h-8 w-8 rounded-full"
|
||||
class="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline justify-between mb-2">
|
||||
<span class="font-semibold text-gray-900"
|
||||
>{thread.op.author.name}</span
|
||||
>
|
||||
<span class="ml-2 text-xs text-gray-400"
|
||||
<span class="text-sm text-gray-400 ml-4 flex-shrink-0"
|
||||
>{formatDate(thread.op.createdAt)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prose leading-6 max-w-none text-gray-700 mb-4">
|
||||
<div class="prose leading-5 max-w-none text-gray-700">
|
||||
{#each thread.op.content.split("\n\n") as para}
|
||||
<p>{para}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<Reactions reactions={thread.op.reactions} zaps={thread.op.zaps} />
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<Reactions
|
||||
reactions={thread.op.reactions}
|
||||
zaps={thread.op.zaps}
|
||||
/>
|
||||
<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">React</button
|
||||
>
|
||||
<button class="hover:text-amber-500 transition-colors"
|
||||
>⚡ Zap</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
{#if thread.op.replies && thread.op.replies.length > 0}
|
||||
<div class="border-t border-gray-200 pt-6 space-y-6">
|
||||
<h2
|
||||
class="text-sm font-semibold uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
{thread.op.replies.length}
|
||||
{thread.op.replies.length === 1 ? "reply" : "replies"}
|
||||
</h2>
|
||||
{#each thread.op.replies as reply}
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="divide-y divide-gray-100">
|
||||
{#each thread.op.replies as reply, i}
|
||||
<div bind:this={replyEls[i]} class="py-6">
|
||||
<div class="flex gap-6 items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
src={reply.author.picture}
|
||||
alt={reply.author.name}
|
||||
class="h-7 w-7 rounded-full"
|
||||
class="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline justify-between mb-2">
|
||||
<span class="font-semibold text-gray-900"
|
||||
>{reply.author.name}</span
|
||||
>
|
||||
<span class="ml-2 text-xs text-gray-400"
|
||||
<span class="text-sm text-gray-400 ml-4 flex-shrink-0"
|
||||
>{formatDate(reply.createdAt)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class=" text-gray-700 mb-2">{reply.content}</p>
|
||||
<p class="text-gray-700 leading-5">{reply.content}</p>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<Reactions reactions={reply.reactions} zaps={reply.zaps} />
|
||||
<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"
|
||||
>React</button
|
||||
>
|
||||
<button class="hover:text-amber-500 transition-colors"
|
||||
>⚡ Zap</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -107,6 +188,10 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline scrubber -->
|
||||
<ThreadScrubber posts={allPosts} {postEls} topOffset={opTopOffset} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-6 py-6 text-gray-500">Thread not found.</div>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue