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
|
|
@ -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,92 +56,141 @@
|
|||
</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>
|
||||
<!-- 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>
|
||||
<div class="flex gap-2">
|
||||
|
||||
<!-- Tags: not sticky, scroll away -->
|
||||
<div class="flex gap-2 pt-3 pb-6">
|
||||
{#each thread.tags as tag}
|
||||
<Tag label={tag.label} color={tag.color} />
|
||||
<Tag label={tag.label} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OP -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<img
|
||||
src={thread.op.author.picture}
|
||||
alt={thread.op.author.name}
|
||||
class="h-8 w-8 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<span class="font-semibold text-gray-900"
|
||||
>{thread.op.author.name}</span
|
||||
>
|
||||
<span class="ml-2 text-xs text-gray-400"
|
||||
>{formatDate(thread.op.createdAt)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prose leading-6 max-w-none text-gray-700 mb-4">
|
||||
{#each thread.op.content.split("\n\n") as para}
|
||||
<p>{para}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<Reactions reactions={thread.op.reactions} zaps={thread.op.zaps} />
|
||||
</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">
|
||||
<img
|
||||
src={reply.author.picture}
|
||||
alt={reply.author.name}
|
||||
class="h-7 w-7 rounded-full"
|
||||
<!-- 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="w-12 h-12 rounded-full"
|
||||
/>
|
||||
</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="text-sm text-gray-400 ml-4 flex-shrink-0"
|
||||
>{formatDate(thread.op.createdAt)}</span
|
||||
>
|
||||
</div>
|
||||
<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>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<Reactions
|
||||
reactions={thread.op.reactions}
|
||||
zaps={thread.op.zaps}
|
||||
/>
|
||||
<div>
|
||||
<span class="font-semibold text-gray-900"
|
||||
>{reply.author.name}</span
|
||||
<div
|
||||
class="flex items-center gap-4 text-sm text-gray-400 flex-shrink-0"
|
||||
>
|
||||
<button class="hover:text-brand transition-colors">Quote</button
|
||||
>
|
||||
<span class="ml-2 text-xs text-gray-400"
|
||||
>{formatDate(reply.createdAt)}</span
|
||||
<button class="hover:text-brand transition-colors">React</button
|
||||
>
|
||||
<button class="hover:text-amber-500 transition-colors"
|
||||
>⚡ Zap</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class=" text-gray-700 mb-2">{reply.content}</p>
|
||||
<Reactions reactions={reply.reactions} zaps={reply.zaps} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Reply box -->
|
||||
<div class="mt-8 border-t border-gray-200 pt-6">
|
||||
<textarea
|
||||
rows="4"
|
||||
placeholder="Write a reply..."
|
||||
class="w-full rounded border border-gray-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand"
|
||||
></textarea>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
class="rounded bg-brand px-6 py-1.5 font-medium text-white hover:bg-brand-hover"
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
<!-- Replies -->
|
||||
{#if thread.op.replies && thread.op.replies.length > 0}
|
||||
<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="w-12 h-12 rounded-full"
|
||||
/>
|
||||
</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="text-sm text-gray-400 ml-4 flex-shrink-0"
|
||||
>{formatDate(reply.createdAt)}</span
|
||||
>
|
||||
</div>
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<!-- Reply box -->
|
||||
<div class="mt-8 border-t border-gray-200 pt-6">
|
||||
<textarea
|
||||
rows="4"
|
||||
placeholder="Write a reply..."
|
||||
class="w-full rounded border border-gray-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand"
|
||||
></textarea>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
class="rounded bg-brand px-6 py-1.5 font-medium text-white hover:bg-brand-hover"
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue