Skip to content
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
12 changes: 8 additions & 4 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { SessionProvider } from "@/components/providers/SessionProvider";
import { ThemeProvider } from "@/components/providers/ThemeProvider";
import { ToastProvider } from "@/components/providers/ToastProvider";
import BackToTop from "@/components/ui/BackToTop";

export const metadata: Metadata = {
Expand Down Expand Up @@ -35,13 +36,16 @@ export default function RootLayout({
>
<ThemeProvider>
<SessionProvider>
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
<BackToTop />
<ToastProvider>
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
<BackToTop />
</ToastProvider>
</SessionProvider>
</ThemeProvider>
</body>
</html>
);
}

82 changes: 82 additions & 0 deletions src/components/providers/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use client";

import React, { createContext, useContext, useState, useRef, useCallback } from "react";
import { FeedEvent } from "@/types";
import { Toast } from "@/components/ui/Toast";
import { useLiveFeed } from "@/hooks/useLiveFeed";

interface ToastContextType {
addToast: (event: FeedEvent) => void;
}

const ToastContext = createContext<ToastContextType | undefined>(undefined);

export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
}
return context;
}

/**
* Internal listener component that renders inside the ToastProvider.
* Since it is a descendant of ToastProvider, calling useLiveFeed() here
* will successfully access the ToastContext and trigger toasts when
* real-time events are received globally (on all pages).
*/
function LiveFeedToastListener() {
useLiveFeed();
return null;
}

export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<{ id: string; event: FeedEvent }[]>([]);
const seenIdsRef = useRef<Set<string>>(new Set());

const addToast = useCallback((event: FeedEvent) => {
// Prevent duplicate toasts for the same event
if (seenIdsRef.current.has(event.id)) {
return;
}
seenIdsRef.current.add(event.id);

setToasts((prev) => {
const newToast = { id: event.id, event };
const next = [...prev, newToast];
if (next.length > 3) {
// Remove the oldest toast (index 0) and keep only the latest 3
return next.slice(next.length - 3);
}
return next;
});
}, []);

const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);

return (
<ToastContext.Provider value={{ addToast }}>
{children}

{/* Global listener to run the realtime live feed subscription on all pages */}
<LiveFeedToastListener />

{/* Global Toast Container - Bottom-Left corner */}
<div
className="fixed bottom-4 left-4 z-50 flex flex-col gap-2 max-w-sm pointer-events-none"
>
{toasts.map(({ id, event }) => (
<div key={id} className="pointer-events-auto">
<Toast
toastId={id}
event={event}
onDismiss={() => removeToast(id)}
/>
</div>
))}
</div>
</ToastContext.Provider>
);
}
115 changes: 115 additions & 0 deletions src/components/ui/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { FeedEvent } from "@/types";
import { slugify } from "@/lib/utils";

interface ToastProps {
toastId: string;
event: FeedEvent;
onDismiss: () => void;
}

const eventTypeConfig: Record<string, { icon: string; color: string; label: string }> = {
model_added: { icon: "✦", color: "text-atlas-green", label: "added" },
review_posted: { icon: "β˜…", color: "text-atlas-amber", label: "reviewed" },
price_updated: { icon: "↻", color: "text-atlas-blue", label: "pricing updated" },
tool_added: { icon: "⚑", color: "text-atlas-purple", label: "added" },
};

export function Toast({ toastId, event, onDismiss }: ToastProps) {
const router = useRouter();

const config = eventTypeConfig[event.eventType] ?? {
icon: "β€’",
color: "text-atlas-text-secondary",
label: event.eventType,
};

// Auto-dismiss after 4 seconds
useEffect(() => {
const timer = setTimeout(() => {
onDismiss();
}, 4000);

return () => clearTimeout(timer);
}, [onDismiss]);

const handleNavigate = () => {
if (event.entityType === "model") {
// Resolve slugs with special overrides for known ones with hyphens (e.g. Gemini 2.5 Pro -> gemini-2-5-pro)
let slug = slugify(event.entityName);
if (event.entityName === "Gemini 2.5 Pro") {
slug = "gemini-2-5-pro";
} else if (event.entityName === "Gemini 2.5 Flash") {
slug = "gemini-2-5-flash";
}
router.push(`/models/${slug}`);
} else if (event.entityType === "tool") {
router.push("/tools");
}
onDismiss();
};

// Format the absolute timestamp (e.g., 6:12:31 PM)
const formattedTime = new Date(event.createdAt).toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
});

const actionText = config.label;
const message = `${event.entityName} ${actionText} just now`;

return (
<div
role="alert"
aria-live="polite"
className="flex items-start justify-between gap-3 p-4 bg-atlas-bg-card/95 backdrop-blur border border-atlas-border rounded-lg shadow-lg hover:border-atlas-border-hover transition-all duration-300 w-80 text-left cursor-pointer group focus-within:ring-2 focus-within:ring-atlas-green animate-fade-in motion-reduce:animate-none"
onClick={handleNavigate}
>
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Event Icon with Type Color */}
<span className={`text-lg shrink-0 ${config.color} leading-none mt-0.5`} aria-hidden="true">
{config.icon}
</span>

{/* Content info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-atlas-text-primary group-hover:text-atlas-green transition-colors break-words">
{message}
</p>
<span className="text-[10px] font-mono text-atlas-text-muted mt-1 block">
{formattedTime}
</span>
</div>
</div>

{/* Dismiss Button */}
<button
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
className="text-atlas-text-muted hover:text-atlas-text-primary p-1 rounded hover:bg-atlas-bg-tertiary transition-colors shrink-0 -mt-1 -mr-1 focus:outline-none focus:ring-1 focus:ring-atlas-border"
aria-label="Dismiss notification"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
);
}
17 changes: 16 additions & 1 deletion src/hooks/useLiveFeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { FeedEvent } from "@/types";
import { subscribeToFeed } from "@/lib/realtime";
import { useToast } from "@/components/providers/ToastProvider";

/**
* Fetches the initial feed from the API and then subscribes to
Expand All @@ -13,6 +14,14 @@ export function useLiveFeed(initialEvents: FeedEvent[] = []) {
const [events, setEvents] = useState<FeedEvent[]>(initialEvents);
const [isLoading, setIsLoading] = useState(initialEvents.length === 0);

let addToast: ((event: FeedEvent) => void) | undefined;
try {
const toast = useToast();
addToast = toast.addToast;
} catch {
// Fallback when hook is used outside ToastProvider
}

useEffect(() => {
let active = true;

Expand Down Expand Up @@ -50,12 +59,18 @@ export function useLiveFeed(initialEvents: FeedEvent[] = []) {
const channel = subscribeToFeed((raw) => {
const event = raw as unknown as FeedEvent;
setEvents((prev) => [event, ...prev].slice(0, 100));

// Trigger the global toast notification
if (addToast) {
addToast(event);
}
});

return () => {
channel?.unsubscribe();
};
}, []);
}, [addToast]);

return { events, isLoading };
}

Loading