feat: whyrating - initial project from turbostarter boilerplate
This commit is contained in:
82
apps/web/src/modules/image/composer/aspect-selector.tsx
Normal file
82
apps/web/src/modules/image/composer/aspect-selector.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
67
apps/web/src/modules/image/composer/image-count-selector.tsx
Normal file
67
apps/web/src/modules/image/composer/image-count-selector.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
103
apps/web/src/modules/image/composer/index.tsx
Normal file
103
apps/web/src/modules/image/composer/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
121
apps/web/src/modules/image/composer/use-composer.tsx
Normal file
121
apps/web/src/modules/image/composer/use-composer.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
38
apps/web/src/modules/image/generation/new.tsx
Normal file
38
apps/web/src/modules/image/generation/new.tsx
Normal 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";
|
||||
75
apps/web/src/modules/image/generation/view/details.tsx
Normal file
75
apps/web/src/modules/image/generation/view/details.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
317
apps/web/src/modules/image/generation/view/images.tsx
Normal file
317
apps/web/src/modules/image/generation/view/images.tsx
Normal 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,
|
||||
};
|
||||
117
apps/web/src/modules/image/generation/view/index.tsx
Normal file
117
apps/web/src/modules/image/generation/view/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
44
apps/web/src/modules/image/history/cta.tsx
Normal file
44
apps/web/src/modules/image/history/cta.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
127
apps/web/src/modules/image/history/index.tsx
Normal file
127
apps/web/src/modules/image/history/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
83
apps/web/src/modules/image/layout/background-grid.tsx
Normal file
83
apps/web/src/modules/image/layout/background-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
apps/web/src/modules/image/layout/examples.tsx
Normal file
70
apps/web/src/modules/image/layout/examples.tsx
Normal 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";
|
||||
14
apps/web/src/modules/image/layout/headline.tsx
Normal file
14
apps/web/src/modules/image/layout/headline.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
42
apps/web/src/modules/image/lib/api.ts
Normal file
42
apps/web/src/modules/image/lib/api.ts
Normal 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;
|
||||
221
apps/web/src/modules/image/use-image-generation.tsx
Normal file
221
apps/web/src/modules/image/use-image-generation.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user