feat: whyrating - initial project from turbostarter boilerplate

This commit is contained in:
Alejandro Gutiérrez
2026-02-04 01:54:52 +01:00
commit 5cdc07cd39
1618 changed files with 338230 additions and 0 deletions

View File

@@ -0,0 +1,227 @@
"use client";
import { motion } from "motion/react";
import { AnimatePresence } from "motion/react";
import { createContext, memo, useContext, useMemo } from "react";
import { useState } from "react";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { Viewer } from "~/modules/common/image";
import type { DropzoneOptions, DropzoneState } from "react-dropzone";
const DropzoneContext = createContext<{
dropzone: DropzoneState;
} | null>(null);
interface DropzoneProps extends DropzoneOptions {
children: React.ReactNode;
dialog?: React.ReactNode;
}
const Dropzone = ({ children, dialog, ...options }: DropzoneProps) => {
const dropzone = useDropzone({
accept: {
"image/*": [".png", ".gif", ".jpeg", ".webp", ".jpg"],
},
onError: (error) => toast.error(error.message),
noClick: true,
noKeyboard: true,
multiple: true,
...options,
});
return (
<DropzoneContext.Provider value={{ dropzone }}>
<div {...dropzone.getRootProps()} className="relative h-full w-full">
{children}
<AnimatePresence>
{dropzone.isDragActive && dialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center">
<motion.div
className="bg-background/50 absolute inset-0 backdrop-blur-sm md:rounded-lg"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
/>
{dialog}
</div>
)}
</AnimatePresence>
</div>
</DropzoneContext.Provider>
);
};
const Input = memo<React.ButtonHTMLAttributes<HTMLButtonElement>>((props) => {
const { t } = useTranslation(["ai", "common"]);
const context = useContext(DropzoneContext);
return (
<>
<input
{...context?.dropzone.getInputProps()}
disabled={props.disabled ?? false}
/>
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
type="button"
{...props}
className={cn(
"text-muted-foreground shrink-0 rounded-full dark:bg-transparent",
props.className,
)}
onClick={(event) => {
context?.dropzone.open();
props.onClick?.(event);
}}
>
<Icons.Paperclip className="size-4" />
<span className="sr-only">{t("chat.composer.files.add")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>{t("chat.composer.files.add")}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
);
});
Input.displayName = "Input";
interface PreviewProps extends React.HTMLAttributes<HTMLDivElement> {
attachments: File[];
onRemove: (file: File) => void;
}
export const Preview = memo<PreviewProps>(
({ attachments, onRemove, className, ...props }) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedImage, setSelectedImage] = useState(0);
if (!attachments.length) {
return null;
}
return (
<>
<div
className={cn(
"-mb-2.5 flex w-full flex-wrap gap-3 px-2 pt-4 @[480px]/input:px-2.5",
className,
)}
{...props}
>
{attachments.map((attachment, index) => (
<Thumbnail
key={attachment.name}
attachment={attachment}
onRemove={() => onRemove(attachment)}
onClick={() => {
setSelectedImage(index);
setIsOpen(true);
}}
/>
))}
</div>
<Viewer
open={isOpen}
onOpenChange={setIsOpen}
images={attachments.map((attachment) => ({
url: URL.createObjectURL(attachment),
}))}
selectedImage={selectedImage}
setSelectedImage={setSelectedImage}
/>
</>
);
},
);
Preview.displayName = "Preview";
interface ThumbnailProps extends React.HTMLAttributes<HTMLButtonElement> {
attachment: File;
onRemove: () => void;
}
const Thumbnail = memo<ThumbnailProps>(({ attachment, onRemove, ...props }) => {
const { t } = useTranslation(["ai"]);
const preview = useMemo(() => URL.createObjectURL(attachment), [attachment]);
return (
<div className="group relative">
<button {...props} type="button">
<Avatar className="size-16 shrink-0 rounded-xl">
<AvatarImage
src={preview}
alt={`Preview of ${attachment.name}`}
className="rounded-xl border object-cover"
/>
<AvatarFallback className="rounded-xl">
<Icons.Image className="text-muted-foreground size-8" />
</AvatarFallback>
</Avatar>
<span className="sr-only">{t("chat.composer.files.preview")}</span>
</button>
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="bg-card dark:bg-card absolute top-0 right-0 size-5 translate-x-1/3 -translate-y-1/3 p-1"
onClick={onRemove}
type="button"
>
<Icons.X className="size-full" />
</Button>
</TooltipTrigger>
<TooltipContent
side="bottom"
className="rounded-md px-2 py-1 text-xs"
>
<span>{t("chat.composer.files.remove")}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
});
Thumbnail.displayName = "Thumbnail";
export const Attachments = {
Input,
Dropzone,
Preview,
};

View File

@@ -0,0 +1,90 @@
import { useEffect, useRef } from "react";
import { cn } from "@turbostarter/ui";
import { TextareaAutosize } from "@turbostarter/ui-web/textarea";
import { Attachments } from "./attachments";
const Form = ({
className,
children,
...props
}: React.HTMLAttributes<HTMLFormElement>) => {
const ref = useRef<HTMLFormElement>(null);
useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
ref.current
?.closest("main")
?.style.setProperty(
"--composer-height",
`${entry.contentRect.height}px`,
);
});
if (ref.current) {
resizeObserver.observe(ref.current);
}
return () => {
resizeObserver.disconnect();
};
}, []);
return (
<form
ref={ref}
className={cn(
"relative bottom-0 z-10 flex w-full flex-col items-center justify-center gap-2 text-base",
className,
)}
{...props}
>
{children}
</form>
);
};
const Input = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
className={cn(
"bg-card/65 ring-border/75 focus-within:ring-input hover:ring-input hover:focus-within:ring-input @container/input relative w-full max-w-200 rounded-2xl px-2 pb-2 ring-1 backdrop-blur-xl duration-100 ring-inset focus-within:ring-1 @lg:rounded-3xl @lg:shadow-xs",
className,
)}
{...props}
/>
);
};
const Textarea = ({
className,
...props
}: Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "style">) => {
return (
<TextareaAutosize
dir="auto"
className={cn(
"text-foreground mb-3 min-h-20 w-full resize-none bg-transparent px-2 pt-5 align-bottom focus:outline-none @[480px]/input:px-3",
className,
)}
spellCheck={false}
maxRows={6}
autoFocus
maxLength={5_000}
{...props}
/>
);
};
export const Composer = {
Form,
Input,
Textarea,
Attachments,
};

View File

