chore: remove files importing pruned packages (ai, cms, cognitive-context)

Step 3 pruned packages/{ai,cms,cognitive-context} but left whole
route groups + feature modules that depended on them. Those files
were unbuildable since that prune. Removes them now so the workspace
can be validated:

Route groups:
- apps/web/src/app/[locale]/(apps)/{chat,image,pdf,tts}/
- apps/web/src/app/[locale]/(marketing)/blog/

Feature modules:
- apps/web/src/modules/{chat,image,pdf,tts,common/ai,marketing/blog}/
- packages/api/src/modules/ai/  (chat, image, pdf, stt, tts, router)

3 stragglers remain (separate handoff to claudemesh-2):
- apps/web/src/app/[locale]/(marketing)/legal/[slug]/page.tsx  (cms)
- apps/web/src/app/sitemap.ts                                   (cms)
- apps/web/src/modules/common/layout/credits/index.tsx          (ai)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 22:02:26 +01:00
parent 8ce8b04e75
commit 1f094c4c53
122 changed files with 0 additions and 11536 deletions

View File

@@ -1,125 +0,0 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import type { VoiceButtonProps } from "../types";
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
const VoiceLevelBars = ({ level }: { level: number }) => {
// Create 3 bars with different thresholds
const bars = [
{ threshold: 10, delay: "0ms" },
{ threshold: 30, delay: "100ms" },
{ threshold: 50, delay: "200ms" },
];
return (
<div className="flex items-end gap-0.5 h-3">
{bars.map((bar, i) => (
<div
key={i}
className={cn(
"w-0.5 bg-white rounded-full transition-all duration-150",
level > bar.threshold ? "opacity-100" : "opacity-30"
)}
style={{
height: level > bar.threshold ? `${Math.min(12, 4 + (level / 100) * 8)}px` : "4px",
animationDelay: bar.delay,
}}
/>
))}
</div>
);
};
export const VoiceButton = ({
state,
duration,
audioLevel,
disabled = false,
onToggle,
onCancel: _onCancel,
}: VoiceButtonProps) => {
const { t } = useTranslation("common");
const isRecording = state === "recording";
const isProcessing = state === "processing";
const getTooltipContent = () => {
if (isRecording) {
return t("pressEscapeToCancel");
}
if (isProcessing) {
return t("transcribing");
}
return t("record");
};
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="relative">
{/* Recording state indicator - shows duration and level */}
{isRecording && (
<div className="absolute -top-8 left-1/2 -translate-x-1/2 flex items-center gap-1.5 bg-destructive text-destructive-foreground px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-white" />
</span>
<span>{formatDuration(duration)}</span>
<VoiceLevelBars level={audioLevel} />
</div>
)}
<Button
className={cn(
"shrink-0 rounded-full transition-all duration-200",
isRecording && "bg-destructive hover:bg-destructive/90 text-destructive-foreground animate-pulse-ring",
isProcessing && "opacity-70"
)}
size="icon"
type="button"
variant={isRecording ? "destructive" : "ghost"}
onClick={onToggle}
disabled={disabled || isProcessing}
>
{isProcessing ? (
<>
<Icons.Loader2 className="size-4 animate-spin" />
<span className="sr-only">{t("transcribing")}</span>
</>
) : isRecording ? (
<>
<Icons.Square className="size-3.5 fill-current" />
<span className="sr-only">{t("stop")}</span>
</>
) : (
<>
<Icons.Mic className="size-4" />
<span className="sr-only">{t("record")}</span>
</>
)}
</Button>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{getTooltipContent()}
</TooltipContent>
</Tooltip>
);
};
export default VoiceButton;

View File

@@ -1,56 +0,0 @@
"use client";
import { motion } from "motion/react";
import { memo } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { Icons } from "@turbostarter/ui-web/icons";
import { Attachments } from "~/modules/common/ai/composer/attachments";
import { useAttachments } from "./hooks/use-attachments";
const DropzoneDialog = () => {
const { t } = useTranslation("ai");
return (
<motion.div
className="bg-background relative z-10 mx-6 flex flex-col items-center justify-center rounded-xl border p-6 py-8 sm:p-8 sm:py-10 md:px-12 md:py-10"
initial={{ opacity: 0, translateY: 10 }}
animate={{ opacity: 1, translateY: 0 }}
exit={{ opacity: 0, translateY: 10 }}
transition={{ duration: 0.2 }}
>
<Icons.ImagePlus className="text-muted-foreground size-12" />
<span className="mt-3 text-lg font-medium">
{t("chat.composer.files.dropzone.title")}
</span>
<p className="text-muted-foreground text-center">
{t("chat.composer.files.dropzone.description")}
</p>
</motion.div>
);
};
interface ChatDropzoneProps {
readonly children: React.ReactNode;
readonly disabled?: boolean;
}
export const ChatDropzone = memo<ChatDropzoneProps>(
({ children, disabled }) => {
const { onAdd } = useAttachments();
return (
<Attachments.Dropzone
onDrop={onAdd}
dialog={<DropzoneDialog />}
disabled={disabled}
>
{children}
</Attachments.Dropzone>
);
},
);
ChatDropzone.displayName = "ChatDropzone";

View File

@@ -1,148 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { useCallback } from "react";
import { toast } from "sonner";
import * as z from "zod";
import { create } from "zustand";
import { useTranslation } from "@turbostarter/i18n";
import { generateId } from "@turbostarter/shared/utils";
import { uploadWithRetry } from "~/utils";
const MAX_FILE_SIZE_IN_MB = 5;
const MAX_FILE_SIZE = MAX_FILE_SIZE_IN_MB * 1024 * 1024;
const MAX_FILES_COUNT = 5;
const ACCEPTED_FILE_TYPES = [
"image/png",
"image/gif",
"image/jpeg",
"image/webp",
"image/jpg",
];
const useValidation = () => {
const { t } = useTranslation(["validation"]);
const fileSchema = z
.instanceof(File)
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), {
message: t("error.file.type", {
type: "image",
}),
})
.refine((file) => file.size <= MAX_FILE_SIZE, {
message: t("error.tooBig.file.notInclusive", {
size: MAX_FILE_SIZE_IN_MB,
}),
});
const validate = (files: File[], attachments: File[]) => {
const errors = new Set<string>();
Array.from(files).forEach((file) => {
try {
fileSchema.parse(file);
} catch (error) {
if (error instanceof z.ZodError && error.issues[0]) {
errors.add(error.issues[0].message);
}
}
});
if (files.length + attachments.length > MAX_FILES_COUNT) {
errors.add(
t("error.file.maxCount", {
count: MAX_FILES_COUNT,
}),
);
}
return {
errors,
files: files
.filter((file) => fileSchema.safeParse(file).success)
.slice(0, MAX_FILES_COUNT - attachments.length)
.map((file) => new File([file], generateId(), { type: file.type })),
};
};
return { validate };
};
interface AttachmentsState {
attachments: File[];
setAttachments: (attachments: File[]) => void;
}
export const useAttachmentsStore = create<AttachmentsState>((set) => ({
attachments: [],
setAttachments: (attachments) => set({ attachments }),
}));
export const useAttachments = () => {
const { validate } = useValidation();
const { attachments, setAttachments } = useAttachmentsStore();
const upload = useMutation({
mutationFn: async ({ directory }: { directory: string }) => {
setAttachments([]);
await Promise.allSettled(
attachments.map((attachment) =>
uploadWithRetry({
path: `${directory}/${attachment.name}.${
attachment.type.split("/")[1] ?? "png"
}`,
file: attachment,
}),
),
);
},
onError: (error) => {
console.error(error);
},
});
const onAdd = useCallback(
(files: File[]) => {
const { errors, files: filesToAdd } = validate(files, attachments);
for (const error of errors) {
toast.error(error);
}
if (!filesToAdd.length) {
return;
}
setAttachments([...attachments, ...filesToAdd]);
},
[attachments, setAttachments, validate],
);
const onRemove = useCallback(
(file: File) => {
setAttachments(attachments.filter((a) => a.name !== file.name));
},
[attachments, setAttachments],
);
const onPaste = useCallback(
(event: React.ClipboardEvent) => {
const items = event.clipboardData.items;
const files = Array.from(items)
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
onAdd(files);
}
},
[onAdd],
);
const onClear = useCallback(() => {
setAttachments([]);
}, [setAttachments]);
return { attachments, upload, onAdd, onRemove, onPaste, onClear };
};

View File

@@ -1,209 +0,0 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { Chat } from "@ai-sdk/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport } from "ai";
import { useCallback, useEffect, useState } from "react";
import { useForm, useFormContext } from "react-hook-form";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { MODELS } from "@turbostarter/ai/chat/constants";
import { chatMessageOptionsSchema } from "@turbostarter/ai/chat/schema";
import { useDebounceCallback } from "@turbostarter/shared/hooks";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/client";
import { authClient } from "~/lib/auth/client";
import { chat as chatApi } from "~/modules/chat/lib/api";
import { useAIError } from "~/modules/common/hooks/use-ai-error";
import { useCredits } from "~/modules/common/layout/credits";
import { useAttachments } from "./use-attachments";
import type { ChatMessageOptionsPayload } from "@turbostarter/ai/chat/schema";
import type { ChatMessage } from "@turbostarter/ai/chat/types";
import type { WatchObserver } from "react-hook-form";
interface ChatOptionsState {
options: ChatMessageOptionsPayload;
setOptions: (options: Partial<ChatMessageOptionsPayload>) => void;
}
export const useChatOptions = create<ChatOptionsState>()(
persist(
(set) => ({
options: {
reason: false,
search: false,
model: MODELS[0].id,
},
setOptions: (options) =>
set((state) => ({
options: {
...state.options,
...options,
},
})),
}),
{
name: "chat-options",
},
),
);
const chats = new Map<string, Chat<ChatMessage>>();
const getChatInstance = ({
id,
...options
}: ConstructorParameters<typeof Chat<ChatMessage>>[0]) => {
if (!id || !chats.has(id)) {
const chat = new Chat<ChatMessage>({
id,
...options,
});
chats.set(id ?? chat.id, chat);
}
const instance = chats.get(id ?? "");
if (!instance) {
throw new Error(`Chat instance with id ${id} not found!`);
}
return instance;
};
interface UseComposerProps {
readonly id?: string;
readonly initialMessages?: ChatMessage[];
}
export const useComposer = ({ id, initialMessages }: UseComposerProps = {}) => {
const [input, setInput] = useState("");
const { onError } = useAIError();
const { invalidate } = useCredits();
const { data } = authClient.useSession();
const queryClient = useQueryClient();
const { options, setOptions } = useChatOptions();
const { attachments, upload, onClear } = useAttachments();
const newForm = useForm({
resolver: zodResolver(chatMessageOptionsSchema),
defaultValues: options,
});
const contextForm = useFormContext<ChatMessageOptionsPayload>();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const form = contextForm ?? newForm;
const chat = getChatInstance({
id,
transport: new DefaultChatTransport({
api: api.ai.chat.chats.$url().toString(),
prepareSendMessagesRequest: ({ messages, id }) => {
const lastMessage = messages.at(-1);
const directory = `attachments/${id}/${lastMessage?.id}`;
upload.mutate({
directory,
});
return {
body: {
...lastMessage,
chatId: id,
parts: lastMessage?.parts.map((part) =>
part.type === "file"
? {
...part,
path: `${directory}/${part.filename}.${part.mediaType.split("/")[1] ?? "png"}`,
}
: part,
),
},
};
},
}),
messages: initialMessages,
onFinish: () => {
void invalidate();
if (!initialMessages?.length) {
void queryClient.invalidateQueries(
chatApi.queries.chats.user.getAll(data?.user.id ?? ""),
);
}
},
onError,
});
const { messages, sendMessage, ...rest } = useChat({
chat,
});
const syncOptions: WatchObserver<ChatMessageOptionsPayload> = useCallback(
(values) => setOptions(values),
[setOptions],
);
const debouncedSyncOptions = useDebounceCallback(syncOptions, 500);
useEffect(() => {
const subscription = form.watch(debouncedSyncOptions);
return () => subscription.unsubscribe();
}, [form, debouncedSyncOptions]);
const onSubmit = useCallback(
(prompt?: string) => {
const url = pathsConfig.apps.chat.chat(chat.id);
window.history.replaceState({}, "", url);
if (prompt) {
return sendMessage({
text: prompt,
metadata: {
options: chatMessageOptionsSchema.parse(form.getValues()),
},
});
} else {
const dataTransfer = new DataTransfer();
attachments.forEach((attachment) => {
dataTransfer.items.add(attachment);
});
void sendMessage({
text: input,
files: dataTransfer.files,
metadata: {
options: chatMessageOptionsSchema.parse(form.getValues()),
},
});
setInput("");
}
},
[sendMessage, input, attachments, chat.id, form],
);
const model = MODELS.find((model) => model.id === form.watch("model"));
useEffect(() => {
if (!model?.attachments) {
onClear();
}
}, [model?.attachments, onClear]);
return {
messages,
form,
onSubmit,
input,
setInput,
model,
...rest,
};
};

View File

@@ -1,261 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "~/lib/api/client";
import type {
UseVoiceRecordingOptions,
UseVoiceRecordingReturn,
VoiceRecordingState,
} from "../types";
export const useVoiceRecording = (
options: UseVoiceRecordingOptions = {}
): UseVoiceRecordingReturn => {
const { onTranscription, onError, onStateChange } = options;
const [state, setState] = useState<VoiceRecordingState>("idle");
const [duration, setDuration] = useState(0);
const [audioLevel, setAudioLevel] = useState(0);
const [error, setError] = useState<Error | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const animationFrameRef = useRef<number | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
// Update state and notify
const updateState = useCallback(
(newState: VoiceRecordingState) => {
setState(newState);
onStateChange?.(newState);
},
[onStateChange]
);
// Cleanup function
const cleanup = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (audioContextRef.current) {
void audioContextRef.current.close();
audioContextRef.current = null;
}
analyserRef.current = null;
mediaRecorderRef.current = null;
chunksRef.current = [];
setDuration(0);
setAudioLevel(0);
}, []);
// Monitor audio levels
const monitorAudioLevel = useCallback(() => {
if (!analyserRef.current) return;
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
analyserRef.current.getByteFrequencyData(dataArray);
// Calculate average volume (0-100)
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
const normalizedLevel = Math.min(100, Math.round((average / 255) * 100 * 2));
setAudioLevel(normalizedLevel);
animationFrameRef.current = requestAnimationFrame(monitorAudioLevel);
}, []);
// Start recording
const startRecording = useCallback(async () => {
try {
setError(null);
cleanup();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
// Setup audio analysis
const audioContext = new AudioContext();
audioContextRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
analyserRef.current = analyser;
// Start audio level monitoring
monitorAudioLevel();
// Setup media recorder
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported("audio/webm")
? "audio/webm"
: "audio/mp4",
});
mediaRecorderRef.current = mediaRecorder;
chunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
// Stop level monitoring
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
console.log("[Voice] Recording stopped, chunks:", chunksRef.current.length);
if (chunksRef.current.length === 0) {
console.log("[Voice] No chunks recorded, aborting");
cleanup();
updateState("idle");
return;
}
updateState("processing");
try {
const audioBlob = new Blob(chunksRef.current, {
type: mediaRecorder.mimeType,
});
console.log("[Voice] Audio blob:", audioBlob.size, "bytes,", mediaRecorder.mimeType);
const formData = new FormData();
formData.append(
"audio",
audioBlob,
`recording.${mediaRecorder.mimeType.includes("webm") ? "webm" : "mp4"}`
);
const url = api.ai.stt.$url().toString();
console.log("[Voice] Sending to:", url);
const response = await fetch(url, {
method: "POST",
body: formData,
credentials: "include",
});
console.log("[Voice] Response status:", response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error("[Voice] Error response:", errorData);
throw new Error(
(errorData as { message?: string }).message ??
"Transcription failed"
);
}
const result = (await response.json()) as { text: string };
console.log("[Voice] Transcription result:", result.text);
onTranscription?.(result.text);
} catch (err) {
console.error("[Voice] Error:", err);
const transcriptionError =
err instanceof Error ? err : new Error("Transcription failed");
setError(transcriptionError);
onError?.(transcriptionError);
} finally {
cleanup();
updateState("idle");
}
};
mediaRecorder.start();
updateState("recording");
// Start duration timer
setDuration(0);
timerRef.current = setInterval(() => {
setDuration((prev) => prev + 1);
}, 1000);
} catch (err) {
const accessError =
err instanceof Error
? err
: new Error("Failed to access microphone");
setError(accessError);
onError?.(accessError);
cleanup();
updateState("idle");
}
}, [cleanup, monitorAudioLevel, onTranscription, onError, updateState]);
// Stop recording (will trigger transcription)
const stopRecording = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (mediaRecorderRef.current?.state === "recording") {
mediaRecorderRef.current.stop();
}
}, []);
// Cancel recording (no transcription)
const cancelRecording = useCallback(() => {
cleanup();
updateState("idle");
}, [cleanup, updateState]);
// Toggle recording
const toggleRecording = useCallback(() => {
if (state === "recording") {
stopRecording();
} else if (state === "idle") {
void startRecording();
}
}, [state, startRecording, stopRecording]);
// Escape key handler
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && state === "recording") {
e.preventDefault();
cancelRecording();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [state, cancelRecording]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
return {
state,
duration,
audioLevel,
error,
isRecording: state === "recording",
isProcessing: state === "processing",
startRecording,
stopRecording,
cancelRecording,
toggleRecording,
};
};

View File

@@ -1,185 +0,0 @@
"use client";
import { toast } from "sonner";
import { MODELS } from "@turbostarter/ai/chat/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 { Toggle } from "@turbostarter/ui-web/toggle";
import { Composer } from "~/modules/common/ai/composer";
import { ModelSelector } from "~/modules/common/ai/composer/model-selector";
import { VoiceButton } from "./components/voice-button";
import { useAttachments } from "./hooks/use-attachments";
import { useComposer } from "./hooks/use-composer";
import { useVoiceRecording } from "./hooks/use-voice-recording";
import type { ChatMessage } from "@turbostarter/ai/chat/types";
interface ChatComposerProps {
readonly id?: string;
readonly initialMessages?: ChatMessage[];
}
export const ChatComposer = ({
id,
initialMessages,
}: ChatComposerProps = {}) => {
const { t } = useTranslation(["ai", "common"]);
const { status, stop, form, onSubmit, model, input, setInput } = useComposer({
id,
initialMessages,
});
const { attachments, onRemove, onPaste } = useAttachments();
const {
state: voiceState,
duration,
audioLevel,
toggleRecording,
cancelRecording,
} = useVoiceRecording({
onTranscription: (text) => {
setInput((prev) => (prev ? `${prev} ${text}` : text));
},
onError: (error) => {
const message = error.message.includes("microphone")
? t("microphoneDenied", { ns: "common" })
: t("transcriptionFailed", { ns: "common" });
toast.error(message);
},
});
const isSubmitting = ["submitted", "streaming"].includes(status);
return (
<Form {...form}>
<Composer.Form onSubmit={form.handleSubmit(() => onSubmit())}>
<Composer.Input className="pb-12">
<Composer.Attachments.Preview
attachments={attachments}
onRemove={onRemove}
/>
<Composer.Textarea
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
maxLength={5_000}
placeholder={t("chat.composer.placeholder")}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!isSubmitting) {
return form.handleSubmit(() => onSubmit())();
}
}
}}
onPaste={onPaste}
/>
<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">
<Composer.Attachments.Input disabled={!model?.attachments} />
<div className="flex max-w-full grow gap-1.5">
<FormField
control={form.control}
name="search"
render={({ field }) => (
<FormItem>
<FormControl>
<Toggle
variant="outline"
className="text-muted-foreground w-9 gap-1.5 rounded-full p-0 @lg:w-auto @lg:px-3.5"
pressed={model?.tools && !!field.value}
onPressedChange={field.onChange}
disabled={!model?.tools}
>
<Icons.Globe className="size-4 shrink-0" />
<span className="text-foreground hidden @lg:inline">
{t("search.label")}
</span>
</Toggle>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormControl>
<Toggle
variant="outline"
className="text-muted-foreground w-9 gap-1.5 rounded-full p-0 @lg:w-auto @lg:px-3.5"
pressed={model?.reason && !!field.value}
onPressedChange={field.onChange}
disabled={!model?.reason}
>
<Icons.Sparkle className="size-4" />
<span className="text-foreground hidden @lg:inline">
{t("reason")}
</span>
</Toggle>
</FormControl>
</FormItem>
)}
/>
</div>
<ModelSelector
control={form.control}
name="model"
options={MODELS}
/>
<VoiceButton
state={voiceState}
duration={duration}
audioLevel={audioLevel}
disabled={isSubmitting}
onToggle={toggleRecording}
onCancel={cancelRecording}
/>
<Button
className="shrink-0 rounded-full"
disabled={!input.trim() && !isSubmitting}
size="icon"
type="submit"
onClick={(e) => {
if (isSubmitting) {
e.preventDefault();
return stop();
}
}}
>
{isSubmitting ? (
<>
<Icons.Square className="size-4 fill-current" />
<span className="sr-only">{t("stop")}</span>
</>
) : (
<>
<Icons.ArrowUp className="size-5" />
<span className="sr-only">{t("send")}</span>
</>
)}
</Button>
</div>
</Composer.Input>
</Composer.Form>
</Form>
);
};

View File

@@ -1,38 +0,0 @@
// Voice Recording Types
export type VoiceRecordingState = "idle" | "recording" | "processing";
export interface VoiceRecordingData {
state: VoiceRecordingState;
duration: number; // seconds elapsed
audioLevel: number; // 0-100 volume level
error: Error | null;
}
export interface UseVoiceRecordingOptions {
onTranscription?: (text: string) => void;
onError?: (error: Error) => void;
onStateChange?: (state: VoiceRecordingState) => void;
}
export interface UseVoiceRecordingReturn {
state: VoiceRecordingState;
duration: number;
audioLevel: number;
error: Error | null;
isRecording: boolean;
isProcessing: boolean;
startRecording: () => Promise<void>;
stopRecording: () => void;
cancelRecording: () => void;
toggleRecording: () => void;
}
export interface VoiceButtonProps {
state: VoiceRecordingState;
duration: number;
audioLevel: number;
disabled?: boolean;
onToggle: () => void;
onCancel: () => void;
}

