rework markdown rendering and TTS

This commit is contained in:
Jack Merrill 2025-04-22 17:00:56 -04:00
parent 8f70d83785
commit 31e0848c95
Signed by: jack
GPG Key ID: F6BFCA1B80EA6AF7
12 changed files with 606 additions and 191 deletions

View File

@ -14,7 +14,6 @@ export async function GET(request: Request) {
const supabase = await createClient(); const supabase = await createClient();
await supabase.auth.exchangeCodeForSession(code); await supabase.auth.exchangeCodeForSession(code);
} }
console.log("code", code);
if (redirectTo) { if (redirectTo) {
return NextResponse.redirect(`${origin}${redirectTo}`); return NextResponse.redirect(`${origin}${redirectTo}`);
} }

View File

@ -1,5 +1,4 @@
import { AppSidebar } from "@/components/app-sidebar"; import { AppSidebar } from "@/components/app-sidebar";
import KokoroReader from "@/components/KokoroReader";
import { NavActions } from "@/components/nav-actions"; import { NavActions } from "@/components/nav-actions";
import { import {
Breadcrumb, Breadcrumb,
@ -7,12 +6,7 @@ import {
BreadcrumbList, BreadcrumbList,
BreadcrumbPage, BreadcrumbPage,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import {
SidebarInset, SidebarInset,
@ -20,18 +14,23 @@ import {
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { createClient } from "@/utils/supabase/server"; import { createClient } from "@/utils/supabase/server";
import { Speech } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { remark } from "remark";
import remarkHtml from "remark-html"; import { TTSProvider } from "@/components/TTSProvider";
import MarkdownRenderer from "@/components/MarkdownRenderer";
export default async function DocumentPage(props: { params: { id: string } }) { export default async function DocumentPage(props: { params: { id: string } }) {
const supabase = await createClient(); const supabase = await createClient();
const { const {
data: { user }, data: { user },
error: userError,
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
if (userError) {
console.error("Error fetching user:", userError);
}
if (!user) { if (!user) {
return redirect("/login"); return redirect("/login");
} }
@ -66,44 +65,46 @@ export default async function DocumentPage(props: { params: { id: string } }) {
const pages = (document.ocr_data as any).map((page: any) => page.markdown); const pages = (document.ocr_data as any).map((page: any) => page.markdown);
const processedContent = await remark() // Here we simply recreate the pages string:
.use(remarkHtml) const rawContent =
.process(pages.join("\n")); (document?.ocr_data as any)?.map((page: any) => page.markdown).join("\n") ||
"";
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar <TTSProvider pages={pages}>
documents={documents.map((d) => { <AppSidebar
return { documents={documents.map((d) => {
name: d.file_name, return {
url: `/dashboard/documents/${d.id}`, name: d.file_name,
emoji: "📄", url: `/dashboard/documents/${d.id}`,
}; emoji: "📄",
})} };
/> })}
<SidebarInset> />
<header className="flex h-14 shrink-0 items-center gap-2"> <SidebarInset>
<div className="flex flex-1 items-center gap-2 px-3"> <header className="grid grid-cols-2 h-14 items-center gap-2 sticky top-0 right-0 w-full bg-background border-b border-slate-800 z-10">
<SidebarTrigger /> <div className="flex items-center gap-2 px-3">
<Separator <SidebarTrigger />
orientation="vertical" <Separator
className="mr-2 data-[orientation=vertical]:h-4" orientation="vertical"
/> className="mr-2 data-[orientation=vertical]:h-4"
<Breadcrumb> />
<BreadcrumbList> <Breadcrumb>
<BreadcrumbItem> <BreadcrumbList>
<BreadcrumbPage className="line-clamp-1"> <BreadcrumbItem>
{document.file_name || "Document Details"} <BreadcrumbPage className="line-clamp-1">
</BreadcrumbPage> {document.file_name || "Document Details"}
</BreadcrumbItem> </BreadcrumbPage>
</BreadcrumbList> </BreadcrumbItem>
</Breadcrumb> </BreadcrumbList>
</div> </Breadcrumb>
<div className="ml-auto px-3"> </div>
<NavActions pages={pages} /> <div className="flex items-center justify-end gap-2 px-3">
</div> <NavActions pages={pages} />
</header> </div>
<div </header>
{/* <div
className="prose mx-auto px-4 py-10 className="prose mx-auto px-4 py-10
text-white text-white
prose-h1:font-semibold prose-h1:text-2xl prose-h1:mb-4 prose-h1:text-white prose-h1:font-semibold prose-h1:text-2xl prose-h1:mb-4 prose-h1:text-white
@ -117,8 +118,12 @@ export default async function DocumentPage(props: { params: { id: string } }) {
prose-code:bg-gray-800 prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-gray-200 prose-code:bg-gray-800 prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-gray-200
prose-img:rounded-lg prose-img:shadow-sm" prose-img:rounded-lg prose-img:shadow-sm"
dangerouslySetInnerHTML={{ __html: String(processedContent) }} dangerouslySetInnerHTML={{ __html: String(processedContent) }}
></div> ></div> */}
</SidebarInset> <div className="mx-auto px-12 py-20 gap-2">
<MarkdownRenderer rawContent={rawContent} />
</div>
</SidebarInset>
</TTSProvider>
</SidebarProvider> </SidebarProvider>
); );
} }

View File

@ -8,6 +8,7 @@ import { ThemeProvider } from "next-themes";
import Link from "next/link"; import Link from "next/link";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
const defaultUrl = process.env.VERCEL_URL const defaultUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}` ? `https://${process.env.VERCEL_URL}`
@ -15,8 +16,8 @@ const defaultUrl = process.env.VERCEL_URL
export const metadata = { export const metadata = {
metadataBase: new URL(defaultUrl), metadataBase: new URL(defaultUrl),
title: "Next.js and Supabase Starter Kit", title: "Neuroread",
description: "The fastest way to build apps with Next.js and Supabase", description: "The easiest way to read articles and papers.",
}; };
const geistSans = Geist({ const geistSans = Geist({
@ -38,7 +39,7 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
{children} <TooltipProvider>{children}</TooltipProvider>
<Toaster /> <Toaster />
<ThemeSwitcher /> <ThemeSwitcher />
</ThemeProvider> </ThemeProvider>

BIN
bun.lockb

Binary file not shown.

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect } from "react";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Play } from "lucide-react"; import { Loader, Pause, Play } from "lucide-react";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@ -9,93 +9,54 @@ import {
AccordionTrigger, AccordionTrigger,
} from "./ui/accordion"; } from "./ui/accordion";
import { Label } from "./ui/label"; import { Label } from "./ui/label";
import { useTTS } from "./TTSProvider";
export default function KokoroReader({ pages }: { pages: any[] }) { export default function KokoroReader({ pages }: { pages: any[] }) {
// Create a reference to the worker object. const {
const worker = useRef<Worker>(null); voices,
selectedSpeaker,
setSelectedSpeaker,
skipToSentence,
currentSentence,
setCurrentSentence,
playSentence,
playInOrder,
status,
} = useTTS();
const [inputText, setInputText] = useState( const [playing, setPlaying] = useState(false);
"Life is like a box of chocolates. You never know what you're gonna get."
);
const [selectedSpeaker, setSelectedSpeaker] = useState("af_heart");
const [voices, setVoices] = useState<any[]>([]);
const [status, setStatus] = useState<"ready" | "running" | null>(null);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("Loading...");
const [results, setResults] = useState<{ text: string; src: string }[]>([]);
// We use the `useEffect` hook to setup the worker as soon as the `App` component is mounted.
useEffect(() => { useEffect(() => {
// Create the worker if it does not yet exist. setCurrentSentence(0); // this might just jumpstart the audio
console.log("Initializing worker..."); }, [status === "ready"]);
worker.current ??= new Worker("/workers/kokoro-worker.js", {
type: "module",
});
console.log("Worker initialized"); const play = () => {
if (playing) {
setPlaying(false);
return;
}
// Create a callback function for messages from the worker thread. setPlaying(true);
const onMessageReceived = (e: any) => { playInOrder(currentSentence || 0);
switch (e.data.status) {
case "device":
setLoadingMessage(`Loading model (device="${e.data.device}")`);
break;
case "ready":
setStatus("ready");
setVoices(e.data.voices);
break;
case "error":
setError(e.data.data);
break;
case "complete":
const { audio, text } = e.data;
// Generation complete: re-enable the "Generate" button
setResults((prev) => [{ text, src: audio }, ...prev]);
setStatus("ready");
break;
}
};
console.log("onmessagereceived");
const onErrorReceived = (e: any) => {
console.error("Worker error:", e);
setError(e.message);
};
console.log("Attaching event listeners to worker");
// Attach the callback function as an event listener.
worker.current.addEventListener("message", onMessageReceived);
worker.current.addEventListener("error", onErrorReceived);
console.log(worker.current);
// Define a cleanup function for when the component is unmounted.
return () => {
worker.current!.removeEventListener("message", onMessageReceived);
worker.current!.removeEventListener("error", onErrorReceived);
};
}, []);
const handleSubmit = (e: any) => {
e.preventDefault();
setStatus("running");
worker.current!.postMessage({
type: "generate",
text: inputText.trim(),
voice: selectedSpeaker,
});
}; };
return ( return (
<div className="flex flex-col items-center justify-center pt-4 relative overflow-hidden font-sans"> <div className="flex flex-col items-center justify-center pt-4 relative overflow-hidden font-sans">
<div className="max-w-3xl w-full relative z-[2]"> <div className="max-w-3xl w-full relative z-[2]">
<div className="items-center justify-center text-center"> <div className="items-center justify-center text-center">
<Button variant="ghost" size="icon" className="h-10 w-10"> <Button
<Play /> variant="ghost"
size="icon"
className="h-10 w-10"
onClick={play}
disabled={status === null}
>
{status === "running" ? (
<Loader className="animate-spin" />
) : (
<span className="sr-only">Play</span>
)}
{playing ? <Pause /> : <Play />}
</Button> </Button>
</div> </div>
@ -123,53 +84,6 @@ export default function KokoroReader({ pages }: { pages: any[] }) {
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
</div> </div>
{/* <div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<textarea
placeholder="Enter text..."
value={inputText}
onChange={(e) => setInputText(e.target.value)}
className="w-full min-h-[100px] max-h-[300px] bg-gray-700/50 backdrop-blur-sm border-2 border-gray-600 rounded-xl resize-y text-gray-100 placeholder-gray-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={Math.min(8, inputText.split("\n").length)}
/>
<div className="flex flex-col items-center space-y-4">
<select
value={selectedSpeaker}
onChange={(e) => setSelectedSpeaker(e.target.value)}
className="w-full bg-gray-700/50 backdrop-blur-sm border-2 border-gray-600 rounded-xl text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{Object.entries(voices).map(([id, voice]) => (
<option key={id} value={id}>
{voice.name} (
{voice.language === "en-us" ? "American" : "British"}{" "}
{voice.gender})
</option>
))}
</select>
<button
type="submit"
className="inline-flex justify-center items-center px-6 py-2 text-lg font-semibold bg-gradient-to-t from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transition-colors duration-300 rounded-xl text-white disabled:opacity-50"
disabled={status === "running" || inputText.trim() === ""}
>
{status === "running" ? "Generating..." : "Generate"}
</button>
</div>
</form>
</div> */}
{results.map((result, i) => (
<div key={i}>
<div className="text-white bg-gray-800/70 backdrop-blur-sm border border-gray-700 rounded-lg p-4 z-10">
<span className="absolute right-5 font-bold">
#{results.length - i}
</span>
<p className="mb-3 max-w-[95%]">{result.text}</p>
<audio controls src={result.src} className="w-full">
Your browser does not support the audio element.
</audio>
</div>
</div>
))}
</div> </div>
); );
} }

View File

@ -0,0 +1,106 @@
"use client";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { useMemo } from "react";
import ReactMarkdown, { Components } from "react-markdown";
import rehypeRaw from "rehype-raw";
import { useTTS } from "./TTSProvider";
import rehypeHighlight from "@/lib/utils";
// Utility to escape regex special characters:
function escapeRegExp(text: string) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
}
export default function MarkdownRenderer({
rawContent,
}: {
rawContent: string;
}) {
// Obtain TTS info from context.
// TTSProvider is already wrapping this component higher in the tree.
const { currentSentence, sentences } = useTTS();
// Determine the text to highlight.
const textToHighlight = useMemo(() => {
if (!sentences || sentences.length === 0) return "";
return sentences[currentSentence] || "";
}, [sentences, currentSentence]);
// Setup rehype plugins including our highlight plugin.
const rehypePlugins = useMemo(
() => [rehypeRaw, [rehypeHighlight, { textToHighlight }] as any],
[textToHighlight]
);
const components: Components = {
h1: ({ node, ...props }) => (
<h1 className="text-2xl font-semibold mb-4 text-white" {...props} />
),
h2: ({ node, ...props }) => (
<h2 className="text-xl font-medium mb-3 text-white" {...props} />
),
h3: ({ node, ...props }) => (
<h3 className="text-lg font-medium mb-2 text-gray-300" {...props} />
),
h4: ({ node, ...props }) => (
<h4 className="text-lg font-medium mb-2 text-gray-300" {...props} />
),
p: ({ node, ...props }) => (
<p className="leading-7 text-gray-200" {...props} />
),
img: ({ node, ...props }) => (
<img
className="rounded-lg shadow-sm"
style={{ maxWidth: "100%", height: "auto" }}
{...props}
/>
),
a: ({ node, ...props }) => (
<a className="text-blue-400 hover:underline" {...props} />
),
strong: ({ node, ...props }) => (
<strong className="text-gray-200 font-semibold" {...props} />
),
blockquote: ({ node, ...props }) => (
<blockquote
className="italic border-l-4 pl-4 border-gray-600 text-gray-300"
{...props}
/>
),
code: ({ node, ...props }) => (
<code
className="bg-gray-800 rounded px-1 py-0.5 text-gray-200"
{...props}
/>
),
sup: ({ node, ...props }) => (
// TODO: get the references from the document and display them in a popover
<Popover>
<PopoverTrigger asChild>
<sup
className="text-gray-200 cursor-pointer underline hover:cursor-pointer"
{...props}
/>
</PopoverTrigger>
<PopoverContent className="w-56 overflow-hidden rounded-lg p-0">
<div className="p-4">
{/* Replace with actual reference content */}
<p>Reference content goes here.</p>
</div>
</PopoverContent>
</Popover>
),
};
return (
<ReactMarkdown
children={rawContent}
components={components}
rehypePlugins={rehypePlugins}
/>
);
}

265
components/TTSProvider.tsx Normal file
View File

@ -0,0 +1,265 @@
"use client";
import React, {
createContext,
useContext,
useState,
useEffect,
useRef,
ReactNode,
} from "react";
import removeMarkdown from "remove-markdown";
// More robust sentence splitter using Intl.Segmenter for better accuracy.
function splitIntoSentences(text: string): string[] {
if (typeof Intl !== "undefined" && Intl.Segmenter) {
const segmenter = new Intl.Segmenter("en", { granularity: "sentence" });
return Array.from(segmenter.segment(text)).map(
(segment) => segment.segment
);
}
// Fallback to regex-based splitting if Intl.Segmenter is unavailable.
return text.match(/[^\.!\?]+[\.!\?]+/g) || [text];
}
interface TTSContextType {
sentences: string[];
currentSentence: number;
ttsBuffer: (string | null)[];
voices: any[];
selectedSpeaker: string;
status: "ready" | "running" | null;
setSelectedSpeaker: (speaker: string) => void;
setCurrentSentence: (index: number) => void;
playSentence: (index: number) => void;
skipToSentence: (index: number) => void;
playInOrder: (index: number) => void;
pause: () => void;
resume: () => void;
stop: () => void;
}
const TTSContext = createContext<TTSContextType | undefined>(undefined);
export const TTSProvider = ({
pages,
children,
}: {
pages: string[];
children: ReactNode;
}) => {
// Combine pages and split into sentences.
const fullText = pages.join("\n");
const sentences = splitIntoSentences(fullText);
const [currentSentence, setCurrentSentence] = useState(0);
const [ttsBuffer, setTtsBuffer] = useState<(string | null)[]>(
Array(sentences.length).fill(null)
);
const audioRef = useRef<HTMLAudioElement>(null);
// Create a reference to the worker object.
const worker = useRef<Worker>(null);
const [selectedSpeaker, setSelectedSpeaker] = useState("af_heart");
const [playing, setPlaying] = useState(false);
const [sentence, setSentence] = useState<number>();
const [voices, setVoices] = useState<any[]>([]);
const [status, setStatus] = useState<"ready" | "running" | null>(null);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("Loading...");
const [results, setResults] = useState<{ text: string; src: string }[]>([]);
async function generateTTSForIndex(
sentence: string,
index: number
): Promise<string> {
const key = `tts-${index}`;
const cached = localStorage.getItem(key);
if (cached) {
return cached;
}
worker.current!.postMessage({
type: "generate",
text: sentence,
voice: selectedSpeaker,
});
setStatus("running");
setLoadingMessage("Generating audio...");
return new Promise((resolve, reject) => {
worker.current!.addEventListener(
"message",
(e: any) => {
if (e.data.status === "complete") {
localStorage.setItem(key, e.data.audio);
resolve(e.data.audio);
} else if (e.data.status === "error") {
reject(e.data.error);
}
},
{ once: true }
);
});
}
// We use the `useEffect` hook to setup the worker as soon as the `App` component is mounted.
useEffect(() => {
// Create the worker if it does not yet exist.
console.log("Initializing worker...");
worker.current ??= new Worker("/workers/kokoro-worker.js", {
type: "module",
});
console.log("Worker initialized");
// Create a callback function for messages from the worker thread.
const onMessageReceived = (e: any) => {
switch (e.data.status) {
case "device":
setLoadingMessage(`Loading model (device="${e.data.device}")`);
break;
case "ready":
setStatus("ready");
setVoices(e.data.voices);
break;
case "error":
setError(e.data.data);
break;
case "complete":
const { audio, text } = e.data;
// Generation complete: re-enable the "Generate" button
setResults((prev) => [{ text, src: audio }, ...prev]);
setStatus("ready");
break;
}
};
console.log("onmessagereceived");
const onErrorReceived = (e: any) => {
console.error("Worker error:", e);
setError(e.message);
};
console.log("Attaching event listeners to worker");
// Attach the callback function as an event listener.
worker.current.addEventListener("message", onMessageReceived);
worker.current.addEventListener("error", onErrorReceived);
console.log(worker.current);
// Define a cleanup function for when the component is unmounted.
return () => {
worker.current!.removeEventListener("message", onMessageReceived);
worker.current!.removeEventListener("error", onErrorReceived);
};
}, []);
// Pre-buffer current and next 2 sentences.
useEffect(() => {
async function preloadBuffer() {
const newBuffer = [...ttsBuffer];
const end = Math.min(sentences.length, currentSentence + 3);
for (let i = currentSentence; i < end; i++) {
if (!newBuffer[i]) {
newBuffer[i] = await generateTTSForIndex(
removeMarkdown(sentences[i]),
i
);
}
}
setTtsBuffer(newBuffer);
}
preloadBuffer();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSentence, sentences.join(" ")]);
const playSentence = async (index: number) => {
setCurrentSentence(index);
let audioUrl = ttsBuffer[index];
if (!audioUrl) {
audioUrl = await generateTTSForIndex(
removeMarkdown(sentences[index]),
index
);
setTtsBuffer((prev) => {
const updated = [...prev];
updated[index] = audioUrl;
return updated;
});
}
if (audioRef.current) {
audioRef.current.src = audioUrl;
await audioRef.current.play();
}
};
const skipToSentence = (index: number) => {
if (index < 0 || index >= sentences.length) return;
playSentence(index);
};
const playInOrder = async (index: number) => {
if (index < 0 || index >= sentences.length) return;
setCurrentSentence(index);
for (let i = index; i < sentences.length; i++) {
await playSentence(i);
if (i < sentences.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
};
const pause = () => {
if (audioRef.current) {
audioRef.current.pause();
}
};
const resume = () => {
if (audioRef.current) {
audioRef.current.play();
}
};
const stop = () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
};
const value: TTSContextType = {
sentences,
currentSentence,
ttsBuffer,
voices,
playSentence,
skipToSentence,
selectedSpeaker,
setSelectedSpeaker,
setCurrentSentence,
playInOrder,
pause,
resume,
stop,
status,
};
return (
<TTSContext.Provider value={value}>
{children}
{/* Hidden audio element used for playback */}
<audio ref={audioRef} style={{ display: "none" }} />
</TTSContext.Provider>
);
};
export const useTTS = (): TTSContextType => {
const context = useContext(TTSContext);
if (!context) {
throw new Error("useTTS must be used within a TTSProvider");
}
return context;
};

View File

@ -37,6 +37,8 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import KokoroReader from "./KokoroReader"; import KokoroReader from "./KokoroReader";
import { Slider } from "./ui/slider";
import { cn } from "@/lib/utils";
const data = [ const data = [
[ [
@ -102,24 +104,38 @@ const data = [
]; ];
export function NavActions({ pages }: { pages: any[] }) { export function NavActions({ pages }: { pages: any[] }) {
const [isOpen, setIsOpen] = React.useState(false);
React.useEffect(() => {
setIsOpen(true);
}, []);
return ( return (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
{/* <div className="text-muted-foreground hidden font-medium md:inline-block">
Edit Oct 08
</div> */}
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7"> <Button variant="ghost" size="icon" className="h-7 w-7">
<ALargeSmall /> <ALargeSmall />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent></PopoverContent> <PopoverContent>
<div className="grid grid-cols-5 justify-center">
{["sm", "md", "lg", "xl", "2xl"].map((size) => (
<Button
key={size}
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 uppercase text-right",
size === "md" && "bg-accent data-[state=open]:bg-accent"
)}
>
{size}
</Button>
))}
</div>
<Slider
defaultValue={[3]}
min={1}
max={5}
step={1}
className="w-full"
/>
</PopoverContent>
</Popover> </Popover>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -134,7 +150,7 @@ export function NavActions({ pages }: { pages: any[] }) {
<Button variant="ghost" size="icon" className="h-7 w-7"> <Button variant="ghost" size="icon" className="h-7 w-7">
<Star /> <Star />
</Button> </Button>
<Popover open={isOpen} onOpenChange={setIsOpen}> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"

63
components/ui/slider.tsx Normal file
View File

@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@ -1,5 +1,7 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { visit } from "unist-util-visit";
import { VFile } from "vfile";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@ -13,3 +15,43 @@ export async function detectWebGPU() {
return false; return false;
} }
} }
/**
* A custom rehype plugin that wraps the first occurrence of "textToHighlight" in a <mark> tag.
* options: { textToHighlight: string }
*/
export default function rehypeHighlight(options: { textToHighlight: string }) {
return (tree: any, file: VFile) => {
let found = false;
visit(tree, "text", (node: any) => {
if (found) return;
const { value } = node;
const text = options.textToHighlight;
const index = value.toLowerCase().indexOf(text.toLowerCase());
if (index !== -1) {
found = true;
// Split the text into three parts: before, match, and after.
const before = value.slice(0, index);
const match = value.slice(index, index + text.length);
const after = value.slice(index + text.length);
// Replace the current node with three nodes.
node.type = "element";
node.tagName = "span";
node.properties = {};
node.children = [];
if (before) {
node.children.push({ type: "text", value: before });
}
node.children.push({
type: "element",
tagName: "mark",
properties: { style: "background-color: yellow;" },
children: [{ type: "text", value: match }],
});
if (after) {
node.children.push({ type: "text", value: after });
}
}
});
};
}

View File

@ -17,8 +17,9 @@
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.2.4",
"@supabase/ssr": "latest", "@supabase/ssr": "latest",
"@supabase/supabase-js": "latest", "@supabase/supabase-js": "latest",
"@tailwindcss/postcss": "^4.1.0", "@tailwindcss/postcss": "^4.1.0",
@ -35,8 +36,11 @@
"prettier": "^3.3.3", "prettier": "^3.3.3",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-html": "^16.0.1", "remark-html": "^16.0.1",
"remove-markdown": "^0.6.0",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"sse.js": "^2.6.0", "sse.js": "^2.6.0",
"tw-animate-css": "^1.2.5", "tw-animate-css": "^1.2.5",

View File

@ -22,17 +22,17 @@ export const updateSession = async (request: NextRequest) => {
}, },
setAll(cookiesToSet) { setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value), request.cookies.set(name, value)
); );
response = NextResponse.next({ response = NextResponse.next({
request, request,
}); });
cookiesToSet.forEach(({ name, value, options }) => cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options), response.cookies.set(name, value, options)
); );
}, },
}, },
}, }
); );
// This will refresh session if expired - required for Server Components // This will refresh session if expired - required for Server Components
@ -40,12 +40,12 @@ export const updateSession = async (request: NextRequest) => {
const user = await supabase.auth.getUser(); const user = await supabase.auth.getUser();
// protected routes // protected routes
if (request.nextUrl.pathname.startsWith("/protected") && user.error) { if (request.nextUrl.pathname.startsWith("/dashboard") && user.error) {
return NextResponse.redirect(new URL("/sign-in", request.url)); return NextResponse.redirect(new URL("/login", request.url));
} }
if (request.nextUrl.pathname === "/" && !user.error) { if (request.nextUrl.pathname === "/" && !user.error) {
return NextResponse.redirect(new URL("/protected", request.url)); return NextResponse.redirect(new URL("/dashboard", request.url));
} }
return response; return response;