@@ -0,0 +1,68 @@
"use client";
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
import {
Select,
SelectContent,
SelectItem,
SelectPortal,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import { ProviderIcons } from "~/modules/common/ai/icons";
import type { Provider } from "@turbostarter/ai";
import type { Control, FieldValues, Path } from "react-hook-form";
interface ModelSelectorProps<T extends FieldValues> {
readonly control: Control<T>;
readonly name: Path<T>;
readonly options: readonly {
readonly id: string;
readonly name: string;
readonly provider: Provider;
}[];
}
export const ModelSelector = <T extends FieldValues>({
name,
control,
options,
}: ModelSelectorProps<T>) => {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className="min-w-0">
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectPortal>
<SelectContent align="end">
{options.map((option) => {
const Icon = ProviderIcons[option.provider];
return (
<SelectItem key={option.id} value={option.id}>
<div className="flex items-center gap-2.5">
<Icon className="text-foreground size-4 shrink-0" />
<span className="min-w-0 truncate font-medium">
{option.name}
</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</SelectPortal>
</Select>
</FormControl>
</FormItem>
)}
/>
);
};

View File

@@ -0,0 +1,16 @@
import { Provider } from "@turbostarter/ai";
import { Icons } from "@turbostarter/ui-web/icons";
export const ProviderIcons = {
[Provider.OPENAI]: Icons.OpenAI,
[Provider.GEMINI]: Icons.Gemini,
[Provider.CLAUDE]: Icons.Claude,
[Provider.GROK]: Icons.Grok,
[Provider.DEEPSEEK]: Icons.DeepSeek,
[Provider.REPLICATE]: Icons.Replicate,
[Provider.LUMA]: Icons.Luma,
[Provider.STABILITY_AI]: Icons.StabilityAI,
[Provider.RECRAFT]: Icons.Recraft,
[Provider.ELEVEN_LABS]: Icons.ElevenLabs,
[Provider.NVIDIA]: Icons.Nvidia,
};

View File

@@ -0,0 +1,149 @@
import { motion } from "motion/react";
import { useTranslation } from "@turbostarter/i18n";
import { TextShimmer } from "@turbostarter/ui-web/text-shimmer";
import type { Transition } from "motion/react";
const transition: Transition = {
duration: 2.5,
ease: [0.175, 0.885, 0.32, 1],
times: [0, 0.6, 0.6, 1],
repeat: Infinity,
repeatType: "mirror",
repeatDelay: 0.2,
};
export const AnalyzingImage = () => {
const { t } = useTranslation("common");
return (
<div className="flex items-center gap-2.5">
<div className="relative isolate flex items-center justify-center">
<motion.div
initial={{
clipPath: "inset(0px 0px 0px 0px)",
}}
animate={{
clipPath: [
"inset(0px 0px 0px 0px)",
"inset(0px 24px 0px 0px)",
"inset(0px 24px 0px 0px)",
"inset(0px 0px 0px 0px)",
],
}}
transition={transition}
className="bg-background z-10"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-muted-foreground/65"
>
<rect width="20" height="20" fill="hsl(var(--background))" />
<path
d="M4.27209 20.7279L10.8686 14.1314C11.2646 13.7354 11.4627 13.5373 11.691 13.4632C11.8918 13.3979 12.1082 13.3979 12.309 13.4632C12.5373 13.5373 12.7354 13.7354 13.1314 14.1314L19.6839 20.6839M14 15L16.8686 12.1314C17.2646 11.7354 17.4627 11.5373 17.691 11.4632C17.8918 11.3979 18.1082 11.3979 18.309 11.4632C18.5373 11.5373 18.7354 11.7354 19.1314 12.1314L22 15M10 9C10 10.1046 9.10457 11 8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9ZM6.8 21H17.2C18.8802 21 19.7202 21 20.362 20.673C20.9265 20.3854 21.3854 19.9265 21.673 19.362C22 18.7202 22 17.8802 22 16.2V7.8C22 6.11984 22 5.27976 21.673 4.63803C21.3854 4.07354 20.9265 3.6146 20.362 3.32698C19.7202 3 18.8802 3 17.2 3H6.8C5.11984 3 4.27976 3 3.63803 3.32698C3.07354 3.6146 2.6146 4.07354 2.32698 4.63803C2 5.27976 2 6.11984 2 7.8V16.2C2 17.8802 2 18.7202 2.32698 19.362C2.6146 19.9265 3.07354 20.3854 3.63803 20.673C4.27976 21 5.11984 21 6.8 21Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.div>
<motion.div
initial={{ transform: "translateX(10px)" }}
animate={{
transform: [
"translateX(10px)",
"translateX(-10px)",
"translateX(-10px)",
"translateX(10px)",
],
}}
transition={transition}
className="bg-muted-foreground/65 absolute z-10 h-full w-[3px] rounded-full"
/>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-muted-foreground/65 absolute"
>
<rect width="20" height="20" fill="hsl(var(--background))" />
<path
d="M6.8 21H17.2C18.8802 21 19.7202 21 20.362 20.673C20.9265 20.3854 21.3854 19.9265 21.673 19.362C22 18.7202 22 17.8802 22 16.2V7.8C22 6.11984 22 5.27976 21.673 4.63803C21.3854 4.07354 20.9265 3.6146 20.362 3.32698C19.7202 3 18.8802 3 17.2 3H6.8C5.11984 3 4.27976 3 3.63803 3.32698C3.07354 3.6146 2.6146 4.07354 2.32698 4.63803C2 5.27976 2 6.11984 2 7.8V16.2C2 17.8802 2 18.7202 2.32698 19.362C2.6146 19.9265 3.07354 20.3854 3.63803 20.673C4.27976 21 5.11984 21 6.8 21Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<rect x="6" y="19" width="1" height="1" fill="currentColor" />
<rect x="7" y="18" width="1" height="1" fill="currentColor" />
<rect x="7" y="19" width="3" height="1" fill="currentColor" />
<rect x="9" y="18" width="1" height="1" fill="currentColor" />
<rect x="14" y="19" width="3" height="1" fill="currentColor" />
<rect x="15" y="18" width="1" height="1" fill="currentColor" />
<rect x="5" y="18" width="2" height="1" fill="currentColor" />
<rect x="5" y="17" width="1" height="1" fill="currentColor" />
<rect x="10" y="19" width="1" height="1" fill="currentColor" />
<rect x="7" y="17" width="1" height="1" fill="currentColor" />
<rect x="11" y="19" width="1" height="1" fill="currentColor" />
<rect x="10" y="18" width="1" height="1" fill="currentColor" />
<rect x="17" y="19" width="1" height="1" fill="currentColor" />
<rect x="15" y="4" width="2" height="1" fill="currentColor" />
<rect x="3" y="9" width="1" height="3" fill="currentColor" />
<rect x="4" y="10" width="1" height="2" fill="currentColor" />
<rect x="6" y="9" width="1" height="1" fill="currentColor" />
<rect x="15" y="5" width="1" height="1" fill="currentColor" />
<rect x="20" y="8" width="1" height="3" fill="currentColor" />
<rect x="19" y="9" width="1" height="1" fill="currentColor" />
<rect x="7" y="13" width="1" height="1" fill="currentColor" />
<rect x="9" y="11" width="1" height="1" fill="currentColor" />
<rect x="16" y="12" width="1" height="2" fill="currentColor" />
<rect x="13" y="14" width="1" height="1" fill="currentColor" />
<rect x="12" y="11" width="1" height="1" fill="currentColor" />
<rect x="10" y="9" width="1" height="1" fill="currentColor" />
<rect x="10" y="15" width="1" height="1" fill="currentColor" />
<rect x="10" y="13" width="1" height="1" fill="currentColor" />
<rect x="15" y="9" width="1" height="1" fill="currentColor" />
<rect x="13" y="10" width="1" height="1" fill="currentColor" />
<rect x="12" y="14" width="1" height="1" fill="currentColor" />
<rect x="5" y="4" width="3" height="1" fill="currentColor" />
<rect x="6" y="5" width="1" height="1" fill="currentColor" />
<rect x="7" y="14" width="1" height="2" fill="currentColor" />
<rect x="6" y="14" width="3" height="1" fill="currentColor" />
<rect x="16" y="8" width="1" height="1" fill="currentColor" />
<rect x="8" y="9" width="1" height="1" fill="currentColor" />
<rect x="20" y="16" width="1" height="1" fill="currentColor" />
<rect x="12" y="12" width="1" height="1" fill="currentColor" />
<rect x="8" y="8" width="1" height="1" fill="currentColor" />
<rect x="14" y="12" width="1" height="1" fill="currentColor" />
<rect x="17" y="16" width="2" height="1" fill="currentColor" />
<rect x="14" y="17" width="1" height="1" fill="currentColor" />
<rect x="11" y="5" width="3" height="1" fill="currentColor" />
<rect x="12" y="4" width="1" height="1" fill="currentColor" />
<rect x="12" y="7" width="1" height="1" fill="currentColor" />
<rect x="7" y="11" width="1" height="1" fill="currentColor" />
<rect x="15" y="15" width="1" height="1" fill="currentColor" />
<rect x="11" y="11" width="1" height="1" fill="currentColor" />
<rect x="13" y="9" width="1" height="1" fill="currentColor" />
<rect x="12" y="15" width="1" height="1" fill="currentColor" />
<rect x="9" y="12" width="2" height="1" fill="currentColor" />
<rect x="19" y="13" width="2" height="1" fill="currentColor" />
<rect x="9" y="6" width="1" height="1" fill="currentColor" />
<rect x="20" y="4" width="1" height="1" fill="currentColor" />
<rect x="19" y="4" width="1" height="1" fill="currentColor" />
<rect x="3" y="15" width="1" height="2" fill="currentColor" />
<rect x="3" y="19" width="1" height="1" fill="currentColor" />
</svg>
</div>
<TextShimmer className="text-sm font-medium" duration={1.5}>
{t("analyzingImage")}
</TextShimmer>
</div>
);
};

View File

@@ -0,0 +1,77 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { getMessageTextContent } from "@turbostarter/ai";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { useCopy } from "~/modules/common/hooks/use-copy";
import type { UIMessage } from "@ai-sdk/react";
const transition = {
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.8 },
transition: { duration: 0.1, ease: "easeInOut" as const },
};
interface ThreadMessageCopyProps<MESSAGE extends UIMessage = UIMessage> {
message: MESSAGE;
}
export const ThreadMessageCopy = <MESSAGE extends UIMessage = UIMessage>({
message,
}: ThreadMessageCopyProps<MESSAGE>) => {
const { t } = useTranslation("common");
const { copied, copy } = useCopy();
return (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group/button size-8 rounded-full"
onClick={() => copy(getMessageTextContent(message))}
>
<div className="relative size-3.5">
<AnimatePresence mode="wait" initial={false}>
{copied ? (
<motion.div
key="check"
{...transition}
className="absolute inset-0"
>
<Icons.Check className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
</motion.div>
) : (
<motion.div
key="copy"
{...transition}
className="absolute inset-0"
>
<Icons.Copy className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
</motion.div>
)}
</AnimatePresence>
</div>
<span className="sr-only">{t("copy")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("copy")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,25 @@
import { cn } from "@turbostarter/ui";
import { ThreadMessageCopy } from "./copy";
import { ThreadMessageLikes } from "./likes";
import type { UIMessage } from "@ai-sdk/react";
interface ControlsProps {
message: UIMessage;
}
export const Controls = ({ message }: ControlsProps) => {
return (
<div
className={cn(
"bg-background start-0 -ml-4 flex w-max items-center gap-px rounded-lg px-2 pb-2 text-xs opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 md:start-3",
)}
>
{message.parts.some(
(part) => part.type === "text" && part.text.length > 0,
) && <ThreadMessageCopy message={message} />}
<ThreadMessageLikes />
</div>
);
};

View File

@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
export const ThreadMessageLikes = () => {
const { t } = useTranslation("common");
const [likeState, setLikeState] = useState<-1 | 0 | 1>(0);
return (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group/button size-8 rounded-full"
onClick={() => setLikeState(likeState === 1 ? 0 : 1)}
>
<Icons.ThumbsUp
className={cn(
"size-3.5 transition-colors",
likeState === 1
? "text-primary fill-current"
: "text-muted-foreground group-hover/button:text-foreground",
)}
/>
<span className="sr-only">{t("like")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("like")}</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group/button size-8 rounded-full"
onClick={() => setLikeState(likeState === -1 ? 0 : -1)}
>
<Icons.ThumbsDown
className={cn(
"size-3.5 transition-colors",
likeState === -1
? "text-primary fill-current"
: "text-muted-foreground group-hover/button:text-foreground",
)}
/>
<span className="sr-only">{t("dislike")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("dislike")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from "react";
import { Role } from "@turbostarter/ai/chat/types";
import type { UIMessage } from "@ai-sdk/react";
interface UseThreadLayoutProps<MESSAGE extends UIMessage> {
readonly messages: MESSAGE[];
readonly initialMessages?: MESSAGE[];
}
export const useThreadLayout = <MESSAGE extends UIMessage>({
messages,
initialMessages,
}: UseThreadLayoutProps<MESSAGE>) => {
const [scrolledByUser, setScrolledByUser] = useState(false);
const lastMessage = messages.at(-1);
const lastMessageRef = useRef<HTMLDivElement>(null);
const isChatActive = initialMessages?.length !== messages.length;
const lastUserMessageIndex = [...messages]
.reverse()
.findIndex((m) => m.role === Role.USER);
const lastResponseMessages = messages.slice(
lastUserMessageIndex !== 0 ? -2 : -1,
);
const previousMessages = messages.slice(0, -lastResponseMessages.length);
useEffect(() => {
if (!lastMessageRef.current) return;
const parent = lastMessageRef.current.parentElement;
let timeoutId: NodeJS.Timeout;
const handleScroll = () => {
setScrolledByUser(true);
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
setScrolledByUser(false);
}, 1000);
};
parent?.addEventListener("scroll", handleScroll);
return () => {
parent?.removeEventListener("scroll", handleScroll);
clearTimeout(timeoutId);
};
}, [lastMessageRef]);
useEffect(() => {
if (!lastMessageRef.current) return;
const parent = lastMessageRef.current.parentElement;
const isAtBottom = () => {
const container = parent?.closest("[data-radix-scroll-area-viewport]");
if (!container) return false;
const scrollBottom = container.scrollTop + container.clientHeight;
return Math.abs(container.scrollHeight - scrollBottom) < 150;
};
if (isChatActive) {
if (lastMessage?.role === Role.USER) {
requestAnimationFrame(() => {
parent?.scrollIntoView({
behavior: "smooth",
block: "end",
});
});
} else if (isAtBottom() && !scrolledByUser) {
requestAnimationFrame(() => {
parent?.scrollIntoView({
behavior: "instant",
block: "end",
});
});
}
return;
}
const animationFrameId = requestAnimationFrame(() => {
parent?.scrollIntoView({
behavior: "smooth",
block: "end",
});
});
return () => cancelAnimationFrame(animationFrameId);
}, [lastMessage, scrolledByUser, isChatActive]);
return {
lastMessage,
lastMessageRef,
isChatActive,
lastResponseMessages,
previousMessages,
};
};

View File

@@ -0,0 +1,134 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { Role } from "@turbostarter/ai/chat/types";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
import { AnalyzingImage } from "./analyzing-image";
import { useThreadLayout } from "./hooks/use-thread-layout";
import { ThreadMessage } from "./message";
import type { ThreadMessageComponents } from "./message";
import type { UIMessage } from "@ai-sdk/react";
interface ThreadProps<MESSAGE extends UIMessage> {
readonly messages: MESSAGE[];
readonly initialMessages?: MESSAGE[];
readonly status: string;
readonly error?: Error | null;
readonly regenerate?: () => Promise<void>;
readonly className?: string;
readonly components: ThreadMessageComponents<MESSAGE>;
readonly footer?: React.ReactNode;
}
export const Thread = <MESSAGE extends UIMessage>({
messages,
initialMessages,
status,
error,
regenerate,
className,
components,
footer,
}: ThreadProps<MESSAGE>) => {
const { t } = useTranslation("common");
const isReloading = useRef(false);
const {
lastMessage,
lastMessageRef,
isChatActive,
previousMessages,
lastResponseMessages,
} = useThreadLayout({ messages, initialMessages });
useEffect(() => {
if (
messages.at(-1)?.role === Role.USER &&
status === "ready" &&
!isReloading.current
) {
isReloading.current = true;
void regenerate?.().finally(() => {
isReloading.current = false;
});
}
}, [regenerate, messages, status]);
const renderMessage = useCallback(
(message: MESSAGE) => {
return (
<ThreadMessage.Message
message={message}
key={message.id}
status={status}
components={components}
{...(message.id === lastMessage?.id && { ref: lastMessageRef })}
/>
);
},
[lastMessage?.id, lastMessageRef, status, components],
);
return (
<ScrollArea
className={cn(
"@container/thread h-full w-full pt-12 pb-4 md:pt-14",
className,
)}
>
<div className="px-5">
{previousMessages.map(renderMessage)}
<div
className={cn("mx-auto flex w-full max-w-3xl flex-col", {
"min-h-[calc(100vh-4rem)] md:min-h-[calc(100vh-5.5rem)]":
isChatActive,
})}
>
{lastResponseMessages.map(renderMessage)}
{["submitted", "streaming"].includes(status) && (
<div className="relative py-4 md:px-4">
{status === "submitted" &&
messages.at(-1)?.role === Role.USER &&
messages
.at(-1)
?.parts.some(
(part) =>
part.type === "file" && part.mediaType.startsWith("image"),
) ? (
<AnalyzingImage />
) : (
<Icons.Loader className="text-muted-foreground size-5 animate-spin" />
)}
</div>
)}
{footer}
{error && (
<div className="relative pb-4 @lg/thread:px-2 @xl/thread:px-4">
<div className="bg-destructive/10 dark:bg-destructive/40 flex w-fit flex-wrap items-center gap-3 rounded-xl p-5 py-3">
<p className="text-destructive dark:text-foreground">
{t("error.general")}
</p>
<Button
variant="destructive"
className="h-auto gap-2"
onClick={() => regenerate?.()}
>
<Icons.RotateCw className="size-4" />
{t("reload")}
</Button>
</div>
</div>
)}
<div className="w-full pb-[calc(var(--composer-height)+20px)]"></div>
</div>
</div>
</ScrollArea>
);
};

View File

@@ -0,0 +1,66 @@
import { cn } from "@turbostarter/ui";
import { Controls } from "./controls";
import type { UIMessage } from "@ai-sdk/react";
export type ThreadMessageComponents<MESSAGE extends UIMessage> = Record<
string,
React.ComponentType<ThreadMessageProps<MESSAGE>>
>;
export interface ThreadMessageProps<T extends UIMessage = UIMessage> {
readonly status: string;
readonly message: T;
readonly ref?: React.RefObject<HTMLDivElement | null>;
}
const Message = <MESSAGE extends UIMessage>(
props: ThreadMessageProps<MESSAGE> & {
components: ThreadMessageComponents<MESSAGE>;
},
) => {
const role = props.message.role;
const isSupportedRole = (
role: string,
): role is keyof typeof props.components => {
return role in props.components;
};
if (!isSupportedRole(role)) {
return null;
}
const Component = props.components[role];
if (!Component) {
return null;
}
return <Component {...props} />;
};
const Layout = ({
children,
className,
...props
}: React.ComponentProps<"div">) => {
return (
<div
className={cn(
"group relative mx-auto flex w-full max-w-3xl scroll-mb-[calc(var(--composer-height,140px)+36px)] flex-col justify-center gap-1 py-4 @md/thread:px-1 @lg/thread:px-2 @xl/thread:px-4",
className,
)}
{...props}
>
{children}
</div>
);
};
export const ThreadMessage = {
Layout,
Message,
Controls,
};

View File

@@ -0,0 +1,399 @@
"use client";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation, useMutationState } from "@tanstack/react-query";
import { createContext, useContext, useState } from "react";
import { FormProvider, useForm, useFormContext } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { handle } from "@turbostarter/api/utils";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { api } from "~/lib/api/client";
interface AvatarFormProps {
readonly id: string;
readonly image?: string | null;
readonly update: (
image: string | null,
) => Promise<{ error: { message?: string } | null }>;
}
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
];
const mutations = {
upload: {
mutationKey: ["avatar", "upload"],
mutationFn: async ({
avatar,
id,
image,
update,
}: AvatarFormProps & { avatar?: File }) => {
const extension = avatar?.type.split("/").pop();
const uuid = crypto.randomUUID();
const path = `avatars/${id}-${uuid}.${extension}`;
const { url: uploadUrl } = await handle(api.storage.upload.$get)({
query: { path },
});
const response = await fetch(uploadUrl, {
method: "PUT",
body: avatar,
headers: {
"Content-Type": avatar?.type ?? "",
},
});
if (!response.ok) {
throw new Error();
}
const { url: publicUrl } = await handle(api.storage.public.$get)({
query: { path },
});
const { error } = await update(publicUrl);
if (error) {
throw new Error(error.message);
}
return { publicUrl, oldImage: image };
},
},
remove: {
mutationKey: ["avatar", "remove"],
mutationFn: async ({ image, update }: Omit<AvatarFormProps, "id">) => {
const path = image?.split("/").pop();
if (!path) {
return;
}
const { url: deleteUrl } = await handle(api.storage.delete.$get)({
query: { path: `avatars/${path}` },
});
const { error } = await update(null);
if (error) {
throw new Error(error.message);
}
void fetch(deleteUrl, {
method: "DELETE",
});
},
},
};
const useAvatarFormSchema = () => {
const { t, i18n } = useTranslation("validation");
return z.object({
avatar: z
.custom<FileList>()
.refine(
(files) => files.length === 1,
t("error.file.maxCount", { count: 1 }),
)
.transform((files) => files[0])
.refine(
(file) => (file?.size ?? 0) <= MAX_FILE_SIZE,
t("error.tooBig.file.inclusive", {
maximum: MAX_FILE_SIZE,
}),
)
.refine(
(file) => ACCEPTED_IMAGE_TYPES.includes(file?.type ?? ""),
t("error.file.type", {
types: new Intl.ListFormat(i18n.language, {
style: "long",
type: "conjunction",
}).format(ACCEPTED_IMAGE_TYPES.map((t) => t.replace("image/", "."))),
}),
),
});
};
interface AvatarFormContextValue extends AvatarFormProps {
previewUrl: string | null;
setPreviewUrl: (previewUrl: string | null) => void;
}
const AvatarFormContext = createContext<AvatarFormContextValue | null>(null);
const useAvatarFormContext = () => {
const context = useContext(AvatarFormContext);
if (!context) {
throw new Error("useAvatarFormContext must be used within a AvatarForm!");
}
return context;
};
const AvatarForm = ({
id,
image,
update,
children,
}: AvatarFormProps & {
children: React.ReactNode;
}) => {
const [previewUrl, setPreviewUrl] = useState(image ?? null);
const avatarSchema = useAvatarFormSchema();
const form = useForm({
resolver: standardSchemaResolver(avatarSchema),
});
return (
<AvatarFormContext.Provider
value={{ id, image, update, previewUrl, setPreviewUrl }}
>
<FormProvider {...form}>{children}</FormProvider>
</AvatarFormContext.Provider>
);
};
const AvatarFormInput = ({
className,
children,
onUpload,
disabled,
...props
}: React.ComponentProps<"input"> & { onUpload?: () => void }) => {
const { t } = useTranslation("common");
const { image, setPreviewUrl, id, update } = useAvatarFormContext();
const avatarSchema = useAvatarFormSchema();
const { register, handleSubmit, reset } =
useFormContext<z.infer<typeof avatarSchema>>();
const upload = useMutation({
...mutations.upload,
onError: (error) => {
setPreviewUrl(image ?? null);
toast.error(error.message || t("error.general"));
},
onSuccess: async ({ publicUrl, oldImage }) => {
await new Promise((resolve) => {
const img = new Image();
img.src = publicUrl;
img.onload = resolve;
});
if (oldImage) {
const oldPath = oldImage.split("/").pop();
if (oldPath) {
const { url: deleteUrl } = await handle(api.storage.delete.$get)({
query: { path: `avatars/${oldPath}` },
});
void fetch(deleteUrl, { method: "DELETE" });
}
}
onUpload?.();
},
});
const [removeStatus] = useMutationState({
filters: { mutationKey: mutations.remove.mutationKey },
select: (mutation) => mutation.state.status,
});
const onSubmit = (data: z.infer<typeof avatarSchema>) => {
upload.mutate({
...data,
id,
image,
update,
});
reset();
};
return (
<label
className={cn(
"group",
{
"cursor-pointer": !disabled,
"cursor-not-allowed opacity-50": disabled,
},
className,
)}
>
{children}
<input
{...register("avatar", {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
return;
}
const isValid = avatarSchema.safeParse({
avatar: e.target.files,
});
if (isValid.success) {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
}
void handleSubmit(onSubmit)();
},
})}
type="file"
accept={ACCEPTED_IMAGE_TYPES.join(",")}
className="sr-only"
aria-label={t("update")}
onClick={(e) => "value" in e.target && (e.target.value = "")}
disabled={disabled ?? (upload.isPending || removeStatus === "pending")}
{...props}
/>
</label>
);
};
const AvatarFormPreview = ({
className,
fallback,
...props
}: React.ComponentProps<typeof Avatar> & { fallback?: React.ReactNode }) => {
const { previewUrl } = useAvatarFormContext();
const { formState } = useFormContext();
const mutationStatues = useMutationState({
filters: {
predicate: (mutation) =>
[mutations.upload.mutationKey, mutations.remove.mutationKey].includes(
mutation.options.mutationKey as string[],
),
},
select: (mutation) => mutation.state.status,
});
const hasError =
formState.errors.avatar ??
mutationStatues.some((status) => status === "error");
return (
<Avatar
className={cn(
`relative size-20 transition-all ${
hasError
? "ring-destructive ring-offset-background ring-2 ring-offset-2"
: "group-focus-within:ring-primary group-focus-within:ring-offset-background group-focus-within:ring-2 group-focus-within:ring-offset-2"
}`,
className,
)}
{...props}
>
{previewUrl && <AvatarImage src={previewUrl} />}
{mutationStatues.some((status) => status === "pending") && (
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-full backdrop-blur-sm">
<Icons.Loader2 className="text-muted-foreground size-7 animate-spin" />
</div>
)}
<AvatarFallback>
{fallback ?? <Icons.UserRound className="size-10" />}
</AvatarFallback>
</Avatar>
);
};
const AvatarFormRemoveButton = ({
className,
onRemove,
...props
}: React.ComponentProps<typeof Button> & { onRemove?: () => void }) => {
const { t } = useTranslation("common");
const { image, update, previewUrl, setPreviewUrl } = useAvatarFormContext();
const { clearErrors } = useFormContext();
const [uploadStatus] = useMutationState({
filters: { mutationKey: mutations.upload.mutationKey },
select: (mutation) => mutation.state.status,
});
const remove = useMutation({
...mutations.remove,
onMutate: () => {
setPreviewUrl(null);
},
onSuccess: () => {
setPreviewUrl(null);
onRemove?.();
},
});
return (
previewUrl &&
uploadStatus !== "pending" && (
<Button
variant="outline"
size="icon"
className={cn(
"bg-background dark:bg-background hover:bg-muted dark:hover:bg-muted absolute -top-1 -right-1 size-6 rounded-full",
className,
)}
disabled={remove.isPending}
onClick={() => {
clearErrors();
remove.mutate({
image,
update,
});
}}
{...props}
>
<Icons.X className="size-3.5" />
<span className="sr-only">{t("remove")}</span>
</Button>
)
);
};
const AvatarFormErrorMessage = (props: React.ComponentProps<"span">) => {
const _avatarSchema = useAvatarFormSchema();
const { formState } = useFormContext<z.infer<typeof _avatarSchema>>();
if (!formState.errors.avatar) {
return null;
}
return (
<span
className={cn("text-destructive text-xs", props.className)}
{...props}
>
{formState.errors.avatar.message}
</span>
);
};
export {
AvatarForm,
AvatarFormInput,
AvatarFormPreview,
AvatarFormRemoveButton,
AvatarFormErrorMessage,
};

View File

@@ -0,0 +1,142 @@
/* eslint-disable i18next/no-literal-string */
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@turbostarter/ui-web/accordion";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@turbostarter/ui-web/alert-dialog";
import { Icons } from "@turbostarter/ui-web/icons";
interface ImpactItem {
id: string;
displayName: string;
}
interface CascadeImpact {
label: string;
count: number;
items: ImpactItem[];
hasMore: boolean;
}
interface DestructiveActionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
impacts: CascadeImpact[];
isLoading?: boolean;
isExecuting?: boolean;
onConfirm: () => void;
onCancel: () => void;
confirmLabel?: string;
}
export function DestructiveActionDialog({
open,
onOpenChange,
title,
description,
impacts,
isLoading = false,
isExecuting = false,
onConfirm,
onCancel,
confirmLabel = "Delete",
}: DestructiveActionDialogProps) {
const hasImpacts = impacts.length > 0;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Icons.Loader2 className="h-4 w-4 animate-spin" />
Checking impact...
</div>
) : hasImpacts ? (
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
<div className="flex items-start gap-2">
<Icons.AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div className="flex-1 text-sm">
<p className="font-medium text-destructive">
This will also delete:
</p>
<Accordion type="multiple" className="mt-2">
{impacts.map((impact) => (
<AccordionItem
key={impact.label}
value={impact.label}
className="border-b-0"
>
<AccordionTrigger className="py-1.5 text-sm text-muted-foreground hover:no-underline">
<span>
{impact.count} {impact.label}
</span>
</AccordionTrigger>
<AccordionContent className="pb-2 pt-0">
<ul className="space-y-0.5 text-xs text-muted-foreground">
{impact.items.map((item) => (
<li
key={item.id}
className="truncate pl-2"
title={item.displayName}
>
{item.displayName}
</li>
))}
{impact.hasMore && (
<li className="pl-2 italic">
+{impact.count - impact.items.length} more...
</li>
)}
</ul>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
</div>
) : null}
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel} disabled={isExecuting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isLoading || isExecuting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isExecuting ? (
<>
<Icons.Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
confirmLabel
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,123 @@
import { toast } from "sonner";
import { isAPIError } from "@turbostarter/api/utils";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
const InsufficientCredits = () => {
const { t } = useTranslation("common");
const list = [
t("credits.description", { count: 5 }),
t("credits.description2"),
t("credits.description3"),
];
return (
<div className="flex gap-2">
<Icons.BanknoteX className="size-5" />
<div className="flex flex-col gap-6">
<span className="leading-tight font-medium">
{t("error.insufficientCredits")}
</span>
<ul className="flex flex-col gap-2">
{list.map((item) => (
<li key={item} className="flex items-center gap-2.5">
<div className="bg-success size-2 rounded-full"></div>
<span className="leading-tight">{item}</span>
</li>
))}
</ul>
<a
href="https://turbostarter.dev/ai#pricing"
target="_blank"
className={cn(
buttonVariants({ variant: "outline" }),
"group/button gap-2",
)}
>
{t("credits.cta")}{" "}
<Icons.ArrowRight className="size-4 transition-all group-hover/button:translate-x-1" />
</a>
</div>
</div>
);
};
const RateLimit = () => {
const { t } = useTranslation("common");
const list = [
t("rateLimit.description"),
t("rateLimit.description2"),
t("rateLimit.description3"),
];
return (
<div className="flex gap-2">
<Icons.ClockAlert className="size-5" />
<div className="flex flex-col gap-6">
<span className="leading-tight font-medium">
{t("error.rateLimit")}
</span>
<ul className="flex flex-col gap-2">
{list.map((item) => (
<li key={item} className="flex items-center gap-2.5">
<div className="bg-success size-2 rounded-full"></div>
<span className="leading-tight">{item}</span>
</li>
))}
</ul>
<a
href="https://turbostarter.dev/ai#pricing"
target="_blank"
className={cn(
buttonVariants({ variant: "outline" }),
"group/button gap-2",
)}
>
{t("rateLimit.cta")}{" "}
<Icons.ArrowRight className="size-4 transition-all group-hover/button:translate-x-1" />
</a>
</div>
</div>
);
};
const parseError = (error: Error) => {
try {
const parsed = JSON.parse(error.message);
return parsed;
} catch {
return error.message;
}
};
export const useAIError = () => {
const { t } = useTranslation("common");
const onError = (error: Error) => {
console.error(error);
const parsed = parseError(error);
if (!isAPIError(parsed)) {
return toast.error(error.message || t("error.general"));
}
if (parsed.code === "error.insufficientCredits") {
return toast(<InsufficientCredits />);
}
if (parsed.code === "error.rateLimit") {
return toast(<RateLimit />);
}
};
return { onError };
};

View File

@@ -0,0 +1,32 @@
import { useCallback, useState } from "react";
import { logger } from "@turbostarter/shared/logger";
type CopiedValue = string | null;
type CopyFn = (text: string) => Promise<boolean>;
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
const copy: CopyFn = useCallback(async (text) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!navigator?.clipboard) {
logger.warn("Clipboard not supported");
return false;
}
// Try to save to clipboard then save it in the state if worked
try {
await navigator.clipboard.writeText(text);
setCopiedText(text);
return true;
} catch (error) {
logger.warn("Copy failed", error);
setCopiedText(null);
return false;
}
}, []);
return [copiedText, copy];
}