View File

@@ -1,25 +0,0 @@
import { useTranslation } from "@turbostarter/i18n";
import { CommandGroup, CommandItem } from "@turbostarter/ui-web/command";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
interface ChatActionsProps {
onSelect: () => void;
}
export const ChatActions = ({ onSelect }: ChatActionsProps) => {
const { t } = useTranslation(["common", "ai"]);
return (
<CommandGroup heading={t("actions")}>
<CommandItem asChild>
<TurboLink href={pathsConfig.apps.chat.index} onClick={onSelect}>
<Icons.SquarePen />
<span>{t("chat.new")}</span>
</TurboLink>
</CommandItem>
</CommandGroup>
);
};

View File

@@ -1,97 +0,0 @@
"use client";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime";
import { useState, useEffect } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
CommandDialog,
CommandEmpty,
CommandInput,
CommandList,
} from "@turbostarter/ui-web/command";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { ChatActions } from "./actions";
import { ChatHistoryList } from "./list";
dayjs.extend(duration);
dayjs.extend(relativeTime);
interface CommandMenuProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const CommandMenu = ({ open, onOpenChange }: CommandMenuProps) => {
const { t } = useTranslation("ai");
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
showCloseButton={false}
>
<CommandInput placeholder={t("chat.command.search")} />
<CommandList className="h-[420px]">
<CommandEmpty className="py-10">{t("chat.command.empty")}</CommandEmpty>
<ChatActions onSelect={() => onOpenChange(false)} />
<ChatHistoryList onSelect={() => onOpenChange(false)} />
</CommandList>
</CommandDialog>
);
};
export const ChatHistory = () => {
const { t } = useTranslation("common");
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setIsOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
<>
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group relative"
onClick={() => setIsOpen(true)}
>
<Icons.TextSearch className="text-muted-foreground group-hover:text-foreground size-5" />
<span className="sr-only">{t("history")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>{t("history")}</span>
<kbd className="text-muted-foreground pointer-events-none inline-flex items-center gap-0.5 pl-1 font-mono select-none">
{/* eslint-disable-next-line i18next/no-literal-string */}
<span className=""></span>K
</kbd>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<CommandMenu open={isOpen} onOpenChange={setIsOpen} />
</>
);
};

View File

@@ -1,57 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "@turbostarter/i18n";
import { useDateGroups } from "@turbostarter/shared/hooks";
import { CommandGroup } from "@turbostarter/ui-web/command";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import { authClient } from "~/lib/auth/client";
import { chat } from "../../lib/api";
import { ChatHistoryListItem } from "./item";
interface ChatHistoryListProps {
onSelect: () => void;
}
export const ChatHistoryList = ({ onSelect }: ChatHistoryListProps) => {
const { t } = useTranslation("common");
const { data: session } = authClient.useSession();
const userChats = useQuery(
chat.queries.chats.user.getAll(session?.user.id ?? ""),
);
const groups = useDateGroups(userChats.data ?? []);
if (userChats.isLoading) {
return (
<CommandGroup heading={t("history")} className="w-full">
<Skeleton className="mb-2 h-11 w-3/4 rounded-xl" />
<Skeleton className="mb-2 h-11 w-full rounded-xl" />
<Skeleton className="h-11 w-1/2 rounded-xl" />
</CommandGroup>
);
}
return (
<>
{groups.map(
(group) =>
group.items.length > 0 && (
<CommandGroup heading={group.label} key={group.label}>
{group.items.map((chat) => (
<ChatHistoryListItem
key={chat.id}
chat={chat}
onSelect={onSelect}
/>
))}
</CommandGroup>
),
)}
</>
);
};

View File

@@ -1,167 +0,0 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs";
import { usePathname, useRouter } from "next/navigation";
import { toast } from "sonner";
import { useTranslation } from "@turbostarter/i18n";
import { Badge } from "@turbostarter/ui-web/badge";
import { Button } from "@turbostarter/ui-web/button";
import { CommandItem } from "@turbostarter/ui-web/command";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { TurboLink } from "~/modules/common/turbo-link";
import { chat as chatApi } from "../../lib/api";
import type { Chat } from "@turbostarter/ai/chat/types";
interface ChatHistoryListItemProps {
readonly chat: Chat;
readonly onSelect: () => void;
}
export const ChatHistoryListItem = ({
chat,
onSelect,
}: ChatHistoryListItemProps) => {
const { t } = useTranslation("common");
const router = useRouter();
const pathname = usePathname();
return (
<CommandItem
key={chat.id}
value={`${chat.id}-${chat.name}`}
asChild
onSelect={() => {
router.push(pathsConfig.apps.chat.chat(chat.id));
onSelect();
}}
className="group"
>
<div>
<TurboLink
href={pathsConfig.apps.chat.chat(chat.id)}
onClick={onSelect}
className="flex min-w-0 grow items-center justify-start gap-3"
>
<Icons.MessagesSquare />
<span className="min-w-0 truncate">{chat.name}</span>
{pathname.includes(chat.id) && (
<Badge variant="outline">{t("current")}</Badge>
)}
</TurboLink>
<Controls chat={chat} />
</div>
</CommandItem>
);
};
const Controls = ({ chat }: { chat: Chat }) => {
const { data: session } = authClient.useSession();
const userId = session?.user.id ?? "";
const { t } = useTranslation("common");
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const { mutate } = useMutation({
...chatApi.mutations.chats.delete,
onMutate: async (data) => {
await queryClient.cancelQueries({
queryKey: chatApi.queries.chats.user.getAll(userId).queryKey,
});
const previousChats = queryClient.getQueryData(
chatApi.queries.chats.user.getAll(userId).queryKey,
);
queryClient.setQueryData(
chatApi.queries.chats.user.getAll(userId).queryKey,
(old: Chat[]) => old.filter((chat) => chat.id !== data.id),
);
if (pathname.includes(chat.id)) {
router.push(pathsConfig.apps.chat.index);
}
return { previousChats };
},
onError: (error, _, context) => {
toast.error(error.message);
queryClient.setQueryData(
chatApi.queries.chats.user.getAll(userId).queryKey,
context?.previousChats,
);
},
onSettled: async () => {
await queryClient.invalidateQueries(
chatApi.queries.chats.user.getAll(userId),
);
},
});
return (
<>
<span className="text-muted-foreground ml-auto whitespace-nowrap group-data-[selected=true]:hidden">
{dayjs(chat.createdAt).fromNow()}
</span>
<div className="-my-2 ml-auto hidden items-center gap-2 group-data-[selected=true]:flex">
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hover:bg-muted-foreground/10 size-7 rounded-lg"
onClick={(e) => {
e.stopPropagation();
window.open(pathsConfig.apps.chat.chat(chat.id), "_blank");
}}
>
<Icons.ExternalLink className="text-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("newTab")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hover:bg-muted-foreground/10 size-7 rounded-lg"
onClick={(e) => {
e.stopPropagation();
mutate({ id: chat.id });
}}
>
<Icons.Trash className="text-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("delete")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</>
);
};

View File

@@ -1,78 +0,0 @@
"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";
import { useComposer } from "~/modules/chat/composer/hooks/use-composer";
const examples = [
{
icon: Icons.FileText,
label: "chat.example.summarize.label",
prompt: "chat.example.summarize.prompt",
},
{
icon: Icons.ChartNoAxesColumn,
label: "chat.example.analyze.label",
prompt: "chat.example.analyze.prompt",
},
{
icon: Icons.Code,
label: "chat.example.code.label",
prompt: "chat.example.code.prompt",
},
{
icon: Icons.Zap,
label: "chat.example.brainstorm.label",
prompt: "chat.example.brainstorm.prompt",
},
{
icon: Icons.PackageOpen,
label: "chat.example.surprise.label",
prompt: "chat.example.surprise.prompt",
},
] as const;
interface ExamplesProps {
readonly id?: string;
readonly className?: string;
}
export const Examples = memo<ExamplesProps>(({ className, id }) => {
const { t } = useTranslation("ai");
const { onSubmit } = useComposer({ id });
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(({ icon: Icon, 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="text-muted-foreground gap-2 rounded-full"
onClick={() => onSubmit(t(prompt))}
>
<Icon className="size-4" />
<span>{t(label)}</span>
</Button>
</motion.div>
))}
</div>
);
});
Examples.displayName = "Examples";

View File

@@ -1,14 +0,0 @@
import { useTranslation } from "@turbostarter/i18n";
import { getGreeting } from "@turbostarter/shared/utils";
export const Headline = () => {
const { t } = useTranslation(["common", "ai"]);
const { text, emoji } = getGreeting();
return (
<h1 className="leading-tighter flex w-full flex-col items-center justify-center text-center text-2xl tracking-tight @sm:text-3xl @md:text-4xl">
{t(`greeting.${text}`)} {emoji}
<span className="text-muted-foreground">{t("ai:chat.headline")}</span>
</h1>
);
};

View File

@@ -1,32 +0,0 @@
import { memo } from "react";
import { ChatComposer } from "~/modules/chat/composer";
import { ChatDropzone } from "~/modules/chat/composer/dropzone";
import { useComposer } from "~/modules/chat/composer/hooks/use-composer";
import { Examples } from "~/modules/chat/layout/examples";
import { Headline } from "~/modules/chat/layout/headline";
interface NewChatProps {
id: string;
}
export const NewChat = memo<NewChatProps>(({ id }) => {
const { model } = useComposer({ id });
return (
<ChatDropzone disabled={!model?.attachments}>
<div className="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">
<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" id={id} />
<div className="relative w-full px-3 pb-3">
<ChatComposer id={id} />
</div>
</div>
</div>
</ChatDropzone>
);
});
NewChat.displayName = "NewChat";

View File

@@ -1,34 +0,0 @@
"use client";
import { memo } from "react";
import { ChatComposer } from "~/modules/chat/composer";
import { ChatDropzone } from "~/modules/chat/composer/dropzone";
import { Chat } from "~/modules/chat/thread";
import { useComposer } from "../composer/hooks/use-composer";
import type { ChatMessage } from "@turbostarter/ai/chat/types";
interface ViewChatProps {
readonly id: string;
readonly initialMessages?: ChatMessage[];
}
export const ViewChat = memo<ViewChatProps>(({ id, initialMessages }) => {
const { model } = useComposer({ id, initialMessages });
return (
<ChatDropzone disabled={!model?.attachments}>
<Chat id={id} initialMessages={initialMessages} />
<div className="absolute inset-x-0 bottom-0 z-50 mx-auto max-w-[50rem]">
<div className="relative z-40 flex w-full flex-col items-center px-3 pb-3">
<ChatComposer id={id} initialMessages={initialMessages} />
</div>
</div>
</ChatDropzone>
);
});
ViewChat.displayName = "ViewChat";

View File

@@ -1,38 +0,0 @@
import * as z from "zod";
import { chatSchema } from "@turbostarter/ai/chat/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
const KEY = "chat";
const queries = {
chats: {
user: {
getAll: (userId: string) => ({
queryKey: [KEY, "chats", userId],
queryFn: handle(api.ai.chat.chats.$get, {
schema: z.array(chatSchema),
}),
}),
},
},
};
const mutations = {
chats: {
delete: {
mutationKey: [KEY, "chats", "delete"],
mutationFn: ({ id }: { id: string }) =>
handle(api.ai.chat.chats[":id"].$delete)({
param: { id },
}),
},
},
};
export const chat = {
queries,
mutations,
} as const;

View File

@@ -1,39 +0,0 @@
"use client";
import { Role } from "@turbostarter/ai/chat/types";
import { Thread } from "../../common/ai/thread";
import { useComposer } from "../composer/hooks/use-composer";
import { AssistantMessage } from "./message/assistant";
import { UserMessage } from "./message/user";
import type { ChatMessage } from "@turbostarter/ai/chat/types";
interface ChatProps {
readonly id?: string;
readonly initialMessages?: ChatMessage[];
}
const components = {
[Role.USER]: UserMessage,
[Role.ASSISTANT]: AssistantMessage,
};
export const Chat = ({ id, initialMessages }: ChatProps = {}) => {
const { messages, regenerate, error, status } = useComposer({
id,
initialMessages,
});
return (
<Thread
messages={messages}
initialMessages={initialMessages}
status={status}
components={components}
error={error}
regenerate={regenerate}
/>
);
};

View File

@@ -1,67 +0,0 @@
import { memo } from "react";
import { WebSearch } from "~/modules/chat/thread/message/assistant/tools/web-search";
import { ThreadMessage } from "~/modules/common/ai/thread/message";
import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown";
import { Prose } from "~/modules/common/prose";
import { ReasoningMessagePart } from "./reasoning";
import type { ChatMessage } from "@turbostarter/ai/chat/types";
import type { ThreadMessageProps } from "~/modules/common/ai/thread/message";
export const AssistantMessage = memo<ThreadMessageProps<ChatMessage>>(
({ message, ref, status }) => {
return (
<ThreadMessage.Layout className="items-start" ref={ref}>
<Prose className="w-full max-w-none">
{message.parts.map((part, partIndex) => {
switch (part.type) {
case "text":
return (
<MemoizedMarkdown
key={`${message.id}-${partIndex}`}
content={part.text}
id={`text-${partIndex}`}
/>
);
case "reasoning":
return (
<ReasoningMessagePart
key={`${message.id}-${partIndex}`}
part={part}
reasoning={
status === "streaming" &&
partIndex === message.parts.length - 1
}
defaultExpanded={status === "streaming"}
/>
);
case "tool-web-search":
switch (part.state) {
case "input-available":
case "output-available":
return (
<WebSearch
key={`${message.id}-${partIndex}`}
{...part}
annotations={message.parts.filter(
(p) => p.type === "data-query_completion",
)}
/>
);
}
}
})}
</Prose>
{!["submitted", "streaming"].includes(status) && (
<ThreadMessage.Controls message={message} />
)}
</ThreadMessage.Layout>
);
},
);
AssistantMessage.displayName = "AssistantMessage";

View File

@@ -1,86 +0,0 @@
"use client";
import { motion } from "motion/react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@turbostarter/ui-web/accordion";
import { Icons } from "@turbostarter/ui-web/icons";
import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown";
import type { ReasoningUIPart } from "ai";
interface ReasoningMessagePartProps {
part: ReasoningUIPart;
reasoning: boolean;
defaultExpanded?: boolean;
}
export function ReasoningMessagePart({
part,
reasoning,
defaultExpanded = false,
}: ReasoningMessagePartProps) {
const { t } = useTranslation("common");
if (!part.text) {
return null;
}
return (
<div className="w-full">
<Accordion
type="single"
collapsible
defaultValue={defaultExpanded ? "reasoning" : undefined}
className="w-full"
>
<AccordionItem value="reasoning" className="border-none [&_h3]:my-0">
<AccordionTrigger
className={cn(
"not-prose border-border bg-background rounded-xl border p-3 pr-4 shadow-xs hover:no-underline",
"data-[state=open]:rounded-b-none",
)}
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-muted rounded-lg p-1 md:p-1.5">
{reasoning ? (
<Icons.Loader className="text-muted-foreground size-3.5 animate-spin md:size-4" />
) : (
<Icons.Sparkle className="text-muted-foreground size-3.5 md:size-4" />
)}
</div>
<h2 className="text-left font-medium">
{reasoning
? t("reasoning.inProgress")
: t("reasoning.completed")}
</h2>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="mt-0 border-0 py-0">
<div className="rounded-b-xl border border-t-0 px-5 py-3 shadow-xs">
<div className="text-muted-foreground prose-p:my-1.5 text-sm">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<MemoizedMarkdown id={part.type} content={part.text} />
</motion.div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -1,110 +0,0 @@
import { useState } from "react";
import { cn } from "@turbostarter/ui";
import { useBreakpoint } from "@turbostarter/ui-web";
import { Thumbnail, ThumbnailImage, Viewer } from "~/modules/common/image";
import type { SearchResult } from ".";
type SearchImage = SearchResult["images"][number];
export const PREVIEW_IMAGE_COUNT = {
MOBILE: 4,
DESKTOP: 5,
};
interface ImageGridProps {
images: SearchImage[];
showAll?: boolean;
}
const ImageThumbnail = ({
image,
index,
onClick,
isLast,
hasMore,
moreCount,
}: {
image: SearchImage;
index: number;
onClick: () => void;
isLast: boolean;
hasMore: boolean;
moreCount: number;
}) => (
<Thumbnail onClick={onClick} index={index}>
<ThumbnailImage src={image.url} alt={image.description} />
{image.description && (!isLast || !hasMore) && (
<div className="absolute inset-0 flex items-end bg-black/60 px-3 py-4 opacity-0 transition-opacity duration-200 group-hover/thumbnail:opacity-100">
<p className="line-clamp-3 text-xs text-white">{image.description}</p>
</div>
)}
{isLast && hasMore && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
<span className="text-sm font-medium text-white">+{moreCount}</span>
</div>
)}
</Thumbnail>
);
export const ImageGrid = ({ images, showAll = false }: ImageGridProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedImage, setSelectedImage] = useState(0);
const isDesktop = useBreakpoint("md");
const displayImages = showAll
? images
: images.slice(
0,
isDesktop ? PREVIEW_IMAGE_COUNT.DESKTOP : PREVIEW_IMAGE_COUNT.MOBILE,
);
const hasMore =
images.length >
(isDesktop ? PREVIEW_IMAGE_COUNT.DESKTOP : PREVIEW_IMAGE_COUNT.MOBILE);
return (
<div>
<div
className={cn(
"grid gap-2",
"grid-cols-2",
displayImages.length === 1 && "grid-cols-1",
"sm:grid-cols-3",
"lg:grid-cols-4",
"*:aspect-4/3",
"[&>*:first-child]:col-span-1 [&>*:first-child]:row-span-1",
isDesktop &&
displayImages.length > 1 &&
"[&>*:first-child]:col-span-2 [&>*:first-child]:row-span-2",
displayImages.length === 1 &&
"grid-cols-1! [&>*:first-child]:col-span-1! [&>*:first-child]:row-span-2!",
)}
>
{displayImages.map((image, index) => (
<ImageThumbnail
key={index}
image={image}
index={index}
onClick={() => {
setSelectedImage(index);
setIsOpen(true);
}}
isLast={index === displayImages.length - 1}
hasMore={!showAll && hasMore}
moreCount={images.length - displayImages.length}
/>
))}
</div>
<Viewer
open={isOpen}
onOpenChange={setIsOpen}
images={images}
selectedImage={selectedImage}
setSelectedImage={setSelectedImage}
/>
</div>
);
};

View File

