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,38 @@
"use client";
import { memo, useState } from "react";
import { ImageComposer } from "../composer";
import { BackgroundGrid } from "../layout/background-grid";
import { Examples } from "../layout/examples";
import { Headline } from "../layout/headline";
interface NewGenerationProps {
readonly id: string;
}
export const NewGeneration = memo<NewGenerationProps>(({ id }) => {
const [prompt, setPrompt] = useState("");
return (
<>
<BackgroundGrid />
<div className="absolute inset-0 z-10 mx-auto flex h-full w-full flex-col items-center justify-between gap-6 md:justify-center md:gap-9 md:p-2">
<div className="flex w-full grow items-end justify-center">
<Headline />
</div>
<div className="flex w-full grow flex-col items-center justify-between md:flex-col-reverse md:justify-end md:gap-5">
<Examples className="flex" onSelect={setPrompt} />
<div className="relative w-full px-3 pb-3">
<ImageComposer
id={id}
prompt={prompt}
reset={() => setPrompt("")}
/>
</div>
</div>
</div>
</>
);
});
NewGeneration.displayName = "NewImageGeneration";

View File

@@ -0,0 +1,75 @@
import { MODELS } from "@turbostarter/ai/image/constants";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@turbostarter/ui-web/popover";
import type { ImageGeneration } from "../../use-image-generation";
interface DetailsProps {
readonly generation: ImageGeneration;
}
export const Details = ({ generation }: DetailsProps) => {
const { t, i18n } = useTranslation("common");
const model = MODELS.find(
(model) => model.id === generation.input?.options.model,
);
if (!model) {
return null;
}
const aspectRatio = model.dimensions.find(
(dimension) => dimension.id === generation.input?.options.aspectRatio,
);
const data = [
{
label: t("model"),
value: model.name,
},
{
label: t("aspectRatio"),
value: generation.input?.options.aspectRatio
? `${t(generation.input.options.aspectRatio)} (${aspectRatio?.value})`
: "---",
},
{
label: t("count"),
value: generation.input?.options.count,
},
{
label: t("createdAt"),
value: generation.createdAt?.toLocaleString(i18n.language),
},
{
label: t("completedAt"),
value: generation.completedAt?.toLocaleString(i18n.language) ?? "---",
},
];
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2">
<Icons.Info className="size-4" />
<span className="hidden @lg:block">{t("details")}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="flex w-64 flex-col gap-3 p-4">
{data.map((item) => (
<div key={item.label} className="flex flex-col items-start gap-1">
<span className="text-muted-foreground text-xs">{item.label}</span>
<span className="font-medium">{item.value}</span>
</div>
))}
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,317 @@
import { useMutation } from "@tanstack/react-query";
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { AspectRatio } from "@turbostarter/ai/image/types";
import { useTranslation } from "@turbostarter/i18n";
import { splitArray } from "@turbostarter/shared/utils";
import { cn } from "@turbostarter/ui";
import { Badge } from "@turbostarter/ui-web/badge";
import { Button, buttonVariants } from "@turbostarter/ui-web/button";
import { GridPattern } from "@turbostarter/ui-web/grid-pattern";
import { Icons } from "@turbostarter/ui-web/icons";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { pathsConfig } from "~/config/paths";
import {
getImageSource,
Thumbnail,
ThumbnailImage,
Viewer,
} from "~/modules/common/image";
import { TurboLink } from "~/modules/common/turbo-link";
import { shareOrDownload } from "~/utils";
import type { ImageGenerationImage } from "../../use-image-generation";
const getAspectRatioClass = (aspectRatio?: AspectRatio) => {
switch (aspectRatio) {
case AspectRatio.SQUARE:
return "aspect-square";
case AspectRatio.STANDARD:
return "aspect-[4/3]";
case AspectRatio.PORTRAIT:
return "aspect-[9/16]";
case AspectRatio.LANDSCAPE:
return "aspect-[16/9]";
default:
return "aspect-square";
}
};
const ColumnsContext = createContext<number>(0);
const Layout = ({ children }: { children: React.ReactNode }) => {
const [columns, setColumns] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const getColumnsCount = () => {
if (!ref.current) {
return 0;
}
setColumns(
window
.getComputedStyle(ref.current)
.getPropertyValue("grid-template-columns")
.split(" ").length,
);
};
useEffect(() => {
if (!ref.current) {
return;
}
getColumnsCount();
const resizeObserver = new ResizeObserver(getColumnsCount);
resizeObserver.observe(ref.current);
return () => resizeObserver.disconnect();
}, []);
return (
<ColumnsContext.Provider value={columns}>
<div
className="grid w-full grid-cols-[repeat(auto-fill,minmax(min(20rem,100%),1fr))] gap-4"
ref={ref}
>
{children}
</div>
</ColumnsContext.Provider>
);
};
interface GridProps {
readonly images: (ImageGenerationImage & {
generationId: string;
description?: string;
aspectRatio?: AspectRatio;
model?: string;
})[];
readonly fetching?: boolean;
readonly withDetails?: boolean;
}
const Grid = ({ images, fetching, withDetails }: GridProps) => {
const { t } = useTranslation(["ai", "common"]);
const [isOpen, setIsOpen] = useState(false);
const [selectedImage, setSelectedImage] = useState(0);
const columns = useContext(ColumnsContext);
const share = useMutation({
mutationFn: (image: (typeof images)[number]) =>
shareOrDownload({
url: getImageSource(image),
filename: `${image.model ?? "image"}-${Date.now()}.png`,
}),
onError: () => toast.error(t("error.general")),
});
const chunks = useMemo(() => splitArray(images, columns), [images, columns]);
return (
<>
{chunks.map((chunk, chunkIndex) => (
<div key={chunkIndex} className="flex w-full flex-col gap-2.5">
{chunk.map((image, imageIndex) => {
const index = images.findIndex(
(img) =>
(img.url && img.url === image.url) ??
(img.base64 && img.base64 === image.base64),
);
return (
<div className="group relative" key={imageIndex}>
<Thumbnail
index={index}
onClick={() => {
setIsOpen(true);
setSelectedImage(index);
}}
className={getAspectRatioClass(image.aspectRatio)}
>
{withDetails && (
<Badge
className="bg-background/75 absolute top-3 left-3 backdrop-blur-md"
variant="secondary"
>
{image.model}
</Badge>
)}
<ThumbnailImage
src={getImageSource(image)}
alt={image.description ?? ""}
/>
</Thumbnail>
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="bg-background/75 absolute bottom-5 left-3 opacity-0 backdrop-blur-md transition-all duration-200 group-hover:opacity-100 focus:opacity-100 disabled:opacity-0 hover:disabled:opacity-50 [@media(hover:none)]:opacity-100"
onClick={() => share.mutate(image)}
disabled={share.isPending}
>
{share.isPending ? (
<Icons.Loader className="size-4 animate-spin" />
) : (
<Icons.Download className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" sideOffset={5}>
<span>{t("download")}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{withDetails && (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<TurboLink
target="_blank"
className={cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"bg-background/75 absolute right-3 bottom-5 opacity-0 backdrop-blur-md transition-all duration-200 group-hover:opacity-100 focus:opacity-100 disabled:opacity-0 hover:disabled:opacity-50 [@media(hover:none)]:opacity-100",
)}
href={pathsConfig.apps.image.generation(
image.generationId,
)}
>
<Icons.ExternalLink className="size-4" />
</TurboLink>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="center"
sideOffset={5}
>
<span>{t("image.generation.goTo")}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
})}
{fetching && (
<Skeleton className={cn("rounded-lg", getAspectRatioClass())} />
)}
</div>
))}
<Viewer
open={isOpen}
onOpenChange={setIsOpen}
images={images}
selectedImage={selectedImage}
setSelectedImage={setSelectedImage}
/>
</>
);
};
const Empty = () => {
const { t } = useTranslation(["ai"]);
return (
<div className="relative flex h-full w-full flex-1 flex-col items-center justify-center gap-4 rounded-lg border-2 border-dashed p-4">
<GridPattern
width={50}
height={50}
x={-1}
y={-1}
strokeDasharray={"4 2"}
className="mask-[radial-gradient(white,transparent)]"
/>
<Icons.ImageOff className="size-20" />
<span className="text-2xl font-medium @lg:text-3xl">
{t("image.generation.empty.title")}
</span>
<p className="text-muted-foreground max-w-md text-center text-pretty @lg:text-lg">
{t("image.generation.empty.description")}
</p>
<TurboLink
href={pathsConfig.apps.image.index}
className={cn(buttonVariants({ variant: "secondary" }), "mt-3 gap-2")}
>
<Icons.Plus className="size-5" />
{t("image.generation.new")}
</TurboLink>
</div>
);
};
const Loading = ({
aspectRatio,
count,
}: {
aspectRatio?: AspectRatio;
count?: number;
}) => {
const columns = useContext(ColumnsContext);
return (
<>
{Array.from({ length: count ?? columns * 2 }).map((_, index) => (
<Skeleton
key={index}
className={cn("rounded-lg", getAspectRatioClass(aspectRatio))}
/>
))}
</>
);
};
const Error = ({ onRetry }: { onRetry: () => void }) => {
const { t } = useTranslation(["ai", "common"]);
return (
<div className="border-destructive relative flex h-full w-full flex-1 flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-4">
<GridPattern
width={50}
height={50}
x={-1}
y={-1}
strokeDasharray={"4 2"}
className="mask-[radial-gradient(white,transparent)]"
/>
<Icons.CircleX className="text-destructive size-20" />
<span className="text-destructive text-2xl font-medium @lg:text-3xl">
{t("error.title")}
</span>
<p className="text-muted-foreground max-w-md py-1 text-center text-pretty @lg:text-lg">
{t("error.general")}
</p>
<Button variant="outline" className="mt-2" onClick={onRetry}>
<Icons.RefreshCcw className="mr-2 size-4" />
{t("regenerate")}
</Button>
</div>
);
};
export const Images = {
Layout,
Grid,
Empty,
Loading,
Error,
};

View File

@@ -0,0 +1,117 @@
"use client";
import { MODELS } from "@turbostarter/ai/image/constants";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
import { ProviderIcons } from "~/modules/common/ai/icons";
import { Stopwatch } from "~/modules/common/stopwatch";
import { useImageGeneration } from "../../use-image-generation";
import { Details } from "./details";
import { Images } from "./images";
import type { ImageGeneration } from "../../use-image-generation";
interface ViewGenerationProps {
id: string;
initialGeneration?: ImageGeneration;
}
export const ViewGeneration = ({
id,
initialGeneration,
}: ViewGenerationProps) => {
const { t } = useTranslation(["common", "ai"]);
const { generation, stop, reload } = useImageGeneration({
id,
initialGeneration,
});
if (!generation) {
return null;
}
const model = MODELS.find(
(model) => model.id === generation.input?.options.model,
);
const Icon = model ? ProviderIcons[model.provider] : null;
return (
<ScrollArea className="w-full grow">
<div className="flex h-full w-full flex-1 flex-col gap-8 px-5 pt-16 pb-5 md:px-6 md:pt-18 md:pb-6">
<div className="flex flex-col gap-4">
<div className="flex w-full items-start justify-between">
<div className="flex flex-col gap-4">
<div className="ml-px flex items-center gap-3.5">
{Icon && <Icon className="size-5 shrink-0" />}
<span className="text-lg font-medium">{model?.name}</span>
</div>
<span className="text-5xl font-semibold">
{generation.createdAt && !generation.completedAt && (
<Stopwatch startTime={generation.createdAt} key={id} />
)}
{generation.completedAt &&
(
(generation.completedAt.getTime() -
(generation.createdAt?.getTime() ?? 0)) /
1000
).toFixed(1)}
{`s`}
</span>
</div>
<div className="flex items-center gap-2">
<Details generation={generation} />
{generation.completedAt ? (
<Button className="gap-2" onClick={reload}>
<Icons.RefreshCcw className="size-4" />
<span className="hidden @lg:block">{t("regenerate")}</span>
</Button>
) : (
<Button className="gap-2" onClick={stop}>
<Icons.Square className="size-4 fill-current" />
<span className="hidden @lg:block">{t("stop")}</span>
</Button>
)}
</div>
</div>
<p className="text-muted-foreground text-2xl italic @xl:text-3xl">
{generation.input?.prompt}
</p>
</div>
{["created", "loading"].includes(generation.status ?? "") ? (
<Images.Layout>
<Images.Loading
aspectRatio={generation.input?.options.aspectRatio}
count={generation.input?.options.count}
/>
</Images.Layout>
) : generation.status === "error" ? (
<Images.Error onRetry={reload} />
) : generation.images?.length ? (
<Images.Layout>
<Images.Grid
images={generation.images.map((image) => ({
...image,
generationId: id,
description: generation.input?.prompt,
aspectRatio: generation.input?.options.aspectRatio,
model: generation.input?.options.model,
}))}
/>
</Images.Layout>
) : (
<Images.Empty />
)}
</div>
</ScrollArea>
);
};