View File

@@ -0,0 +1,46 @@
import { useCallback, useEffect, useState } from "react";
type CopiedValue = string | null;
type CopyFn = (text: string) => Promise<boolean>;
export function useCopy() {
const [text, setText] = useState<CopiedValue>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (copied) {
timeoutId = setTimeout(() => {
setCopied(false);
setText(null);
}, 3000);
}
return () => clearTimeout(timeoutId);
}, [copied]);
const copy: CopyFn = useCallback(async (text) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!navigator?.clipboard) {
console.warn("Clipboard not supported");
return false;
}
try {
await navigator.clipboard.writeText(text);
setText(text);
setCopied(true);
return true;
} catch (error) {
console.warn("Copy failed", error);
setText(null);
setCopied(false);
return false;
}
}, []);
return {
copied,
copy,
text,
};
}

View File

@@ -0,0 +1,90 @@
import { createParser } from "nuqs/server";
import * as z from "zod";
import { sortSchema } from "@turbostarter/shared/schema";
import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table";
const PAGE_KEY = "page";
const PER_PAGE_KEY = "perPage";
const SORT_KEY = "sort";
const ARRAY_SEPARATOR = ",";
const DEBOUNCE_MS = 500;
const THROTTLE_MS = 50;
interface CommonProps {
debounceMs?: number;
}
const getFiltersFromColumnFilters = <TData>({
columnFilters,
previousColumnFilters,
filterableColumns,
}: {
columnFilters: ColumnFiltersState;
previousColumnFilters?: ColumnFiltersState;
filterableColumns: ColumnDef<TData>[];
}) => {
const filterUpdates = columnFilters.reduce<
Record<string, string | string[] | null>
>((acc, filter) => {
if (filterableColumns.find((column) => column.id === filter.id)) {
acc[filter.id] = filter.value as string | string[];
}
return acc;
}, {});
for (const prevFilter of previousColumnFilters ?? []) {
if (!columnFilters.some((filter) => filter.id === prevFilter.id)) {
filterUpdates[prevFilter.id] = null;
}
}
return filterUpdates;
};
const getSortingStateParser = (columnIds?: string[] | Set<string>) => {
const validKeys = columnIds
? columnIds instanceof Set
? columnIds
: new Set(columnIds)
: null;
return createParser({
parse: (value) => {
try {
const parsed = JSON.parse(value);
const result = z.array(sortSchema).safeParse(parsed);
if (!result.success) return null;
if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
return null;
}
return result.data;
} catch {
return null;
}
},
serialize: (value) => JSON.stringify(value),
eq: (a, b) =>
a.length === b.length &&
a.every(
(item, index) =>
item.id === b[index]?.id && item.desc === b[index].desc,
),
});
};
export {
PAGE_KEY,
PER_PAGE_KEY,
SORT_KEY,
ARRAY_SEPARATOR,
DEBOUNCE_MS,
THROTTLE_MS,
getFiltersFromColumnFilters,
getSortingStateParser,
type CommonProps,
};

View File

@@ -0,0 +1,38 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useLocalDataTable } from "./local";
import { useSearchParamsDataTable } from "./search-params";
import type { UseLocalDataTableProps } from "./local";
import type { UseSearchParamsDataTableProps } from "./search-params";
import type { useQuery } from "@tanstack/react-query";
import type { UseQueryOptions } from "@tanstack/react-query";
import type { useReactTable } from "@tanstack/react-table";
export function useDataTable<
TData,
TQueryFnData extends { data?: TData[]; total?: number },
TQuery extends UseQueryOptions<TQueryFnData>,
>(
props: UseLocalDataTableProps<TData, TQueryFnData, TQuery>,
): {
table: ReturnType<typeof useReactTable<TData>>;
query: ReturnType<typeof useQuery<TQueryFnData>>;
};
export function useDataTable<TData>(
props: UseSearchParamsDataTableProps<TData>,
): { table: ReturnType<typeof useReactTable<TData>> };
export function useDataTable<
TData,
TQueryFnData extends { data?: TData[]; total?: number },
TQuery extends UseQueryOptions<TQueryFnData>,
>(
props:
| UseLocalDataTableProps<TData, TQueryFnData, TQuery>
| UseSearchParamsDataTableProps<TData>,
) {
if (props.persistance === "local") {
return useLocalDataTable(props);
}
return useSearchParamsDataTable(props);
}

View File

@@ -0,0 +1,189 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getFacetedUniqueValues,
getFacetedRowModel,
getSortedRowModel,
getPaginationRowModel,
getFilteredRowModel,
getCoreRowModel,
useReactTable,
getFacetedMinMaxValues,
} from "@tanstack/react-table";
import { useCallback, useMemo, useState } from "react";
import { useDebounceCallback } from "@turbostarter/shared/hooks";
import { DEBOUNCE_MS, getFiltersFromColumnFilters } from "./common";
import type { CommonProps } from "./common";
import type { UseQueryOptions } from "@tanstack/react-query";
import type {
ColumnFiltersState,
PaginationState,
RowSelectionState,
SortingState,
Updater,
VisibilityState,
} from "@tanstack/react-table";
import type { TableOptions } from "@tanstack/react-table";
export interface UseLocalDataTableProps<
TData,
TQueryFnData extends { data?: TData[]; pageCount?: number },
TQuery extends UseQueryOptions<TQueryFnData>,
> extends CommonProps,
Omit<TableOptions<TData>, "data" | "state" | "getCoreRowModel"> {
persistance: "local";
query: (params: {
page: number;
perPage: number;
sorting: SortingState;
filters: Record<string, string | string[] | null>;
}) => TQuery;
}
export function useLocalDataTable<
TData,
TQueryFnData extends { data?: TData[]; total?: number },
TQuery extends UseQueryOptions<TQueryFnData>,
>(props: UseLocalDataTableProps<TData, TQueryFnData, TQuery>) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
props.initialState?.rowSelection ?? {},
);
const [sorting, setSorting] = useState<SortingState>(
props.initialState?.sorting ?? [],
);
const [page, setPage] = useState(
(props.initialState?.pagination?.pageIndex ?? 0) + 1,
);
const [perPage, setPerPage] = useState(
props.initialState?.pagination?.pageSize ?? 10,
);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(
props.initialState?.columnFilters ?? [],
);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
props.initialState?.columnVisibility ?? {},
);
const filterableColumns = useMemo(() => {
return props.columns.filter((column) => column.enableColumnFilter);
}, [props.columns]);
const [filters, setFilters] = useState<
Record<string, string | string[] | null>
>(getFiltersFromColumnFilters({ columnFilters, filterableColumns }));
const debouncedSetFilters = useDebounceCallback(
(values: Record<string, string | string[] | null>) => {
setPage(1);
setFilters(values);
},
DEBOUNCE_MS,
);
const pagination: PaginationState = useMemo(
() => ({
pageIndex: page - 1,
pageSize: perPage,
}),
[page, perPage],
);
const onPaginationChange = useCallback(
(updaterOrValue: Updater<PaginationState>) => {
const newPagination =
typeof updaterOrValue === "function"
? updaterOrValue(pagination)
: updaterOrValue;
setPage(newPagination.pageIndex + 1);
setPerPage(newPagination.pageSize);
},
[pagination, setPage, setPerPage],
);
const onSortingChange = useCallback(
(updaterOrValue: Updater<SortingState>) => {
setSorting(
typeof updaterOrValue === "function"
? updaterOrValue(sorting)
: updaterOrValue,
);
},
[sorting, setSorting],
);
const onColumnFiltersChange = useCallback(
(updaterOrValue: Updater<ColumnFiltersState>) => {
setColumnFilters((prev) => {
const next =
typeof updaterOrValue === "function"
? updaterOrValue(prev)
: updaterOrValue;
debouncedSetFilters(
getFiltersFromColumnFilters({
columnFilters: next,
previousColumnFilters: prev,
filterableColumns,
}),
);
return next;
});
},
[debouncedSetFilters, filterableColumns],
);
const params = useMemo(
() => ({
page,
perPage,
sorting,
filters,
}),
[page, perPage, sorting, filters],
);
const query = useQuery({
...props.query(params),
enabled: !!props.query,
placeholderData: keepPreviousData,
});
const pageCount = useMemo(() => {
return Math.ceil((query.data?.total ?? 0) / perPage);
}, [query.data?.total, perPage]);
const table = useReactTable({
data: query.data?.data ?? [],
pageCount,
state: {
pagination,
sorting,
columnVisibility,
columnFilters,
rowSelection,
},
enableRowSelection: true,
onPaginationChange,
onSortingChange,
onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getFacetedMinMaxValues: getFacetedMinMaxValues(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
...props,
});
return { table, query };
}