@@ -1,187 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import { motion } from "motion/react";
import { useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@turbostarter/ui-web/accordion";
import { Badge } from "@turbostarter/ui-web/badge";
import { Icons } from "@turbostarter/ui-web/icons";
import { ImageGrid } from "./images";
import { SearchLoading } from "./loading";
import type {
ChatDataParts,
ChatTools,
Tool,
} from "@turbostarter/ai/chat/types";
import type { DataUIPart } from "ai";
const ResultCard = ({
result,
}: {
result: SearchResult["results"][number];
}) => {
const [imageLoaded, setImageLoaded] = useState(false);
return (
<div className="border-border bg-background h-full w-[300px] shrink-0 rounded-xl border shadow-xs">
<div className="p-4">
<div className="mb-3 flex items-center gap-2.5">
<div className="bg-muted relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
{!imageLoaded && (
<div className="bg-muted-foreground/10 absolute inset-0 animate-pulse" />
)}
<img
src={`https://www.google.com/s2/favicons?sz=128&domain=${new URL(result.url).hostname}`}
alt=""
className={cn("size-8 object-cover", !imageLoaded && "opacity-0")}
onLoad={() => setImageLoaded(true)}
onError={(e) => {
setImageLoaded(true);
e.currentTarget.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='8' x2='12' y2='16'/%3E%3Cline x1='8' y1='12' x2='16' y2='12'/%3E%3C/svg%3E";
}}
/>
</div>
<div>
<h3 className="line-clamp-1 text-sm font-medium">{result.title}</h3>
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"
>
{new URL(result.url).hostname}
<Icons.ExternalLink className="size-2.5" />
</a>
</div>
</div>
<p className="text-muted-foreground line-clamp-3 text-sm">
{result.content}
</p>
{result.publishedDate && (
<div className="pt-2">
<time className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Icons.Calendar className="size-3" />
{new Date(result.publishedDate).toLocaleDateString()}
</time>
</div>
)}
</div>
</div>
);
};
export type SearchResult = NonNullable<
ChatTools[typeof Tool.WEB_SEARCH]["output"]
>["searches"][number];
export const WebSearch = (
props: Partial<ChatTools[typeof Tool.WEB_SEARCH]> & {
annotations: DataUIPart<ChatDataParts>[];
},
) => {
const { input, output, annotations } = props;
const { t } = useTranslation("common");
if (!output) {
return (
<SearchLoading queries={input?.queries ?? []} annotations={annotations} />
);
}
const allImages = output.searches.reduce<SearchResult["images"]>(
(acc, search) => {
return [...acc, ...search.images];
},
[],
);
const totalResults = output.searches.reduce(
(sum, search) => sum + search.results.length,
0,
);
return (
<div className="not-prose w-full space-y-4 pb-2">
<Accordion
type="single"
collapsible
defaultValue="search"
className="w-full"
>
<AccordionItem value="search" className="border-none [&_h3]:my-0">
<AccordionTrigger
className={cn(
"border-border bg-background rounded-xl border p-3 pr-4 shadow-xs hover:no-underline",
"[&[data-state=open]]:rounded-b-none",
)}
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-muted rounded-lg p-1.5">
<Icons.Globe className="text-muted-foreground size-4" />
</div>
<h2 className="text-left font-medium">
{t("search.completed")}
</h2>
</div>
<div className="mr-2 flex items-center gap-2">
<Badge
variant="secondary"
className="bg-muted rounded-full px-3 py-1"
>
<Icons.Search className="mr-1.5 size-3" />
{totalResults} {t("results")}
</Badge>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="mt-0 border-0 py-0">
<div className="border-border bg-background rounded-b-xl border border-t-0 px-4 py-3 shadow-xs">
<div className="no-scrollbar mb-3 flex gap-2 overflow-x-auto pb-1">
{output.searches.map((search, i) => (
<Badge
key={i}
variant="secondary"
className="bg-muted flex-shrink-0 rounded-full px-3 py-1.5"
>
<Icons.Search className="mr-1.5 size-3" />
{search.query.q}
</Badge>
))}
</div>
<div className="no-scrollbar flex gap-3 overflow-x-auto">
{output.searches.map((search) =>
search.results.map((result, resultIndex) => (
<motion.div
key={`${search.query.q}-${resultIndex}`}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: resultIndex * 0.1 }}
>
<ResultCard result={result} />
</motion.div>
)),
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{allImages.length > 0 && <ImageGrid images={allImages} />}
</div>
);
};

View File

@@ -1,148 +0,0 @@
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { useBreakpoint } from "@turbostarter/ui-web";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@turbostarter/ui-web/accordion";
import { Badge } from "@turbostarter/ui-web/badge";
import { Icons } from "@turbostarter/ui-web/icons";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import { PREVIEW_IMAGE_COUNT } from "./images";
import type {
ChatTools,
ChatDataParts,
Tool,
} from "@turbostarter/ai/chat/types";
import type { DataUIPart } from "ai";
export const SearchLoading = ({
queries,
annotations,
}: {
queries: ChatTools[typeof Tool.WEB_SEARCH]["input"]["queries"];
annotations: DataUIPart<ChatDataParts>[];
}) => {
const isDesktop = useBreakpoint("md");
const { t } = useTranslation("common");
const totalResults = annotations.reduce(
(sum, a) => sum + a.data.resultsCount,
0,
);
return (
<div className="not-prose w-full space-y-4 pb-2">
<Accordion
type="single"
collapsible
defaultValue="search"
className="w-full"
>
<AccordionItem value="search" className="border-none [&_h3]:my-0">
<AccordionTrigger
className={cn(
"border-border bg-background rounded-xl border p-3 shadow-xs hover:no-underline",
"data-[state=open]:rounded-b-none",
)}
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-muted rounded-lg p-1.5">
<Icons.Loader className="text-muted-foreground size-4 animate-spin" />
</div>
<h2 className="text-left font-medium">
{t("search.inProgress")}
</h2>
</div>
<div className="mr-2 flex items-center gap-2">
<Badge
variant="secondary"
className="bg-muted rounded-full px-3 py-1"
>
<Icons.Search className="mr-1.5 size-3" />
{totalResults || "0"} {t("results")}
</Badge>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="mt-0 border-0 py-0">
<div className="border-border bg-background rounded-b-xl border border-t-0 px-4 py-3 shadow-xs">
<div className="no-scrollbar mb-3 flex gap-2 overflow-x-auto pb-1">
{queries.map((query, i) => {
const annotation = annotations.find(
(a) =>
a.data.query.q === query.q &&
a.data.status === "completed",
);
return (
<Badge
key={i}
variant="secondary"
className={cn(
"shrink-0 gap-1.5 rounded-full px-3 py-1.5",
!annotation && "text-muted-foreground",
)}
>
{annotation ? (
<Icons.Check className="size-3" />
) : (
<Icons.Loader2 className="size-3 animate-spin stroke-[3px]" />
)}
{query.q}
</Badge>
);
})}
</div>
<div className="no-scrollbar flex gap-3 overflow-x-auto pb-1">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="border-border bg-background w-[300px] shrink-0 rounded-xl border shadow-xs"
>
<div className="p-4">
<div className="mb-3 flex items-center gap-2.5">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-4/6" />
</div>
</div>
</div>
))}
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({
length: isDesktop
? PREVIEW_IMAGE_COUNT.DESKTOP
: PREVIEW_IMAGE_COUNT.MOBILE,
}).map((_, i) => (
<Skeleton
key={i}
className={cn(
"aspect-4/3 rounded-xl",
i === 0 && "sm:col-span-2 sm:row-span-2",
)}
/>
))}
</div>
</div>
);
};

View File

@@ -1,85 +0,0 @@
import { memo, useState } from "react";
import { ThreadMessage } from "~/modules/common/ai/thread/message";
import { Thumbnail, ThumbnailImage, Viewer } from "~/modules/common/image";
import { Prose } from "~/modules/common/prose";
import type { ChatMessage } from "@turbostarter/ai/chat/types";
import type { FileUIPart } from "ai";
import type { ThreadMessageProps } from "~/modules/common/ai/thread/message";
const Attachments = ({ attachments }: { attachments: FileUIPart[] }) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedImage, setSelectedImage] = useState(0);
if (!attachments.length) {
return null;
}
return (
<>
<div className="mb-1 flex max-w-full flex-row flex-wrap items-center justify-end gap-1.5">
{attachments
.filter((attachment) => attachment.mediaType.includes("image/"))
.map((attachment, index) => {
return (
<Thumbnail
key={attachment.url}
index={index}
onClick={() => {
setIsOpen(true);
setSelectedImage(index);
}}
className="aspect-square h-24 w-24 border bg-transparent shadow-none sm:h-32 sm:w-32 dark:bg-transparent"
>
<ThumbnailImage
src={attachment.url}
alt=""
key={attachment.url}
/>
</Thumbnail>
);
})}
</div>
<Viewer
open={isOpen}
onOpenChange={setIsOpen}
images={attachments}
selectedImage={selectedImage}
setSelectedImage={setSelectedImage}
/>
</>
);
};
export const UserMessage = memo<ThreadMessageProps<ChatMessage>>(
({ message, ref }) => {
const attachments = message.parts.filter((part) => part.type === "file");
return (
<ThreadMessage.Layout className="items-end" ref={ref}>
{attachments.length > 0 && (
<Attachments
key={`${message.id}-attachments`}
attachments={attachments}
/>
)}
{message.parts.map((part, index) => {
switch (part.type) {
case "text":
return (
<Prose
key={`${message.id}-${index}`}
className="bg-muted min-h-7 max-w-full rounded-3xl rounded-br-lg border px-4 py-2.5 sm:max-w-[90%]"
>
{part.text}
</Prose>
);
}
})}
</ThreadMessage.Layout>
);
},
);
UserMessage.displayName = "UserMessage";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,82 +0,0 @@
"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

@@ -1,67 +0,0 @@
"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

@@ -1,103 +0,0 @@
"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

@@ -1,121 +0,0 @@
"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

@@ -1,38 +0,0 @@
"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

@@ -1,75 +0,0 @@
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

@@ -1,317 +0,0 @@
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

@@ -1,117 +0,0 @@
"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

@@ -1,44 +0,0 @@
"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

@@ -1,127 +0,0 @@
"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

@@ -1,83 +0,0 @@
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

@@ -1,70 +0,0 @@
"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

@@ -1,14 +0,0 @@
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

@@ -1,42 +0,0 @@
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

@@ -1,221 +0,0 @@
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,
};
};

View File

@@ -1,36 +0,0 @@
"use client";
import { parseAsStringLiteral, useQueryState } from "nuqs";
import { ContentTag } from "@turbostarter/cms";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
export const TagsPicker = () => {
const { t } = useTranslation("marketing");
const [activeTag, setActiveTag] = useQueryState("tag", {
...parseAsStringLiteral(Object.values(ContentTag)),
shallow: false,
});
return (
<div className="mx-auto flex flex-wrap items-center justify-center gap-1.5">
{Object.values(ContentTag).map((tag) => (
<Button
key={tag}
variant={tag === activeTag ? "default" : "outline"}
size="sm"
className={cn("rounded-full px-4", {
"border-primary border": tag === activeTag,
})}
onClick={() =>
activeTag === tag ? setActiveTag(null) : setActiveTag(tag)
}
>
{t(`blog.tag.${tag}`)}
</Button>
))}
</div>
);
};

View File

@@ -1,140 +0,0 @@
/* eslint-disable i18next/no-literal-string */
"use client";
import { cn } from "@turbostarter/ui";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { useEmbedding } from "../hooks";
import type { Citation } from "@turbostarter/ai/pdf/types";
import type { ReactNode } from "react";
// ============================================================================
// Types
// ============================================================================
export interface CitationPreviewProps {
/** Citation data with excerpt and metadata */
citation: Citation;
/** The citation element to wrap (trigger) */
children: ReactNode;
/** Side to show the tooltip (default: top) */
side?: "top" | "bottom" | "left" | "right";
/** Optional className for content styling */
className?: string;
}
// ============================================================================
// Helpers
// ============================================================================
/**
* Format relevance score as percentage
*/
function formatRelevance(score: number): string {
return `${Math.round(score * 100)}%`;
}
/**
* Truncate excerpt to max length with ellipsis
*/
function truncateExcerpt(excerpt: string, maxLength = 120): string {
if (excerpt.length <= maxLength) return excerpt;
return excerpt.substring(0, maxLength).trim() + "...";
}
// ============================================================================
// Citation Preview Component
// ============================================================================
/**
* Tooltip/popover wrapper showing citation details on hover.
* Displays page number, relevance score, and excerpt preview.
* Fetches embedding content if excerpt is not available.
*
* @example
* ```tsx
* <CitationPreview citation={citation}>
* <Citation citation={citation} />
* </CitationPreview>
* ```
*/
export function CitationPreview({
citation,
children,
side = "top",
className,
}: CitationPreviewProps) {
// Fetch embedding content if excerpt is empty
const { data: embedding, isLoading } = useEmbedding(
citation.excerpt ? null : citation.embeddingId,
);
// Use citation excerpt if available, otherwise use fetched embedding content
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const excerptText = citation.excerpt || embedding?.content || "";
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side={side}
sideOffset={8}
className={cn(
"max-w-xs p-3",
"bg-popover text-popover-foreground",
"border border-border rounded-lg shadow-lg",
className,
)}
>
<div className="space-y-2">
{/* Header with page number and relevance */}
<div className="flex items-center justify-between gap-4">
<span className="text-xs font-medium text-muted-foreground">
Page {citation.pageNumber}
</span>
{citation.relevance > 0 && (
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded-full",
citation.relevance >= 0.8
? "bg-green-500/10 text-green-600"
: citation.relevance >= 0.5
? "bg-yellow-500/10 text-yellow-600"
: "bg-muted text-muted-foreground",
)}
>
{formatRelevance(citation.relevance)} match
</span>
)}
</div>
{/* Excerpt preview */}
{isLoading ? (
<Skeleton className="h-12 w-full" />
) : excerptText ? (
<p className="text-sm text-foreground leading-relaxed">
"{truncateExcerpt(excerptText)}"
</p>
) : (
<p className="text-sm text-muted-foreground italic">
No excerpt available
</p>
)}
{/* Click hint */}
<p className="text-xs text-muted-foreground italic">
Click to jump to source
</p>
</div>
</TooltipContent>
</Tooltip>
);
}
export default CitationPreview;

View File

@@ -1,86 +0,0 @@
"use client";
import { cn } from "@turbostarter/ui";
import { Icons } from "@turbostarter/ui-web/icons";
import { usePdfViewer } from "../context/pdf-viewer-context";
import type { Citation } from "@turbostarter/ai/pdf/types";
// ============================================================================
// Types
// ============================================================================
export interface CitationProps {
/** Citation data from parsed AI response */
citation: Citation;
/** Optional className for styling overrides */
className?: string;
}
// ============================================================================
// Citation Component
// ============================================================================
/**
* Clickable inline citation component displayed as [1], [2], etc.
* Clicking navigates to the cited page and highlights the source.
*
* @example
* ```tsx
* <Citation citation={{ index: 1, pageNumber: 5, embeddingId: "abc123", ... }} />
* // Renders: [1] - clickable, navigates to page 5
* ```
*/
export function Citation({ citation, className }: CitationProps) {
const { navigateTo, activeHighlight, clearHighlight } = usePdfViewer();
// Check if this citation is currently active
const isActive = activeHighlight === citation.embeddingId;
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
// Toggle behavior: if already active, deactivate; otherwise activate
if (isActive) {
clearHighlight();
} else {
navigateTo({
page: citation.pageNumber,
embeddingId: citation.embeddingId,
animate: true,
});
}
};
return (
<button
type="button"
onClick={handleClick}
aria-pressed={isActive}
className={cn(
"inline-flex items-center justify-center",
"min-w-[1.5rem] h-5 px-1.5",
"text-xs font-semibold",
"border rounded-full",
"cursor-pointer",
"focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-1",
"transition-all duration-150",
"align-text-top",
// Active vs inactive states
isActive
? "bg-primary text-primary-foreground border-primary shadow-sm"
: "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20 hover:border-primary/30",
className,
)}
aria-label={`Citation ${citation.index}, page ${citation.pageNumber}${isActive ? " (active)" : ""}`}
title={isActive ? "Click to deactivate" : `Go to page ${citation.pageNumber}`}
>
<Icons.FileText className="size-3 mr-0.5" aria-hidden />
<span>{citation.index}</span>
</button>
);
}
export default Citation;

View File

@@ -1,8 +0,0 @@
// PDF Components
// Navigation
export { NavigationControls } from "./navigation-controls";
// Citations
export { Citation, type CitationProps } from "./citation";
export { CitationPreview, type CitationPreviewProps } from "./citation-preview";

View File

@@ -1,37 +0,0 @@
"use client";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { usePdfNavigation } from "../hooks/use-pdf-navigation";
/**
* Back/Forward navigation controls for PDF viewer.
* Compact toolbar-style buttons for history navigation.
*/
export function NavigationControls() {
const { goBack, goForward, canGoBack, canGoForward } = usePdfNavigation();
return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
>
<Icons.ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
>
<Icons.ChevronRight className="h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -1,136 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { toast } from "sonner";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { TurboLink } from "~/modules/common/turbo-link";
import { pdf } from "../lib/api";
import type { Chat } from "@turbostarter/ai/chat/types";
dayjs.extend(relativeTime);
const ChatCard = ({ chat }: { chat: Chat }) => {
const { data: session } = authClient.useSession();
const userId = session?.user.id ?? "";
const queryClient = useQueryClient();
const { mutate: deleteChat, isPending } = useMutation({
...pdf.mutations.chats.delete,
onMutate: async (data) => {
await queryClient.cancelQueries({
queryKey: pdf.queries.chats.user.getAll(userId).queryKey,
});
const previousChats = queryClient.getQueryData(
pdf.queries.chats.user.getAll(userId).queryKey,
);
queryClient.setQueryData(
pdf.queries.chats.user.getAll(userId).queryKey,
(old: Chat[]) => old.filter((c) => c.id !== data.id),
);
return { previousChats };
},
onError: (error, _, context) => {
toast.error(error.message);
queryClient.setQueryData(
pdf.queries.chats.user.getAll(userId).queryKey,
context?.previousChats,
);
},
onSettled: async () => {
await queryClient.invalidateQueries(pdf.queries.chats.user.getAll(userId));
},
});
return (
<div className="group bg-card hover:bg-accent/50 relative flex flex-col gap-2 rounded-lg border p-4 transition-colors">
<TurboLink
href={pathsConfig.apps.pdf.chat(chat.id)}
className="absolute inset-0 z-0"
/>
<div className="flex items-start justify-between gap-2">
<div className="bg-primary/10 text-primary flex size-10 shrink-0 items-center justify-center rounded-lg">
<Icons.FileText className="size-5" />
</div>
<Button
variant="ghost"
size="icon"
className="relative z-10 size-8 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.preventDefault();
deleteChat({ id: chat.id });
}}
disabled={isPending}
>
<Icons.Trash className="text-muted-foreground hover:text-destructive size-4" />
</Button>
</div>
<div className="min-w-0">
<p className="truncate font-medium">{chat.name}</p>
<p className="text-muted-foreground text-sm">
{dayjs(chat.createdAt).fromNow()}
</p>
</div>
</div>
);
};
export const RecentChats = () => {
const { t } = useTranslation("ai");
const { data: session } = authClient.useSession();
const userChats = useQuery({
...pdf.queries.chats.user.getAll(session?.user.id ?? ""),
enabled: !!session?.user.id,
});
if (!session?.user.id) {
return null;
}
if (userChats.isLoading) {
return (
<div className="mt-8 w-full max-w-2xl">
<div className="mb-4 flex items-center gap-2">
<Icons.ClockFading className="text-muted-foreground size-4" />
<Skeleton className="h-5 w-24" />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-24 rounded-lg" />
</div>
</div>
);
}
const recentChats = userChats.data?.slice(0, 6) ?? [];
if (recentChats.length === 0) {
return null;
}
return (
<div className="mt-8 w-full max-w-2xl">
<div className="text-muted-foreground mb-4 flex items-center gap-2 text-sm font-medium">
<Icons.ClockFading className="size-4" />
<span>{t("pdf.recent")}</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{recentChats.map((chat) => (
<ChatCard key={chat.id} chat={chat} />
))}
</div>
</div>
);
};

View File

@@ -1,126 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
// ============================================================================
// Types
// ============================================================================
interface TextSelectionActionProps {
onAskAbout: (text: string) => void;
disabled?: boolean;
}
interface SelectionState {
text: string;
x: number;
y: number;
}
// ============================================================================
// Text Selection Action
// ============================================================================
/**
* Floating action button that appears when text is selected in the PDF viewer.
* Clicking "Ask about this" sends the selected text to the chat composer.
*
* Uses mouseup event instead of selectionchange to avoid excessive re-renders.
*/
export function TextSelectionAction({
onAskAbout,
disabled,
}: TextSelectionActionProps) {
const { t } = useTranslation("ai");
const [selection, setSelection] = useState<SelectionState | null>(null);
const buttonRef = useRef<HTMLDivElement>(null);
// Handle mouse up to check for selection
const handleMouseUp = useCallback(() => {
// Small delay to ensure selection is complete
requestAnimationFrame(() => {
const sel = window.getSelection();
const text = sel?.toString().trim();
if (text && text.length > 3) {
// Only show for meaningful selections (more than 3 chars)
const range = sel?.getRangeAt(0);
const rect = range?.getBoundingClientRect();
if (rect && rect.width > 0) {
setSelection({
text,
x: rect.left + rect.width / 2,
y: rect.top - 10,
});
}
}
});
}, []);
// Clear selection when clicking outside
const handleMouseDown = useCallback((e: MouseEvent) => {
const target = e.target as HTMLElement;
// Don't clear if clicking on the action button itself
if (buttonRef.current?.contains(target)) return;
setSelection(null);
}, []);
// Listen for mouse events
useEffect(() => {
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("mousedown", handleMouseDown);
};
}, [handleMouseUp, handleMouseDown]);
const handleAskAbout = useCallback(() => {
if (selection?.text) {
onAskAbout(selection.text);
// Clear selection after asking
window.getSelection()?.removeAllRanges();
setSelection(null);
}
}, [selection, onAskAbout]);
if (!selection) {
return null;
}
return (
<div
ref={buttonRef}
data-selection-action
className={cn(
"fixed z-50 -translate-x-1/2 -translate-y-full",
"animate-in fade-in-0 zoom-in-95 duration-150"
)}
style={{
left: selection.x,
top: selection.y,
}}
>
<Button
size="sm"
variant="default"
className="gap-2 shadow-lg"
onClick={handleAskAbout}
disabled={disabled}
>
<Icons.MessagesSquare className="size-3.5" />
{t("pdf.selection.askAbout")}
</Button>
</div>
);
}
export default TextSelectionAction;

View File

@@ -1,82 +0,0 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { Composer } from "~/modules/common/ai/composer";
import { useComposer } from "./use-composer";
import type { PdfMessage } from "@turbostarter/ai/pdf/types";
interface ChatComposerProps {
readonly id?: string;
readonly initialMessages?: PdfMessage[];
}
export const ChatComposer = ({
id,
initialMessages,
}: ChatComposerProps = {}) => {
const { t } = useTranslation(["ai", "common"]);
const { status, input, setInput, sendMessage, stop } = useComposer({
id,
initialMessages,
});
return (
<Composer.Form
onSubmit={(e) => {
e.preventDefault();
void sendMessage({
text: input,
});
setInput("");
}}
>
<Composer.Input className="rounded-xl @sm:rounded-2xl">
<Composer.Textarea
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
maxLength={5_000}
placeholder={t("pdf.composer.placeholder")}
className="pr-11 @[480px]/input:pr-11"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!["submitted", "streaming"].includes(status)) {
void sendMessage({
text: input,
});
setInput("");
}
}
}}
/>
<Button
className="absolute right-3 bottom-3 rounded-full"
disabled={
!input.trim() && !["submitted", "streaming"].includes(status)
}
size="icon"
type="submit"
onClick={(e) => {
if (["submitted", "streaming"].includes(status)) {
e.preventDefault();
return stop();
}
}}
>
{["submitted", "streaming"].includes(status) ? (
<Icons.Pause className="size-4" />
) : (
<Icons.Send className="size-4" />
)}
</Button>
</Composer.Input>
</Composer.Form>
);
};

View File

@@ -1,90 +0,0 @@
import { useChat } from "@ai-sdk/react";
import { Chat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useState } from "react";
import { getMessageTextContent } from "@turbostarter/ai";
import { generateId } from "@turbostarter/shared/utils";
import { api } from "~/lib/api/client";
import { useAIError } from "~/modules/common/hooks/use-ai-error";
import { useCredits } from "~/modules/common/layout/credits";
import type { PdfMessage } from "@turbostarter/ai/pdf/types";
const chats = new Map<string, Chat<PdfMessage>>();
const getChatInstance = ({
id,
...options
}: ConstructorParameters<typeof Chat<PdfMessage>>[0]) => {
if (!id || !chats.has(id)) {
const chat = new Chat<PdfMessage>({
id,
...options,
});
chats.set(id ?? chat.id, chat);
}
const instance = chats.get(id ?? "");
if (!instance) {
throw new Error(`Chat instance with id ${id} not found!`);
}
return instance;
};
interface UseComposerProps {
readonly id?: string;
readonly initialMessages?: PdfMessage[];
}
export const useComposer = ({
id: passedId,
initialMessages,
}: UseComposerProps = {}) => {
const [input, setInput] = useState("");
const { onError } = useAIError();
const { invalidate } = useCredits();
const id = passedId ?? generateId();
const chat = getChatInstance({
id,
transport: new DefaultChatTransport({
api: api.ai.pdf.chats[":id"].messages
.$url({
param: {
id,
},
})
.toString(),
prepareSendMessagesRequest: ({ messages }) => {
const lastMessage = messages.at(-1);
return {
body: {
id: lastMessage?.id,
role: lastMessage?.role,
content: getMessageTextContent(lastMessage),
},
};
},
}),
messages: initialMessages,
onFinish: () => {
void invalidate();
},
onError,
});
const result = useChat({
chat,
});
return {
...result,
input,
setInput,
};
};

