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,82 @@
"use client";
import { AspectRatio } from "@turbostarter/ai/image/types";
import { useTranslation } from "@turbostarter/i18n";
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Select,
SelectContent,
SelectItem,
SelectPortal,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import type { Control, FieldValues, Path } from "react-hook-form";
const icons = {
[AspectRatio.SQUARE]: Icons.Square,
[AspectRatio.STANDARD]: Icons.Square,
[AspectRatio.LANDSCAPE]: Icons.RectangleHorizontal,
[AspectRatio.PORTRAIT]: Icons.RectangleVertical,
};
interface AspectSelectorProps<T extends FieldValues> {
readonly control: Control<T>;
readonly name: Path<T>;
readonly options: readonly {
readonly id: AspectRatio;
readonly value: string;
}[];
}
export const AspectSelector = <T extends FieldValues>({
name,
control,
options,
}: AspectSelectorProps<T>) => {
const { t } = useTranslation("common");
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className="max-w-24 min-w-0 @sm:max-w-none">
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectPortal>
<SelectContent align="end">
{options.map(({ id, value }) => {
const Icon = icons[id];
return (
<SelectItem key={id} value={id}>
<div className="flex items-center gap-2.5">
<Icon className="size-4 shrink-0" />
<span className="min-w-0 truncate font-medium">
<span className="hidden @lg:inline">
{t(
id.toLowerCase() as Lowercase<
keyof typeof AspectRatio
>,
)}{" "}
</span>
<span>{`(${value})`}</span>
</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</SelectPortal>
</Select>
</FormControl>
</FormItem>
)}
/>
);
};

View File

@@ -0,0 +1,67 @@
"use client";
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Select,
SelectContent,
SelectItem,
SelectPortal,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import type { Control, FieldValues, Path } from "react-hook-form";
interface ImageCountSelectorProps<T extends FieldValues> {
readonly control: Control<T>;
readonly name: Path<T>;
readonly min?: number;
readonly max?: number;
}
export const ImageCountSelector = <T extends FieldValues>({
name,
control,
min = 1,
max = 5,
}: ImageCountSelectorProps<T>) => {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className="min-w-0 gap-0">
<FormControl>
<Select
value={`${field.value}`}
onValueChange={(value) => field.onChange(parseInt(value, 10))}
>
<SelectTrigger size="sm">
<div className="flex items-center gap-2">
<Icons.Image className="size-4 shrink-0" />
<SelectValue />
</div>
</SelectTrigger>
<SelectPortal>
<SelectContent align="end">
{Array.from({ length: max - min + 1 }, (_, i) => i + min).map(
(count) => (
<SelectItem key={count} value={count.toString()}>
<div className="flex items-center gap-2.5">
<span className="min-w-0 truncate font-medium">
{count}
</span>
</div>
</SelectItem>
),
)}
</SelectContent>
</SelectPortal>
</Select>
</FormControl>
</FormItem>
)}
/>
);
};

View File

@@ -0,0 +1,103 @@
"use client";
import { useEffect } from "react";
import { MODELS } from "@turbostarter/ai/image/constants";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormControl,
FormField,
FormItem,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import { Composer } from "~/modules/common/ai/composer";
import { ModelSelector } from "~/modules/common/ai/composer/model-selector";
import { AspectSelector } from "./aspect-selector";
import { ImageCountSelector } from "./image-count-selector";
import { useComposer } from "./use-composer";
interface ImageComposerProps {
id?: string;
prompt?: string;
reset?: () => void;
}
export const ImageComposer = ({
id,
prompt: initialPrompt,
reset,
}: ImageComposerProps) => {
const { t } = useTranslation(["ai", "common"]);
const { form, model, onSubmit } = useComposer({ id });
const prompt = form.watch("prompt");
useEffect(() => {
if (initialPrompt) {
form.setValue("prompt", initialPrompt);
form.setFocus("prompt");
reset?.();
}
}, [initialPrompt, form, reset]);
return (
<Form {...form}>
<Composer.Form onSubmit={form.handleSubmit(onSubmit)}>
<Composer.Input className="pb-12">
<FormField
control={form.control}
name="prompt"
render={({ field }) => (
<FormItem>
<FormControl>
<Composer.Textarea
{...field}
autoFocus
maxLength={5_000}
placeholder={t("image.composer.placeholder")}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
return form.handleSubmit(onSubmit)();
}
}}
/>
</FormControl>
</FormItem>
)}
/>
<div className="absolute inset-x-0 bottom-0 flex w-full gap-1.5 overflow-hidden border-2 border-transparent p-2 @[480px]/input:p-3">
<div className="flex max-w-full grow gap-px">
<ImageCountSelector control={form.control} name="options.count" />
<AspectSelector
control={form.control}
name="options.aspectRatio"
options={MODELS.find((m) => m.id === model)?.dimensions ?? []}
/>
</div>
<ModelSelector
control={form.control}
name="options.model"
options={MODELS}
/>
<Button
className="ml-auto shrink-0 rounded-full"
disabled={!prompt.trim()}
size="icon"
type="submit"
>
<Icons.ImagePlay className="size-5" />
</Button>
</div>
</Composer.Input>
</Composer.Form>
</Form>
);
};