View File

@@ -0,0 +1,260 @@
import {
getFacetedUniqueValues,
getFacetedRowModel,
getSortedRowModel,
getPaginationRowModel,
getFilteredRowModel,
getCoreRowModel,
useReactTable,
getFacetedMinMaxValues,
} from "@tanstack/react-table";
import {
parseAsArrayOf,
parseAsString,
parseAsInteger,
useQueryState,
useQueryStates,
} from "nuqs";
import { useCallback, useMemo, useState } from "react";
import { useDebounceCallback } from "@turbostarter/shared/hooks";
import {
ARRAY_SEPARATOR,
DEBOUNCE_MS,
getFiltersFromColumnFilters,
PAGE_KEY,
PER_PAGE_KEY,
SORT_KEY,
THROTTLE_MS,
} from "./common";
import { getSortingStateParser } from "./common";
import type { CommonProps } from "./common";
import type {
ColumnFiltersState,
PaginationState,
RowSelectionState,
SortingState,
Updater,
VisibilityState,
} from "@tanstack/react-table";
import type { TableOptions } from "@tanstack/react-table";
import type { Parser, UseQueryStateOptions } from "nuqs";
export interface UseSearchParamsDataTableProps<TData>
extends CommonProps,
Omit<TableOptions<TData>, "state" | "getCoreRowModel"> {
persistance: "searchParams";
history?: "push" | "replace";
throttleMs?: number;
clearOnDefault?: boolean;
enableAdvancedFilter?: boolean;
scroll?: boolean;
shallow?: boolean;
startTransition?: React.TransitionStartFunction;
}
export function useSearchParamsDataTable<TData>(
props: UseSearchParamsDataTableProps<TData>,
) {
const {
columns,
initialState,
history = "replace",
debounceMs = DEBOUNCE_MS,
throttleMs = THROTTLE_MS,
clearOnDefault = false,
scroll = false,
shallow = true,
startTransition,
...tableProps
} = props;
const queryStateOptions = useMemo<
Omit<UseQueryStateOptions<string>, "parse">
>(
() => ({
history,
scroll,
shallow,
throttleMs,
debounceMs,
clearOnDefault,
startTransition,
}),
[
history,
scroll,
shallow,
throttleMs,
debounceMs,
clearOnDefault,
startTransition,
],
);
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initialState?.rowSelection ?? {},
);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
initialState?.columnVisibility ?? {},
);
const [page, setPage] = useQueryState(
PAGE_KEY,
parseAsInteger.withOptions(queryStateOptions).withDefault(1),
);
const [perPage, setPerPage] = useQueryState(
PER_PAGE_KEY,
parseAsInteger
.withOptions(queryStateOptions)
.withDefault(initialState?.pagination?.pageSize ?? 10),
);
const pagination: PaginationState = useMemo(() => {
return {
pageIndex: page - 1,
pageSize: perPage,
};
}, [page, perPage]);
const onPaginationChange = useCallback(
(updaterOrValue: Updater<PaginationState>) => {
const newPagination =
typeof updaterOrValue === "function"
? updaterOrValue(pagination)
: updaterOrValue;
void setPage(newPagination.pageIndex + 1);
void setPerPage(newPagination.pageSize);
},
[pagination, setPage, setPerPage],
);
const columnIds = useMemo(() => {
return new Set(columns.map((column) => column.id).filter(Boolean));
}, [columns]);
const [sorting, setSorting] = useQueryState(
SORT_KEY,
getSortingStateParser(columnIds)
.withOptions(queryStateOptions)
.withDefault(initialState?.sorting ?? []),
);
const onSortingChange = useCallback(
(updaterOrValue: Updater<SortingState>) => {
void setSorting(
typeof updaterOrValue === "function"
? updaterOrValue(sorting)
: updaterOrValue,
);
},
[sorting, setSorting],
);
const filterableColumns = useMemo(
() => columns.filter((column) => column.enableColumnFilter),
[columns],
);
const filterParsers = useMemo(() => {
return filterableColumns.reduce<
Record<string, Parser<string> | Parser<string[]>>
>((acc, column) => {
if (column.meta && "options" in column.meta && column.meta.options) {
acc[column.id ?? ""] = parseAsArrayOf(
parseAsString,
ARRAY_SEPARATOR,
).withOptions(queryStateOptions);
} else {
acc[column.id ?? ""] = parseAsString.withOptions(queryStateOptions);
}
return acc;
}, {});
}, [filterableColumns, queryStateOptions]);
const [filters, setFilters] = useQueryStates(filterParsers);
const debouncedSetFilters = useDebounceCallback((values: typeof filters) => {
void setPage(1);
void setFilters(values);
}, debounceMs);
const initialColumnFilters: ColumnFiltersState = useMemo(() => {
return Object.entries(filters).reduce<ColumnFiltersState>(
(filters, [key, value]) => {
if (value !== null) {
const processedValue = Array.isArray(value)
? value
: typeof value === "string" && /[^a-zA-Z0-9]/.test(value)
? value.split(/[^a-zA-Z0-9]+/).filter(Boolean)
: [value];
filters.push({
id: key,
value: processedValue,
});
}
return filters;
},
[],
);
}, [filters]);
const [columnFilters, setColumnFilters] =
useState<ColumnFiltersState>(initialColumnFilters);
const onColumnFiltersChange = useCallback(
(updaterOrValue: Updater<ColumnFiltersState>) => {
setColumnFilters((prev) => {
const next =
typeof updaterOrValue === "function"
? updaterOrValue(prev)
: updaterOrValue;
debouncedSetFilters(
getFiltersFromColumnFilters({
columnFilters: next,
previousColumnFilters: prev,
filterableColumns,
}),
);
return next;
});
},
[debouncedSetFilters, filterableColumns],
);
const table = useReactTable({
columns,
initialState,
state: {
pagination,
sorting,
columnVisibility,
columnFilters,
rowSelection,
},
enableRowSelection: true,
onPaginationChange,
onSortingChange,
onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getFacetedMinMaxValues: getFacetedMinMaxValues(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
...tableProps,
});
return { table };
}

View File

@@ -0,0 +1,154 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useState, useCallback } from "react";
interface ImpactItem {
id: string;
displayName: string;
}
interface CascadeImpact {
label: string;
count: number;
items: ImpactItem[];
hasMore: boolean;
}
interface PreviewResponse {
userFacingImpacts: CascadeImpact[];
totalAffected: number;
}
interface UseDestructiveActionOptions<TParams> {
/** Function to call for preview (returns impact counts) */
previewFn: (params: TParams) => Promise<PreviewResponse>;
/** Function to call for actual deletion */
executeFn: (params: TParams) => Promise<void>;
/** Callback on successful deletion */
onSuccess?: () => void;
/** Callback on error */
onError?: (error: Error) => void;
}
interface UseDestructiveActionReturn<TParams> {
/** Initiate the destructive action (shows preview dialog) */
initiate: (params: TParams) => Promise<void>;
/** Confirm and execute the action */
confirm: () => Promise<void>;
/** Cancel the action */
cancel: () => void;
/** Whether the dialog is open */
isOpen: boolean;
/** Whether preview is loading */
isLoadingPreview: boolean;
/** Whether deletion is executing */
isExecuting: boolean;
/** Cascade impacts to display */
impacts: CascadeImpact[];
/** The pending parameters (useful for displaying entity name) */
pendingParams: TParams | null;
}
/**
* Hook for managing destructive actions with cascade impact preview
*
* @example
* const deleteAction = useDestructiveAction({
* previewFn: async ({ id }) => {
* const res = await api.connections[":id"].$delete({
* param: { id },
* query: { orgId, preview: "true" },
* });
* return res.json();
* },
* executeFn: async ({ id }) => {
* await api.connections[":id"].$delete({
* param: { id },
* query: { orgId },
* });
* },
* onSuccess: () => {
* queryClient.invalidateQueries({ queryKey: ["connections"] });
* toast.success("Deleted successfully");
* },
* });
*
* // In JSX:
* <Button onClick={() => deleteAction.initiate({ id: connection.id })}>Delete</Button>
* <DestructiveActionDialog
* open={deleteAction.isOpen}
* impacts={deleteAction.impacts}
* isLoading={deleteAction.isLoadingPreview}
* isExecuting={deleteAction.isExecuting}
* onConfirm={deleteAction.confirm}
* onCancel={deleteAction.cancel}
* />
*/
export function useDestructiveAction<TParams>({
previewFn,
executeFn,
onSuccess,
onError,
}: UseDestructiveActionOptions<TParams>): UseDestructiveActionReturn<TParams> {
const [pendingParams, setPendingParams] = useState<TParams | null>(null);
const [impacts, setImpacts] = useState<CascadeImpact[]>([]);
const previewMutation = useMutation({
mutationFn: previewFn,
onSuccess: (data) => {
setImpacts(data.userFacingImpacts);
},
onError: (error) => {
// Still show dialog even if preview fails
console.error("Preview failed:", error);
setImpacts([]);
},
});
const executeMutation = useMutation({
mutationFn: executeFn,
onSuccess: () => {
setPendingParams(null);
setImpacts([]);
onSuccess?.();
},
onError: (error) => {
onError?.(error instanceof Error ? error : new Error(String(error)));
},
});
const initiate = useCallback(
async (params: TParams) => {
setPendingParams(params);
setImpacts([]);
await previewMutation.mutateAsync(params);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[previewMutation.mutateAsync],
);
const confirm = useCallback(async () => {
if (pendingParams) {
await executeMutation.mutateAsync(pendingParams);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingParams, executeMutation.mutateAsync]);
const cancel = useCallback(() => {
setPendingParams(null);
setImpacts([]);
}, []);
return {
initiate,
confirm,
cancel,
isOpen: pendingParams !== null,
isLoadingPreview: previewMutation.isPending,
isExecuting: executeMutation.isPending,
impacts,
pendingParams,
};
}

View File

@@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from "react";
interface State {
isIntersecting: boolean;
entry?: IntersectionObserverEntry;
}
interface UseIntersectionObserverOptions {
root?: Element | Document | null;
rootMargin?: string;
threshold?: number | number[];
freezeOnceVisible?: boolean;
onChange?: (
isIntersecting: boolean,
entry: IntersectionObserverEntry,
) => void;
initialIsIntersecting?: boolean;
}
type IntersectionReturn = [
(node?: Element | null) => void,
boolean,
IntersectionObserverEntry | undefined,
] & {
ref: (node?: Element | null) => void;
isIntersecting: boolean;
entry?: IntersectionObserverEntry;
};
export function useIntersectionObserver({
threshold = 0,
root = null,
rootMargin = "0%",
freezeOnceVisible = false,
initialIsIntersecting = false,
onChange,
}: UseIntersectionObserverOptions = {}): IntersectionReturn {
const [ref, setRef] = useState<Element | null>(null);
const [state, setState] = useState<State>(() => ({
isIntersecting: initialIsIntersecting,
entry: undefined,
}));
const callbackRef =
useRef<UseIntersectionObserverOptions["onChange"]>(undefined);
callbackRef.current = onChange;
const frozen = state.entry?.isIntersecting && freezeOnceVisible;
useEffect(() => {
// Ensure we have a ref to observe
if (!ref) return;
// Ensure the browser supports the Intersection Observer API
if (!("IntersectionObserver" in window)) return;
// Skip if frozen
if (frozen) return;
let unobserve: (() => void) | undefined;
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => {
const thresholds = Array.isArray(observer.thresholds)
? observer.thresholds
: [observer.thresholds];
entries.forEach((entry) => {
const isIntersecting =
entry.isIntersecting &&
thresholds.some(
(threshold) => entry.intersectionRatio >= (threshold as number),
);
setState({ isIntersecting, entry });
if (callbackRef.current) {
callbackRef.current(isIntersecting, entry);
}
if (isIntersecting && freezeOnceVisible && unobserve) {
unobserve();
unobserve = undefined;
}
});
},
{ threshold, root, rootMargin },
);
observer.observe(ref);
return () => {
observer.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
ref,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(threshold),
root,
rootMargin,
frozen,
freezeOnceVisible,
]);
// ensures that if the observed element changes, the intersection observer is reinitialized
const prevRef = useRef<Element | null>(null);
useEffect(() => {
if (
!ref &&
state.entry?.target &&
!freezeOnceVisible &&
!frozen &&
prevRef.current !== state.entry.target
) {
prevRef.current = state.entry.target;
setState({ isIntersecting: initialIsIntersecting, entry: undefined });
}
}, [ref, state.entry, freezeOnceVisible, frozen, initialIsIntersecting]);
const result = [
setRef,
!!state.isIntersecting,
state.entry,
] as IntersectionReturn;
// Support object destructuring, by adding the specific values.
result.ref = result[0];
result.isIntersecting = result[1];
result.entry = result[2];
return result;
}

View File

@@ -0,0 +1,12 @@
"use server";
import { cookies } from "next/headers";
import { config } from "@turbostarter/i18n";
import type { Locale } from "@turbostarter/i18n";
export const setLocaleCookie = async (locale: Locale) => {
const cookieStore = await cookies();
cookieStore.set(config.cookie, locale);
};

View File

@@ -0,0 +1,35 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useCallback } from "react";
import { getPathname } from "@turbostarter/i18n";
import { LocaleCustomizer } from "@turbostarter/ui-web/i18n";
import { appConfig } from "~/config/app";
import { setLocaleCookie } from "./actions";
import type { Locale } from "@turbostarter/i18n";
export const I18nControls = () => {
const router = useRouter();
const path = usePathname();
const onChange = useCallback(
async (locale: Locale) => {
await setLocaleCookie(locale);
router.push(
getPathname({
locale,
path,
defaultLocale: appConfig.locale,
}),
);
router.refresh();
},
[path, router],
);
return <LocaleCustomizer onChange={onChange} />;
};

View File

@@ -0,0 +1,200 @@
/* eslint-disable @next/next/no-img-element */
import { motion } from "motion/react";
import { memo, useCallback, useEffect, useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { buttonVariants } from "@turbostarter/ui-web/button";
import {
Dialog,
DialogClose,
DialogContent,
} from "@turbostarter/ui-web/dialog";
import { Icons } from "@turbostarter/ui-web/icons";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
export const Thumbnail = ({
index,
className,
...props
}: React.ComponentProps<typeof motion.button> & { index?: number }) => {
return (
<motion.button
className={cn(
"group/thumbnail relative cursor-pointer overflow-hidden rounded-lg",
"ring-offset-background hover:ring-primary focus:ring-primary hover:ring-2 hover:ring-offset-2 focus:ring-2 focus:ring-offset-2",
"transition-all duration-200",
"bg-primary/5 dark:bg-primary/10 shadow-xs",
className,
)}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: (index ?? 0) * 0.1 }}
{...props}
/>
);
};
export const ThumbnailImage = ({
className,
...props
}: React.ImgHTMLAttributes<HTMLImageElement>) => {
const [loaded, setLoaded] = useState(false);
return (
<>
<img
className={cn(
"h-full w-full object-cover object-center",
"opacity-0 transition-all duration-500",
loaded && "opacity-100",
className,
)}
onLoad={() => {
setLoaded(true);
}}
{...props}
/>
{!loaded && <Skeleton className="absolute inset-0" />}
</>
);
};
interface ImageSource {
url?: string | null;
base64?: string | null;
}
export const getImageSource = (image: ImageSource) => {
if (image.url) {
return image.url;
}
if (image.base64) {
return `data:image/jpeg;base64,${image.base64}`;
}
return "";
};
interface ViewerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
images: (ImageSource & { description?: string | null })[];
selectedImage: number;
setSelectedImage: (index: number) => void;
}
export const Viewer = memo<ViewerProps>(
({ open, onOpenChange, images, selectedImage, setSelectedImage }) => {
const { t } = useTranslation("common");
const currentImage = images[selectedImage];
const navigatePrevious = useCallback(() => {
if (images.length <= 1) return;
setSelectedImage(
selectedImage === 0 ? images.length - 1 : selectedImage - 1,
);
}, [images.length, selectedImage, setSelectedImage]);
const navigateNext = useCallback(() => {
if (images.length <= 1) return;
setSelectedImage(
selectedImage === images.length - 1 ? 0 : selectedImage + 1,
);
}, [images.length, selectedImage, setSelectedImage]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!open || images.length <= 1) return;
if (event.key === "ArrowLeft") {
navigatePrevious();
} else if (event.key === "ArrowRight") {
navigateNext();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [open, selectedImage, images.length, navigatePrevious, navigateNext]);
if (!currentImage) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="h-full w-full max-w-full border-none bg-transparent p-0 shadow-none sm:max-w-full"
showCloseButton={false}
>
<div className="relative flex grow flex-col gap-4">
<div className="flex grow items-center justify-center">
{images.length > 1 && (
<button
className="group flex h-full cursor-pointer items-center justify-center p-2 backdrop-blur-sm transition-all hover:backdrop-blur-md md:p-3"
onClick={navigatePrevious}
aria-label={t("previous")}
>
<div
className={cn(
buttonVariants({ variant: "outline" }),
"bg-background/50 text-muted-foreground group-hover:bg-accent group-hover:text-accent-foreground size-10 border-0 p-0 backdrop-blur-md md:-left-6 md:size-12",
)}
>
<Icons.ChevronLeft className="size-6 md:size-7" />
</div>
</button>
)}
<div className="relative flex w-full grow items-center justify-center px-2">
<motion.img
key={getImageSource(currentImage)} // Use a stable key like the source
src={getImageSource(currentImage)}
alt={currentImage.description ?? ""}
className="h-full max-h-[70vh] w-full rounded-lg object-contain"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
fetchPriority="high"
/>
</div>
{images.length > 1 && (
<button
className="group flex h-full cursor-pointer items-center justify-center p-2 backdrop-blur-sm transition-all hover:backdrop-blur-md md:p-3"
onClick={navigateNext}
aria-label={t("next")}
>
<div
className={cn(
buttonVariants({ variant: "outline" }),
"bg-background/50 text-muted-foreground group-hover:bg-accent group-hover:text-accent-foreground size-10 border-0 p-0 backdrop-blur-md md:-left-6 md:size-12",
)}
>
<Icons.ChevronRight className="size-6 md:size-7" />
</div>
</button>
)}
</div>
{currentImage.description && (
<div className="absolute right-3 bottom-4 left-3 md:bottom-6 lg:bottom-8">
<p className="bg-background/75 mx-auto max-w-3xl rounded-2xl border px-6 py-4 text-center text-xs leading-normal backdrop-blur-md backdrop-saturate-150 md:text-sm">
{currentImage.description}
</p>
</div>
)}
</div>
<DialogClose className="absolute top-3 right-3 md:top-3.5 md:right-3.5">
<Icons.X />
<span className="sr-only">{t("close")}</span>
</DialogClose>
</DialogContent>
</Dialog>
);
},
);
Viewer.displayName = "Viewer";