View File

@@ -1,6 +0,0 @@
export {
PdfViewerProvider,
usePdfViewer,
useCanGoBack,
useCanGoForward,
} from "./pdf-viewer-context";

View File

@@ -1,242 +0,0 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import type {
NavigationEntry,
PdfViewerActions,
PdfViewerState,
PreciseCitation,
TextHighlight,
} from "@turbostarter/ai/pdf/types";
import type { ReactNode } from "react";
// ============================================================================
// Context Types
// ============================================================================
/** Navigation request to be consumed by PageSync */
export interface PendingNavigation {
page: number;
embeddingId?: string;
animate?: boolean;
}
interface PdfViewerContextValue extends PdfViewerState, PdfViewerActions {
/** Pending navigation request (consumed by PageSync, then cleared) */
pendingNavigation: PendingNavigation | null;
/** Clear the pending navigation after it's been processed */
clearPendingNavigation: () => void;
/** Text highlights from highlightText tool calls */
textHighlights: TextHighlight[];
/** Add a citation from highlightText tool call */
addTextHighlight: (citation: PreciseCitation) => void;
/** Update highlight rects after text search resolves */
updateTextHighlightRects: (id: string, rects: DOMRect[], found: boolean) => void;
/** Clear all text highlights (e.g., on new message) */
clearTextHighlights: () => void;
}
// ============================================================================
// Context
// ============================================================================
const PdfViewerContext = createContext<PdfViewerContextValue | null>(null);
// ============================================================================
// Provider
// ============================================================================
interface PdfViewerProviderProps {
children: ReactNode;
/** Initial page to display */
initialPage?: number;
}
export function PdfViewerProvider({
children,
initialPage = 1,
}: PdfViewerProviderProps) {
// State
const [currentPage, setCurrentPage] = useState(initialPage);
const [zoomLevel, _setZoomLevel] = useState(1);
const [scrollPosition, _setScrollPosition] = useState(0);
const [activeHighlight, setActiveHighlight] = useState<string | null>(null);
const [history, setHistory] = useState<NavigationEntry[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [pendingNavigation, setPendingNavigation] =
useState<PendingNavigation | null>(null);
const [textHighlights, setTextHighlights] = useState<TextHighlight[]>([]);
// Actions
const navigateTo = useCallback(
(options: { page: number; embeddingId?: string; animate?: boolean }) => {
const { page, embeddingId, animate = true } = options;
// Add to history
const entry: NavigationEntry = {
page,
embeddingId,
timestamp: Date.now(),
};
setHistory((prev) => {
// If we're in the middle of history, truncate forward entries
const newHistory =
historyIndex >= 0 ? prev.slice(0, historyIndex + 1) : prev;
return [...newHistory, entry];
});
setHistoryIndex((prev) => prev + 1);
// Set highlight for HighlightLayer
setActiveHighlight(embeddingId ?? null);
// Set pending navigation for PageSync to consume
// PageSync will call lector's jumpToPage and update currentPage
setPendingNavigation({ page, embeddingId, animate });
},
[historyIndex],
);
const clearPendingNavigation = useCallback(() => {
setPendingNavigation(null);
}, []);
const goBack = useCallback(() => {
if (historyIndex <= 0) return;
const prevIndex = historyIndex - 1;
const entry = history[prevIndex];
if (!entry) return;
setHistoryIndex(prevIndex);
setCurrentPage(entry.page);
setActiveHighlight(entry.embeddingId ?? null);
}, [history, historyIndex]);
const goForward = useCallback(() => {
if (historyIndex >= history.length - 1) return;
const nextIndex = historyIndex + 1;
const entry = history[nextIndex];
if (!entry) return;
setHistoryIndex(nextIndex);
setCurrentPage(entry.page);
setActiveHighlight(entry.embeddingId ?? null);
}, [history, historyIndex]);
const clearHighlight = useCallback(() => {
setActiveHighlight(null);
}, []);
// Text highlight actions (for highlightText tool)
const addTextHighlight = useCallback((citation: PreciseCitation) => {
setTextHighlights((prev) => [
...prev,
{
id: citation.citationId,
text: citation.text,
page: citation.page,
rects: [], // Populated when page renders
found: false,
},
]);
}, []);
const updateTextHighlightRects = useCallback(
(id: string, rects: DOMRect[], found: boolean) => {
setTextHighlights((prev) =>
prev.map((h) => (h.id === id ? { ...h, rects, found } : h)),
);
},
[],
);
const clearTextHighlights = useCallback(() => {
setTextHighlights([]);
}, []);
// Memoized context value
const value = useMemo<PdfViewerContextValue>(
() => ({
// State
currentPage,
zoomLevel,
scrollPosition,
activeHighlight,
history,
historyIndex,
pendingNavigation,
textHighlights,
// Actions
navigateTo,
goBack,
goForward,
clearHighlight,
clearPendingNavigation,
setCurrentPage,
addTextHighlight,
updateTextHighlightRects,
clearTextHighlights,
}),
[
currentPage,
zoomLevel,
scrollPosition,
activeHighlight,
history,
historyIndex,
pendingNavigation,
textHighlights,
navigateTo,
goBack,
goForward,
clearHighlight,
clearPendingNavigation,
addTextHighlight,
updateTextHighlightRects,
clearTextHighlights,
],
);
return (
<PdfViewerContext.Provider value={value}>
{children}
</PdfViewerContext.Provider>
);
}
// ============================================================================
// Hook
// ============================================================================
export function usePdfViewer(): PdfViewerContextValue {
const context = useContext(PdfViewerContext);
if (!context) {
throw new Error("usePdfViewer must be used within a PdfViewerProvider");
}
return context;
}
/**
* Check if we can go back in navigation history
*/
export function useCanGoBack(): boolean {
const { historyIndex } = usePdfViewer();
return historyIndex > 0;
}
/**
* Check if we can go forward in navigation history
*/
export function useCanGoForward(): boolean {
const { history, historyIndex } = usePdfViewer();
return historyIndex < history.length - 1;
}

View File

@@ -1,25 +0,0 @@
import { useTranslation } from "@turbostarter/i18n";
import { CommandGroup, CommandItem } from "@turbostarter/ui-web/command";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
interface ChatActionsProps {
onSelect: () => void;
}
export const ChatActions = ({ onSelect }: ChatActionsProps) => {
const { t } = useTranslation(["common", "ai"]);
return (
<CommandGroup heading={t("actions")}>
<CommandItem asChild>
<TurboLink href={pathsConfig.apps.pdf.index} onClick={onSelect}>
<Icons.FileUpIcon />
<span>{t("pdf.new")}</span>
</TurboLink>
</CommandItem>
</CommandGroup>
);
};

View File

@@ -1,96 +0,0 @@
"use client";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime";
import { useState, useEffect } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
CommandDialog,
CommandEmpty,
CommandInput,
CommandList,
} from "@turbostarter/ui-web/command";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { ChatActions } from "./actions";
import { ChatHistoryList } from "./list";
dayjs.extend(duration);
dayjs.extend(relativeTime);
interface CommandMenuProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const CommandMenu = ({ open, onOpenChange }: CommandMenuProps) => {
const { t } = useTranslation("ai");
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
showCloseButton={false}
>
<CommandInput placeholder={t("pdf.command.search")} />
<CommandList className="h-[420px]">
<CommandEmpty className="py-10">{t("pdf.command.empty")}</CommandEmpty>
<ChatActions onSelect={() => onOpenChange(false)} />
<ChatHistoryList onSelect={() => onOpenChange(false)} />
</CommandList>
</CommandDialog>
);
};
export const ChatHistory = () => {
const { t } = useTranslation("common");
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setIsOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
<>
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group relative"
onClick={() => setIsOpen(true)}
>
<Icons.TextSearch className="text-muted-foreground group-hover:text-foreground size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>{t("history")}</span>
<kbd className="text-muted-foreground pointer-events-none inline-flex items-center gap-0.5 pl-1 font-mono select-none">
{/* eslint-disable-next-line i18next/no-literal-string */}
<span className=""></span>K
</kbd>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<CommandMenu open={isOpen} onOpenChange={setIsOpen} />
</>
);
};

View File

@@ -1,57 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "@turbostarter/i18n";
import { useDateGroups } from "@turbostarter/shared/hooks";
import { CommandGroup } from "@turbostarter/ui-web/command";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import { authClient } from "~/lib/auth/client";
import { pdf } from "../../lib/api";
import { ChatHistoryListItem } from "./item";
interface ChatHistoryListProps {
onSelect: () => void;
}
export const ChatHistoryList = ({ onSelect }: ChatHistoryListProps) => {
const { t } = useTranslation("common");
const { data: session } = authClient.useSession();
const userChats = useQuery(
pdf.queries.chats.user.getAll(session?.user.id ?? ""),
);
const groups = useDateGroups(userChats.data ?? []);
if (userChats.isLoading) {
return (
<CommandGroup heading={t("history")} className="w-full">
<Skeleton className="mb-2 h-11 w-3/4 rounded-xl" />
<Skeleton className="mb-2 h-11 w-full rounded-xl" />
<Skeleton className="h-11 w-1/2 rounded-xl" />
</CommandGroup>
);
}
return (
<>
{groups.map(
(group) =>
group.items.length > 0 && (
<CommandGroup heading={group.label} key={group.label}>
{group.items.map((chat) => (
<ChatHistoryListItem
key={chat.id}
chat={chat}
onSelect={onSelect}
/>
))}
</CommandGroup>
),
)}
</>
);
};

View File

@@ -1,166 +0,0 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs";
import { usePathname, useRouter } from "next/navigation";
import { toast } from "sonner";
import { useTranslation } from "@turbostarter/i18n";
import { Badge } from "@turbostarter/ui-web/badge";
import { Button } from "@turbostarter/ui-web/button";
import { CommandItem } from "@turbostarter/ui-web/command";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { TurboLink } from "~/modules/common/turbo-link";
import { pdf } from "~/modules/pdf/lib/api";
import type { Chat } from "@turbostarter/ai/chat/types";
interface ChatHistoryListItemProps {
readonly chat: Chat;
readonly onSelect: () => void;
}
export const ChatHistoryListItem = ({
chat,
onSelect,
}: ChatHistoryListItemProps) => {
const { t } = useTranslation("common");
const router = useRouter();
const pathname = usePathname();
return (
<CommandItem
key={chat.id}
value={`${chat.id}-${chat.name}`}
asChild
onSelect={() => {
router.push(pathsConfig.apps.pdf.chat(chat.id));
onSelect();
}}
className="group"
>
<div>
<TurboLink
href={pathsConfig.apps.pdf.chat(chat.id)}
onClick={onSelect}
className="flex min-w-0 grow items-center justify-start gap-3"
>
<Icons.MessagesSquare />
<span className="truncate">{chat.name}</span>
{pathname.includes(chat.id) && (
<Badge variant="outline">{t("current")}</Badge>
)}
</TurboLink>
<Controls chat={chat} />
</div>
</CommandItem>
);
};
const Controls = ({ chat }: { chat: Chat }) => {
const { data: session } = authClient.useSession();
const userId = session?.user.id ?? "";
const { t } = useTranslation("common");
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const { mutate } = useMutation({
...pdf.mutations.chats.delete,
onMutate: async (data) => {
await queryClient.cancelQueries({
queryKey: pdf.queries.chats.user.getAll(userId).queryKey,
});
const previousChats = queryClient.getQueryData(
pdf.queries.chats.user.getAll(userId).queryKey,
);
queryClient.setQueryData(
pdf.queries.chats.user.getAll(userId).queryKey,
(old: Chat[]) => old.filter((chat) => chat.id !== data.id),
);
if (pathname.includes(chat.id)) {
router.push(pathsConfig.apps.pdf.index);
}
return { previousChats };
},
onError: (error, _, context) => {
toast.error(error.message);
queryClient.setQueryData(
pdf.queries.chats.user.getAll(userId).queryKey,
context?.previousChats,
);
},
onSettled: async () => {
await queryClient.invalidateQueries(
pdf.queries.chats.user.getAll(userId),
);
},
});
return (
<>
<span className="text-muted-foreground ml-auto whitespace-nowrap group-data-[selected=true]:hidden">
{dayjs(chat.createdAt).fromNow()}
</span>
<div className="-my-2 ml-auto hidden items-center gap-2 group-data-[selected=true]:flex">
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hover:bg-muted-foreground/10 size-7 rounded-lg"
onClick={(e) => {
e.stopPropagation();
window.open(pathsConfig.apps.pdf.chat(chat.id), "_blank");
}}
>
<Icons.ExternalLink className="text-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("newTab")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hover:bg-muted-foreground/10 size-7 rounded-lg"
onClick={(e) => {
e.stopPropagation();
mutate({ id: chat.id });
}}
>
<Icons.Trash className="text-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("delete")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</>
);
};

View File

@@ -1,7 +0,0 @@
// PDF Hooks
export { usePdfViewer, useCanGoBack, useCanGoForward } from "../context";
export { usePdfNavigation } from "./use-pdf-navigation";
export { useEmbedding } from "./use-embedding";
export { useCitationUnit } from "./use-citation-unit";
export type { CitationUnitDetail, BoundingBox } from "./use-citation-unit";

View File

@@ -1,73 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "~/lib/api/client";
// ============================================================================
// Types
// ============================================================================
/**
* Bounding box for pixel-perfect highlighting
*/
export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
/**
* Citation unit with precise location for highlighting (WF-0028)
*/
export interface CitationUnitDetail {
id: string;
content: string;
pageNumber: number;
paragraphIndex: number;
charStart: number;
charEnd: number;
bbox: BoundingBox | null;
sectionTitle: string | null;
unitType: string;
}
// ============================================================================
// Hook
// ============================================================================
/**
* Fetch citation unit details by ID for bounding box-based highlighting
*
* Falls back to legacy embedding endpoint if citation unit not found
*/
export function useCitationUnit(unitId: string | null) {
return useQuery({
queryKey: ["pdf", "citation-unit", unitId],
queryFn: async (): Promise<CitationUnitDetail | null> => {
if (!unitId) return null;
// Try citation unit endpoint first (WF-0028 dual-resolution)
const response = await api.ai.pdf.search["citation-units"].single[":id"].$get({
param: { id: unitId },
});
if (response.ok) {
const result = await response.json();
return (result as { data: CitationUnitDetail }).data;
}
// If not found in citation units, this might be a legacy embedding ID
// Return null - the highlight layer will fall back to word overlap
if (response.status === 404) {
return null;
}
throw new Error("Failed to fetch citation unit");
},
enabled: Boolean(unitId),
staleTime: Infinity, // Citation units don't change
gcTime: 1000 * 60 * 30, // Keep in cache for 30 minutes
});
}

View File