View File

@@ -0,0 +1,121 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useEffect, useRef } from "react";
import { useForm, useFormContext } from "react-hook-form";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { MODELS } from "@turbostarter/ai/image/constants";
import { imageGenerationSchema } from "@turbostarter/ai/image/schema";
import { useDebounceCallback } from "@turbostarter/shared/hooks";
import { generateId } from "@turbostarter/shared/utils";
import { useImageGeneration } from "~/modules/image/use-image-generation";
import type {
ImageGenerationOptionsPayload,
ImageGenerationPayload,
} from "@turbostarter/ai/image/schema";
import type { WatchObserver } from "react-hook-form";
interface ImageComposerState {
prompt: string;
options: ImageGenerationOptionsPayload;
setPrompt: (prompt: string) => void;
setOptions: (options: Partial<ImageGenerationOptionsPayload>) => void;
reset: () => void;
}
const DEFAULT_OPTIONS = {
model: MODELS[0].id,
aspectRatio: MODELS[0].dimensions[0].id,
count: 1,
};
const useImageComposerStore = create<ImageComposerState>()(
persist(
(set) => ({
prompt: "",
options: DEFAULT_OPTIONS,
setPrompt: (prompt) => set({ prompt }),
setOptions: (options) =>
set((state) => ({
options: { ...state.options, ...options },
})),
reset: () =>
set({
prompt: "",
options: DEFAULT_OPTIONS,
}),
}),
{
name: "image-options",
partialize: (state) => ({ options: state.options }),
},
),
);
interface UseComposerProps {
id?: string;
}
export const useComposer = ({ id: passedId }: UseComposerProps = {}) => {
const { prompt, options, setPrompt, setOptions, reset } =
useImageComposerStore();
const currentId = useRef(passedId);
const { createGeneration } = useImageGeneration({
id: currentId.current,
});
useEffect(() => {
if (currentId.current !== passedId) {
reset();
currentId.current = passedId ?? generateId();
}
}, [passedId, reset]);
const newForm = useForm({
resolver: zodResolver(imageGenerationSchema),
defaultValues: {
prompt,
options,
},
});
const contextForm = useFormContext<ImageGenerationPayload>();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const form = contextForm ?? newForm;
const model = form.watch("options.model");
const sync: WatchObserver<ImageGenerationPayload> = useCallback(
(values) => {
setPrompt(values.prompt ?? "");
setOptions(values.options ?? DEFAULT_OPTIONS);
},
[setOptions, setPrompt],
);
const debouncedSync = useDebounceCallback(sync, 500, {
leading: true,
});
useEffect(() => {
const subscription = form.watch(debouncedSync);
return () => subscription.unsubscribe();
}, [form, debouncedSync]);
const onSubmit = (input: ImageGenerationPayload) => {
form.resetField("prompt");
createGeneration.mutate(input);
};
return {
form,
model,
prompt,
onSubmit,
reset,
};
};

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>
);
};

View File

