Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

News in Sidebar #1557

Merged
merged 19 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/web/app/app.dub.co/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { MainNav } from "@/ui/layout/main-nav";
import { HelpButtonRSC } from "@/ui/layout/sidebar/help-button-rsc";
import { NewsRSC } from "@/ui/layout/sidebar/news-rsc";
import Toolbar from "@/ui/layout/toolbar/toolbar";
import { constructMetadata } from "@dub/utils";
import { ReactNode } from "react";
Expand All @@ -12,7 +13,9 @@ export default async function Layout({ children }: { children: ReactNode }) {
return (
<Providers>
<div className="min-h-screen w-full bg-white">
<MainNav toolContent={<HelpButtonRSC />}>{children}</MainNav>
<MainNav toolContent={<HelpButtonRSC />} newsContent={<NewsRSC />}>
{children}
</MainNav>
</div>
{/* <ChangelogPopup /> */}
<Toolbar show={["onboarding"]} />
Expand Down
8 changes: 8 additions & 0 deletions apps/web/lib/fetchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { prisma } from "@/lib/prisma";
import { cache } from "react";
import { getSession } from "./auth";

export const getContentAPI = cache(async () => {
return await fetch("https://dub.co/api/content", {
next: {
revalidate: 60 * 60 * 24, // cache for 24 hours
},
}).then((res) => res.json());
});

export const getDefaultWorkspace = cache(async () => {
const session = await getSession();
if (!session) {
Expand Down
5 changes: 3 additions & 2 deletions apps/web/ui/layout/main-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export const SideNavContext = createContext<SideNavContext>({
export function MainNav({
children,
toolContent,
}: PropsWithChildren<{ toolContent?: ReactNode }>) {
newsContent,
}: PropsWithChildren<{ toolContent?: ReactNode; newsContent?: ReactNode }>) {
const pathname = usePathname();

const { isMobile } = useMediaQuery();
Expand Down Expand Up @@ -75,7 +76,7 @@ export function MainNav({
)}
/>
</div>
<SidebarNav toolContent={toolContent} />
<SidebarNav toolContent={toolContent} newsContent={newsContent} />
</div>
</div>
<div className="bg-neutral-100 md:pt-1.5">
Expand Down
10 changes: 2 additions & 8 deletions apps/web/ui/layout/sidebar/help-button-rsc.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
import { getContentAPI } from "@/lib/fetchers";
import { HelpButton } from "./help-button";

export async function HelpButtonRSC() {
const { popularHelpArticles, allHelpArticles } = await fetch(
"https://dub.co/api/content",
{
next: {
revalidate: 60 * 60 * 24, // cache for 24 hours
},
},
).then((res) => res.json());
const { popularHelpArticles, allHelpArticles } = await getContentAPI();

return (
<HelpButton
Expand Down
19 changes: 19 additions & 0 deletions apps/web/ui/layout/sidebar/news-rsc.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getContentAPI } from "@/lib/fetchers";
import { ClientOnly } from "@dub/ui";
import { News, NewsArticle } from "./news";

export async function NewsRSC() {
const { latestNewsArticles } = await getContentAPI();

return (
<ClientOnly>
<News
articles={
(Array.isArray(latestNewsArticles)
? latestNewsArticles
: []) as NewsArticle[]
}
/>
</ClientOnly>
);
}
255 changes: 255 additions & 0 deletions apps/web/ui/layout/sidebar/news.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
"use client";

import { useLocalStorage, useMediaQuery } from "@dub/ui";
import { cn } from "@dub/utils";
import Image from "next/image";
import Link from "next/link";
import { CSSProperties, useRef, useState } from "react";

export interface NewsArticle {
href: string;
title: string;
summary: string;
image: string;
}

const OFFSET_FACTOR = 4;
const SCALE_FACTOR = 0.03;
const OPACITY_FACTOR = 0.1;

export function News({ articles }: { articles: NewsArticle[] }) {
const [dismissedNews, setDismissedNews] = useLocalStorage<string[]>(
"dismissed-news",
[],
);

const cards = articles.filter(({ href }) => !dismissedNews.includes(href));
const cardCount = cards.length;

return cards.length ? (
<div className="group overflow-hidden px-3 pb-3 pt-8">
<div className="relative size-full">
{cards.toReversed().map(({ href, title, summary, image }, idx) => (
<div
key={href}
className={cn(
"absolute left-0 top-0 size-full scale-[var(--scale)] transition-[opacity,transform] duration-200",
cardCount - idx > 3
? [
"opacity-0 sm:group-hover:translate-y-[var(--y)] sm:group-hover:opacity-[var(--opacity)]",
"sm:group-has-[*[data-dragging=true]]:translate-y-[var(--y)] sm:group-has-[*[data-dragging=true]]:opacity-[var(--opacity)]",
]
: "translate-y-[var(--y)] opacity-[var(--opacity)]",
)}
style={
{
"--y": `-${(cardCount - (idx + 1)) * OFFSET_FACTOR}%`,
"--scale": 1 - (cardCount - (idx + 1)) * SCALE_FACTOR,
"--opacity":
// Hide cards that are too far down (will show top 6)
cardCount - (idx + 1) >= 6
? 0
: 1 - (cardCount - (idx + 1)) * OPACITY_FACTOR,
} as CSSProperties
}
aria-hidden={idx !== cardCount - 1}
>
<NewsCard
key={idx}
title={title}
description={summary}
image={image}
href={href}
hideContent={cardCount - idx > 2}
active={idx === cardCount - 1}
onDismiss={
() => setDismissedNews([href, ...dismissedNews.slice(0, 50)]) // Limit to keep storage size low
}
/>
</div>
))}
<div className="pointer-events-none invisible" aria-hidden>
<NewsCard title="Title" description="Description" />
</div>
</div>
</div>
) : null;
}

function NewsCard({
title,
description,
image,
onDismiss,
hideContent,
href,
active,
}: {
title: string;
description: string;
image?: string;
onDismiss?: () => void;
hideContent?: boolean;
href?: string;
active?: boolean;
}) {
const { isMobile } = useMediaQuery();

const ref = useRef<HTMLDivElement>(null);
const drag = useRef<{
start: number;
delta: number;
startTime: number;
maxDelta: number;
}>({
start: 0,
delta: 0,
startTime: 0,
maxDelta: 0,
});
const animation = useRef<Animation>();
const [dragging, setDragging] = useState(false);

const onDragMove = (e: PointerEvent) => {
if (!ref.current) return;
const { clientX } = e;
const dx = clientX - drag.current.start;
drag.current.delta = dx;
drag.current.maxDelta = Math.max(drag.current.maxDelta, Math.abs(dx));
ref.current.style.setProperty("--dx", dx.toString());
};

const dismiss = () => {
if (!ref.current) return;

const cardWidth = ref.current.getBoundingClientRect().width;
const translateX = Math.sign(drag.current.delta) * cardWidth;

// Dismiss card
animation.current = ref.current.animate(
{ opacity: 0, transform: `translateX(${translateX}px)` },
{ duration: 150, easing: "ease-in-out", fill: "forwards" },
);
animation.current.onfinish = () => onDismiss?.();
};

const stopDragging = (cancelled: boolean) => {
if (!ref.current) return;
unbindListeners();
setDragging(false);

const dx = drag.current.delta;
if (Math.abs(dx) > ref.current.clientWidth / (cancelled ? 2 : 3)) {
dismiss();
return;
}

// Animate back to original position
animation.current = ref.current.animate(
{ transform: "translateX(0)" },
{ duration: 150, easing: "ease-in-out" },
);
animation.current.onfinish = () =>
ref.current?.style.setProperty("--dx", "0");

drag.current = { start: 0, delta: 0, startTime: 0, maxDelta: 0 };
};

const onDragEnd = () => stopDragging(false);
const onDragCancel = () => stopDragging(true);

const onPointerDown = (e: React.PointerEvent) => {
if (!active || !ref.current || animation.current?.playState === "running")
return;

bindListeners();
setDragging(true);
drag.current.start = e.clientX;
drag.current.startTime = Date.now();
drag.current.delta = 0;
ref.current.style.setProperty("--w", ref.current.clientWidth.toString());
};

const onClick = () => {
if (!ref.current) return;
if (
isMobile &&
drag.current.maxDelta < ref.current.clientWidth / 10 &&
(!drag.current.startTime || Date.now() - drag.current.startTime < 250)
) {
// Touch user didn't drag far or for long, open the link
window.open(href, "_blank");
}
};

const bindListeners = () => {
document.addEventListener("pointermove", onDragMove);
document.addEventListener("pointerup", onDragEnd);
document.addEventListener("pointercancel", onDragCancel);
};

const unbindListeners = () => {
document.removeEventListener("pointermove", onDragMove);
document.removeEventListener("pointerup", onDragEnd);
document.removeEventListener("pointercancel", onDragCancel);
};

return (
<div
ref={ref}
className={cn(
"relative select-none gap-2 rounded-lg border border-neutral-200 bg-white p-3 text-[0.8125rem]",
"translate-x-[calc(var(--dx)*1px)] rotate-[calc(var(--dx)*0.05deg)] opacity-[calc(1-max(var(--dx),-1*var(--dx))/var(--w)/2)]",
"transition-shadow data-[dragging=true]:shadow-[0_4px_12px_0_#0000000D]",
)}
data-dragging={dragging}
onPointerDown={onPointerDown}
onClick={onClick}
>
<div className={cn(hideContent && "invisible")}>
<div className="flex flex-col gap-1">
<span className="line-clamp-1 font-medium text-neutral-900">
{title}
</span>
<p className="line-clamp-2 h-10 leading-5 text-neutral-500">
{description}
</p>
</div>
<div className="relative mt-3 aspect-[16/9] w-full shrink-0 overflow-hidden rounded border border-neutral-200 bg-neutral-100">
{image && (
<Image
src={image}
alt=""
fill
className="rounded object-cover object-center"
draggable={false}
/>
)}
</div>
<div
className={cn(
"h-0 overflow-hidden opacity-0 transition-[height,opacity] duration-200",
"sm:group-hover:h-7 sm:group-hover:opacity-100 sm:group-has-[*[data-dragging=true]]:h-7 sm:group-has-[*[data-dragging=true]]:opacity-100",
)}
>
<div className="flex items-center justify-between pt-3 text-xs">
<Link
href={href || "https://dub.co"}
target="_blank"
className="font-medium text-neutral-700 transition-colors duration-75 hover:text-neutral-900"
>
Read more
</Link>
<button
type="button"
onClick={dismiss}
className="text-neutral-600 transition-colors duration-75 hover:text-neutral-900"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
);
}
Loading
Loading