View File

@@ -0,0 +1,37 @@
import { Geist_Mono, Geist } from "next/font/google";
import { cn } from "@turbostarter/ui";
import { appConfig } from "~/config/app";
const sans = Geist({
subsets: ["latin"],
display: "swap",
variable: "--font-sans",
});
const mono = Geist_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
weight: ["300", "400", "500"],
});
interface BaseLayoutProps {
readonly locale: string;
readonly children: React.ReactNode;
}
export const BaseLayout = ({ children, locale }: BaseLayoutProps) => {
return (
<html lang={locale} className={cn(sans.variable, mono.variable)}>
<body
suppressHydrationWarning
className="bg-background text-foreground flex min-h-screen flex-col items-center justify-center font-sans antialiased"
data-theme={appConfig.theme.color}
>
{children}
</body>
</html>
);
};

View File

@@ -0,0 +1,17 @@
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
export const queries = {
get: (params: { id: string }) => ({
queryKey: ["credits", params.id],
queryFn: () => handle(api.ai.credits.$get)(),
}),
};
export const mutations = {};
export const credits = {
queries,
mutations,
} as const;

View File

@@ -0,0 +1,93 @@
import NumberFlow from "@number-flow/react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "motion/react";
import {
getCreditsLevel,
getCreditsProgress,
} from "@turbostarter/ai/credits/utils";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { authClient } from "~/lib/auth/client";
import { credits } from "./api";
export const useCredits = () => {
const queryClient = useQueryClient();
const { data } = authClient.useSession();
const query = useQuery({
...credits.queries.get({ id: data?.user.id ?? "" }),
enabled: !!data?.user.id,
});
const invalidate = () =>
queryClient.invalidateQueries(
credits.queries.get({ id: data?.user.id ?? "" }),
);
return {
// eslint-disable-next-line @tanstack/query/no-rest-destructuring
...query,
invalidate,
};
};
export const Credits = () => {
const { t } = useTranslation("common");
const { data: credits } = useCredits();
if (typeof credits !== "number") {
return null;
}
const level = getCreditsLevel(credits);
const progress = getCreditsProgress(credits);
return (
<li
className={cn("mx-2 mt-2 flex flex-col overflow-hidden rounded-md", {
"bg-success/10": level === "high",
"bg-yellow-500/10": level === "medium",
"bg-destructive/10": level === "low",
})}
>
<div className="flex items-center justify-center gap-2 py-2.5">
<div
className={cn("size-2.5 animate-pulse rounded-full", {
"bg-success": level === "high",
"bg-yellow-500": level === "medium",
"bg-destructive": level === "low",
})}
></div>
<span className={cn("text-foreground text-sm font-medium", {})}>
<NumberFlow value={credits} format={{ style: "decimal" }} />{" "}
{t("creditsLeft")}
</span>
</div>
<div
className={cn("relative h-1 w-full", {
"bg-success/35": level === "high",
"bg-yellow-500/35": level === "medium",
"bg-destructive/35": level === "low",
})}
>
<motion.div
className={cn("absolute top-0 left-0 h-1 w-full origin-left", {
"bg-success": level === "high",
"bg-yellow-500": level === "medium",
"bg-destructive": level === "low",
})}
initial={{
scaleX: 0,
}}
animate={{
scaleX: progress,
}}
/>
</div>
</li>
);
};

View File

@@ -0,0 +1,17 @@
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/server";
import { getQueryClient } from "~/lib/query/server";
import { credits } from "./api";
export const prefetchCredits = async (id: string) => {
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
...credits.queries.get({ id }),
queryFn: handle(api.ai.credits.$get),
});
return queryClient;
};

View File