@@ -1,48 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "~/lib/api/client";
// ============================================================================
// Types
// ============================================================================
export interface EmbeddingDetail {
id: string;
content: string;
pageNumber: number;
charStart?: number;
charEnd?: number;
sectionTitle?: string;
}
// ============================================================================
// Hook
// ============================================================================
/**
* Fetch embedding details by ID for citation highlighting
*/
export function useEmbedding(embeddingId: string | null) {
return useQuery({
queryKey: ["pdf", "embedding", embeddingId],
queryFn: async (): Promise<EmbeddingDetail | null> => {
if (!embeddingId) return null;
const response = await api.ai.pdf.embeddings[":id"].$get({
param: { id: embeddingId },
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error("Failed to fetch embedding");
}
return response.json() as Promise<EmbeddingDetail>;
},
enabled: Boolean(embeddingId),
staleTime: Infinity, // Embeddings don't change
gcTime: 1000 * 60 * 30, // Keep in cache for 30 minutes
});
}

View File

@@ -1,26 +0,0 @@
"use client";
import { useCanGoBack, useCanGoForward, usePdfViewer } from "../context";
/**
* Convenience hook for PDF navigation controls.
* Combines navigation state and actions in one place.
*/
export function usePdfNavigation() {
const { goBack, goForward, navigateTo, history, historyIndex } =
usePdfViewer();
const canGoBack = useCanGoBack();
const canGoForward = useCanGoForward();
return {
// Actions
goBack,
goForward,
navigateTo,
// State
canGoBack,
canGoForward,
historyLength: history.length,
currentIndex: historyIndex,
};
}

View File

@@ -1,29 +0,0 @@
// PDF Module
// Exports for external use
// Context & Hooks
export {
PdfViewerProvider,
usePdfViewer,
useCanGoBack,
useCanGoForward,
} from "./context";
export { usePdfNavigation } from "./hooks";
// Layout Components
export { PdfLayout } from "./layout/layout";
export { PdfPreview } from "./layout/preview";
// Thread Components
export { Chat as PdfChat } from "./thread";
export { ChatComposer as PdfComposer } from "./composer";
export { CitationMarkdown } from "./thread/citation-markdown";
// Navigation & Citations
export {
NavigationControls,
Citation,
CitationPreview,
type CitationProps,
type CitationPreviewProps,
} from "./components";

View File

@@ -1,209 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { useBreakpoint } from "@turbostarter/ui-web";
import { Button } from "@turbostarter/ui-web/button";
import { Drawer, DrawerContent } from "@turbostarter/ui-web/drawer";
import { Icons } from "@turbostarter/ui-web/icons";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@turbostarter/ui-web/resizable";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { Header } from "~/modules/common/layout/header";
import { ThemeSwitcher } from "~/modules/common/theme";
import { PdfViewerProvider } from "../context";
import { ChatHistory } from "../history";
import { pdf } from "../lib/api";
import { PdfPreview } from "./preview";
const Trigger = ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
const { t } = useTranslation("ai");
return (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group relative"
onClick={() => onOpenChange(!open)}
>
<Icons.FileText className="text-muted-foreground group-hover:text-foreground size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" sideOffset={5}>
<span>{t("pdf.preview.toggle")}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const MobileDocumentPreview = ({
id,
open,
onOpenChange,
}: {
id: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="border-b-none fixed right-0 bottom-0 left-0 mx-[-1px] flex max-h-[97%] flex-col gap-3 rounded-t-[10px] border px-3 pb-3">
<Documents id={id} />
</DrawerContent>
</Drawer>
);
};
const ProcessingIndicator = () => {
const { t } = useTranslation("ai");
return (
<div className="bg-background/95 absolute inset-0 z-20 flex flex-col items-center justify-center gap-4 backdrop-blur-sm">
<div className="flex items-center gap-3">
<Icons.Loader2 className="text-primary size-6 animate-spin" />
<span className="text-muted-foreground text-sm font-medium">
{t("pdf.processing.indexing")}
</span>
</div>
<p className="text-muted-foreground max-w-xs text-center text-xs">
{t("pdf.processing.indexingDescription")}
</p>
</div>
);
};
const Documents = ({ id }: { id: string }) => {
const { t } = useTranslation("ai");
const documents = useQuery(pdf.queries.chats.documents.getAll(id));
const document = documents.data?.[0];
// Poll for document processing status
const status = useQuery({
...pdf.queries.chats.documents.getStatus(document?.id ?? ""),
enabled: !!document?.id,
refetchInterval: (query) => {
const data = query.state.data;
// Stop polling once ready, failed, or error
if (!data || "error" in data) {
return false;
}
if (data.processingStatus === "ready" || data.processingStatus === "failed") {
return false;
}
return 2000; // Poll every 2 seconds while processing
},
});
const url = useQuery({
...pdf.queries.chats.documents.getUrl(document?.path ?? ""),
enabled: !!document,
staleTime: 1000 * 60 * 60,
});
if (documents.isLoading || url.isLoading) {
return <Skeleton className="h-full w-full" />;
}
if (!url.data?.url) {
return (
<div className="text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-4 rounded-lg border p-6">
<Icons.FileX className="size-12" />
<p className="max-w-sm text-center">{t("pdf.preview.noDocuments")}</p>
</div>
);
}
// Check if still processing (not ready, not failed, not error)
const statusData = status.data && !("error" in status.data) ? status.data : null;
const isProcessing = statusData?.processingStatus !== "ready" &&
statusData?.processingStatus !== "failed";
return (
<div className="relative h-full w-full">
<PdfPreview url={url.data.url} />
{isProcessing && <ProcessingIndicator />}
</div>
);
};
export const PdfLayout = ({
children,
id,
}: {
children: React.ReactNode;
id: string;
}) => {
const [open, setOpen] = useState(true);
const isDesktop = useBreakpoint("lg");
if (isDesktop) {
return (
<PdfViewerProvider>
<ResizablePanelGroup orientation="horizontal" className="h-full w-full">
<ResizablePanel defaultSize={50} minSize={30}>
<div className="relative flex h-full flex-col">
<Header>
<div className="flex items-center gap-1">
<ChatHistory />
<ThemeSwitcher />
<Trigger open={open} onOpenChange={setOpen} />
</div>
</Header>
{children}
</div>
</ResizablePanel>
{open && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50} minSize={25}>
<Documents id={id} />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</PdfViewerProvider>
);
}
return (
<PdfViewerProvider>
<div className="relative flex h-full w-full flex-col overflow-hidden">
<Header>
<div className="flex items-center gap-1">
<ChatHistory />
<ThemeSwitcher />
<Trigger open={open} onOpenChange={setOpen} />
</div>
</Header>
{children}
<MobileDocumentPreview open={open} onOpenChange={setOpen} id={id} />
</div>
</PdfViewerProvider>
);
};

View File

@@ -1,81 +0,0 @@
import { usePdf } from "@anaralabs/lector";
import { useState } from "react";
import { toast } from "sonner";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@turbostarter/ui-web/dropdown-menu";
import { Icons } from "@turbostarter/ui-web/icons";
interface DocumentMenuProps {
readonly documentUrl: string;
}
export const DocumentMenu = ({ documentUrl }: DocumentMenuProps) => {
const { t } = useTranslation("common");
const [isDownloading, setIsDownloading] = useState(false);
const pdfDocumentProxy = usePdf((state) => state.pdfDocumentProxy);
const handleDownload = async () => {
if (isDownloading) return;
try {
setIsDownloading(true);
const pdfData = await pdfDocumentProxy.getData();
const blob = new Blob([pdfData as BlobPart], { type: "application/pdf" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const filename = documentUrl.split("/").pop() ?? "document.pdf";
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
toast.error(t("error.general"));
} finally {
setIsDownloading(false);
}
};
const handleOpenClick = () => {
window.open(documentUrl, "_blank");
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Icons.Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={handleDownload}
disabled={isDownloading || !pdfDocumentProxy}
className="gap-2"
>
<Icons.DownloadCloud className="size-4" />
{isDownloading ? t("downloading") : t("download")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenClick} className="gap-2">
<Icons.Link className="size-4" /> {t("open")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default DocumentMenu;

View File

@@ -1,551 +0,0 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
"use client";
import { memo, useCallback, useEffect, useRef } from "react";
import { usePdfViewer } from "../../context";
import { useEmbedding } from "../../hooks";
import { useCitationUnit } from "../../hooks/use-citation-unit";
import type { BoundingBox } from "../../hooks/use-citation-unit";
// ============================================================================
// Constants
// ============================================================================
/** Duration in ms before auto-clearing the highlight */
const HIGHLIGHT_DURATION_MS = 5000;
/** Minimum word match percentage to consider a span relevant (legacy fallback) */
const MIN_MATCH_PERCENTAGE = 0.25;
/** CSS class for primary highlight (exact citation - violet) */
const HIGHLIGHT_PRIMARY_CLASS = "pdf-citation-primary";
/** CSS class for secondary highlight (context - yellow) */
const HIGHLIGHT_SECONDARY_CLASS = "pdf-citation-secondary";
/** Legacy class name for backward compatibility */
const HIGHLIGHT_CLASS = "pdf-citation-highlight";
/** Data attribute to mark highlighted spans */
const HIGHLIGHT_ATTR = "data-citation-highlight";
// ============================================================================
// Styles
// ============================================================================
/** Injected styles for text span highlighting (two-level: primary + secondary) */
const HIGHLIGHT_STYLES = `
/* Secondary highlight - yellow/amber for context */
.${HIGHLIGHT_SECONDARY_CLASS} {
background-color: rgba(250, 204, 21, 0.25) !important;
border-radius: 2px;
transition: background-color 300ms ease-in-out;
}
/* Primary highlight - violet for exact citation (overrides secondary) */
.${HIGHLIGHT_PRIMARY_CLASS} {
background-color: rgba(139, 92, 246, 0.4) !important;
border-radius: 2px;
box-shadow: 0 0 6px rgba(139, 92, 246, 0.5);
transition: background-color 300ms ease-in-out;
}
/* Legacy highlight class (backward compatibility) */
.${HIGHLIGHT_CLASS} {
background-color: rgba(250, 204, 21, 0.4) !important;
border-radius: 2px;
box-shadow: 0 0 4px rgba(250, 204, 21, 0.6);
transition: background-color 300ms ease-in-out;
}
`;
// ============================================================================
// Utilities - Legacy Word Overlap Matching
// ============================================================================
/**
* Inject highlight styles into document head (once)
*/
function ensureStylesInjected(): void {
if (typeof document === "undefined") return;
if (document.getElementById("pdf-highlight-styles")) return;
const style = document.createElement("style");
style.id = "pdf-highlight-styles";
style.textContent = HIGHLIGHT_STYLES;
document.head.appendChild(style);
}
/**
* Normalize text for comparison - removes extra whitespace, lowercases
*/
function normalizeText(text: string): string {
return text.toLowerCase().replace(/\s+/g, " ").trim();
}
/**
* Get significant words from text (words with 3+ characters)
*/
function getSignificantWords(text: string): Set<string> {
const normalized = normalizeText(text);
const words = normalized.split(/\s+/).filter((w) => w.length >= 3);
return new Set(words);
}
/**
* Calculate word overlap percentage between two texts
*/
function calculateWordOverlap(text1: string, text2: string): number {
const words1 = getSignificantWords(text1);
const words2 = getSignificantWords(text2);
if (words1.size === 0 || words2.size === 0) return 0;
let matchCount = 0;
for (const word of words1) {
if (words2.has(word)) matchCount++;
}
return matchCount / Math.min(words1.size, words2.size);
}
/**
* Find text layer spans that match the embedding content and apply highlights
*/
function applyHighlightsToSpans(
container: Element,
embeddingContent: string,
): number {
// Find the TextLayer - it has class "textLayer" from pdfjs
const textLayers = container.querySelectorAll(".textLayer");
if (textLayers.length === 0) {
console.debug("[HighlightLayer] No TextLayer found");
return 0;
}
let highlightCount = 0;
// Check each text layer
for (const textLayer of textLayers) {
const spans = textLayer.querySelectorAll("span");
// For each span, check if it contains significant words from the embedding
for (const span of spans) {
const spanText = span.textContent ?? "";
if (spanText.trim().length < 3) continue;
const overlap = calculateWordOverlap(spanText, embeddingContent);
if (overlap >= MIN_MATCH_PERCENTAGE) {
span.classList.add(HIGHLIGHT_CLASS);
span.setAttribute(HIGHLIGHT_ATTR, "true");
highlightCount++;
}
}
}
// If no individual spans match, try grouping consecutive spans
if (highlightCount === 0) {
for (const textLayer of textLayers) {
const spans = Array.from(textLayer.querySelectorAll("span"));
const combinedText = spans.map((s) => s.textContent ?? "").join(" ");
// Check if the combined text contains significant content from embedding
const overlap = calculateWordOverlap(combinedText, embeddingContent);
if (overlap >= MIN_MATCH_PERCENTAGE) {
// Find contiguous groups that match
for (let i = 0; i < spans.length; i++) {
let groupText = "";
for (let j = i; j < Math.min(i + 10, spans.length); j++) {
groupText += " " + (spans[j]?.textContent ?? "");
const groupOverlap = calculateWordOverlap(
groupText,
embeddingContent,
);
if (groupOverlap >= MIN_MATCH_PERCENTAGE) {
// Highlight all spans in this group
for (let k = i; k <= j; k++) {
const span = spans[k];
if (span) {
span.classList.add(HIGHLIGHT_CLASS);
span.setAttribute(HIGHLIGHT_ATTR, "true");
highlightCount++;
}
}
break;
}
}
if (highlightCount > 0) break;
}
}
}
}
console.debug(`[HighlightLayer] Highlighted ${highlightCount} spans (legacy)`);
return highlightCount;
}
/**
* Remove all highlights from the document (clears all highlight classes)
*/
function clearAllHighlights(container: Element | null): void {
if (!container) return;
const highlighted = container.querySelectorAll(`[${HIGHLIGHT_ATTR}]`);
for (const el of highlighted) {
el.classList.remove(HIGHLIGHT_PRIMARY_CLASS);
el.classList.remove(HIGHLIGHT_SECONDARY_CLASS);
el.classList.remove(HIGHLIGHT_CLASS);
el.removeAttribute(HIGHLIGHT_ATTR);
}
}
// ============================================================================
// Utilities - Bounding Box to Text Span Highlighting
// ============================================================================
/**
* Parse percentage value from CSS style string (e.g., "left: 31.1%" -> 31.1)
*/
function parsePercentage(style: string, property: string): number | null {
const regex = new RegExp(`${property}:\\s*([\\d.]+)%`);
const match = style.match(regex);
return match?.[1] ? parseFloat(match[1]) : null;
}
/** Margin settings for highlight levels */
interface HighlightMargins {
horizontal: number;
vertical: number;
}
/** Tight margins for primary highlight (exact citation) */
const PRIMARY_MARGINS: HighlightMargins = { horizontal: 1, vertical: 0.5 };
/** Wider margins for secondary highlight (context) */
const SECONDARY_MARGINS: HighlightMargins = { horizontal: 5, vertical: 3 };
/**
* Check if a span's position overlaps with the bbox region
* Both bbox (0-1 normalized) and span positions (0-100 percentage) need alignment
*/
function spanOverlapsBbox(
span: HTMLElement,
bbox: BoundingBox,
margins: HighlightMargins = SECONDARY_MARGINS,
): boolean {
const style = span.getAttribute("style") ?? "";
// Parse span position from inline style (percentage-based)
const spanLeft = parsePercentage(style, "left");
const spanTop = parsePercentage(style, "top");
if (spanLeft === null || spanTop === null) {
return false;
}
// Convert bbox normalized coords (0-1) to percentage (0-100) for comparison
const bboxLeft = bbox.x * 100;
const bboxTop = bbox.y * 100;
const bboxRight = (bbox.x + bbox.width) * 100;
const bboxBottom = (bbox.y + bbox.height) * 100;
const spanInHorizontalRange = spanLeft >= (bboxLeft - margins.horizontal) &&
spanLeft <= (bboxRight + margins.horizontal);
const spanInVerticalRange = spanTop >= (bboxTop - margins.vertical) &&
spanTop <= (bboxBottom + margins.vertical);
return spanInHorizontalRange && spanInVerticalRange;
}
/** Highlight level for two-tier highlighting */
type HighlightLevel = "primary" | "secondary";
/**
* Find and highlight text layer spans that fall within a bounding box
* Supports two-level highlighting: primary (exact citation) and secondary (context)
*/
function applyBboxHighlightsToSpans(
container: Element,
bbox: BoundingBox,
pageNumber: number,
level: HighlightLevel = "primary",
): number {
// Find the specific page's text layer
const pageElement = container.querySelector(`[data-page-number="${pageNumber}"]`);
const textLayer = pageElement?.querySelector(".textLayer") ??
container.querySelector(".textLayer");
if (!textLayer) {
console.debug("[HighlightLayer] No TextLayer found for bbox highlight");
return 0;
}
const spans = textLayer.querySelectorAll("span");
let highlightCount = 0;
// Select margins and class based on level
const margins = level === "primary" ? PRIMARY_MARGINS : SECONDARY_MARGINS;
const highlightClass = level === "primary" ? HIGHLIGHT_PRIMARY_CLASS : HIGHLIGHT_SECONDARY_CLASS;
for (const span of spans) {
const spanText = span.textContent ?? "";
if (spanText.trim().length < 1) continue;
if (spanOverlapsBbox(span as HTMLElement, bbox, margins)) {
span.classList.add(highlightClass);
span.setAttribute(HIGHLIGHT_ATTR, level);
highlightCount++;
}
}
console.debug(`[HighlightLayer] Highlighted ${highlightCount} spans via bbox (${level})`);
return highlightCount;
}
// ============================================================================
// Main Component
// ============================================================================
/**
* HighlightLayer - Applies CSS highlights to PDF TextLayer spans based on citations
*
* All highlighting is done by applying CSS classes directly to the PDF.js TextLayer
* span elements, ensuring highlights scroll naturally with the document.
*
* Supports two highlight detection modes:
* 1. Bounding Box (WF-0028): Uses bbox coordinates to find spans within the region
* 2. Word Overlap (legacy): Falls back to text matching when bbox unavailable
*
* When `activeHighlight` is set in the PdfViewerContext, this component:
* 1. Fetches the citation unit data (or legacy embedding)
* 2. If bbox available: Finds TextLayer spans within the bounding box region
* 3. If no bbox: Searches TextLayer for spans with matching text content
* 4. Applies CSS classes to matching spans for highlighting
*
* The highlight auto-clears after 5 seconds.
*/
export const HighlightLayer = memo(function HighlightLayer() {
const { activeHighlight, clearHighlight } = usePdfViewer();
// Try citation unit first (WF-0028)
const { data: citationUnit, isLoading: citationLoading } = useCitationUnit(activeHighlight);
// Fall back to legacy embedding if citation unit not found
const shouldFetchEmbedding = Boolean(activeHighlight) && !citationLoading && !citationUnit;
const { data: embedding } = useEmbedding(shouldFetchEmbedding ? activeHighlight : null);
const containerRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isApplyingHighlightsRef = useRef(false);
const lastHighlightIdRef = useRef<string | null>(null);
// Determine which mode to use
const hasBbox = citationUnit?.bbox != null;
const fallbackContent = citationUnit?.content ?? embedding?.content;
// Debug logging for highlight mode selection
useEffect(() => {
if (activeHighlight) {
console.debug("[HighlightLayer] Active highlight:", activeHighlight, {
citationLoading,
hasCitationUnit: Boolean(citationUnit),
hasBbox,
hasEmbedding: Boolean(embedding),
fallbackContentPreview: fallbackContent?.slice(0, 50),
});
}
}, [activeHighlight, citationLoading, citationUnit, hasBbox, embedding, fallbackContent]);
// Ensure styles are injected (used by both bbox and legacy modes)
useEffect(() => {
ensureStylesInjected();
}, []);
// Apply bbox-based highlights (CSS on text spans within bounding box)
// Two-level highlighting: secondary (yellow, context) then primary (violet, exact citation)
// Note: Clearing is handled by the main effect, not here
const applyBboxHighlights = useCallback(() => {
const container = containerRef.current?.parentElement;
if (!container || !citationUnit?.bbox) {
return;
}
// First: Apply secondary highlights (yellow) with wider margins for context
const secondaryCount = applyBboxHighlightsToSpans(
container,
citationUnit.bbox,
citationUnit.pageNumber,
"secondary",
);
// Second: Apply primary highlights (violet) with tight margins for exact citation
// This overlays secondary highlights on matching spans (CSS priority handles override)
const primaryCount = applyBboxHighlightsToSpans(
container,
citationUnit.bbox,
citationUnit.pageNumber,
"primary",
);
console.debug(
`[HighlightLayer] Two-level highlights: ${primaryCount} primary (violet), ${secondaryCount} secondary (yellow)`,
);
}, [citationUnit]);
// Apply legacy highlights to matching text (word overlap)
// Note: Clearing is handled by the main effect, not here
const applyLegacyHighlights = useCallback(() => {
const container = containerRef.current?.parentElement;
if (!container || !fallbackContent) {
console.debug("[HighlightLayer] Legacy mode: no container or content", {
hasContainer: Boolean(container),
contentPreview: fallbackContent?.slice(0, 100),
});
return;
}
console.debug("[HighlightLayer] Applying legacy highlights with content:", fallbackContent.slice(0, 100) + "...");
// Apply new highlights using word overlap matching
const count = applyHighlightsToSpans(container, fallbackContent);
if (count === 0) {
console.warn("[HighlightLayer] No spans matched for legacy highlight. TextLayers found:", container.querySelectorAll(".textLayer").length);
}
}, [fallbackContent]);
// Apply highlights when data changes (unified for both modes)
useEffect(() => {
const container = containerRef.current?.parentElement;
if (!activeHighlight) {
// Only clear if we previously had a highlight
if (lastHighlightIdRef.current !== null) {
clearAllHighlights(container ?? null);
lastHighlightIdRef.current = null;
}
return;
}
// Choose highlight method based on available data
const applyHighlights = hasBbox ? applyBboxHighlights : applyLegacyHighlights;
// Wait for condition to be ready
if (hasBbox && !citationUnit?.bbox) return;
if (!hasBbox && !fallbackContent) return;
if (!container) return;
// Check if this is a NEW highlight (different ID) - only then clear old highlights
const isNewHighlight = lastHighlightIdRef.current !== activeHighlight;
if (isNewHighlight) {
clearAllHighlights(container);
lastHighlightIdRef.current = activeHighlight;
}
// Use ref-based flag to prevent MutationObserver re-triggering across effect runs
const safeApplyHighlights = () => {
if (isApplyingHighlightsRef.current) return;
isApplyingHighlightsRef.current = true;
try {
// Don't clear again - just apply (clearing already done above for new highlights)
applyHighlights();
} finally {
// Reset flag after a brief delay to allow DOM to settle
setTimeout(() => {
isApplyingHighlightsRef.current = false;
}, 100);
}
};
// Give the TextLayer time to render after page navigation
const initialTimeout = setTimeout(safeApplyHighlights, 100);
// Observe DOM changes in case TextLayer loads later (e.g., lazy loading pages)
// Only re-apply when NEW TextLayer content is added, not when we modify highlight classes
let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
const observer = new MutationObserver((mutations) => {
// Check if any mutation added new TextLayer spans (not just class changes)
const hasNewTextContent = mutations.some((mutation) => {
// Only care about added nodes
if (mutation.type !== "childList" || mutation.addedNodes.length === 0) {
return false;
}
// Check if added nodes contain TextLayer or span elements
return Array.from(mutation.addedNodes).some((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const el = node as Element;
return el.classList.contains("textLayer") ||
el.querySelector(".textLayer") !== null ||
(el.tagName === "SPAN" && el.closest(".textLayer"));
});
});
if (!hasNewTextContent) return;
// Debounce to handle multiple rapid mutations
if (debounceTimeout) clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(safeApplyHighlights, 150);
});
observer.observe(container, {
childList: true,
subtree: true,
});
return () => {
clearTimeout(initialTimeout);
if (debounceTimeout) clearTimeout(debounceTimeout);
observer.disconnect();
};
}, [activeHighlight, hasBbox, citationUnit, fallbackContent, applyBboxHighlights, applyLegacyHighlights]);
// Auto-clear highlight after duration
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (activeHighlight) {
timeoutRef.current = setTimeout(() => {
clearAllHighlights(containerRef.current?.parentElement ?? null);
clearHighlight();
timeoutRef.current = null;
}, HIGHLIGHT_DURATION_MS);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [activeHighlight, clearHighlight]);
// Cleanup highlights on unmount
useEffect(() => {
const container = containerRef.current;
return () => {
clearAllHighlights(container?.parentElement ?? null);
};
}, []);
// This component no longer renders any visible elements
// All highlighting is done via CSS classes on TextLayer spans
return (
<div
ref={containerRef}
data-highlight-layer
style={{ display: "none" }}
aria-hidden="true"
/>
);
});
HighlightLayer.displayName = "HighlightLayer";

View File

@@ -1,93 +0,0 @@
"use client";
import {
CanvasLayer,
Page,
Pages,
Root,
TextLayer,
usePdf,
usePdfJump,
} from "@anaralabs/lector";
import { GlobalWorkerOptions } from "pdfjs-dist";
import React, { memo, useEffect } from "react";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import { usePdfViewer } from "../../context";
import { DocumentMenu } from "./document-menu";
import { HighlightLayer } from "./highlight-layer";
import { PageNavigation } from "./page-navigation";
import "./pdf-viewer.css";
import { TextHighlightLayer } from "./text-highlight-layer";
import { ZoomMenu } from "./zoom-menu";
// Import extracted PDF text layer styles (avoids problematic pdfjs-dist CSS with relative image imports)
GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.mjs",
import.meta.url,
).toString();
interface PdfPreviewProps {
readonly url: string;
}
/**
* Syncs lector's current page with our PdfViewerContext.
* Also handles navigation requests from citations.
* Must be rendered inside lector's Root provider.
*/
const PageSync = () => {
const lectorPage = usePdf((state) => state.currentPage);
const { setCurrentPage, pendingNavigation, clearPendingNavigation } =
usePdfViewer();
const { jumpToPage } = usePdfJump();
// Sync lector page changes to our context (user scrolling)
useEffect(() => {
setCurrentPage(lectorPage);
}, [lectorPage, setCurrentPage]);
// Handle navigation requests from citations
useEffect(() => {
if (pendingNavigation) {
const behavior = pendingNavigation.animate ? "smooth" : "auto";
jumpToPage(pendingNavigation.page, { behavior });
clearPendingNavigation();
}
}, [pendingNavigation, jumpToPage, clearPendingNavigation]);
return null;
};
export const PdfPreview = memo<PdfPreviewProps>(({ url }) => {
return (
<Root
className="flex h-full w-full flex-col overflow-hidden"
source={url}
isZoomFitWidth={true}
loader={<Skeleton className="h-full w-full" />}
>
<PageSync />
<div className="relative flex justify-between border-b p-1">
<ZoomMenu />
<PageNavigation />
<DocumentMenu documentUrl={url} />
</div>
<div className="relative flex-1 overflow-hidden">
<HighlightLayer />
<TextHighlightLayer />
<Pages className="dark:brightness-80 dark:contrast-228 dark:hue-rotate-180 dark:invert-94">
<Page>
<CanvasLayer />
<TextLayer />
</Page>
</Pages>
</div>
</Root>
);
});
PdfPreview.displayName = "PdfPreview";

View File

@@ -1,80 +0,0 @@
import { usePdf, usePdfJump } from "@anaralabs/lector";
import { useEffect, useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
export const PageNavigation = () => {
const { t } = useTranslation("ai");
const pages = usePdf((state) => state.pdfDocumentProxy.numPages);
const currentPage = usePdf((state) => state.currentPage);
const [pageNumber, setPageNumber] = useState<string | number>(currentPage);
const { jumpToPage } = usePdfJump();
const handlePreviousPage = () => {
if (currentPage > 1) {
jumpToPage(currentPage - 1, { behavior: "auto" });
}
};
const handleNextPage = () => {
if (currentPage < pages) {
jumpToPage(currentPage + 1, { behavior: "auto" });
}
};
useEffect(() => {
setPageNumber(currentPage);
}, [currentPage]);
return (
<div className="absolute top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2 transform flex-row items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={handlePreviousPage}
disabled={currentPage <= 1}
aria-label={t("pdf.preview.navigation.previous")}
className="h-8 w-8"
>
<Icons.ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<input
type="number"
value={pageNumber}
onChange={(e) => setPageNumber(e.target.value)}
onBlur={(e) => {
if (currentPage !== Number(e.target.value)) {
jumpToPage(Number(e.target.value), {
behavior: "auto",
});
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
className="bg-accent focus:ring-primary/20 w-10 [appearance:textfield] rounded-md border-none text-center text-sm font-medium focus:ring-2 focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<span className="text-muted-foreground text-sm font-medium">
/ {pages}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleNextPage}
disabled={currentPage >= pages}
aria-label={t("pdf.preview.navigation.next")}
className="h-8 w-8"
>
<Icons.ChevronRight className="h-4 w-4" />
</Button>
</div>
);
};

View File

@@ -1,73 +0,0 @@
/* Essential styles for PDF text layer - extracted from pdfjs-dist/web/pdf_viewer.css */
/* This avoids importing the full pdfjs CSS which has problematic relative image imports */
.textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
-webkit-text-size-adjust: none;
-moz-text-size-adjust: none;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 0;
}
.textLayer.highlighting {
touch-action: none;
}
.textLayer :is(span, br) {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
.textLayer {
--min-font-size: 1;
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
--min-font-size-inv: calc(1 / var(--min-font-size));
}
.textLayer > :not(.markedContent),
.textLayer .markedContent span:not(.markedContent) {
z-index: 1;
--font-height: 0;
font-size: calc(var(--text-scale-factor) * var(--font-height));
--scale-x: 1;
--rotate: 0deg;
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
}
.textLayer .markedContent {
display: contents;
}
.textLayer span[role="img"] {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
cursor: default;
}
.textLayer ::-moz-selection {
background: rgba(0, 0, 255, 0.25);
}
.textLayer ::selection {
background: rgba(0, 0, 255, 0.25);
}
.textLayer br::-moz-selection {
background: transparent;
}
.textLayer br::selection {
background: transparent;
}

View File

@@ -1,207 +0,0 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
"use client";
import { memo, useEffect, useState } from "react";
import { usePdfViewer } from "../../context";
// ============================================================================
// Constants
// ============================================================================
/** Highlight color - yellow for text highlights */
const HIGHLIGHT_COLOR = "rgba(250, 204, 21, 0.4)";
/** Duration in ms before auto-clearing highlights */
const HIGHLIGHT_DURATION_MS = 8000;
// ============================================================================
// Styles
// ============================================================================
/** Injected styles for text span highlighting */
const TEXT_HIGHLIGHT_STYLES = `
.pdf-text-highlight {
background-color: ${HIGHLIGHT_COLOR} !important;
border-radius: 2px;
pointer-events: none;
animation: pdf-highlight-fade-in 300ms ease-in-out;
}
@keyframes pdf-highlight-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
`;
// ============================================================================
// Utilities
// ============================================================================
/**
* Inject highlight styles into document head (once)
*/
function ensureStylesInjected(): void {
if (typeof document === "undefined") return;
if (document.getElementById("pdf-text-highlight-styles")) return;
const style = document.createElement("style");
style.id = "pdf-text-highlight-styles";
style.textContent = TEXT_HIGHLIGHT_STYLES;
document.head.appendChild(style);
}
/**
* Find text in PDF text layer and apply CSS highlights.
* Searches the text layer for exact text matches and applies highlight classes.
*
* @param container - Parent element containing the PDF viewer
* @param text - Text phrase to search for and highlight
* @param pageNumber - Page number where the text should be found
* @returns Number of spans highlighted
*/
function applyTextHighlights(
container: Element,
text: string,
pageNumber: number,
): number {
// Find the text layer for this page
const pageEl = container.querySelector(`[data-page-number="${pageNumber}"]`);
const textLayer =
pageEl?.querySelector(".textLayer") ?? container.querySelector(".textLayer");
if (!textLayer) {
console.debug("[TextHighlightLayer] No TextLayer found for page", pageNumber);
return 0;
}
const spans = textLayer.querySelectorAll("span");
const normalizedSearch = text.toLowerCase().trim();
let highlightCount = 0;
// Build concatenated text from spans to find matches
let fullText = "";
const spanRanges: { start: number; end: number; span: Element }[] = [];
for (const span of spans) {
const spanText = span.textContent ?? "";
const start = fullText.length;
fullText += spanText;
spanRanges.push({ start, end: fullText.length, span });
}
// Find the search text in the full text
const normalizedFull = fullText.toLowerCase();
const foundIndex = normalizedFull.indexOf(normalizedSearch);
if (foundIndex === -1) {
console.debug(
`[TextHighlightLayer] Text not found: "${text.slice(0, 50)}..."`,
);
return 0;
}
const matchEnd = foundIndex + normalizedSearch.length;
// Highlight spans that overlap with the match
for (const { start, end, span } of spanRanges) {
if (end > foundIndex && start < matchEnd) {
span.classList.add("pdf-text-highlight");
span.setAttribute("data-text-highlight", "true");
highlightCount++;
}
}
return highlightCount;
}
/**
* Clear all text highlights from the container
*/
function clearTextHighlights(container: Element | null): void {
if (!container) return;
const highlighted = container.querySelectorAll("[data-text-highlight]");
for (const el of highlighted) {
el.classList.remove("pdf-text-highlight");
el.removeAttribute("data-text-highlight");
}
}
// ============================================================================
// Main Component
// ============================================================================
/**
* TextHighlightLayer - Applies CSS highlights to PDF TextLayer spans based on exact text matches.
*
* This component finds and highlights specific text phrases in the PDF viewer.
* It uses the pdf.js text layer spans and applies CSS classes for highlighting.
*
* When `textHighlights` are set in the PdfViewerContext, this component:
* 1. Finds the text layer spans that contain the search text
* 2. Applies CSS highlight classes to matching spans
* 3. Auto-clears highlights after HIGHLIGHT_DURATION_MS
*
* The component renders a hidden div to get a ref to the parent container.
*/
export const TextHighlightLayer = memo(function TextHighlightLayer() {
const { textHighlights, clearTextHighlights: clearFromContext } = usePdfViewer();
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
// Inject styles once on mount
useEffect(() => {
ensureStylesInjected();
}, []);
// Apply highlights when they change
useEffect(() => {
const container = containerRef?.parentElement;
if (!container || textHighlights.length === 0) {
if (container) clearTextHighlights(container);
return;
}
// Clear previous highlights
clearTextHighlights(container);
// Apply each highlight
for (const highlight of textHighlights) {
const count = applyTextHighlights(container, highlight.text, highlight.page);
console.debug(
`[TextHighlightLayer] Applied ${count} highlights for "${highlight.text.slice(0, 30)}..."`,
);
}
// Auto-clear after duration
const timeout = setTimeout(() => {
clearTextHighlights(container);
clearFromContext();
}, HIGHLIGHT_DURATION_MS);
return () => clearTimeout(timeout);
}, [textHighlights, containerRef, clearFromContext]);
// Cleanup on unmount
useEffect(() => {
return () => {
const container = containerRef?.parentElement;
if (container) {
clearTextHighlights(container);
}
};
}, [containerRef]);
// Hidden container for ref - renders nothing visible
return (
<div
ref={setContainerRef}
data-text-highlight-layer
style={{ display: "none" }}
aria-hidden="true"
/>
);
});
TextHighlightLayer.displayName = "TextHighlightLayer";

View File

@@ -1,74 +0,0 @@
import { usePdf } from "@anaralabs/lector";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@turbostarter/ui-web/dropdown-menu";
import { Icons } from "@turbostarter/ui-web/icons";
export const ZoomMenu = () => {
const { t } = useTranslation("ai");
const zoom = usePdf((state) => state.zoom);
const setCustomZoom = usePdf((state) => state.updateZoom);
const fitToWidth = usePdf((state) => state.zoomFitWidth);
const handleZoomDecrease = () => setCustomZoom((zoom) => zoom * 0.9);
const handleZoomIncrease = () => setCustomZoom((zoom) => zoom * 1.1);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-1"
aria-label={t("pdf.preview.zoom.options")}
>
{Math.round(zoom * 100)}%
<Icons.ChevronUp className="text-muted-foreground h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuItem className="flex justify-between">
<span>{`${Math.round(zoom * 100)}%`}</span>
</DropdownMenuItem>
<Button
variant="ghost"
onClick={handleZoomDecrease}
size="icon"
aria-label={t("pdf.preview.zoom.out")}
>
<Icons.MinusIcon className="size-4" />
</Button>
<Button
variant="ghost"
onClick={handleZoomIncrease}
size="icon"
aria-label={t("pdf.preview.zoom.in")}
>
<Icons.PlusIcon className="size-4" />
</Button>
<DropdownMenuItem onSelect={() => fitToWidth()}>
{t("pdf.preview.zoom.fit")}
</DropdownMenuItem>
{[0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4].map((zoomLevel) => (
<DropdownMenuItem
key={zoomLevel}
onSelect={() => setCustomZoom(zoomLevel)}
>
{`${zoomLevel * 100}%`}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -1,70 +0,0 @@
import * as z from "zod";
import { chatSchema } from "@turbostarter/ai/pdf/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
import type { InferRequestType } from "hono/client";
const KEY = "pdf";
const queries = {
chats: {
user: {
getAll: (userId: string) => ({
queryKey: [KEY, "chats", userId],
queryFn: handle(api.ai.pdf.chats.$get, {
schema: z.array(chatSchema),
}),
}),
},
documents: {
getAll: (id: string) => ({
queryKey: [KEY, "chats", id, "documents"],
queryFn: () =>
handle(api.ai.pdf.chats[":id"].documents.$get)({
param: { id },
}),
}),
getUrl: (path: string) => ({
queryKey: [KEY, "documents", "url", path],
queryFn: () =>
handle(api.storage.signed.$get)({
query: { path },
}),
}),
getStatus: (documentId: string) => ({
queryKey: [KEY, "documents", documentId, "status"],
queryFn: async () => {
const response = await api.ai.pdf.documents[":id"].status.$get({
param: { id: documentId },
});
return response.json();
},
}),
},
},
};
const mutations = {
chats: {
create: {
mutationKey: [KEY, "chats", "create"],
mutationFn: (data: InferRequestType<typeof api.ai.pdf.chats.$post>) =>
handle(api.ai.pdf.chats.$post)(data),
},
delete: {
mutationKey: [KEY, "chats", "delete"],
mutationFn: ({ id }: { id: string }) =>
handle(api.ai.pdf.chats[":id"].$delete)({
param: { id },
}),
},
},
};
export const pdf = {
queries,
mutations,
} as const;

View File

@@ -1,90 +0,0 @@
import { memo, useEffect, useRef } from "react";
import { getMessageTextContent } from "@turbostarter/ai";
import { cn } from "@turbostarter/ui";
import { ThreadMessageLikes } from "~/modules/common/ai/thread/controls/likes";
import { ThreadMessage } from "~/modules/common/ai/thread/message";
import { Prose } from "~/modules/common/prose";
import { usePdfViewer } from "../context";
import { CitationMarkdown } from "./citation-markdown";
import { CopyWithCitations } from "./copy-with-citations";
import type { PdfMessage, PreciseCitation } from "@turbostarter/ai/pdf/types";
import type { ThreadMessageProps } from "~/modules/common/ai/thread/message";
/**
* Extract PreciseCitation results from highlightText tool calls in message parts.
* Tool parts are typed as "tool-{toolName}" in Vercel AI SDK.
*/
function extractHighlightCitations(message: PdfMessage): PreciseCitation[] {
const citations: PreciseCitation[] = [];
for (const part of message.parts) {
if (part.type === "tool-highlightText") {
// Tool invocations have different states - only extract when result is available
const toolPart = part as unknown as { state: string; output?: PreciseCitation };
if (toolPart.state === "result" && toolPart.output) {
citations.push(toolPart.output);
}
}
}
return citations;
}
/**
* Assistant message component with citation support.
* Renders AI responses with clickable [[cite:id:page]] markers as interactive citations.
* Also triggers text highlights in the PDF viewer when highlightText tool is invoked.
*/
export const AssistantMessage = memo<ThreadMessageProps<PdfMessage>>(
({ message, ref, status }) => {
const { addTextHighlight } = usePdfViewer();
const processedCitationsRef = useRef<Set<string>>(new Set());
// Process highlightText tool invocations and trigger highlights
useEffect(() => {
const citations = extractHighlightCitations(message);
for (const citation of citations) {
// Only process each citation once (avoid duplicate highlights on re-renders)
if (!processedCitationsRef.current.has(citation.citationId)) {
processedCitationsRef.current.add(citation.citationId);
addTextHighlight(citation);
}
}
}, [message, addTextHighlight]);
const hasTextContent = message.parts.some(
(part) => part.type === "text" && part.text.length > 0
);
return (
<ThreadMessage.Layout className="items-start" ref={ref}>
<Prose className="w-full max-w-none">
<CitationMarkdown
id={message.id}
content={getMessageTextContent(message)}
/>
</Prose>
{!["submitted", "streaming"].includes(status) && (
<div
className={cn(
"bg-background start-0 -ml-4 flex w-max items-center gap-px rounded-lg px-2 pb-2 text-xs",
"opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 md:start-3"
)}
>
{hasTextContent && <CopyWithCitations message={message} />}
<ThreadMessageLikes />
</div>
)}
</ThreadMessage.Layout>
);
},
);
AssistantMessage.displayName = "AssistantMessage";

View File

@@ -1,247 +0,0 @@
"use client";
import "katex/dist/katex.min.css";
import { marked } from "marked";
import { memo, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import { rehypeInlineCodeProperty } from "react-shiki";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { CodeHighlight } from "~/modules/common/markdown/code";
import { preprocessMarkdown } from "~/modules/common/markdown/utils";
import { Citation } from "../components/citation";
import { CitationPreview } from "../components/citation-preview";
import type { Citation as CitationType } from "@turbostarter/ai/pdf/types";
// ============================================================================
// Types
// ============================================================================
interface CitationMarkdownProps {
content: string;
id: string;
}
interface ParsedContent {
/** Content with citation markers replaced with placeholders */
text: string;
/** Parsed citations */
citations: CitationType[];
}
// ============================================================================
// Citation Parser
// ============================================================================
const CITATION_REGEX = /\[\[cite:([a-zA-Z0-9]+):(\d+)\]\]/g;
/**
* Parse citation markers from content and extract citation data
* Returns content with markers replaced by `[n]` placeholders and citation array
*/
function parseCitationsFromContent(content: string): ParsedContent {
const citations: CitationType[] = [];
const seenIds = new Map<string, number>();
const parsedText = content.replace(
CITATION_REGEX,
(_match, embeddingId: string, pageNumStr: string) => {
const pageNumber = parseInt(pageNumStr, 10);
// Check if we've seen this embeddingId before
if (seenIds.has(embeddingId)) {
const existingIndex = seenIds.get(embeddingId)!;
return `[[CITE_PLACEHOLDER:${existingIndex}]]`;
}
// New citation
const index = citations.length + 1;
seenIds.set(embeddingId, index);
citations.push({
index,
embeddingId,
pageNumber,
relevance: 0.9, // Default - actual relevance from embedding search
excerpt: "", // Will be populated if we have the embedding data
});
return `[[CITE_PLACEHOLDER:${index}]]`;
}
);
return { text: parsedText, citations };
}
/**
* Split content into text segments and citation placeholders
*/
function splitContentWithCitations(
content: string,
citations: CitationType[]
): { type: "text" | "citation"; value: string | CitationType }[] {
const PLACEHOLDER_REGEX = /\[\[CITE_PLACEHOLDER:(\d+)\]\]/g;
const segments: { type: "text" | "citation"; value: string | CitationType }[] = [];
let lastIndex = 0;
let match;
while ((match = PLACEHOLDER_REGEX.exec(content)) !== null) {
// Add text before the placeholder
if (match.index > lastIndex) {
segments.push({
type: "text",
value: content.slice(lastIndex, match.index),
});
}
// Add citation
const citationIndex = parseInt(match[1] ?? "0", 10);
const citation = citations.find((c) => c.index === citationIndex);
if (citation) {
segments.push({ type: "citation", value: citation });
}
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < content.length) {
segments.push({ type: "text", value: content.slice(lastIndex) });
}
return segments;
}
// ============================================================================
// Markdown Block Component
// ============================================================================
const MarkdownSegment = memo(({ content }: { content: string }) => {
const processedContent = preprocessMarkdown(content);
return (
<ReactMarkdown
rehypePlugins={[rehypeRaw, rehypeKatex, rehypeInlineCodeProperty]}
remarkPlugins={[remarkGfm, remarkMath]}
components={{
code: CodeHighlight,
}}
>
{processedContent}
</ReactMarkdown>
);
});
MarkdownSegment.displayName = "MarkdownSegment";
// ============================================================================
// Citation Markdown Component
// ============================================================================
/**
* Markdown renderer with inline citation support.
* Parses [[cite:embeddingId:pageNum]] markers and renders them as clickable citations.
*
* @example
* ```tsx
* <CitationMarkdown
* content="The contract states X [[cite:abc123:5]] and Y [[cite:def456:8]]."
* id="msg-1"
* />
* // Renders markdown with [1] and [2] as clickable citation buttons
* ```
*/
export const CitationMarkdown = memo<CitationMarkdownProps>(
({ content, id }) => {
// Parse citations from raw content
const { text: parsedText, citations } = useMemo(
() => parseCitationsFromContent(content),
[content]
);
// Parse markdown into blocks - use parsedText if we have citations, raw content otherwise
const blocks = useMemo(() => {
const textToParse = citations.length === 0 ? content : parsedText;
const tokens = marked.lexer(textToParse);
return tokens.map((token) => token.raw);
}, [content, parsedText, citations.length]);
// If no citations, render as regular markdown
if (citations.length === 0) {
return blocks.map((block, index) => (
<MarkdownSegment content={block} key={`${id}-block_${index}`} />
));
}
// Render each block, handling citation placeholders
return blocks.map((block, blockIndex) => {
// Check if this block contains citation placeholders
if (!block.includes("[[CITE_PLACEHOLDER:")) {
// For tiny punctuation-only blocks, render as plain inline text
// to avoid orphan dots/punctuation wrapped in <p> tags
const trimmedBlock = block.trim();
if (trimmedBlock.length <= 3 && /^[.,;:!?)\]}>…]+$/.test(trimmedBlock)) {
return <span key={`${id}-block_${blockIndex}`}>{trimmedBlock}</span>;
}
return (
<MarkdownSegment content={block} key={`${id}-block_${blockIndex}`} />
);
}
// Split block into segments with citations
const segments = splitContentWithCitations(block, citations);
return (
<span key={`${id}-block_${blockIndex}`} className="inline">
{segments.map((segment, segIndex) => {
if (segment.type === "text") {
let textContent = segment.value as string;
// Strip leading dot if this segment follows a citation
// (dots after citations look bad visually)
const prevSegment = segments[segIndex - 1];
if (prevSegment?.type === "citation") {
textContent = textContent.replace(/^\./, "");
}
const trimmed = textContent.trim();
// Skip empty segments after stripping
if (!trimmed) {
return null;
}
// For text segments, render inline markdown
return (
<MarkdownSegment
key={`${id}-seg_${blockIndex}_${segIndex}`}
content={textContent}
/>
);
}
// Citation segment - render interactive citation
const citation = segment.value as CitationType;
return (
<CitationPreview
key={`${id}-cite_${blockIndex}_${segIndex}`}
citation={citation}
>
<Citation citation={citation} />
</CitationPreview>
);
})}
</span>
);
});
}
);
CitationMarkdown.displayName = "CitationMarkdown";
export default CitationMarkdown;

View File

@@ -1,129 +0,0 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { getMessageTextContent } from "@turbostarter/ai";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { useCopy } from "~/modules/common/hooks/use-copy";
import type { PdfMessage } from "@turbostarter/ai/pdf/types";
// ============================================================================
// Types
// ============================================================================
interface CopyWithCitationsProps {
message: PdfMessage;
}
// ============================================================================
// Helpers
// ============================================================================
const CITATION_REGEX = /\[\[cite:([a-zA-Z0-9]+):(\d+)\]\]/g;
/**
* Format message content with readable citations.
* Replaces [[cite:embeddingId:pageNum]] with [p.X] format.
*/
function formatContentWithCitations(content: string): string {
const seenPages = new Map<string, number>();
let citationIndex = 0;
return content.replace(
CITATION_REGEX,
(_match, _embeddingId: string, pageNumStr: string) => {
const pageNumber = pageNumStr;
// Track unique citations
if (!seenPages.has(pageNumber)) {
citationIndex++;
seenPages.set(pageNumber, citationIndex);
}
return `[p.${pageNumber}]`;
}
);
}
// ============================================================================
// Animation
// ============================================================================
const transition = {
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.8 },
transition: { duration: 0.1, ease: "easeInOut" as const },
};
// ============================================================================
// Component
// ============================================================================
/**
* Copy button that formats citations as readable page references.
* Transforms [[cite:xxx:5]] into [p.5] for clean clipboard content.
*/
export function CopyWithCitations({ message }: CopyWithCitationsProps) {
const { t } = useTranslation("common");
const { copied, copy } = useCopy();
const handleCopy = () => {
const rawContent = getMessageTextContent(message);
const formattedContent = formatContentWithCitations(rawContent);
void copy(formattedContent);
};
return (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group/button size-8 rounded-full"
onClick={handleCopy}
>
<div className="relative size-3.5">
<AnimatePresence mode="wait" initial={false}>
{copied ? (
<motion.div
key="check"
{...transition}
className="absolute inset-0"
>
<Icons.Check className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
</motion.div>
) : (
<motion.div
key="copy"
{...transition}
className="absolute inset-0"
>
<Icons.Copy className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
</motion.div>
)}
</AnimatePresence>
</div>
<span className="sr-only">{t("copy")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("copy")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export default CopyWithCitations;

View File

@@ -1,69 +0,0 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
// ============================================================================
// Types
// ============================================================================
interface FollowUpSuggestionsProps {
onSelect: (question: string) => void;
disabled?: boolean;
}
// ============================================================================
// Follow-up Suggestions
// ============================================================================
/**
* Contextual follow-up questions shown after AI responses.
* Helps users continue the conversation with relevant questions.
*/
export function FollowUpSuggestions({
onSelect,
disabled,
}: FollowUpSuggestionsProps) {
const { t } = useTranslation("ai");
const suggestions = [
{
icon: Icons.Search,
text: t("pdf.followUp.moreDetail"),
},
{
icon: Icons.MessageCircle,
text: t("pdf.followUp.clarify"),
},
{
icon: Icons.ArrowRight,
text: t("pdf.followUp.continue"),
},
];
return (
<div className="flex flex-wrap gap-2 pt-2 pb-4">
{suggestions.map((s, i) => (
<Button
key={i}
variant="outline"
size="sm"
className={cn(
"h-auto gap-2 px-3 py-1.5 text-xs",
"hover:bg-accent hover:text-accent-foreground",
"border-dashed transition-colors"
)}
onClick={() => onSelect(s.text)}
disabled={disabled}
>
<s.icon className="text-muted-foreground size-3 shrink-0" />
<span>{s.text}</span>
</Button>
))}
</div>
);
}
export default FollowUpSuggestions;

View File

@@ -1,112 +0,0 @@
"use client";
import { useCallback, useMemo } from "react";
import { Role } from "@turbostarter/ai/pdf/types";
import { useTranslation } from "@turbostarter/i18n";
import { Icons } from "@turbostarter/ui-web/icons";
import { Thread } from "~/modules/common/ai/thread";
import { TextSelectionAction } from "../components/text-selection-action";
import { useComposer } from "../composer/use-composer";
import { AssistantMessage } from "./assistant";
import { FollowUpSuggestions } from "./follow-up-suggestions";
import { SuggestedQuestions } from "./suggested-questions";
import { UserMessage } from "./user";
import type { PdfMessage } from "@turbostarter/ai/pdf/types";
interface ChatProps {
readonly id?: string;
readonly initialMessages?: PdfMessage[];
}
const components = {
[Role.USER]: UserMessage,
[Role.ASSISTANT]: AssistantMessage,
};
export const Chat = ({ id, initialMessages }: ChatProps = {}) => {
const { t } = useTranslation("ai");
const { messages, regenerate, error, status, sendMessage } = useComposer({
id,
initialMessages,
});
const handleSuggestedQuestion = useCallback(
(question: string) => {
void sendMessage({ text: question });
},
[sendMessage]
);
const handleAskAboutSelection = useCallback(
(selectedText: string) => {
// Format the question with the selected text
const question = `Regarding this text from the document: "${selectedText}"\n\nCan you explain what this means?`;
void sendMessage({ text: question });
},
[sendMessage]
);
// Show follow-up suggestions after the last assistant message when ready
const showFollowUp = useMemo(() => {
if (status !== "ready") return false;
if (messages.length === 0) return false;
return messages.at(-1)?.role === Role.ASSISTANT;
}, [status, messages]);
const isDisabled = ["submitted", "streaming"].includes(status);
if (!messages.length) {
return (
<>
<TextSelectionAction
onAskAbout={handleAskAboutSelection}
disabled={isDisabled}
/>
<div className="flex w-full grow flex-col items-center justify-center gap-6 px-6">
<div className="flex flex-col items-center gap-2">
<Icons.ScrollText className="text-muted-foreground size-20 stroke-[1.5]" />
<p className="text-muted-foreground max-w-sm text-center">
{t("pdf.composer.empty")}
</p>
</div>
<SuggestedQuestions
onSelect={handleSuggestedQuestion}
disabled={isDisabled}
/>
</div>
</>
);
}
return (
<>
<TextSelectionAction
onAskAbout={handleAskAboutSelection}
disabled={isDisabled}
/>
<Thread
messages={messages}
initialMessages={initialMessages}
status={status}
components={components}
error={error}
regenerate={regenerate}
footer={
showFollowUp ? (
<div className="mx-auto w-full max-w-3xl px-4">
<FollowUpSuggestions
onSelect={handleSuggestedQuestion}
disabled={isDisabled}
/>
</div>
) : null
}
/>
</>
);
};

View File

@@ -1,77 +0,0 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
// ============================================================================
// Types
// ============================================================================
interface SuggestedQuestionsProps {
onSelect: (question: string) => void;
disabled?: boolean;
}
// ============================================================================
// Suggested Questions
// ============================================================================
/**
* Starter questions shown when chat is empty.
* Clicking a question sends it as the first message.
*/
export function SuggestedQuestions({
onSelect,
disabled,
}: SuggestedQuestionsProps) {
const { t } = useTranslation("ai");
const questions = [
{
icon: Icons.FileText,
text: t("pdf.suggestions.summarize"),
},
{
icon: Icons.BookOpen,
text: t("pdf.suggestions.keyPoints"),
},
{
icon: Icons.Lightbulb,
text: t("pdf.suggestions.explain"),
},
{
icon: Icons.Search,
text: t("pdf.suggestions.find"),
},
];
return (
<div className="flex flex-col gap-3">
<p className="text-muted-foreground text-center text-sm">
{t("pdf.suggestions.title")}
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{questions.map((q, i) => (
<Button
key={i}
variant="outline"
className={cn(
"h-auto justify-start gap-3 px-4 py-3 text-left",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors"
)}
onClick={() => onSelect(q.text)}
disabled={disabled}
>
<q.icon className="text-muted-foreground size-4 shrink-0" />
<span className="line-clamp-2 text-sm">{q.text}</span>
</Button>
))}
</div>
</div>
);
}
export default SuggestedQuestions;

View File

@@ -1,20 +0,0 @@
import { memo } from "react";
import { getMessageTextContent } from "@turbostarter/ai";
import { ThreadMessage } from "~/modules/common/ai/thread/message";
import { Prose } from "~/modules/common/prose";
import type { ThreadMessageProps } from "~/modules/common/ai/thread/message";
export const UserMessage = memo<ThreadMessageProps>(({ message, ref }) => {
return (
<ThreadMessage.Layout className="items-end" ref={ref}>
<Prose className="bg-muted min-h-7 max-w-full rounded-3xl rounded-br-lg border px-4 py-2.5 sm:max-w-[90%]">
{getMessageTextContent(message)}
</Prose>
</ThreadMessage.Layout>
);
});
UserMessage.displayName = "UserMessage";

View File

@@ -1,207 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { formatFileSize } from "@turbostarter/ai/pdf/utils";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { useAIError } from "~/modules/common/hooks/use-ai-error";
import { pdf } from "../lib/api";
import { useUpload } from "./hooks/use-upload";
import { getFileName } from "./utils";
import type { FileInput } from "./utils";
const formSchema = z.object({
name: z.string().min(1).max(255),
});
interface PdfUploadConfirmProps {
readonly file: FileInput;
readonly onCancel: () => void;
}
export const PdfUploadConfirm = ({ file, onCancel }: PdfUploadConfirmProps) => {
const { onError } = useAIError();
const { t } = useTranslation(["common", "ai"]);
const { data: session, isPending: isSessionLoading } = authClient.useSession();
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: getFileName(file) ?? "",
},
});
const router = useRouter();
const upload = useUpload();
const createChat = useMutation({
...pdf.mutations.chats.create,
onSuccess: (data) => {
toast.success(t("pdf.upload.success"));
if (data?.id) {
return router.push(pathsConfig.apps.pdf.chat(data.id));
}
},
onError,
});
// Show sign-in prompt for non-authenticated users
if (!isSessionLoading && !session?.user) {
return (
<Card className="relative z-10 flex w-full max-w-xl flex-col justify-center gap-3 overflow-hidden">
<CardHeader className="flex flex-col items-center gap-1 py-8">
<Icons.Lock className="mb-2 size-10 text-muted-foreground" />
<CardTitle>{t("pdf.upload.signIn.title")}</CardTitle>
<CardDescription className="text-center">
{t("pdf.upload.signIn.description")}
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-center gap-2 pb-8">
<Button variant="outline" onClick={onCancel}>
{t("cancel")}
</Button>
<Button asChild>
<Link href={pathsConfig.auth.login}>
{t("pdf.upload.signIn.cta")}
</Link>
</Button>
</CardFooter>
</Card>
);
}
const data = [
{
title: t("file"),
value: "url" in file ? file.url : `./${file.name}`,
},
{
title: t("size"),
value: formatFileSize(file.size),
},
{
title: t("type"),
value: "PDF",
},
];
async function onSubmit(values: z.infer<typeof formSchema>) {
const { path } = await upload.mutateAsync({
file,
...values,
});
await createChat.mutateAsync({
json: {
...values,
path,
},
});
}
if (form.formState.isSubmitting || form.formState.isSubmitSuccessful) {
return (
<Card className="relative z-10 flex w-full max-w-xl flex-col justify-center gap-3 overflow-hidden">
<CardHeader className="flex flex-col items-center gap-1 py-16">
<Icons.Loader className="mb-2 size-8 animate-spin" />
<CardTitle>{t("pdf.upload.loading.title")}</CardTitle>
<CardDescription>
{t("pdf.upload.loading.description")}
</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card className="relative z-10 flex w-full max-w-xl flex-col justify-center gap-3 overflow-hidden">
<CardHeader className="pb-2">
<CardTitle>{t("pdf.upload.confirm.title")}</CardTitle>
<CardDescription>{t("pdf.upload.confirm.description")}</CardDescription>
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent>
<div className="flex flex-col gap-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between gap-8 @md:gap-12">
<FormLabel className="font-medium">
{t("name")}:
</FormLabel>
<FormControl>
<Input
autoFocus
className="h-8 py-0 text-right"
{...field}
/>
</FormControl>
</div>
<FormMessage className="text-right" />
</FormItem>
)}
/>
{data.map(({ title, value }) => (
<div
key={title}
className="mb-0.5 flex items-center justify-between gap-8 text-sm @md:gap-12"
>
<span className="font-medium">{title}:</span>
<span className="truncate">{value}</span>
</div>
))}
</div>
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="grow @lg:grow-0"
>
{t("cancel")}
</Button>
<Button type="submit" className="grow @lg:grow-0">
{t("confirm")}
</Button>
</CardFooter>
</form>
</Form>
</Card>
);
};

View File

@@ -1,44 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { handle } from "@turbostarter/api/utils";
import { useTranslation } from "@turbostarter/i18n";
import { generateId } from "@turbostarter/shared/utils";
import { api } from "~/lib/api/client";
import { authClient } from "~/lib/auth/client";
import { readFile } from "~/modules/pdf/upload/utils";
import type { FileInput } from "~/modules/pdf/upload/utils";
export const useUpload = () => {
const { t } = useTranslation("ai");
const { data: session } = authClient.useSession();
return useMutation({
mutationFn: async (data: { file: FileInput }) => {
if (!session?.user.id) {
throw new Error(t("pdf.upload.error.unauthorized"));
}
const path = `documents/${session.user.id}/${generateId()}.pdf`;
const { url: uploadUrl } = await handle(api.storage.upload.$get)({
query: { path },
});
const response = await fetch(uploadUrl, {
method: "PUT",
body: await readFile(data.file),
headers: {
"Content-Type": "application/pdf",
},
});
if (!response.ok) {
throw new Error(t("pdf.upload.error.api"));
}
return { path };
},
});
};

View File

@@ -1,155 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ErrorCode, useDropzone as useReactDropzone } from "react-dropzone";
import { toast } from "sonner";
import {
EXAMPLE_PDF,
MAX_FILE_SIZE,
MAX_FILE_SIZE_IN_MB,
} from "@turbostarter/ai/pdf/constants";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { GridPattern } from "@turbostarter/ui-web/grid-pattern";
import { Icons } from "@turbostarter/ui-web/icons";
import { PdfUploadConfirm } from "~/modules/pdf/upload/confirm";
import { PdfUrlForm } from "./url-form";
import type { FileInput } from "./utils";
const useDropzone = ({ onDrop }: { onDrop: (files: File[]) => void }) => {
const { t } = useTranslation("validation");
const errorMessages = useMemo(
() => ({
[ErrorCode.FileInvalidType]: t("error.file.type", {
type: "PDF",
}),
[ErrorCode.FileTooLarge]: t("error.tooBig.file.notInclusive", {
maximum: MAX_FILE_SIZE_IN_MB,
}),
[ErrorCode.FileTooSmall]: t("error.tooSmall.file.notInclusive", {
minimum: 0,
}),
[ErrorCode.TooManyFiles]: t("error.file.maxCount", {
count: 1,
}),
}),
[t],
);
const dropzone = useReactDropzone({
accept: {
"application/pdf": [".pdf"],
},
maxFiles: 1,
maxSize: MAX_FILE_SIZE * 1024 * 1024,
onError: (error) => toast.error(error.message),
noClick: true,
noKeyboard: true,
onDrop,
});
useEffect(() => {
const code = dropzone.fileRejections[0]?.errors[0]?.code;
if (code) {
toast.error(errorMessages[code as ErrorCode]);
}
}, [dropzone.fileRejections, errorMessages]);
return dropzone;
};
export const PdfUpload = () => {
const [file, setFile] = useState<FileInput | null>(null);
const { t } = useTranslation(["ai", "common"]);
const { open, getRootProps, getInputProps, isDragAccept, isDragReject } =
useDropzone({ onDrop: (files) => setFile(files[0] ?? null) });
if (file) {
return (
<Layout>
<PdfUploadConfirm file={file} onCancel={() => setFile(null)} />
</Layout>
);
}
return (
<Layout
{...getRootProps()}
className={cn(
{ "border-destructive": isDragReject },
{ "border-muted-foreground": isDragAccept },
)}
>
<input {...getInputProps()} />
<div className="relative z-10 flex w-full max-w-md flex-col items-center justify-center gap-3">
<Icons.FileText className="size-16" />
<h1 className="text-4xl font-medium tracking-tight">
{t("pdf.title")}
</h1>
<p className="text-muted-foreground mb-6 max-w-sm text-center text-sm">
{t("pdf.upload.description")}
</p>
<PdfUrlForm onSuccess={setFile} />
<Button
className="bg-background w-full rounded-md"
variant="outline"
onClick={open}
>
{t("pdf.upload.fromDevice")}
</Button>
<span className="text-muted-foreground text-sm">{t("or")}</span>
<Button
variant="outline"
className="bg-background h-auto w-full gap-3 overflow-hidden rounded-md px-2 pr-4"
onClick={() => setFile(EXAMPLE_PDF)}
>
<div className="bg-muted rounded-md border p-1.5">
<Icons.Paperclip className="size-4 shrink-0" />
</div>
<div className="mr-auto flex min-w-0 flex-col items-start">
<span>{t("pdf.upload.example.cta")}</span>
<span className="text-muted-foreground w-full truncate pr-4 text-xs">
{EXAMPLE_PDF.url}
</span>
</div>
<Icons.ArrowRight className="size-4 shrink-0" />
</Button>
</div>
</Layout>
);
};
const Layout = ({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
className={cn(
"relative flex h-full w-full items-center justify-center rounded-lg border-2 border-dashed p-4",
className,
)}
{...props}
>
{children}
<GridPattern
width={50}
height={50}
x={-1}
y={-1}
strokeDasharray={"4 2"}
className="mask-[radial-gradient(white,transparent)]"
/>
</div>
);
};

View File

@@ -1,88 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { MAX_FILE_SIZE_IN_MB } from "@turbostarter/ai/pdf/constants";
import {
pdfUrlFormSchema,
validateRemotePdfUrl,
} from "@turbostarter/ai/pdf/schema";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import type { PdfUrlFormPayload } from "@turbostarter/ai/pdf/schema";
import type { RemoteFile } from "@turbostarter/ai/pdf/types";
interface PdfUrlFormProps {
readonly onSuccess: (file: RemoteFile) => void;
}
export const PdfUrlForm = ({ onSuccess }: PdfUrlFormProps) => {
const { t } = useTranslation(["common", "ai", "validation"]);
const form = useForm({
resolver: zodResolver(pdfUrlFormSchema),
defaultValues: {
url: "",
},
});
async function onSubmit(values: PdfUrlFormPayload) {
const result = await validateRemotePdfUrl(values.url);
if (typeof result === "string") {
return form.setError("url", {
message: t(result, { maximum: MAX_FILE_SIZE_IN_MB, type: "PDF" }),
});
}
onSuccess(result);
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full gap-2"
>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input
autoFocus
placeholder={t("pdf.upload.fromUrl.placeholder")}
className="bg-background"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="rounded-md"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Icons.Loader2 className="size-5 animate-spin" />
) : (
t("pdf.upload.fromUrl.cta")
)}
</Button>
</form>
</Form>
);
};

View File

@@ -1,37 +0,0 @@
import type { RemoteFile } from "@turbostarter/ai/pdf/types";
export type FileInput = File | RemoteFile;
export const getFileName = (file: FileInput) => {
if ("name" in file) {
return file.name.replace(/\.[^.]+$/, "");
}
const fileName = file.url.split("/").pop() ?? null;
if (!fileName) return null;
return fileName.replace(/\.[^.]+$/, "");
};
export const readFile = async (file: FileInput) => {
if ("url" in file) {
// Use server proxy to fetch external URLs (avoids CORS issues)
const proxyUrl = `/api/storage/proxy?url=${encodeURIComponent(file.url)}`;
const response = await fetch(proxyUrl);
if (!response.ok) {
const error = await response.json().catch(() => ({})) as { error?: string };
throw new Error(error.error ?? "Failed to fetch PDF from URL");
}
const blob = await response.blob();
return blob;
} else {
const reader = new FileReader();
return new Promise<Blob>((resolve, reject) => {
reader.onloadend = () =>
resolve(new Blob([reader.result as ArrayBuffer]));
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
};

View File

@@ -1,66 +0,0 @@
import { useEffect, useRef, useState } from "react";
export const useAudio = (url?: string) => {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
useEffect(() => {
if (!audioRef.current) {
audioRef.current = new Audio(url);
} else if (url) {
audioRef.current.src = url;
}
}, [url]);
useEffect(() => {
const audioElement = audioRef.current;
if (!audioElement) return;
const handleTimeUpdate = () => {
const currentProgress =
(audioElement.currentTime / audioElement.duration) * 100;
setProgress(currentProgress);
};
const handleEnded = () => {
setPlaying(false);
setProgress(0);
};
audioElement.addEventListener("timeupdate", handleTimeUpdate);
audioElement.addEventListener("ended", handleEnded);
return () => {
audioElement.removeEventListener("timeupdate", handleTimeUpdate);
audioElement.removeEventListener("ended", handleEnded);
};
}, []);
const play = () => {
if (audioRef.current) {
void audioRef.current.play();
setPlaying(true);
}
};
const pause = () => {
if (audioRef.current) {
audioRef.current.pause();
setPlaying(false);
}
};
const scroll = (seconds: number) => {
const audioElement = audioRef.current;
if (!audioElement) return;
const newTime = Math.max(
0,
Math.min(audioElement.duration, audioElement.currentTime + seconds),
);
audioElement.currentTime = newTime;
};
return { play, pause, playing, progress, scroll };
};

View File

@@ -1,137 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useEffect } from "react";
import { useForm, useFormContext } from "react-hook-form";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { MODELS } from "@turbostarter/ai/tts/constants";
import { ttsSchema } from "@turbostarter/ai/tts/schema";
import { useDebounceCallback } from "@turbostarter/shared/hooks";
import { useTts } from "~/modules/tts/use-tts";
import type {
TtsOptionsPayload,
TtsPayload,
} from "@turbostarter/ai/tts/schema";
import type { Voice } from "@turbostarter/ai/tts/types";
import type { WatchObserver } from "react-hook-form";
interface TtsComposerState {
text: string;
options: TtsOptionsPayload;
setText: (text: string) => void;
setOptions: (options: Partial<TtsOptionsPayload>) => void;
reset: () => void;
}
const DEFAULT_OPTIONS = {
model: MODELS[0].id,
voice: {
id: "",
speed: 1,
stability: 0.5,
similarity: 0.75,
boost: false,
},
};
const useTtsComposerStore = create<TtsComposerState>()(
persist(
(set) => ({
text: "",
options: DEFAULT_OPTIONS,
setText: (text) => set({ text }),
setOptions: (options) =>
set((state) => ({
options: { ...state.options, ...options },
})),
reset: () =>
set({
text: "",
options: DEFAULT_OPTIONS,
}),
}),
{
name: "tts-options",
partialize: (state) => ({ options: state.options }),
},
),
);
interface UseComposerProps {
voices: Voice[];
}
export const useComposer = ({ voices }: UseComposerProps) => {
const { speak } = useTts();
const { options, reset, setOptions, setText } = useTtsComposerStore();
const newForm = useForm({
resolver: zodResolver(ttsSchema),
defaultValues: {
text: "",
options,
},
});
const contextForm = useFormContext<TtsPayload>();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const form = contextForm ?? newForm;
useEffect(() => {
if (voices.length && !options.voice.id) {
const newOptions = {
...options,
voice: {
...options.voice,
id: voices[0]?.id ?? "",
},
};
setOptions(newOptions);
form.setValue("options", newOptions);
}
}, [voices, options, setOptions, form]);
const sync: WatchObserver<TtsPayload> = useCallback(
(values) => {
setText(values.text ?? "");
setOptions({
...(values.options ?? DEFAULT_OPTIONS),
voice: {
...(values.options?.voice ?? DEFAULT_OPTIONS.voice),
id: values.options?.voice?.id ?? "",
},
});
},
[setText, setOptions],
);
const debouncedSync = useDebounceCallback(sync, 500);
useEffect(() => {
const subscription = form.watch(debouncedSync);
return () => subscription.unsubscribe();
}, [form, debouncedSync]);
const onSubmit = (input: TtsPayload) => {
form.resetField("text");
speak.mutate(input);
};
const resetVoiceSettings = () => {
form.setValue("options.voice", {
...DEFAULT_OPTIONS.voice,
id: options.voice.id,
});
};
return {
form,
setText,
onSubmit,
reset,
resetVoiceSettings,
};
};

View File

@@ -1,104 +0,0 @@
"use client";
import { memo } from "react";
import { MODELS } from "@turbostarter/ai/tts/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 { ScrollArea } from "@turbostarter/ui-web/scroll-area";
import { Composer } from "~/modules/common/ai/composer";
import { ModelSelector } from "~/modules/common/ai/composer/model-selector";
import { useComposer } from "./hooks/use-composer";
import { Settings } from "./settings";
import { VoiceSelector } from "./voice-selector";
import type { UIVoice } from "~/modules/tts/utils/types";
interface TtsComposerProps {
voices: UIVoice[];
}
export const TtsComposer = memo<TtsComposerProps>(({ voices }) => {
const { t } = useTranslation(["common", "ai"]);
const { onSubmit, form, resetVoiceSettings } = useComposer({ voices });
return (
<div className="h-full w-full overflow-hidden pt-12 md:pt-14">
<Form {...form}>
<Composer.Form
onSubmit={form.handleSubmit(onSubmit)}
className="flex h-full w-full flex-col"
>
<ScrollArea className="h-full min-h-0 w-full px-5">
<VoiceSelector
control={form.control}
name="options.voice.id"
options={voices}
/>
</ScrollArea>
<div className="relative z-20 mx-auto w-full px-3 pb-3 shadow-[0_-72px_52px_-16px_var(--background)]">
<Composer.Input className="mx-auto pb-12">
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormControl>
<Composer.Textarea
{...field}
placeholder={t("tts.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">
<Settings
control={form.control}
path="options.voice"
onReset={resetVoiceSettings}
/>
<div className="ml-auto flex w-full items-center justify-end gap-1.5">
<ModelSelector
control={form.control}
name="options.model"
options={MODELS}
/>
<Button
className="shrink-0 rounded-full"
disabled={!form.formState.isValid}
size="icon"
type="submit"
>
<Icons.AudioLines className="size-5" />
</Button>
</div>
</div>
</Composer.Input>
</div>
</Composer.Form>
</Form>
</div>
);
});
TtsComposer.displayName = "TtsComposer";

View File

@@ -1,243 +0,0 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { useBreakpoint } from "@turbostarter/ui-web";
import { Button } from "@turbostarter/ui-web/button";
import {
Drawer,
DrawerContent,
DrawerTrigger,
} from "@turbostarter/ui-web/drawer";
import {
FormControl,
FormField,
FormItem,
FormLabel,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "@turbostarter/ui-web/popover";
import { Slider } from "@turbostarter/ui-web/slider";
import { Switch } from "@turbostarter/ui-web/switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import type { Control, FieldValues, Path } from "react-hook-form";
const SettingLabel = ({
title,
description,
}: {
title: string;
description: string;
}) => {
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<FormLabel className="decoration-border hover:decoration-foreground w-fit cursor-pointer underline decoration-dashed underline-offset-2 transition-colors">
{title}
</FormLabel>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="max-w-sm">
{description}
</TooltipContent>
</Tooltip>
);
};
interface SettingsProps<T extends FieldValues> {
control: Control<T>;
path: Path<T>;
onReset: () => void;
}
export const Settings = <T extends FieldValues>({
control,
path,
onReset,
}: SettingsProps<T>) => {
const { t } = useTranslation(["common", "ai"]);
const isDesktop = useBreakpoint("md");
const renderTrigger = () => (
<Button
variant="outline"
size="icon"
className="text-muted-foreground shrink-0 rounded-full"
>
<Icons.Settings className="size-4" />
<span className="sr-only">{t("settings")}</span>
</Button>
);
const renderContent = () => (
<div className="grid gap-5">
<FormField
control={control}
name={`${path}.speed` as Path<T>}
render={({ field }) => (
<FormItem className="gap-1.5">
<SettingLabel
title={t("speed")}
description={t("tts.composer.settings.voice.speed.description")}
/>
<div className="text-muted-foreground mt-1.5 flex items-center justify-between text-xs">
<span>{t("slower")}</span>
<span>{t("faster")}</span>
</div>
<FormControl>
<div className="flex items-center gap-3">
<Slider
id="speed"
min={0.7}
max={1.2}
step={0.01}
value={[field.value]}
onValueChange={(vals) => {
field.onChange(vals[0]);
}}
className="flex-1"
/>
<span className="text-muted-foreground w-12 text-right text-sm tabular-nums">
{/* eslint-disable-next-line i18next/no-literal-string */}
{Number(field.value).toFixed(2)}x
</span>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name={`${path}.stability` as Path<T>}
render={({ field }) => (
<FormItem className="gap-1.5">
<SettingLabel
title={t("stability")}
description={t(
"tts.composer.settings.voice.stability.description",
)}
/>
<div className="text-muted-foreground mt-1.5 flex items-center justify-between text-xs">
<span>{t("moreVariable")}</span>
<span>{t("moreStable")}</span>
</div>
<FormControl>
<div className="flex items-center gap-3">
<Slider
id="stability"
min={0}
max={1}
step={0.01}
value={[field.value]}
onValueChange={(vals) => {
field.onChange(vals[0]);
}}
className="flex-1"
/>
<span className="text-muted-foreground w-12 text-right text-sm tabular-nums">
{(field.value * 100).toFixed(0)}%
</span>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name={`${path}.similarity` as Path<T>}
render={({ field }) => (
<FormItem className="gap-1.5">
<SettingLabel
title={t("similarity")}
description={t(
"tts.composer.settings.voice.similarity.description",
)}
/>
<div className="text-muted-foreground mt-1.5 flex items-center justify-between text-xs">
<span>{t("low")}</span>
<span>{t("high")}</span>
</div>
<FormControl>
<div className="flex items-center gap-3">
<Slider
id="similarity"
min={0}
max={1}
step={0.01}
value={[field.value]}
onValueChange={(vals) => {
field.onChange(vals[0]);
}}
className="flex-1"
/>
<span className="text-muted-foreground w-12 text-right text-sm tabular-nums">
{(field.value * 100).toFixed(0)}%
</span>
</div>
</FormControl>
</FormItem>
)}
/>
<div className="mt-1 flex items-center justify-between">
<FormField
control={control}
name={`${path}.boost` as Path<T>}
render={({ field }) => (
<FormItem className="flex items-center gap-2.5 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<SettingLabel
title={t("speakerBoost")}
description={t(
"tts.composer.settings.voice.speakerBoost.description",
)}
/>
</FormItem>
)}
/>
<Button variant="outline" className="gap-2" onClick={onReset}>
<Icons.Undo2 className="size-4" />
{t("reset")}
</Button>
</div>
</div>
);
if (isDesktop) {
return (
<Popover>
<PopoverTrigger asChild>{renderTrigger()}</PopoverTrigger>
<PopoverPortal>
<PopoverContent className="w-fit min-w-96 px-5">
{renderContent()}
</PopoverContent>
</PopoverPortal>
</Popover>
);
}
return (
<Drawer>
<DrawerTrigger asChild>{renderTrigger()}</DrawerTrigger>
<DrawerContent className="gap-4 px-5 pb-6">
{renderContent()}
</DrawerContent>
</Drawer>
);
};

View File

@@ -1,248 +0,0 @@
"use client";
import { motion } from "motion/react";
import { memo } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { Badge } from "@turbostarter/ui-web/badge";
import { Button } from "@turbostarter/ui-web/button";
import {
Card,
CardHeader,
CardContent,
CardTitle,
CardDescription,
CardFooter,
} from "@turbostarter/ui-web/card";
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import { Label } from "@turbostarter/ui-web/label";
import { RadioGroup, RadioGroupItem } from "@turbostarter/ui-web/radio-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { useAudio } from "./hooks/use-audio";
import type { UIVoice } from "../utils/types";
import type { FieldValues, Path } from "react-hook-form";
import type { Control } from "react-hook-form";
const Voice = ({ voice, selected }: { voice: UIVoice; selected: boolean }) => {
const { t } = useTranslation("common");
const { play, pause, playing, progress, scroll } = useAudio(voice.previewUrl);
return (
<Card
key={voice.id}
className={cn(
"dark:bg-background flex h-full flex-col overflow-hidden rounded-2xl transition-all",
selected ? "border-primary" : "hover:border-input border",
)}
>
<Label
htmlFor={voice.id}
className="flex grow cursor-pointer flex-col gap-0"
>
<CardHeader className="flex w-full flex-row items-center justify-between gap-3.5 px-5 pt-4 pb-3">
<Avatar className="size-10">
<AvatarFallback>{voice.name.charAt(0)}</AvatarFallback>
<AvatarImage src={voice.avatar?.src} style={voice.avatar?.style} />
</Avatar>
<div className="mr-auto min-w-0">
<CardTitle className="truncate text-lg leading-tight">
{voice.name}
</CardTitle>
<CardDescription className="truncate leading-tight capitalize">
{voice.category}
</CardDescription>
</div>
<RadioGroupItem
value={voice.id}
id={voice.id}
className="mt-0.5 self-start"
/>
</CardHeader>
<CardContent className="flex w-full grow flex-col justify-between gap-3 pb-4">
<div className="-ml-0.5 flex w-full flex-wrap gap-1.5">
{voice.details.map((detail) => (
<Badge
variant="secondary"
key={detail}
className="font-normal lowercase"
>
{detail.replace(/_/g, " ")}
</Badge>
))}
</div>
<div className="text-muted-foreground flex items-center justify-between gap-3 text-xs">
<div className="flex items-center gap-1.5">
<Icons.TextSearch className="size-3.5" />
<span>
{new Date(voice.createdAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<Icons.UsersRound className="size-3.5" />
<span>
{new Intl.NumberFormat("en", {
notation: "compact",
compactDisplay: "short",
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(voice.usage.cloned)}
</span>
</div>
<div className="flex items-center gap-1.5">
<Icons.AudioWaveform className="size-3.5" />
{new Intl.NumberFormat("en", {
notation: "compact",
compactDisplay: "short",
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(voice.usage.character)}
</div>
</div>
</div>
</CardContent>
</Label>
<CardFooter className="bg-muted/50 relative flex justify-center overflow-hidden border-t px-3 py-1">
<div
className="bg-muted absolute top-0 left-0 h-full w-full transition-all duration-300 ease-linear"
style={{
transform: `scaleX(${progress / 100})`,
transformOrigin: "left",
}}
/>
<div className="relative z-10 flex justify-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground size-8 rounded-full"
onClick={() => scroll(-5)}
>
<Icons.Undo className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
-{t("second", { count: 5 })}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground size-8 rounded-full"
onClick={() => {
if (playing) {
pause();
} else {
play();
}
}}
>
{playing ? (
<Icons.Pause className="size-3.5" />
) : (
<Icons.Play className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{playing ? t("pause") : t("play")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground size-8 rounded-full"
onClick={() => scroll(5)}
>
<Icons.Redo className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
+{t("second", { count: 5 })}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardFooter>
</Card>
);
};
interface VoiceSelectorProps<T extends FieldValues> {
readonly control: Control<T>;
readonly name: Path<T>;
readonly options: UIVoice[];
}
type VoiceSelectorComponent = <T extends FieldValues>(
props: VoiceSelectorProps<T>,
) => React.ReactNode;
export const VoiceSelector: VoiceSelectorComponent = memo(
({ control, name, options }) => {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
className="grid w-full min-w-0 grid-cols-[repeat(auto-fill,minmax(min(20rem,100%),1fr))] gap-4 pb-6"
onValueChange={field.onChange}
value={field.value}
>
{options.map((voice, index) => (
<motion.div
key={voice.id}
initial={{ opacity: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
transition={{ duration: 0.2, delay: index * 0.1 }}
>
<Voice
key={voice.id}
voice={voice}
selected={field.value === voice.id}
/>
</motion.div>
))}
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
);
},
);

View File

@@ -1,48 +0,0 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { TtsComposer } from "./composer";
import { Speech } from "./speech";
import { useTts } from "./use-tts";
import type { UIVoice } from "./utils/types";
interface TtsProps {
readonly voices: UIVoice[];
}
export function Tts({ voices }: TtsProps) {
const { status, input } = useTts();
const voice = voices.find((voice) => voice.id === input?.options.voice.id);
const showComposer = ["idle", "error"].includes(status) || !voice;
return (
<AnimatePresence mode="wait">
{showComposer ? (
<motion.div
key="composer"
className="h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<TtsComposer voices={voices} />
</motion.div>
) : (
<motion.div
key="speech"
className="h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<Speech voice={voice} />
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,198 +0,0 @@
import { motion } from "motion/react";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import type {Variants} from "motion/react";
import type { UIVoice } from "~/modules/tts/utils/types";
const avatarAnimationVariants: Variants = {
playing: {
scale: [1, 1.04, 0.97, 1.03, 0.98, 1.02, 1],
rotate: [0, 1.8, -1.2, 2.5, -1.5, 0.8, 0],
x: [0, 3, -2, 2, -3, 1, 0],
y: [0, -3, 2, -4, 3, -1, 0],
filter: [
"brightness(1) contrast(1)",
"brightness(1.08) contrast(1.04)",
"brightness(0.98) contrast(0.99)",
"brightness(1.06) contrast(1.03)",
"brightness(0.97) contrast(0.98)",
"brightness(1.04) contrast(1.02)",
"brightness(1) contrast(1)",
],
transition: {
duration: 5,
repeat: Infinity,
ease: "easeInOut" as const,
filter: {
duration: 5,
repeat: Infinity,
ease: "easeInOut" as const,
},
},
},
loading: {
scale: [1, 1.02, 0.99, 1.01, 1],
filter: [
"brightness(0.9) contrast(0.95) grayscale(1)",
"brightness(0.95) contrast(0.97) grayscale(1)",
"brightness(0.88) contrast(0.94) grayscale(1)",
"brightness(0.93) contrast(0.96) grayscale(1)",
"brightness(0.9) contrast(0.95) grayscale(1)",
],
transition: {
duration: 2,
repeat: Infinity,
ease: "easeInOut" as const,
filter: {
duration: 2,
repeat: Infinity,
ease: "easeInOut" as const,
},
},
},
idle: {
scale: 1,
rotate: 0,
x: 0,
y: 0,
filter: "brightness(1) contrast(1) grayscale(0)",
transition: {
duration: 1,
ease: "easeOut" as const,
},
},
};
const imageAnimationVariants: Variants = {
playing: {
scale: [1, 1.03, 0.98, 1.02, 1],
rotate: [0, -0.5, 0.3, -0.2, 0],
filter: [
"saturate(1.1) brightness(1) grayscale(0)",
"saturate(1.25) brightness(1.05) grayscale(0)",
"saturate(1.15) brightness(0.98) grayscale(0)",
"saturate(1.2) brightness(1.03) grayscale(0)",
"saturate(1.1) brightness(1) grayscale(0)",
],
transition: {
duration: 4,
repeat: Infinity,
ease: "easeInOut" as const,
filter: {
duration: 4,
repeat: Infinity,
ease: "easeInOut" as const,
},
},
},
loading: {
scale: [1, 1.01, 0.99, 1],
filter: [
"saturate(0.8) brightness(0.9) grayscale(1)",
"saturate(0.85) brightness(0.95) grayscale(1)",
"saturate(0.8) brightness(0.88) grayscale(1)",
"saturate(0.8) brightness(0.9) grayscale(1)",
],
transition: {
duration: 1.5,
repeat: Infinity,
ease: "easeInOut" as const,
filter: {
duration: 1.5,
repeat: Infinity,
ease: "easeInOut" as const,
},
},
},
idle: {
scale: 1,
rotate: 0,
filter: "saturate(1.1) grayscale(0)",
transition: {
duration: 1,
ease: "easeOut" as const,
},
},
};
interface VoiceAvatarProps {
readonly voice: UIVoice;
readonly playing: boolean;
readonly loading: boolean;
}
export function VoiceAvatar({ voice, playing, loading }: VoiceAvatarProps) {
const animationState = playing ? "playing" : loading ? "loading" : "idle";
return (
<div className="relative flex grow items-center justify-center">
<motion.div
animate={animationState}
initial="idle"
variants={avatarAnimationVariants}
className="relative z-10 flex h-full max-h-[min(50vw,18rem)] items-center justify-center"
>
<Avatar className="relative aspect-square h-full w-auto">
<AvatarFallback>{voice.name.charAt(0)}</AvatarFallback>
<motion.div
animate={animationState}
initial="idle"
variants={imageAnimationVariants}
style={{ width: "100%", height: "100%" }}
>
<AvatarImage src={voice.avatar?.src} style={voice.avatar?.style} />
</motion.div>
</Avatar>
{!loading && (
<>
<div className="absolute -inset-20 overflow-hidden rounded-full blur-3xl">
<motion.div
className="h-full w-full"
style={{
...voice.avatar?.style,
background: `radial-gradient(circle, hsla(210,100%,55%,0) 0%, hsla(210,100%,55%,0.4) 40%, hsla(210,100%,55%,0.6) 70%, hsla(210,100%,55%,0.2) 100%)`,
mixBlendMode: "soft-light",
}}
animate={{
opacity: [0.5, 0.8, 0.6, 0.75, 0.5],
scale: [0.75, 1.15, 0.95, 1.1, 0.75],
}}
transition={{
duration: 6,
repeat: Infinity,
ease: "easeInOut",
}}
/>
</div>
<div className="absolute -inset-12 overflow-hidden rounded-full blur-xl">
<motion.div
className="h-full w-full"
style={{
...voice.avatar?.style,
background: `radial-gradient(circle, transparent 10%, hsla(210,100%,55%)/0.5) 60%, hsla(210,100%,55%)/0.7) 80%, transparent 100%)`,
}}
animate={{
opacity: [0.6, 0.9, 0.7, 0.85, 0.6],
scale: [0.75, 1.05, 0.95, 1.02, 0.75],
}}
transition={{
duration: 4.5,
repeat: Infinity,
ease: "easeInOut",
delay: 0.5,
}}
/>
</div>
</>
)}
</motion.div>
</div>
);
}

View File

@@ -1,60 +0,0 @@
"use client";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { useTts } from "../use-tts";
import { VoiceAvatar } from "./avatar";
import { VoiceVisualizer } from "./voice-visualizer";
import type { UIVoice } from "~/modules/tts/utils/types";
interface SpeechProps {
voice: UIVoice;
}
export const Speech = ({ voice }: SpeechProps) => {
const { status, reset, pause, play } = useTts();
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 px-5 py-12 md:py-14 @lg:gap-6">
<VoiceAvatar
voice={voice}
playing={status === "playing"}
loading={status === "loading"}
/>
<h1 className="mt-8 mb-2 px-6 text-center text-3xl font-bold @lg:mt-12 @lg:text-4xl">
{voice.name}
</h1>
<VoiceVisualizer
playing={status === "playing"}
loading={status === "loading"}
/>
<div className="mt-16 flex items-center justify-center gap-2 @lg:gap-4">
<Button
variant="outline"
className="size-16 @lg:size-20"
disabled={status === "loading"}
onClick={status === "playing" ? pause : play}
>
{status === "playing" ? (
<Icons.Pause className="size-6 @lg:size-8" />
) : (
<Icons.Play className="size-6 @lg:size-8" />
)}
</Button>
<Button
variant="destructive"
className="size-16 @lg:size-20"
onClick={reset}
>
<Icons.X className="size-6 @lg:size-8" />
</Button>
</div>
</div>
);
};

View File

@@ -1,61 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { cn } from "@turbostarter/ui";
interface VoiceVisualizerProps {
readonly playing: boolean;
readonly loading: boolean;
readonly bars?: number;
}
export function VoiceVisualizer({
playing,
loading,
bars = 40,
}: VoiceVisualizerProps) {
const [time, setTime] = useState(0);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
useEffect(() => {
let intervalId: NodeJS.Timeout;
if (playing) {
intervalId = setInterval(() => {
setTime((t) => t + 1);
}, 1000);
} else {
setTime(0);
}
return () => clearInterval(intervalId);
}, [playing, time]);
return (
<div className="flex h-16 w-4/5 max-w-[45rem] shrink-0 items-center justify-center gap-0.5 @md:h-20 @md:gap-1 @lg:h-24 @lg:gap-1.5">
{Array.from({ length: bars }).map((_, i) => (
<div
key={i}
className={cn(
"grow rounded-full transition-all [transition-duration:200ms]",
playing ? "bg-foreground/65" : "bg-muted h-4/5",
{
"animate-pulse": playing || loading,
},
)}
style={{
...(isClient ? { animationDelay: `${i * 0.05}s` } : {}),
...(playing || loading
? { height: `${20 + Math.random() * 80}%` }
: {}),
}}
/>
))}
</div>
);
}

View File

@@ -1,104 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { create } from "zustand";
import { api } from "~/lib/api/client";
import { useAIError } from "~/modules/common/hooks/use-ai-error";
import { useCredits } from "~/modules/common/layout/credits";
import type { TtsPayload } from "@turbostarter/ai/tts/schema";
interface TtsState {
status: "idle" | "loading" | "playing" | "paused" | "error";
audio: HTMLAudioElement | null;
input: TtsPayload | null;
update: (state: Partial<TtsState>) => void;
}
const useTtsStore = create<TtsState>()((set) => ({
status: "idle",
audio: null,
input: null,
update: (updates) =>
set((state) => ({
...state,
...updates,
})),
}));
export const useTts = () => {
const { onError } = useAIError();
const { invalidate } = useCredits();
const { update, status, audio, input } = useTtsStore();
const speak = useMutation({
mutationFn: async (json: TtsPayload) => {
if (!MediaSource.isTypeSupported("audio/mpeg")) {
throw new Error("Unsupported MIME type or codec: audio/mpeg");
}
const response: Response = await api.ai.tts.$post({
json,
});
if (!response.ok) {
throw new Error("Failed to speak!");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(
/^(http|https|blob:|data:)/.test(url)
? url
: `data:audio/wav;base64,${url}`,
);
audio.onended = () => {
update({ status: "paused" });
};
update({ audio });
return audio;
},
onMutate: (input) => {
update({ status: "loading", input });
},
onSuccess: (audio) => {
update({ status: "playing" });
void invalidate();
void audio.play();
},
onError: (e) => {
onError(e);
update({ status: "error" });
},
});
const play = () => {
if (audio) {
void audio.play();
update({ status: "playing" });
}
};
const pause = () => {
if (audio) {
audio.pause();
update({ status: "paused" });
}
};
const reset = () => {
update({ status: "idle", audio: null, input: null });
};
return {
status,
audio,
input,
speak,
play,
pause,
reset,
};
};

Some files were not shown because too many files have changed in this diff Show More