@@ -0,0 +1,44 @@
"use client";
import { useTranslation } from "node_modules/@turbostarter/i18n/src/client";
import { cn } from "@turbostarter/ui";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
export const HistoryCta = () => {
const { t } = useTranslation("common");
return (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<TurboLink
href={pathsConfig.apps.image.history}
className={cn(
buttonVariants({
variant: "ghost",
size: "icon",
className: "group relative",
}),
)}
>
<Icons.TextSearch className="text-muted-foreground group-hover:text-foreground size-5" />
</TurboLink>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>{t("history")}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,127 @@
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { useIntersectionObserver } from "~/modules/common/hooks/use-intersection-observer";
import { TurboLink } from "~/modules/common/turbo-link";
import { Images } from "../generation/view/images";
import { image } from "../lib/api";
const Headline = () => {
const { t } = useTranslation("ai");
return (
<div className="flex flex-col gap-2">
<div className="flex w-full items-start justify-between gap-3">
<h1 className="text-4xl font-semibold">{t("image.history.title")}</h1>
<TurboLink
href={pathsConfig.apps.image.index}
className={cn(
buttonVariants(),
"h-9 w-9 gap-2 p-0 @lg:h-10 @lg:w-auto @lg:px-4 @lg:py-2",
)}
>
<Icons.Plus className="size-5" />
<span className="hidden @lg:inline">{t("image.generation.new")}</span>
</TurboLink>
</div>
<p className="text-muted-foreground max-w-lg leading-snug @lg:text-lg">
{t("image.history.description")}
</p>
</div>
);
};
const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<ScrollArea className="h-full w-full">
<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">
{children}
</div>
</ScrollArea>
);
};
const Content = () => {
const { data: session } = authClient.useSession();
const { isIntersecting, ref } = useIntersectionObserver({
threshold: 0.5,
});
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
isError,
hasNextPage,
refetch,
} = useInfiniteQuery({
...image.queries.images.user.getAll(session?.user.id ?? ""),
getNextPageParam: (lastPage) => lastPage.at(-1)?.createdAt,
initialPageParam: undefined,
});
useEffect(() => {
if (isIntersecting && hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
}, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage]);
const images = data?.pages.flatMap((page) => page) ?? [];
if (isLoading) {
return (
<Images.Layout>
<Images.Loading />
</Images.Layout>
);
}
if (isError) {
return <Images.Error onRetry={() => refetch()} />;
}
if (!images.length) {
return <Images.Empty />;
}
return (
<>
<Images.Layout>
<Images.Grid
images={images.map((image) => ({
...image,
...image.generation,
description: image.generation.prompt,
}))}
fetching={isFetchingNextPage}
withDetails
/>
</Images.Layout>
<div ref={ref} className="-mt-8 h-5 @lg:h-6" />
</>
);
};
export const History = () => {
return (
<Layout>
<Headline />
<Content />
</Layout>
);
};

View File