@@ -0,0 +1,176 @@
"use client";
import { usePathname } from "next/navigation";
import { Fragment } from "react";
import * as z from "zod";
import { isKey, useTranslation } from "@turbostarter/i18n";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@turbostarter/ui-web/breadcrumb";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { Separator } from "@turbostarter/ui-web/separator";
import { SidebarTrigger } from "@turbostarter/ui-web/sidebar";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
const ROOT_KEY = "home";
const indexSchema = z.object({
index: z.string(),
});
const hasIndex = (value: unknown): value is z.infer<typeof indexSchema> => {
return indexSchema.safeParse(value).success;
};
const getDisplayKey = (rawKey: string, hasPrevious: boolean) => {
if (rawKey === "index") return hasPrevious ? null : ROOT_KEY;
return rawKey;
};
const isSkippedKey = (key: string) => ["user", "organization"].includes(key);
const addCrumb = (
trail: { key: string; path?: string }[],
rawKey: string,
path?: string,
) => {
const displayKey = getDisplayKey(rawKey, trail.length > 0);
if (!displayKey || isSkippedKey(rawKey)) return trail;
return [...trail, { key: displayKey, path }];
};
const getPath = (
obj: unknown,
target: string,
current: { key: string; path?: string }[] = [],
): { key: string; path?: string }[] | null => {
if (!obj || typeof obj !== "object") return null;
for (const [rawKey, value] of Object.entries(
obj as Record<string, unknown>,
)) {
if (typeof value === "string") {
if (value === target) return addCrumb(current, rawKey, value);
continue;
}
if (typeof value === "function") {
const candidates = target.split("/").filter(Boolean);
for (const candidate of candidates) {
try {
const result = (value as (arg: string) => unknown)(candidate);
if (typeof result === "string") {
if (result === target) return addCrumb(current, rawKey, result);
} else if (result && typeof result === "object") {
const parentPath = addCrumb(
current,
rawKey,
hasIndex(result) ? result.index : undefined,
);
const found = getPath(result, target, parentPath);
if (found) return found;
}
} catch {
// Ignore callable errors and continue trying other candidates
}
}
continue;
}
if (value && typeof value === "object") {
const parentPath = addCrumb(
current,
rawKey,
hasIndex(value) ? value.index : undefined,
);
const found = getPath(value, target, parentPath);
if (found) return found;
}
}
return null;
};
export const DashboardActionBar = () => {
const { t, i18n } = useTranslation("common");
const pathname = usePathname();
const rawPath = getPath(pathsConfig, pathname);
const path =
rawPath?.length === 1 ? [{ ...rawPath[0], key: ROOT_KEY }] : rawPath;
const last = path?.at(-1);
return (
<header className="flex h-16 shrink-0 items-center justify-between gap-2 px-4 transition-[width,height] ease-linear md:px-6 lg:px-7">
<div className="flex items-center gap-2 pr-4">
<SidebarTrigger className="-ml-1" />
{path ? (
<>
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
{path.length > 1 &&
path.slice(1, -1).map((item, index, array) => (
<Fragment key={item.key}>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<TurboLink href={item.path ?? "#"}>
{isKey(item.key, i18n, "common")
? t(item.key)
: item.key}
</TurboLink>
</BreadcrumbLink>
</BreadcrumbItem>
{index < array.length - 1 && <BreadcrumbSeparator />}
</Fragment>
))}
{last && (
<>
{path.length > 2 && <BreadcrumbSeparator />}
<BreadcrumbItem>
<BreadcrumbPage>
{isKey(last.key, i18n, "common")
? t(last.key)
: last.key}
</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
</>
) : null}
</div>
<div className="text-muted-foreground flex items-center gap-2">
<a
href="https://github.com/turbostarter"
rel="noopener noreferrer"
target="_blank"
className={buttonVariants({ variant: "ghost", size: "icon" })}
>
<Icons.Github className="size-5" />
</a>
<a
href="https://discord.gg/KjpK2uk3JP"
rel="noopener noreferrer"
target="_blank"
className={buttonVariants({ variant: "ghost", size: "icon" })}
>
<Icons.Discord className="size-5" />
</a>
</div>
</header>
);
};

View File

@@ -0,0 +1,40 @@
import { cn } from "@turbostarter/ui";
export const DashboardHeader = ({
className,
...props
}: React.ComponentProps<"header">) => {
return (
<header
className={cn(
"flex w-full flex-wrap items-center justify-between gap-4 py-2 md:gap-6 lg:gap-10",
className,
)}
{...props}
/>
);
};
export const DashboardHeaderTitle = ({
className,
...props
}: React.ComponentProps<"h1">) => {
return (
<h1
className={cn("text-3xl font-bold tracking-tighter", className)}
{...props}
/>
);
};
export const DashboardHeaderDescription = ({
className,
...props
}: React.ComponentProps<"p">) => {
return (
<p
className={cn("text-muted-foreground text-sm text-pretty", className)}
{...props}
/>
);
};

View File

@@ -0,0 +1,17 @@
"use client";
import { SidebarInset } from "@turbostarter/ui-web/sidebar";
import { DashboardActionBar } from "./action-bar";
import { ScrollContainer } from "./scroll-container";
export const DashboardInset = ({ children }: { children: React.ReactNode }) => {
return (
<SidebarInset className="relative flex h-dvh flex-col sm:h-[calc(100dvh-1rem)]">
<DashboardActionBar />
<ScrollContainer>
{children}
</ScrollContainer>
</SidebarInset>
);
};

View File

@@ -0,0 +1,60 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@turbostarter/ui";
interface ScrollContainerProps {
children: React.ReactNode;
className?: string;
}
export function ScrollContainer({ children, className }: ScrollContainerProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [_canScrollUp, setCanScrollUp] = useState(false);
const [_canScrollDown, setCanScrollDown] = useState(false);
const updateScrollState = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
setCanScrollUp(scrollTop > 1);
setCanScrollDown(scrollTop + clientHeight < scrollHeight - 1);
}, []);
// Check on mount, resize, and content changes
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
// Initial check
updateScrollState();
// Watch for size changes
const observer = new ResizeObserver(updateScrollState);
observer.observe(el);
// Also observe children for content changes
const mutationObserver = new MutationObserver(updateScrollState);
mutationObserver.observe(el, { childList: true, subtree: true });
return () => {
observer.disconnect();
mutationObserver.disconnect();
};
}, [updateScrollState]);
return (
<div className={cn("relative flex-1 overflow-hidden", className)}>
{/* Scroll content - shadows removed, handled by individual components */}
<div
ref={scrollRef}
onScroll={updateScrollState}
className="h-full overflow-auto"
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { cva } from "class-variance-authority";
import * as React from "react";
import { createContext, useContext } from "react";
import { cn } from "@turbostarter/ui";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import type { VariantProps } from "class-variance-authority";
const settingsCardVariant = cva("bg-background h-fit w-full overflow-hidden", {
variants: {
variant: {
default: "dark:border-input",
destructive: "border-destructive/25",
},
},
defaultVariants: {
variant: "default",
},
});
const SettingsCardContext = createContext<
{
disabled: boolean;
} & VariantProps<typeof settingsCardVariant>
>({
disabled: false,
variant: "default",
});
const SettingsCard = ({
className,
variant,
disabled = false,
...props
}: React.ComponentProps<typeof Card> &
VariantProps<typeof settingsCardVariant> & { disabled?: boolean }) => (
<SettingsCardContext.Provider value={{ disabled, variant }}>
<Card
className={cn(
settingsCardVariant({ variant: disabled ? "default" : variant }),
"pb-0",
{
"text-muted-foreground cursor-not-allowed": disabled,
},
className,
)}
{...props}
/>
</SettingsCardContext.Provider>
);
const SettingsCardHeader = CardHeader;
const SettingsCardTitle = ({
className,
...props
}: React.ComponentProps<typeof CardTitle>) => (
<CardTitle className={cn("text-xl", className)} {...props} />
);
const SettingsCardDescription = ({
className,
...props
}: React.ComponentProps<typeof CardDescription>) => {
const { disabled } = useContext(SettingsCardContext);
return (
<CardDescription
className={cn(
"pb-1.5 text-sm",
{
"text-foreground": !disabled,
},
className,
)}
{...props}
/>
);
};
const SettingsCardContent = ({
className,
...props
}: React.ComponentProps<typeof CardContent>) => {
return <CardContent className={cn("-mt-4", className)} {...props} />;
};
const settingsCardFooterVariant = cva(
"flex min-h-14 cursor-auto justify-between gap-10 border-t py-3 text-sm leading-tight [.border-t]:pt-3",
{
variants: {
variant: {
default: "bg-accent text-muted-foreground dark:bg-accent/75",
destructive:
"border-t-destructive/25 bg-destructive/15 dark:bg-destructive/20 border-t",
},
},
defaultVariants: {
variant: "default",
},
},
);
const SettingsCardFooter = ({
className,
...props
}: React.ComponentProps<typeof CardFooter>) => {
const { variant, disabled } = useContext(SettingsCardContext);
return (
<CardFooter
className={cn(
settingsCardFooterVariant({ variant: disabled ? "default" : variant }),
className,
)}
{...props}
/>
);
};
export {
SettingsCard,
SettingsCardHeader,
SettingsCardContent,
SettingsCardFooter,
SettingsCardTitle,
SettingsCardDescription,
};

View File

@@ -0,0 +1,54 @@
"use client";
import { useParams, usePathname } from "next/navigation";
import { memo } from "react";
import { SidebarMenuButton, useSidebar } from "@turbostarter/ui-web/sidebar";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
interface SidebarLinkProps
extends React.ComponentProps<typeof SidebarMenuButton> {
href: string;
}
export const SidebarLink = memo<SidebarLinkProps>(
({ href, children, ...props }) => {
const { setOpenMobile } = useSidebar();
const pathname = usePathname();
const params = useParams();
const normalizedPathname = pathname.replace(
`/${params.locale?.toString()}`,
"",
);
return (
<SidebarMenuButton
asChild
isActive={
[
pathsConfig.admin.index,
pathsConfig.dashboard.user.index,
...(params.organization
? [
pathsConfig.dashboard.organization(
params.organization.toString(),
).index,
]
: []),
].includes(href)
? normalizedPathname === href
: normalizedPathname.startsWith(href)
}
{...props}
>
<TurboLink href={href} onClick={() => setOpenMobile(false)}>
{children}
</TurboLink>
</SidebarMenuButton>
);
},
);

View File

@@ -0,0 +1,117 @@
import { memo } from "react";
import { isKey } from "@turbostarter/i18n";
import { getTranslation } from "@turbostarter/i18n/server";
import { Icons } from "@turbostarter/ui-web/icons";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenuItem,
SidebarRail,
} from "@turbostarter/ui-web/sidebar";
import { SidebarMenu } from "@turbostarter/ui-web/sidebar";
import {
SidebarContent,
SidebarFooter,
SidebarHeader,
} from "@turbostarter/ui-web/sidebar";
import { Sidebar } from "@turbostarter/ui-web/sidebar";
import { pathsConfig } from "~/config/paths";
import { AccountSwitcher } from "~/modules/organization/account-switcher";
import { UserNavigation } from "~/modules/user/user-navigation";
import { SidebarLink } from "./sidebar-link";
import type { User } from "@turbostarter/auth";
import type { Icon } from "@turbostarter/ui-web/icons";
interface DashboardSidebarProps {
readonly user: User;
readonly menu: {
label: string;
items: {
title: string;
href: string;
icon: Icon;
}[];
}[];
}
export const DashboardSidebar = memo<DashboardSidebarProps>(
async ({ user, menu }) => {
const { t, i18n } = await getTranslation({ ns: "common" });
return (
<Sidebar
variant="inset"
className="top-(--banner-height) h-[calc(100svh-var(--banner-height))]"
>
<SidebarHeader>
<AccountSwitcher user={user} />
</SidebarHeader>
<SidebarContent>
{menu.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel className="uppercase">
{isKey(group.label, i18n, "common")
? t(group.label)
: group.label}
</SidebarGroupLabel>
<SidebarMenu>
{group.items.map((item) => {
const title = isKey(item.title, i18n, "common")
? t(item.title)
: item.title;
return (
<SidebarMenuItem key={item.href}>
<SidebarLink href={item.href} tooltip={title}>
<item.icon />
<span>{title}</span>
</SidebarLink>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
))}
<SidebarGroup className="mt-auto">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarLink
href={pathsConfig.marketing.contact}
tooltip={t("support")}
>
<Icons.LifeBuoy />
<span>{t("support")}</span>
</SidebarLink>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarLink
href={pathsConfig.marketing.contact}
tooltip={t("feedback")}
>
<Icons.MessageCircle />
<span>{t("feedback")}</span>
</SidebarLink>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<UserNavigation user={user} />
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
},
);

View File

@@ -0,0 +1,22 @@
import { cn } from "@turbostarter/ui";
import { SidebarTrigger } from "@turbostarter/ui-web/sidebar";
export const Header = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<header
className={cn(
"bg-background absolute top-0 left-0 z-20 flex h-12 w-full items-center justify-between gap-1 p-2 md:h-14 md:rounded-t-lg md:p-3",
className,
)}
>
<SidebarTrigger />
{children}
</header>
);
};

View File

@@ -0,0 +1,519 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Icons } from "@turbostarter/ui-web/icons";
import {
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@turbostarter/ui-web/sidebar";
import { pathsConfig } from "~/config/paths";
import { APPS } from "~/lib/constants";
import { TurboLink } from "~/modules/common/turbo-link";
const FREE_TOOLS = [
{
title: "envin",
href: "https://envin.turbostarter.dev",
icon: Icons.Shrub,
},
{
title: "ideasGenerator",
href: "https://www.turbostarter.dev/ideas",
icon: Icons.Lightbulb,
},
{
title: "extro",
href: "https://git.new/extro",
icon: Icons.Puzzle,
},
{
title: "emojai",
href: "https://chromewebstore.google.com/detail/emojai/gnnjegdgbplhcoadniflbacadnmlepoa",
icon: Icons.Smile,
},
{
title: "syncreads",
href: "https://syncreads.com",
icon: Icons.RefreshCcw,
},
] as const;
const OTHER = [
{
title: "home",
href: "https://turbostarter.dev/ai",
icon: Icons.Home,
},
{
title: "documentation",
href: "https://turbostarter.dev/ai/docs",
icon: Icons.LibraryBig,
},
{
title: "affiliates",
href: "https://turbostarter.lemonsqueezy.com/affiliates",
icon: Icons.BadgeDollarSign,
},
{
title: "blog",
href: "https://turbostarter.dev/blog",
icon: Icons.Newspaper,
},
] as const;
const DEV = [
{
title: "demos",
href: pathsConfig.demo.index,
icon: Icons.LayoutDashboard,
},
] as const;
const sections = [
{ label: "apps", items: APPS },
{ label: "dev", items: DEV },
{ label: "freeTools", items: FREE_TOOLS },
{ label: "other", items: OTHER },
] as const;
export const Content = () => {
const { t } = useTranslation(["common", "ai"]);
const pathname = usePathname();
const { setOpenMobile } = useSidebar();
return (
<SidebarContent>
{sections.map((section) => (
<SidebarGroup key={section.label}>
<SidebarGroupLabel className="uppercase">
{t(section.label)}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{section.items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={
(section.label === "apps" || section.label === "dev") &&
pathname.startsWith(item.href)
}
>
<TurboLink
href={item.href}
target={
item.href.startsWith("http") ? "_blank" : undefined
}
onClick={() => setOpenMobile(false)}
>
<item.icon className="size-4" />
{section.label === "apps"
? t(`ai:${item.title}.title` as "ai:chat.title" | "ai:image.title" | "ai:tts.title" | "ai:pdf.title" | "ai:agent.title")
: t(item.title as "home" | "documentation" | "affiliates" | "blog" | "envin" | "ideasGenerator" | "extro" | "emojai" | "syncreads" | "demos" | "dev")}
</TurboLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
<div className="flex flex-col gap-2 px-3 pb-4">
<AppStoreButton
size="lg"
href="https://apps.apple.com/app/id6754575325"
/>
<GooglePlayButton
size="lg"
href="https://play.google.com/store/apps/details?id=com.turbostarter.ai"
/>
</div>
</SidebarContent>
);
};
const GooglePlayButton = ({
size = "md",
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
return (
<a
aria-label="Get it on Google Play"
target="_blank"
rel="noopener noreferrer"
{...props}
className={cn(
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground flex cursor-pointer items-center justify-center rounded-lg border py-0.5",
props.className,
)}
>
<svg
width={size === "md" ? 135 : 149}
height={size === "md" ? 40 : 44}
viewBox="0 0 135 40"
fill="none"
>
<path
d="M68.136 21.7511C65.784 21.7511 63.867 23.5401 63.867 26.0041C63.867 28.4531 65.784 30.2571 68.136 30.2571C70.489 30.2571 72.406 28.4531 72.406 26.0041C72.405 23.5401 70.488 21.7511 68.136 21.7511ZM68.136 28.5831C66.847 28.5831 65.736 27.5201 65.736 26.0051C65.736 24.4741 66.848 23.4271 68.136 23.4271C69.425 23.4271 70.536 24.4741 70.536 26.0051C70.536 27.5191 69.425 28.5831 68.136 28.5831ZM58.822 21.7511C56.47 21.7511 54.553 23.5401 54.553 26.0041C54.553 28.4531 56.47 30.2571 58.822 30.2571C61.175 30.2571 63.092 28.4531 63.092 26.0041C63.092 23.5401 61.175 21.7511 58.822 21.7511ZM58.822 28.5831C57.533 28.5831 56.422 27.5201 56.422 26.0051C56.422 24.4741 57.534 23.4271 58.822 23.4271C60.111 23.4271 61.222 24.4741 61.222 26.0051C61.223 27.5191 60.111 28.5831 58.822 28.5831ZM47.744 23.0571V24.8611H52.062C51.933 25.8761 51.595 26.6171 51.079 27.1321C50.451 27.7601 49.468 28.4531 47.744 28.4531C45.086 28.4531 43.008 26.3101 43.008 23.6521C43.008 20.9941 45.086 18.8511 47.744 18.8511C49.178 18.8511 50.225 19.4151 50.998 20.1401L52.271 18.8671C51.191 17.8361 49.758 17.0471 47.744 17.0471C44.103 17.0471 41.042 20.0111 41.042 23.6521C41.042 27.2931 44.103 30.2571 47.744 30.2571C49.709 30.2571 51.192 29.6121 52.351 28.4041C53.543 27.2121 53.914 25.5361 53.914 24.1831C53.914 23.7651 53.882 23.3781 53.817 23.0561H47.744V23.0571ZM93.052 24.4581C92.698 23.5081 91.618 21.7511 89.411 21.7511C87.22 21.7511 85.399 23.4751 85.399 26.0041C85.399 28.3881 87.204 30.2571 89.62 30.2571C91.569 30.2571 92.697 29.0651 93.165 28.3721L91.715 27.4051C91.232 28.1141 90.571 28.5811 89.62 28.5811C88.67 28.5811 87.993 28.1461 87.558 27.2921L93.245 24.9401L93.052 24.4581ZM87.252 25.8761C87.204 24.2321 88.525 23.3951 89.476 23.3951C90.217 23.3951 90.845 23.7661 91.055 24.2971L87.252 25.8761ZM82.629 30.0001H84.497V17.4991H82.629V30.0001ZM79.567 22.7021H79.503C79.084 22.2021 78.278 21.7511 77.264 21.7511C75.137 21.7511 73.188 23.6201 73.188 26.0211C73.188 28.4051 75.137 30.2581 77.264 30.2581C78.279 30.2581 79.084 29.8071 79.503 29.2921H79.567V29.9041C79.567 31.5311 78.697 32.4011 77.296 32.4011C76.152 32.4011 75.443 31.5801 75.153 30.8871L73.526 31.5641C73.993 32.6911 75.233 34.0771 77.296 34.0771C79.487 34.0771 81.34 32.7881 81.34 29.6461V22.0101H79.568V22.7021H79.567ZM77.425 28.5831C76.136 28.5831 75.057 27.5031 75.057 26.0211C75.057 24.5221 76.136 23.4271 77.425 23.4271C78.697 23.4271 79.696 24.5221 79.696 26.0211C79.696 27.5031 78.697 28.5831 77.425 28.5831ZM101.806 17.4991H97.335V30.0001H99.2V25.2641H101.805C103.873 25.2641 105.907 23.7671 105.907 21.3821C105.907 18.9971 103.874 17.4991 101.806 17.4991ZM101.854 23.5241H99.2V19.2391H101.854C103.249 19.2391 104.041 20.3941 104.041 21.3821C104.041 22.3501 103.249 23.5241 101.854 23.5241ZM113.386 21.7291C112.035 21.7291 110.636 22.3241 110.057 23.6431L111.713 24.3341C112.067 23.6431 112.727 23.4171 113.418 23.4171C114.383 23.4171 115.364 23.9961 115.38 25.0251V25.1541C115.042 24.9611 114.318 24.6721 113.434 24.6721C111.649 24.6721 109.831 25.6531 109.831 27.4861C109.831 29.1591 111.295 30.2361 112.935 30.2361C114.189 30.2361 114.881 29.6731 115.315 29.0131H115.379V29.9781H117.181V25.1851C117.182 22.9671 115.524 21.7291 113.386 21.7291ZM113.16 28.5801C112.55 28.5801 111.697 28.2741 111.697 27.5181C111.697 26.5531 112.759 26.1831 113.676 26.1831C114.495 26.1831 114.882 26.3601 115.38 26.6011C115.235 27.7601 114.238 28.5801 113.16 28.5801ZM123.743 22.0021L121.604 27.4221H121.54L119.32 22.0021H117.31L120.639 29.5771L118.741 33.7911H120.687L125.818 22.0021H123.743ZM106.937 30.0001H108.802V17.4991H106.937V30.0001Z"
fill="var(--foreground)"
/>
<path
d="M47.418 10.2429C47.418 11.0809 47.1701 11.7479 46.673 12.2459C46.109 12.8379 45.3731 13.1339 44.4691 13.1339C43.6031 13.1339 42.8661 12.8339 42.2611 12.2339C41.6551 11.6329 41.3521 10.8889 41.3521 10.0009C41.3521 9.11194 41.6551 8.36794 42.2611 7.76794C42.8661 7.16694 43.6031 6.86694 44.4691 6.86694C44.8991 6.86694 45.3101 6.95094 45.7001 7.11794C46.0911 7.28594 46.404 7.50894 46.6381 7.78794L46.111 8.31594C45.714 7.84094 45.167 7.60394 44.468 7.60394C43.836 7.60394 43.29 7.82594 42.829 8.26994C42.368 8.71394 42.1381 9.29094 42.1381 9.99994C42.1381 10.7089 42.368 11.2859 42.829 11.7299C43.29 12.1739 43.836 12.3959 44.468 12.3959C45.138 12.3959 45.6971 12.1729 46.1441 11.7259C46.4341 11.4349 46.602 11.0299 46.647 10.5109H44.468V9.78994H47.375C47.405 9.94694 47.418 10.0979 47.418 10.2429Z"
fill="var(--foreground)"
/>
<path
d="M52.0281 7.737H49.2961V9.639H51.7601V10.36H49.2961V12.262H52.0281V13H48.5251V7H52.0281V7.737Z"
fill="var(--foreground)"
/>
<path
d="M55.279 13H54.508V7.737H52.832V7H56.955V7.737H55.279V13Z"
fill="var(--foreground)"
/>
<path d="M59.938 13V7H60.709V13H59.938Z" fill="var(--foreground)" />
<path
d="M64.1281 13H63.3572V7.737H61.6812V7H65.8042V7.737H64.1281V13Z"
fill="var(--foreground)"
/>
<path
d="M73.6089 12.225C73.0189 12.831 72.2859 13.134 71.4089 13.134C70.5319 13.134 69.7989 12.831 69.2099 12.225C68.6199 11.619 68.3259 10.877 68.3259 9.99999C68.3259 9.12299 68.6199 8.38099 69.2099 7.77499C69.7989 7.16899 70.5319 6.86499 71.4089 6.86499C72.2809 6.86499 73.0129 7.16999 73.6049 7.77899C74.1969 8.38799 74.4929 9.12799 74.4929 9.99999C74.4929 10.877 74.1979 11.619 73.6089 12.225ZM69.7789 11.722C70.2229 12.172 70.7659 12.396 71.4089 12.396C72.0519 12.396 72.5959 12.171 73.0389 11.722C73.4829 11.272 73.7059 10.698 73.7059 9.99999C73.7059 9.30199 73.4829 8.72799 73.0389 8.27799C72.5959 7.82799 72.0519 7.60399 71.4089 7.60399C70.7659 7.60399 70.2229 7.82899 69.7789 8.27799C69.3359 8.72799 69.1129 9.30199 69.1129 9.99999C69.1129 10.698 69.3359 11.272 69.7789 11.722Z"
fill="var(--foreground)"
/>
<path
d="M75.5749 13V7H76.513L79.429 11.667H79.4619L79.429 10.511V7H80.1999V13H79.3949L76.344 8.106H76.3109L76.344 9.262V13H75.5749Z"
fill="var(--foreground)"
/>
<path
d="M47.418 10.2429C47.418 11.0809 47.1701 11.7479 46.673 12.2459C46.109 12.8379 45.3731 13.1339 44.4691 13.1339C43.6031 13.1339 42.8661 12.8339 42.2611 12.2339C41.6551 11.6329 41.3521 10.8889 41.3521 10.0009C41.3521 9.11194 41.6551 8.36794 42.2611 7.76794C42.8661 7.16694 43.6031 6.86694 44.4691 6.86694C44.8991 6.86694 45.3101 6.95094 45.7001 7.11794C46.0911 7.28594 46.404 7.50894 46.6381 7.78794L46.111 8.31594C45.714 7.84094 45.167 7.60394 44.468 7.60394C43.836 7.60394 43.29 7.82594 42.829 8.26994C42.368 8.71394 42.1381 9.29094 42.1381 9.99994C42.1381 10.7089 42.368 11.2859 42.829 11.7299C43.29 12.1739 43.836 12.3959 44.468 12.3959C45.138 12.3959 45.6971 12.1729 46.1441 11.7259C46.4341 11.4349 46.602 11.0299 46.647 10.5109H44.468V9.78994H47.375C47.405 9.94694 47.418 10.0979 47.418 10.2429Z"
stroke="var(--foreground)"
strokeWidth="0.2"
strokeMiterlimit="10"
/>
<path
d="M52.0281 7.737H49.2961V9.639H51.7601V10.36H49.2961V12.262H52.0281V13H48.5251V7H52.0281V7.737Z"
stroke="var(--foreground)"
strokeWidth="0.2"
strokeMiterlimit="10"
/>
<path
d="M55.279 13H54.508V7.737H52.832V7H56.955V7.737H55.279V13Z"
stroke="var(--foreground)"
strokeWidth="0.2"
strokeMiterlimit="10"
/>
<path
d="M59.938 13V7H60.709V13H59.938Z"
stroke="var(--foreground)"
strokeWidth="0.2"
strokeMiterlimit="10"
/>
<path
d="M64.1281 13H63.3572V7.737H61.6812V7H65.8042V7.737H64.1281V13Z"
stroke="var(--foreground)"
strokeWidth="0.2"
strokeMiterlimit="10"
/>
<path
d="M73.6089 12.225C73.0189 12.831 72.2859 13.134 71.4089 13.134C70.5319 13.134 69.7989 12.831 69.2099 12.225C68.6199 11.619 68.3259 10.877 68.3259 9.99999C68.3259 9.12299 68.6199 8.38099 69.2099 7.77499C69.7989 7.16899 70.5319 6.86499 71.4089 6.86499C72.2809 6.86499 73.0129 7.16999 73.6049 7.77899C74.1969 8.38799 74.4929 9.12799 74.4929 9.99999C74.4929 10.877 74.1979 11.619 73.6089 12.225ZM69.7789 11.722C70.2229 12.172 70.7659 12.396 71.4089 12.396C72.0519 12.396 72.5959 12.171 73.0389 11.722C73.4829 11.272 73.7059 10.698 73.7059 9.99999C73.7059 9.30199 73.4829 8.72799 73.0389 8.27799C72.5959 7.82799 72.0519 7.60399 71.4089 7.60399C70.7659 7.60399 70.2229 7.82899 69.7789 8.27799C69.3359 8.72799 69.1129 9.30199 69.1129 9.99999C69.1129 10.698 69.3359 11.272 69.7789 11.722Z"
stroke="var(--foreground)"
strokeWidth="0.2"
strokeMiterlimit="10"
/>
<path
d="M75.5749 13V7H76.513L79.429 11.667H79.4619L79.429 10.511V7H80.1999V13H79.3949L76.344 8.106H76.3109L76.344 9.262V13H75.5749Z"
stroke="var(--foreground)"
strokeWidth="0.2"
strokeMiterlimit="10"
/>
<g filter="url(#filter0_ii_1303_2188)">
<path
d="M10.4361 7.53803C10.1451 7.84603 9.97314 8.32403 9.97314 8.94303V31.059C9.97314 31.679 10.1451 32.156 10.4361 32.464L10.5101 32.536L22.8991 20.147V20.001V19.855L10.5101 7.46503L10.4361 7.53803Z"
fill="url(#paint0_linear_1303_2188)"
/>
<path
d="M27.0279 24.278L22.8989 20.147V20.001V19.855L27.0289 15.725L27.1219 15.778L32.0149 18.558C33.4119 19.352 33.4119 20.651 32.0149 21.446L27.1219 24.226L27.0279 24.278Z"
fill="url(#paint1_linear_1303_2188)"
/>
<g filter="url(#filter1_i_1303_2188)">
<path
d="M27.122 24.225L22.898 20.001L10.436 32.464C10.896 32.952 11.657 33.012 12.514 32.526L27.122 24.225Z"
fill="url(#paint2_linear_1303_2188)"
/>
</g>
<path
d="M27.122 15.777L12.514 7.47701C11.657 6.99001 10.896 7.05101 10.436 7.53901L22.899 20.002L27.122 15.777Z"
fill="url(#paint3_linear_1303_2188)"
/>
</g>
<defs>
<filter
id="filter0_ii_1303_2188"
x="9.97314"
y="7.14093"
width="23.0894"
height="25.7207"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.15" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_1303_2188"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="0.15" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0"
/>
<feBlend
mode="normal"
in2="effect1_innerShadow_1303_2188"
result="effect2_innerShadow_1303_2188"
/>
</filter>
<filter
id="filter1_i_1303_2188"
x="10.436"
y="20.001"
width="16.686"
height="12.8607"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.15" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_1303_2188"
/>
</filter>
<linearGradient
id="paint0_linear_1303_2188"
x1="21.8009"
y1="8.70903"
x2="5.01895"
y2="25.491"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#00A0FF" />
<stop offset="0.0066" stopColor="#00A1FF" />
<stop offset="0.2601" stopColor="#00BEFF" />
<stop offset="0.5122" stopColor="#00D2FF" />
<stop offset="0.7604" stopColor="#00DFFF" />
<stop offset="1" stopColor="#00E3FF" />
</linearGradient>
<linearGradient
id="paint1_linear_1303_2188"
x1="33.8334"
y1="20.001"
x2="9.63753"
y2="20.001"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FFE000" />
<stop offset="0.4087" stopColor="#FFBD00" />
<stop offset="0.7754" stopColor="#FFA500" />
<stop offset="1" stopColor="#FF9C00" />
</linearGradient>
<linearGradient
id="paint2_linear_1303_2188"
x1="24.8281"
y1="22.2949"
x2="2.06964"
y2="45.0534"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF3A44" />
<stop offset="1" stopColor="#C31162" />
</linearGradient>
<linearGradient
id="paint3_linear_1303_2188"
x1="7.29743"
y1="0.176806"
x2="17.4597"
y2="10.3391"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#32A071" />
<stop offset="0.0685" stopColor="#2DA771" />
<stop offset="0.4762" stopColor="#15CF74" />
<stop offset="0.8009" stopColor="#06E775" />
<stop offset="1" stopColor="#00F076" />
</linearGradient>
</defs>
</svg>
</a>
);
};
const AppStoreButton = ({
size = "md",
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
return (
<a
aria-label="Download on the App Store"
target="_blank"
rel="noopener noreferrer"
{...props}
className={cn(
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground flex cursor-pointer items-center justify-center rounded-lg border py-px",
props.className,
)}
>
<svg
width={size === "md" ? 120 : 132}
height={size === "md" ? 40 : 44}
viewBox="0 0 120 40"
fill="none"
>
<path
d="M81.5257 19.2009V21.4919H80.0896V22.9944H81.5257V28.0994C81.5257 29.8425 82.3143 30.5398 84.2981 30.5398C84.6468 30.5398 84.9788 30.4983 85.2693 30.4485V28.9626C85.0203 28.9875 84.8626 29.0041 84.5887 29.0041C83.7005 29.0041 83.3104 28.5891 83.3104 27.6428V22.9944H85.2693V21.4919H83.3104V19.2009H81.5257Z"
fill="var(--foreground)"
/>
<path
d="M90.3232 30.6643C92.9628 30.6643 94.5815 28.8962 94.5815 25.9661C94.5815 23.0525 92.9545 21.2761 90.3232 21.2761C87.6835 21.2761 86.0566 23.0525 86.0566 25.9661C86.0566 28.8962 87.6752 30.6643 90.3232 30.6643ZM90.3232 29.0789C88.7709 29.0789 87.8994 27.9416 87.8994 25.9661C87.8994 24.0071 88.7709 22.8616 90.3232 22.8616C91.8671 22.8616 92.747 24.0071 92.747 25.9661C92.747 27.9333 91.8671 29.0789 90.3232 29.0789Z"
fill="var(--foreground)"
/>
<path
d="M95.9664 30.49H97.7511V25.1526C97.7511 23.8826 98.7056 23.0276 100.059 23.0276C100.374 23.0276 100.905 23.0857 101.055 23.1355V21.3757C100.864 21.3259 100.524 21.301 100.258 21.301C99.0792 21.301 98.0748 21.9485 97.8175 22.8367H97.6846V21.4504H95.9664V30.49Z"
fill="var(--foreground)"
/>
<path
d="M105.486 22.7952C106.806 22.7952 107.669 23.7165 107.711 25.136H103.145C103.245 23.7248 104.166 22.7952 105.486 22.7952ZM107.702 28.0496C107.37 28.7551 106.632 29.1453 105.552 29.1453C104.125 29.1453 103.203 28.1409 103.145 26.5554V26.4558H109.529V25.8332C109.529 22.9944 108.009 21.2761 105.494 21.2761C102.946 21.2761 101.327 23.1106 101.327 25.9993C101.327 28.8879 102.913 30.6643 105.503 30.6643C107.57 30.6643 109.014 29.6682 109.421 28.0496H107.702Z"
fill="var(--foreground)"
/>
<path
d="M69.8221 27.1518C69.9598 29.3715 71.8095 30.7911 74.5626 30.7911C77.505 30.7911 79.3462 29.3027 79.3462 26.9281C79.3462 25.0612 78.2966 24.0287 75.7499 23.4351L74.382 23.0996C72.7645 22.721 72.1106 22.2134 72.1106 21.3272C72.1106 20.2088 73.1259 19.4775 74.6487 19.4775C76.0941 19.4775 77.0921 20.1916 77.2727 21.3358H79.1483C79.0365 19.2452 77.1953 17.774 74.6745 17.774C71.9644 17.774 70.1576 19.2452 70.1576 21.4563C70.1576 23.2802 71.1815 24.3643 73.427 24.8891L75.0272 25.2763C76.6705 25.6634 77.3932 26.2312 77.3932 27.1776C77.3932 28.2789 76.2575 29.079 74.7089 29.079C73.0484 29.079 71.8955 28.3305 71.7321 27.1518H69.8221Z"
fill="var(--foreground)"
/>
<path
d="M51.3348 21.301C50.1063 21.301 49.0437 21.9153 48.4959 22.9446H48.3631V21.4504H46.6448V33.4949H48.4295V29.1204H48.5706C49.0437 30.0749 50.0647 30.6394 51.3514 30.6394C53.6341 30.6394 55.0867 28.8381 55.0867 25.9661C55.0867 23.094 53.6341 21.301 51.3348 21.301ZM50.8284 29.0373C49.3343 29.0373 48.3963 27.8586 48.3963 25.9744C48.3963 24.0818 49.3343 22.9031 50.8367 22.9031C52.3475 22.9031 53.2522 24.0569 53.2522 25.9661C53.2522 27.8835 52.3475 29.0373 50.8284 29.0373Z"
fill="var(--foreground)"
/>
<path
d="M61.3316 21.301C60.103 21.301 59.0405 21.9153 58.4927 22.9446H58.3599V21.4504H56.6416V33.4949H58.4263V29.1204H58.5674C59.0405 30.0749 60.0615 30.6394 61.3482 30.6394C63.6309 30.6394 65.0835 28.8381 65.0835 25.9661C65.0835 23.094 63.6309 21.301 61.3316 21.301ZM60.8252 29.0373C59.3311 29.0373 58.3931 27.8586 58.3931 25.9744C58.3931 24.0818 59.3311 22.9031 60.8335 22.9031C62.3443 22.9031 63.249 24.0569 63.249 25.9661C63.249 27.8835 62.3443 29.0373 60.8252 29.0373Z"
fill="var(--foreground)"
/>
<path
d="M43.4428 30.49H45.4905L41.008 18.0751H38.9346L34.4521 30.49H36.431L37.5752 27.1948H42.3072L43.4428 30.49ZM39.8724 20.3292H40.0186L41.8168 25.5774H38.0656L39.8724 20.3292Z"
fill="var(--foreground)"
/>
<path
d="M35.6514 8.71094V14.7H37.8137C39.5984 14.7 40.6318 13.6001 40.6318 11.6868C40.6318 9.80249 39.5901 8.71094 37.8137 8.71094H35.6514ZM36.5811 9.55762H37.71C38.9509 9.55762 39.6855 10.3462 39.6855 11.6992C39.6855 13.073 38.9634 13.8533 37.71 13.8533H36.5811V9.55762Z"
fill="var(--foreground)"
/>
<path
d="M43.7969 14.7871C45.1167 14.7871 45.9261 13.9031 45.9261 12.438C45.9261 10.9812 45.1126 10.093 43.7969 10.093C42.4771 10.093 41.6636 10.9812 41.6636 12.438C41.6636 13.9031 42.4729 14.7871 43.7969 14.7871ZM43.7969 13.9944C43.0208 13.9944 42.585 13.4258 42.585 12.438C42.585 11.4585 43.0208 10.8857 43.7969 10.8857C44.5689 10.8857 45.0088 11.4585 45.0088 12.438C45.0088 13.4216 44.5689 13.9944 43.7969 13.9944Z"
fill="var(--foreground)"
/>
<path
d="M52.8182 10.1802H51.9259L51.1207 13.6292H51.0501L50.1205 10.1802H49.2655L48.3358 13.6292H48.2694L47.4601 10.1802H46.5553L47.8004 14.7H48.7176L49.6473 11.3713H49.7179L50.6517 14.7H51.5772L52.8182 10.1802Z"
fill="var(--foreground)"
/>
<path
d="M53.8458 14.7H54.7382V12.0562C54.7382 11.3506 55.1574 10.9106 55.8173 10.9106C56.4772 10.9106 56.7926 11.2717 56.7926 11.998V14.7H57.685V11.7739C57.685 10.699 57.1288 10.093 56.1203 10.093C55.4396 10.093 54.9914 10.396 54.7714 10.8982H54.705V10.1802H53.8458V14.7Z"
fill="var(--foreground)"
/>
<path
d="M59.0903 14.7H59.9826V8.41626H59.0903V14.7Z"
fill="var(--foreground)"
/>
<path
d="M63.3386 14.7871C64.6584 14.7871 65.4678 13.9031 65.4678 12.438C65.4678 10.9812 64.6543 10.093 63.3386 10.093C62.0188 10.093 61.2053 10.9812 61.2053 12.438C61.2053 13.9031 62.0146 14.7871 63.3386 14.7871ZM63.3386 13.9944C62.5625 13.9944 62.1267 13.4258 62.1267 12.438C62.1267 11.4585 62.5625 10.8857 63.3386 10.8857C64.1106 10.8857 64.5505 11.4585 64.5505 12.438C64.5505 13.4216 64.1106 13.9944 63.3386 13.9944Z"
fill="var(--foreground)"
/>
<path
d="M68.1265 14.0234C67.6409 14.0234 67.2881 13.7869 67.2881 13.3801C67.2881 12.9817 67.5704 12.77 68.1929 12.7285L69.2969 12.658V13.0356C69.2969 13.5959 68.7989 14.0234 68.1265 14.0234ZM67.8982 14.7747C68.4917 14.7747 68.9856 14.5173 69.2554 14.0649H69.326V14.7H70.1851V11.6121C70.1851 10.6575 69.5459 10.093 68.4129 10.093C67.3877 10.093 66.6573 10.5911 66.566 11.3672H67.4292C67.5289 11.0476 67.8733 10.865 68.3714 10.865C68.9815 10.865 69.2969 11.1348 69.2969 11.6121V12.0022L68.0726 12.0728C66.9976 12.1392 66.3916 12.6082 66.3916 13.4216C66.3916 14.2476 67.0267 14.7747 67.8982 14.7747Z"
fill="var(--foreground)"
/>
<path
d="M73.2132 14.7747C73.8358 14.7747 74.3629 14.48 74.6327 13.9861H74.7032V14.7H75.5582V8.41626H74.6659V10.8982H74.5995C74.3546 10.4001 73.8316 10.1055 73.2132 10.1055C72.0719 10.1055 71.3373 11.0103 71.3373 12.438C71.3373 13.8699 72.0636 14.7747 73.2132 14.7747ZM73.4664 10.9065C74.2135 10.9065 74.6825 11.5 74.6825 12.4421C74.6825 13.3884 74.2176 13.9736 73.4664 13.9736C72.711 13.9736 72.2586 13.3967 72.2586 12.438C72.2586 11.4875 72.7152 10.9065 73.4664 10.9065Z"
fill="var(--foreground)"
/>
<path
d="M81.3447 14.7871C82.6645 14.7871 83.4738 13.9031 83.4738 12.438C83.4738 10.9812 82.6604 10.093 81.3447 10.093C80.0249 10.093 79.2114 10.9812 79.2114 12.438C79.2114 13.9031 80.0207 14.7871 81.3447 14.7871ZM81.3447 13.9944C80.5686 13.9944 80.1328 13.4258 80.1328 12.438C80.1328 11.4585 80.5686 10.8857 81.3447 10.8857C82.1166 10.8857 82.5566 11.4585 82.5566 12.438C82.5566 13.4216 82.1166 13.9944 81.3447 13.9944Z"
fill="var(--foreground)"
/>
<path
d="M84.655 14.7H85.5474V12.0562C85.5474 11.3506 85.9666 10.9106 86.6265 10.9106C87.2864 10.9106 87.6018 11.2717 87.6018 11.998V14.7H88.4941V11.7739C88.4941 10.699 87.938 10.093 86.9294 10.093C86.2488 10.093 85.8005 10.396 85.5806 10.8982H85.5142V10.1802H84.655V14.7Z"
fill="var(--foreground)"
/>
<path
d="M92.6039 9.05542V10.2009H91.8858V10.9521H92.6039V13.5046C92.6039 14.3762 92.9981 14.7249 93.9901 14.7249C94.1644 14.7249 94.3304 14.7041 94.4757 14.6792V13.9363C94.3512 13.9487 94.2723 13.957 94.1353 13.957C93.6913 13.957 93.4962 13.7495 93.4962 13.2764V10.9521H94.4757V10.2009H93.4962V9.05542H92.6039Z"
fill="var(--foreground)"
/>
<path
d="M95.6735 14.7H96.5658V12.0603C96.5658 11.3755 96.9726 10.9148 97.703 10.9148C98.3339 10.9148 98.6701 11.28 98.6701 12.0022V14.7H99.5624V11.7822C99.5624 10.7073 98.9689 10.0972 98.006 10.0972C97.3253 10.0972 96.848 10.4001 96.6281 10.9065H96.5575V8.41626H95.6735V14.7Z"
fill="var(--foreground)"
/>
<path
d="M102.781 10.8525C103.441 10.8525 103.873 11.3132 103.894 12.0229H101.611C101.661 11.3174 102.122 10.8525 102.781 10.8525ZM103.89 13.4797C103.724 13.8325 103.354 14.0276 102.815 14.0276C102.101 14.0276 101.64 13.5254 101.611 12.7327V12.6829H104.803V12.3716C104.803 10.9521 104.043 10.093 102.786 10.093C101.511 10.093 100.702 11.0103 100.702 12.4546C100.702 13.8989 101.495 14.7871 102.79 14.7871C103.823 14.7871 104.545 14.2891 104.749 13.4797H103.89Z"
fill="var(--foreground)"
/>
<path
d="M24.769 20.3008C24.7907 18.6198 25.6934 17.0292 27.1256 16.1488C26.2221 14.8584 24.7088 14.0403 23.1344 13.9911C21.4552 13.8148 19.8272 14.9959 18.9715 14.9959C18.0992 14.9959 16.7817 14.0086 15.363 14.0378C13.5137 14.0975 11.7898 15.1489 10.8901 16.7656C8.95607 20.1141 10.3987 25.0351 12.2513 27.7417C13.1782 29.0671 14.2615 30.5475 15.6789 30.495C17.066 30.4375 17.584 29.6105 19.2583 29.6105C20.9171 29.6105 21.4031 30.495 22.8493 30.4616C24.3377 30.4375 25.2754 29.1304 26.1698 27.7925C26.8358 26.8481 27.3483 25.8044 27.6882 24.7C25.9391 23.9602 24.771 22.2 24.769 20.3008Z"
fill="var(--foreground)"
/>
<path
d="M22.0373 12.2111C22.8489 11.2369 23.2487 9.98469 23.1518 8.72046C21.912 8.85068 20.7668 9.44324 19.9443 10.3801C19.14 11.2954 18.7214 12.5255 18.8006 13.7415C20.0408 13.7542 21.2601 13.1777 22.0373 12.2111Z"
fill="var(--foreground)"
/>
</svg>
</a>
);
};

View File

@@ -0,0 +1,54 @@
"use client";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { Icons } from "@turbostarter/ui-web/icons";
import {
SidebarFooter,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@turbostarter/ui-web/sidebar";
import { authClient } from "~/lib/auth/client";
import { Credits } from "../credits";
export function Footer() {
const { data } = authClient.useSession();
const name = data?.user.name ?? "Anonymous";
const email = data?.user.email ?? "...but maybe not at all?";
const image = data?.user.image ?? `https://avatar.vercel.sh/${data?.user.id}`;
return (
<SidebarFooter>
<SidebarMenu>
<Credits />
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="size-8">
<AvatarImage src={image} alt={name} />
<AvatarFallback>
{name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{name}</span>
<span className="truncate text-xs">{email}</span>
</div>
<Icons.EllipsisVertical className="ml-auto size-4" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
);
}

View File

@@ -0,0 +1,19 @@
import { Icons } from "@turbostarter/ui-web/icons";
import { SidebarHeader } from "@turbostarter/ui-web/sidebar";
import { TurboLink } from "~/modules/common/turbo-link";
export const Header = () => {
return (
<SidebarHeader>
<TurboLink
target="_blank"
href="https://turbostarter.dev/ai"
className="flex items-center gap-3 p-2 transition-[padding] group-data-[collapsible=icon]:p-0.5"
>
<Icons.Logo className="text-primary h-8 transition-[width,height]" />
<Icons.LogoText className="text-foreground h-4 group-data-[collapsible=icon]:hidden" />
</TurboLink>
</SidebarHeader>
);
};

View File

@@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import { Sidebar } from "@turbostarter/ui-web/sidebar";
import { Content } from "./content";
import { Footer } from "./footer";
import { Header } from "./header";
export function AppsSidebar({
...props
}: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar variant="inset" {...props}>
<Header />
<Content />
<Footer />
</Sidebar>
);
}

View File

@@ -0,0 +1,45 @@
import { useTheme } from "next-themes";
import ShikiHighlighter from "react-shiki";
import { cn } from "@turbostarter/ui";
import type { ReactNode } from "react";
import type { Element } from "react-shiki";
interface CodeHighlightProps {
className?: string | undefined;
children?: ReactNode | undefined;
node?: Element | undefined;
inline?: boolean;
}
export const CodeHighlight = ({
inline = false,
className,
children,
...props
}: CodeHighlightProps) => {
const { resolvedTheme } = useTheme();
const match = className?.match(/language-(\w+)/);
const language = match ? match[1] : undefined;
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const code = String(children).trim();
return !inline ? (
<ShikiHighlighter
language={language}
theme={`github-${resolvedTheme === "dark" ? "dark" : "light"}`}
{...props}
className={cn(
"overflow-hidden rounded-none border-y @md:rounded-lg @md:border",
className,
)}
>
{code}
</ShikiHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
};

View File

@@ -0,0 +1,52 @@
import "katex/dist/katex.min.css";
import { marked } from "marked";
import { memo, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import { rehypeInlineCodeProperty } from "react-shiki";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { CodeHighlight } from "./code";
import { preprocessMarkdown } from "./utils";
function parseMarkdownIntoBlocks(markdown: string): string[] {
const tokens = marked.lexer(markdown);
return tokens.map((token) => token.raw);
}
const MemoizedMarkdownBlock = memo(
({ content }: { content: string }) => {
const processedContent = preprocessMarkdown(content);
return (
<ReactMarkdown
rehypePlugins={[rehypeRaw, rehypeKatex, rehypeInlineCodeProperty]}
remarkPlugins={[remarkGfm, remarkMath]}
components={{
code: CodeHighlight,
}}
>
{processedContent}
</ReactMarkdown>
);
},
(prevProps, nextProps) => {
if (prevProps.content !== nextProps.content) return false;
return true;
},
);
MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock";
export const MemoizedMarkdown = memo<{ content: string; id: string }>(
({ content, id }) => {
const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]);
return blocks.map((block, index) => (
<MemoizedMarkdownBlock content={block} key={`${id}-block_${index}`} />
));
},
);
MemoizedMarkdown.displayName = "MemoizedMarkdown";

View File

@@ -0,0 +1,15 @@
export const preprocessLaTeX = (content: string) => {
const blockProcessedContent = content.replace(
/\\\[([\s\S]*?)\\\]/g,
(_, equation) => `$$${equation}$$`,
);
const inlineProcessedContent = blockProcessedContent.replace(
/\\\(([\s\S]*?)\\\)/g,
(_, equation) => `$${equation}$`,
);
return inlineProcessedContent;
};
export const preprocessMarkdown = (markdown: string) => {
return preprocessLaTeX(markdown);
};

View File

@@ -0,0 +1,13 @@
import { MDXContent } from "@content-collections/mdx/react";
interface MdxProps {
readonly mdx: string;
}
export const Mdx = ({ mdx }: MdxProps) => {
return (
<div className="prose dark:prose-invert prose-headings:font-semibold py-6">
<MDXContent code={mdx} />
</div>
);
};

View File

@@ -0,0 +1,20 @@
import { cn } from "@turbostarter/ui";
export const Prose = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<div
className={cn(
"prose dark:prose-invert prose-p:opacity-95 prose-strong:opacity-100 prose-pre:-mx-5 prose-pre:rounded-none prose-pre:bg-transparent prose-pre:p-0 @md:prose-pre:mx-0 wrap-break-word",
className,
)}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,22 @@
import dayjs from "dayjs";
import { useEffect, useState } from "react";
interface StopwatchProps {
startTime: Date;
}
export function Stopwatch({ startTime }: StopwatchProps) {
const [elapsed, setElapsed] = useState(dayjs().diff(dayjs(startTime)));
useEffect(() => {
const interval = setInterval(() => {
setElapsed(dayjs().diff(dayjs(startTime)));
}, 100);
return () => clearInterval(interval);
}, [startTime]);
const value = +(elapsed / 1000).toFixed(1);
return value;
}

View File

@@ -0,0 +1,157 @@
"use client";
import { useTheme } from "next-themes";
import { useTranslation } from "@turbostarter/i18n";
import { useBreakpoint } from "@turbostarter/ui-web";
import { Button } from "@turbostarter/ui-web/button";
import {
Drawer,
DrawerTrigger,
DrawerContent,
} from "@turbostarter/ui-web/drawer";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverPortal,
} from "@turbostarter/ui-web/popover";
import { ThemeCustomizer } from "@turbostarter/ui-web/theme";
import { appConfig } from "~/config/app";
import { useThemeConfig } from "~/lib/providers/theme";
import type { ThemeConfig, ThemeMode } from "@turbostarter/ui";
const Trigger = (props: React.ComponentProps<typeof Button>) => {
const { t } = useTranslation("common");
return (
<Button
variant="ghost"
size="icon"
aria-label={t("theme.customization.label")}
{...props}
>
<Icons.PaintBucket className="text-primary size-5" />
</Button>
);
};
const Customizer = () => {
const { t } = useTranslation("common");
const { config, setConfig } = useThemeConfig();
const { setTheme: setMode, theme: mode } = useTheme();
const onChange = (config: ThemeConfig) => {
setConfig(config);
setMode(config.mode);
};
return (
<>
<div className="flex items-start">
<div className="space-y-1 pr-2">
<div className="leading-none font-semibold tracking-tight">
{t("theme.customization.title")}
</div>
<div className="text-muted-foreground text-xs">
{t("theme.customization.description")}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="ml-auto"
onClick={() => {
onChange(appConfig.theme);
}}
>
<Icons.Undo2 className="size-4" />
<span className="sr-only">{t("reset")}</span>
</Button>
</div>
<ThemeCustomizer
defaultConfig={appConfig.theme}
config={{
...config,
mode: (mode as ThemeMode | undefined) ?? appConfig.theme.mode,
}}
onChange={onChange}
/>
</>
);
};
export const ThemeControlsProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const isDesktop = useBreakpoint("md");
if (isDesktop) {
return (
<Popover>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverPortal>
<PopoverContent
align="end"
className="w-88 space-y-4 rounded-lg p-6"
onOpenAutoFocus={(event) => {
event.preventDefault();
}}
onFocusOutside={(event) => {
event.preventDefault();
}}
>
<Customizer />
</PopoverContent>
</PopoverPortal>
</Popover>
);
}
return (
<Drawer>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent className="p-6 pt-0">
<div className="space-y-4 pt-4">
<Customizer />
</div>
</DrawerContent>
</Drawer>
);
};
export const ThemeControls = () => {
return (
<ThemeControlsProvider>
<Trigger />
</ThemeControlsProvider>
);
};
// Simple theme toggle for AI apps
export function ThemeSwitcher() {
const { t } = useTranslation("common");
const { setTheme: setMode, theme: mode } = useTheme();
const toggleTheme = () => {
setMode(mode === "dark" ? "light" : "dark");
};
return (
<Button
variant="ghost"
size="icon"
className="group relative"
onClick={toggleTheme}
aria-label={t("theme.toggle")}
>
<Icons.Sun className="text-muted-foreground group-hover:text-foreground size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Icons.Moon className="text-muted-foreground group-hover:text-foreground absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
</Button>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import Image from "next/image";
import { useTheme } from "next-themes";
import { preload } from "react-dom";
import type { ComponentProps } from "react";
export const ThemedImage = ({
light,
dark,
...props
}: Omit<ComponentProps<typeof Image>, "src"> & {
light: string;
dark: string;
}) => {
preload(light, { as: "image" });
preload(dark, { as: "image" });
const { resolvedTheme } = useTheme();
return <Image src={resolvedTheme === "dark" ? dark : light} {...props} />;
};

View File

@@ -0,0 +1,29 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
import { ThemeMode } from "@turbostarter/ui";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = ThemeMode.SYSTEM } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { ComponentProps } from "react";
type TurboLinkProps = ComponentProps<typeof Link>;
export const TurboLink = ({
onMouseEnter,
onPointerEnter,
onTouchStart,
onFocus,
children,
...props
}: TurboLinkProps) => {
const router = useRouter();
const strHref =
typeof props.href === "string" ? props.href : props.href.href;
const conditionalPrefetch = () => {
if (strHref) {
void router.prefetch(strHref);
}
};
return (
<Link
{...props}
prefetch={false}
onMouseEnter={(e) => {
conditionalPrefetch();
onMouseEnter?.(e);
}}
onPointerEnter={(e) => {
conditionalPrefetch();
onPointerEnter?.(e);
}}
onTouchStart={(e) => {
conditionalPrefetch();
onTouchStart?.(e);
}}
onFocus={(e) => {
conditionalPrefetch();
onFocus?.(e);
}}
>
{children}
</Link>
);
};