@@ -0,0 +1,83 @@
import Image from "next/image";
import { cn } from "@turbostarter/ui";
import { Marquee } from "@turbostarter/ui-web/marquee";
const images = [
"https://images.unsplash.com/photo-1493612276216-ee3925520721",
"https://images.unsplash.com/photo-1731964877414-217cdc9b5b37",
"https://images.unsplash.com/photo-1513542789411-b6a5d4f31634",
"https://images.unsplash.com/photo-1485550409059-9afb054cada4",
"https://images.unsplash.com/photo-1459411552884-841db9b3cc2a",
"https://images.unsplash.com/photo-1726455083595-fb3d23fa3d2d",
"https://images.unsplash.com/photo-1494059980473-813e73ee784b",
"https://images.unsplash.com/photo-1741515277598-64b4da5d212a",
"https://images.unsplash.com/photo-1524856949007-80db29955b17",
"https://images.unsplash.com/photo-1605142859862-978be7eba909",
"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
"https://images.unsplash.com/photo-1536697246787-1f7ae568d89a",
"https://images.unsplash.com/photo-1501426026826-31c667bdf23d",
"https://images.unsplash.com/photo-1554570731-63bcddda4dcd",
"https://images.unsplash.com/photo-1504275107627-0c2ba7a43dba",
"https://images.unsplash.com/photo-1741533699135-b3ef83e27215",
"https://images.unsplash.com/photo-1740532501882-5766c265f637",
"https://images.unsplash.com/photo-1560963619-c9e49c9380bd",
"https://images.unsplash.com/photo-1624239408355-7b06ee576e95",
"https://images.unsplash.com/photo-1468971050039-be99497410af",
];
const chunkSize = Math.ceil(images.length / 4);
const firstRow = images.slice(0, chunkSize);
const secondRow = images.slice(chunkSize, chunkSize * 2);
const thirdRow = images.slice(chunkSize * 2, chunkSize * 3);
const fourthRow = images.slice(chunkSize * 3);
const ImageCard = ({ src }: { src: string }) => {
return (
<div
className={cn(
"relative aspect-square w-80 cursor-pointer overflow-hidden rounded-xl border",
)}
>
<Image className="object-cover" alt="" src={src} fill />
</div>
);
};
export function BackgroundGrid() {
return (
<div className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden rounded-xl">
<div className="bg-background/50 absolute inset-0 z-10 backdrop-blur-md"></div>
<div className="absolute -top-20 left-0 w-full rotate-[-5deg]">
<Marquee>
{firstRow.map((src, index) => (
<ImageCard key={`first-row-${index}`} src={src} />
))}
</Marquee>
</div>
<div className="absolute top-[20%] left-0 w-full rotate-[3deg]">
<Marquee reverse>
{secondRow.map((src, index) => (
<ImageCard key={`second-row-${index}`} src={src} />
))}
</Marquee>
</div>
<div className="absolute top-[calc(50%-5rem)] left-0 w-full rotate-[-4deg]">
<Marquee>
{thirdRow.map((src, index) => (
<ImageCard key={`third-row-${index}`} src={src} />
))}
</Marquee>
</div>
<div className="absolute -bottom-10 left-0 w-full rotate-[6deg]">
<Marquee reverse>
{fourthRow.map((src, index) => (
<ImageCard key={`fourth-row-${index}`} src={src} />
))}
</Marquee>
</div>
<div className="from-background pointer-events-none absolute inset-y-0 left-0 w-2/5 bg-gradient-to-r"></div>
<div className="from-background pointer-events-none absolute inset-y-0 right-0 w-2/5 bg-gradient-to-l"></div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { motion } from "motion/react";
import { memo } 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";
interface ExamplesProps {
readonly className?: string;
readonly onSelect: (prompt: string) => void;
}
const examples = [
{
label: "image.example.fox.label",
prompt: "image.example.fox.prompt",
},
{
label: "image.example.penguin.label",
prompt: "image.example.penguin.prompt",
},
{
label: "image.example.raccoon.label",
prompt: "image.example.raccoon.prompt",
},
{
label: "image.example.elephant.label",
prompt: "image.example.elephant.prompt",
},
{
label: "image.example.dolphin.label",
prompt: "image.example.dolphin.prompt",
},
] as const;
export const Examples = memo<ExamplesProps>(({ className, onSelect }) => {
const { t } = useTranslation("ai");
return (
<div
className={cn(
"flex w-full flex-row flex-wrap items-center justify-center gap-2 px-3 @sm:gap-2",
className,
)}
>
{examples.map(({ label, prompt }, index) => (
<motion.div
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
initial={{ opacity: 0, y: 3, filter: "blur(4px)" }}
transition={{ delay: index * 0.1 }}
key={label}
>
<Button
variant="outline"
className="bg-background/50 text-muted-foreground gap-1 rounded-full border-none backdrop-blur-lg"
onClick={() => onSelect(t(prompt))}
>
<span className="lowercase">{t(label)}</span>
<Icons.ArrowUpRight className="size-4" />
</Button>
</motion.div>
))}
</div>
);
});
Examples.displayName = "Examples";

View File

@@ -0,0 +1,14 @@
import { useTranslation } from "@turbostarter/i18n";
export const Headline = () => {
const { t } = useTranslation("ai");
return (
<h1 className="leading-tighter flex w-full flex-col items-center justify-center text-center text-2xl tracking-tight text-pretty @sm:text-3xl @md:text-4xl">
{t("image.headline.title")}
<span className="text-muted-foreground">
{t("image.headline.subtitle")}
</span>
</h1>
);
};

View File

@@ -0,0 +1,42 @@
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
import type { InferRequestType } from "hono/client";
const KEY = "image";
const queries = {
images: {
user: {
getAll: (userId: string) => ({
queryKey: [KEY, "images", userId],
queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
handle(api.ai.image.images.$get)({
query: {
cursor: pageParam,
},
}),
}),
},
},
};
const mutations = {
generations: {
create: {
mutationKey: [KEY, "generations", "create"],
mutationFn: (
json: InferRequestType<typeof api.ai.image.generations.$post>["json"],
) =>
handle(api.ai.image.generations.$post)({
json,
}),
},
},
};
export const image = {
queries,
mutations,
} as const;

View File

@@ -0,0 +1,221 @@
import { useMutation } from "@tanstack/react-query";
import { useCallback, useEffect } from "react";
import { create } from "zustand";
import { handle } from "@turbostarter/api/utils";
import { generateId } from "@turbostarter/shared/utils";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/client";
import { useAIError } from "~/modules/common/hooks/use-ai-error";
import { useCredits } from "~/modules/common/layout/credits";
import { image } from "./lib/api";
import type { ImageGenerationPayload } from "@turbostarter/ai/image/schema";
export type ImageGenerationStatus =
| "idle"
| "created"
| "loading"
| "success"
| "error";
export interface ImageGenerationImage {
url?: string;
base64?: string;
}
export interface ImageGeneration {
createdAt?: Date | null;
completedAt?: Date | null;
input?: ImageGenerationPayload;
images?: ImageGenerationImage[];
status?: ImageGenerationStatus;
error?: Error;
abortController?: AbortController;
}
interface ImageGenerationStore {
generations: Record<string, ImageGeneration>;
updateGeneration: (id: string, updates: Partial<ImageGeneration>) => void;
}
const useImageGenerationStore = create<ImageGenerationStore>()((set) => ({
generations: {},
updateGeneration: (id, updates) =>
set((state) => {
const existing = state.generations[id] ?? {};
return {
generations: {
...state.generations,
[id]: {
...existing,
...updates,
},
},
};
}),
}));
interface UseImageGenerationProps {
readonly id?: string;
readonly initialGeneration?: ImageGeneration;
}
const generationLocks = new Map<string, boolean>();
export const useImageGeneration = ({
id: passedId,
initialGeneration,
}: UseImageGenerationProps) => {
const { onError: onAIError } = useAIError();
const { invalidate } = useCredits();
const id = passedId ?? generateId();
const generation = useImageGenerationStore(
(state) => state.generations[id] ?? null,
);
const updateGeneration = useImageGenerationStore(
(state) => state.updateGeneration,
);
const update = useCallback(
(updates: Partial<ImageGeneration>) => updateGeneration(id, updates),
[id, updateGeneration],
);
const onError = (error: Error) => {
onAIError(error);
update({
status: "error",
error,
completedAt: new Date(),
});
};
const createGeneration = useMutation({
...image.mutations.generations.create,
mutationFn: (input: ImageGenerationPayload) => {
return handle(api.ai.image.generations.$post)({
json: {
...input,
id,
},
});
},
onMutate: (input) => {
const url = pathsConfig.apps.image.generation(id);
window.history.replaceState({}, "", url);
update({
status: "loading",
createdAt: new Date(),
input,
});
},
onSuccess: () => {
void invalidate();
update({
status: "created",
});
},
onError,
});
const { mutateAsync } = useMutation({
mutationFn: async () => {
const abortController = new AbortController();
update({
abortController,
status: "loading",
});
return handle(api.ai.image.generations[":id"].images.$post)(
{
param: {
id,
},
},
{
init: {
signal: abortController.signal,
},
},
);
},
onSuccess: (images) => {
void invalidate();
update({
status: "success",
images: images.map((image) => ({
base64: image,
})),
});
},
onError,
onSettled: () => {
update({
completedAt: new Date(),
});
},
});
const stop = useCallback(() => {
if (generation?.abortController) {
generation.abortController.abort();
update({
abortController: undefined,
status: "idle",
completedAt: new Date(),
});
}
}, [generation?.abortController, update]);
const reload = useCallback(() => {
update({
createdAt: new Date(),
completedAt: undefined,
status: "created",
images: [],
});
}, [update]);
useEffect(() => {
if (initialGeneration) {
updateGeneration(id, initialGeneration);
}
}, [initialGeneration, id, updateGeneration]);
useEffect(() => {
if (
generation?.status === "created" &&
!generation.completedAt &&
!generationLocks.get(id)
) {
generationLocks.set(id, true);
void mutateAsync().finally(() => {
generationLocks.delete(id);
});
}
}, [generation?.status, generation?.completedAt, mutateAsync, id]);
useEffect(() => {
return () => {
generationLocks.delete(id);
};
}, [id]);
return {
generation,
update,
createGeneration,
stop,
reload,
};
};