feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
227
apps/web/src/modules/common/ai/composer/attachments.tsx
Normal file
227
apps/web/src/modules/common/ai/composer/attachments.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { Viewer } from "~/modules/common/image";
|
||||
|
||||
import type { DropzoneOptions, DropzoneState } from "react-dropzone";
|
||||
|
||||
const DropzoneContext = createContext<{
|
||||
dropzone: DropzoneState;
|
||||
} | null>(null);
|
||||
|
||||
interface DropzoneProps extends DropzoneOptions {
|
||||
children: React.ReactNode;
|
||||
dialog?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Dropzone = ({ children, dialog, ...options }: DropzoneProps) => {
|
||||
const dropzone = useDropzone({
|
||||
accept: {
|
||||
"image/*": [".png", ".gif", ".jpeg", ".webp", ".jpg"],
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
multiple: true,
|
||||
...options,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropzoneContext.Provider value={{ dropzone }}>
|
||||
<div {...dropzone.getRootProps()} className="relative h-full w-full">
|
||||
{children}
|
||||
|
||||
<AnimatePresence>
|
||||
{dropzone.isDragActive && dialog && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||
<motion.div
|
||||
className="bg-background/50 absolute inset-0 backdrop-blur-sm md:rounded-lg"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
|
||||
{dialog}
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DropzoneContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const Input = memo<React.ButtonHTMLAttributes<HTMLButtonElement>>((props) => {
|
||||
const { t } = useTranslation(["ai", "common"]);
|
||||
const context = useContext(DropzoneContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
{...context?.dropzone.getInputProps()}
|
||||
disabled={props.disabled ?? false}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
{...props}
|
||||
className={cn(
|
||||
"text-muted-foreground shrink-0 rounded-full dark:bg-transparent",
|
||||
props.className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
context?.dropzone.open();
|
||||
props.onClick?.(event);
|
||||
}}
|
||||
>
|
||||
<Icons.Paperclip className="size-4" />
|
||||
<span className="sr-only">{t("chat.composer.files.add")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>{t("chat.composer.files.add")}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
interface PreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
attachments: File[];
|
||||
onRemove: (file: File) => void;
|
||||
}
|
||||
|
||||
export const Preview = memo<PreviewProps>(
|
||||
({ attachments, onRemove, className, ...props }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
if (!attachments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"-mb-2.5 flex w-full flex-wrap gap-3 px-2 pt-4 @[480px]/input:px-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{attachments.map((attachment, index) => (
|
||||
<Thumbnail
|
||||
key={attachment.name}
|
||||
attachment={attachment}
|
||||
onRemove={() => onRemove(attachment)}
|
||||
onClick={() => {
|
||||
setSelectedImage(index);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Viewer
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
images={attachments.map((attachment) => ({
|
||||
url: URL.createObjectURL(attachment),
|
||||
}))}
|
||||
selectedImage={selectedImage}
|
||||
setSelectedImage={setSelectedImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Preview.displayName = "Preview";
|
||||
|
||||
interface ThumbnailProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
attachment: File;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const Thumbnail = memo<ThumbnailProps>(({ attachment, onRemove, ...props }) => {
|
||||
const { t } = useTranslation(["ai"]);
|
||||
const preview = useMemo(() => URL.createObjectURL(attachment), [attachment]);
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<button {...props} type="button">
|
||||
<Avatar className="size-16 shrink-0 rounded-xl">
|
||||
<AvatarImage
|
||||
src={preview}
|
||||
alt={`Preview of ${attachment.name}`}
|
||||
className="rounded-xl border object-cover"
|
||||
/>
|
||||
<AvatarFallback className="rounded-xl">
|
||||
<Icons.Image className="text-muted-foreground size-8" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span className="sr-only">{t("chat.composer.files.preview")}</span>
|
||||
</button>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-card dark:bg-card absolute top-0 right-0 size-5 translate-x-1/3 -translate-y-1/3 p-1"
|
||||
onClick={onRemove}
|
||||
type="button"
|
||||
>
|
||||
<Icons.X className="size-full" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
className="rounded-md px-2 py-1 text-xs"
|
||||
>
|
||||
<span>{t("chat.composer.files.remove")}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Thumbnail.displayName = "Thumbnail";
|
||||
|
||||
export const Attachments = {
|
||||
Input,
|
||||
Dropzone,
|
||||
Preview,
|
||||
};
|
||||
90
apps/web/src/modules/common/ai/composer/index.tsx
Normal file
90
apps/web/src/modules/common/ai/composer/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { TextareaAutosize } from "@turbostarter/ui-web/textarea";
|
||||
|
||||
import { Attachments } from "./attachments";
|
||||
|
||||
const Form = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLFormElement>) => {
|
||||
const ref = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
ref.current
|
||||
?.closest("main")
|
||||
?.style.setProperty(
|
||||
"--composer-height",
|
||||
`${entry.contentRect.height}px`,
|
||||
);
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
resizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative bottom-0 z-10 flex w-full flex-col items-center justify-center gap-2 text-base",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const Input = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card/65 ring-border/75 focus-within:ring-input hover:ring-input hover:focus-within:ring-input @container/input relative w-full max-w-200 rounded-2xl px-2 pb-2 ring-1 backdrop-blur-xl duration-100 ring-inset focus-within:ring-1 @lg:rounded-3xl @lg:shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Textarea = ({
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "style">) => {
|
||||
return (
|
||||
<TextareaAutosize
|
||||
dir="auto"
|
||||
className={cn(
|
||||
"text-foreground mb-3 min-h-20 w-full resize-none bg-transparent px-2 pt-5 align-bottom focus:outline-none @[480px]/input:px-3",
|
||||
className,
|
||||
)}
|
||||
spellCheck={false}
|
||||
maxRows={6}
|
||||
autoFocus
|
||||
maxLength={5_000}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Composer = {
|
||||
Form,
|
||||
Input,
|
||||
Textarea,
|
||||
Attachments,
|
||||
};
|
||||
68
apps/web/src/modules/common/ai/composer/model-selector.tsx
Normal file
68
apps/web/src/modules/common/ai/composer/model-selector.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectPortal,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import { ProviderIcons } from "~/modules/common/ai/icons";
|
||||
|
||||
import type { Provider } from "@turbostarter/ai";
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
|
||||
interface ModelSelectorProps<T extends FieldValues> {
|
||||
readonly control: Control<T>;
|
||||
readonly name: Path<T>;
|
||||
readonly options: readonly {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly provider: Provider;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const ModelSelector = <T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
options,
|
||||
}: ModelSelectorProps<T>) => {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0">
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent align="end">
|
||||
{options.map((option) => {
|
||||
const Icon = ProviderIcons[option.provider];
|
||||
|
||||
return (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Icon className="text-foreground size-4 shrink-0" />
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{option.name}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
16
apps/web/src/modules/common/ai/icons.tsx
Normal file
16
apps/web/src/modules/common/ai/icons.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Provider } from "@turbostarter/ai";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
export const ProviderIcons = {
|
||||
[Provider.OPENAI]: Icons.OpenAI,
|
||||
[Provider.GEMINI]: Icons.Gemini,
|
||||
[Provider.CLAUDE]: Icons.Claude,
|
||||
[Provider.GROK]: Icons.Grok,
|
||||
[Provider.DEEPSEEK]: Icons.DeepSeek,
|
||||
[Provider.REPLICATE]: Icons.Replicate,
|
||||
[Provider.LUMA]: Icons.Luma,
|
||||
[Provider.STABILITY_AI]: Icons.StabilityAI,
|
||||
[Provider.RECRAFT]: Icons.Recraft,
|
||||
[Provider.ELEVEN_LABS]: Icons.ElevenLabs,
|
||||
[Provider.NVIDIA]: Icons.Nvidia,
|
||||
};
|
||||
149
apps/web/src/modules/common/ai/thread/analyzing-image.tsx
Normal file
149
apps/web/src/modules/common/ai/thread/analyzing-image.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { TextShimmer } from "@turbostarter/ui-web/text-shimmer";
|
||||
|
||||
import type { Transition } from "motion/react";
|
||||
|
||||
const transition: Transition = {
|
||||
duration: 2.5,
|
||||
ease: [0.175, 0.885, 0.32, 1],
|
||||
times: [0, 0.6, 0.6, 1],
|
||||
repeat: Infinity,
|
||||
repeatType: "mirror",
|
||||
repeatDelay: 0.2,
|
||||
};
|
||||
|
||||
export const AnalyzingImage = () => {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative isolate flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{
|
||||
clipPath: "inset(0px 0px 0px 0px)",
|
||||
}}
|
||||
animate={{
|
||||
clipPath: [
|
||||
"inset(0px 0px 0px 0px)",
|
||||
"inset(0px 24px 0px 0px)",
|
||||
"inset(0px 24px 0px 0px)",
|
||||
"inset(0px 0px 0px 0px)",
|
||||
],
|
||||
}}
|
||||
transition={transition}
|
||||
className="bg-background z-10"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-muted-foreground/65"
|
||||
>
|
||||
<rect width="20" height="20" fill="hsl(var(--background))" />
|
||||
<path
|
||||
d="M4.27209 20.7279L10.8686 14.1314C11.2646 13.7354 11.4627 13.5373 11.691 13.4632C11.8918 13.3979 12.1082 13.3979 12.309 13.4632C12.5373 13.5373 12.7354 13.7354 13.1314 14.1314L19.6839 20.6839M14 15L16.8686 12.1314C17.2646 11.7354 17.4627 11.5373 17.691 11.4632C17.8918 11.3979 18.1082 11.3979 18.309 11.4632C18.5373 11.5373 18.7354 11.7354 19.1314 12.1314L22 15M10 9C10 10.1046 9.10457 11 8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9ZM6.8 21H17.2C18.8802 21 19.7202 21 20.362 20.673C20.9265 20.3854 21.3854 19.9265 21.673 19.362C22 18.7202 22 17.8802 22 16.2V7.8C22 6.11984 22 5.27976 21.673 4.63803C21.3854 4.07354 20.9265 3.6146 20.362 3.32698C19.7202 3 18.8802 3 17.2 3H6.8C5.11984 3 4.27976 3 3.63803 3.32698C3.07354 3.6146 2.6146 4.07354 2.32698 4.63803C2 5.27976 2 6.11984 2 7.8V16.2C2 17.8802 2 18.7202 2.32698 19.362C2.6146 19.9265 3.07354 20.3854 3.63803 20.673C4.27976 21 5.11984 21 6.8 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ transform: "translateX(10px)" }}
|
||||
animate={{
|
||||
transform: [
|
||||
"translateX(10px)",
|
||||
"translateX(-10px)",
|
||||
"translateX(-10px)",
|
||||
"translateX(10px)",
|
||||
],
|
||||
}}
|
||||
transition={transition}
|
||||
className="bg-muted-foreground/65 absolute z-10 h-full w-[3px] rounded-full"
|
||||
/>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-muted-foreground/65 absolute"
|
||||
>
|
||||
<rect width="20" height="20" fill="hsl(var(--background))" />
|
||||
<path
|
||||
d="M6.8 21H17.2C18.8802 21 19.7202 21 20.362 20.673C20.9265 20.3854 21.3854 19.9265 21.673 19.362C22 18.7202 22 17.8802 22 16.2V7.8C22 6.11984 22 5.27976 21.673 4.63803C21.3854 4.07354 20.9265 3.6146 20.362 3.32698C19.7202 3 18.8802 3 17.2 3H6.8C5.11984 3 4.27976 3 3.63803 3.32698C3.07354 3.6146 2.6146 4.07354 2.32698 4.63803C2 5.27976 2 6.11984 2 7.8V16.2C2 17.8802 2 18.7202 2.32698 19.362C2.6146 19.9265 3.07354 20.3854 3.63803 20.673C4.27976 21 5.11984 21 6.8 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect x="6" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="19" width="3" height="1" fill="currentColor" />
|
||||
<rect x="9" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="14" y="19" width="3" height="1" fill="currentColor" />
|
||||
<rect x="15" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="5" y="18" width="2" height="1" fill="currentColor" />
|
||||
<rect x="5" y="17" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="17" width="1" height="1" fill="currentColor" />
|
||||
<rect x="11" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="17" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="4" width="2" height="1" fill="currentColor" />
|
||||
<rect x="3" y="9" width="1" height="3" fill="currentColor" />
|
||||
<rect x="4" y="10" width="1" height="2" fill="currentColor" />
|
||||
<rect x="6" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="5" width="1" height="1" fill="currentColor" />
|
||||
<rect x="20" y="8" width="1" height="3" fill="currentColor" />
|
||||
<rect x="19" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="13" width="1" height="1" fill="currentColor" />
|
||||
<rect x="9" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="16" y="12" width="1" height="2" fill="currentColor" />
|
||||
<rect x="13" y="14" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="15" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="13" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="13" y="10" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="14" width="1" height="1" fill="currentColor" />
|
||||
<rect x="5" y="4" width="3" height="1" fill="currentColor" />
|
||||
<rect x="6" y="5" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="14" width="1" height="2" fill="currentColor" />
|
||||
<rect x="6" y="14" width="3" height="1" fill="currentColor" />
|
||||
<rect x="16" y="8" width="1" height="1" fill="currentColor" />
|
||||
<rect x="8" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="20" y="16" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="12" width="1" height="1" fill="currentColor" />
|
||||
<rect x="8" y="8" width="1" height="1" fill="currentColor" />
|
||||
<rect x="14" y="12" width="1" height="1" fill="currentColor" />
|
||||
<rect x="17" y="16" width="2" height="1" fill="currentColor" />
|
||||
<rect x="14" y="17" width="1" height="1" fill="currentColor" />
|
||||
<rect x="11" y="5" width="3" height="1" fill="currentColor" />
|
||||
<rect x="12" y="4" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="7" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="15" width="1" height="1" fill="currentColor" />
|
||||
<rect x="11" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="13" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="15" width="1" height="1" fill="currentColor" />
|
||||
<rect x="9" y="12" width="2" height="1" fill="currentColor" />
|
||||
<rect x="19" y="13" width="2" height="1" fill="currentColor" />
|
||||
<rect x="9" y="6" width="1" height="1" fill="currentColor" />
|
||||
<rect x="20" y="4" width="1" height="1" fill="currentColor" />
|
||||
<rect x="19" y="4" width="1" height="1" fill="currentColor" />
|
||||
<rect x="3" y="15" width="1" height="2" fill="currentColor" />
|
||||
<rect x="3" y="19" width="1" height="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<TextShimmer className="text-sm font-medium" duration={1.5}>
|
||||
{t("analyzingImage")}
|
||||
</TextShimmer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
77
apps/web/src/modules/common/ai/thread/controls/copy.tsx
Normal file
77
apps/web/src/modules/common/ai/thread/controls/copy.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
import { getMessageTextContent } from "@turbostarter/ai";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { useCopy } from "~/modules/common/hooks/use-copy";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
const transition = {
|
||||
initial: { opacity: 0, scale: 0.8 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.8 },
|
||||
transition: { duration: 0.1, ease: "easeInOut" as const },
|
||||
};
|
||||
|
||||
interface ThreadMessageCopyProps<MESSAGE extends UIMessage = UIMessage> {
|
||||
message: MESSAGE;
|
||||
}
|
||||
|
||||
export const ThreadMessageCopy = <MESSAGE extends UIMessage = UIMessage>({
|
||||
message,
|
||||
}: ThreadMessageCopyProps<MESSAGE>) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { copied, copy } = useCopy();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group/button size-8 rounded-full"
|
||||
onClick={() => copy(getMessageTextContent(message))}
|
||||
>
|
||||
<div className="relative size-3.5">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
{...transition}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<Icons.Check className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
{...transition}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<Icons.Copy className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<span className="sr-only">{t("copy")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("copy")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
25
apps/web/src/modules/common/ai/thread/controls/index.tsx
Normal file
25
apps/web/src/modules/common/ai/thread/controls/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { ThreadMessageCopy } from "./copy";
|
||||
import { ThreadMessageLikes } from "./likes";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
interface ControlsProps {
|
||||
message: UIMessage;
|
||||
}
|
||||
|
||||
export const Controls = ({ message }: ControlsProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background start-0 -ml-4 flex w-max items-center gap-px rounded-lg px-2 pb-2 text-xs opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 md:start-3",
|
||||
)}
|
||||
>
|
||||
{message.parts.some(
|
||||
(part) => part.type === "text" && part.text.length > 0,
|
||||
) && <ThreadMessageCopy message={message} />}
|
||||
<ThreadMessageLikes />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
apps/web/src/modules/common/ai/thread/controls/likes.tsx
Normal file
71
apps/web/src/modules/common/ai/thread/controls/likes.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
export const ThreadMessageLikes = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const [likeState, setLikeState] = useState<-1 | 0 | 1>(0);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group/button size-8 rounded-full"
|
||||
onClick={() => setLikeState(likeState === 1 ? 0 : 1)}
|
||||
>
|
||||
<Icons.ThumbsUp
|
||||
className={cn(
|
||||
"size-3.5 transition-colors",
|
||||
likeState === 1
|
||||
? "text-primary fill-current"
|
||||
: "text-muted-foreground group-hover/button:text-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">{t("like")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("like")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group/button size-8 rounded-full"
|
||||
onClick={() => setLikeState(likeState === -1 ? 0 : -1)}
|
||||
>
|
||||
<Icons.ThumbsDown
|
||||
className={cn(
|
||||
"size-3.5 transition-colors",
|
||||
likeState === -1
|
||||
? "text-primary fill-current"
|
||||
: "text-muted-foreground group-hover/button:text-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">{t("dislike")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("dislike")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Role } from "@turbostarter/ai/chat/types";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
interface UseThreadLayoutProps<MESSAGE extends UIMessage> {
|
||||
readonly messages: MESSAGE[];
|
||||
readonly initialMessages?: MESSAGE[];
|
||||
}
|
||||
|
||||
export const useThreadLayout = <MESSAGE extends UIMessage>({
|
||||
messages,
|
||||
initialMessages,
|
||||
}: UseThreadLayoutProps<MESSAGE>) => {
|
||||
const [scrolledByUser, setScrolledByUser] = useState(false);
|
||||
|
||||
const lastMessage = messages.at(-1);
|
||||
const lastMessageRef = useRef<HTMLDivElement>(null);
|
||||
const isChatActive = initialMessages?.length !== messages.length;
|
||||
|
||||
const lastUserMessageIndex = [...messages]
|
||||
.reverse()
|
||||
.findIndex((m) => m.role === Role.USER);
|
||||
const lastResponseMessages = messages.slice(
|
||||
lastUserMessageIndex !== 0 ? -2 : -1,
|
||||
);
|
||||
const previousMessages = messages.slice(0, -lastResponseMessages.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessageRef.current) return;
|
||||
|
||||
const parent = lastMessageRef.current.parentElement;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrolledByUser(true);
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
setScrolledByUser(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
parent?.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
parent?.removeEventListener("scroll", handleScroll);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [lastMessageRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessageRef.current) return;
|
||||
|
||||
const parent = lastMessageRef.current.parentElement;
|
||||
|
||||
const isAtBottom = () => {
|
||||
const container = parent?.closest("[data-radix-scroll-area-viewport]");
|
||||
|
||||
if (!container) return false;
|
||||
|
||||
const scrollBottom = container.scrollTop + container.clientHeight;
|
||||
return Math.abs(container.scrollHeight - scrollBottom) < 150;
|
||||
};
|
||||
|
||||
if (isChatActive) {
|
||||
if (lastMessage?.role === Role.USER) {
|
||||
requestAnimationFrame(() => {
|
||||
parent?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
});
|
||||
});
|
||||
} else if (isAtBottom() && !scrolledByUser) {
|
||||
requestAnimationFrame(() => {
|
||||
parent?.scrollIntoView({
|
||||
behavior: "instant",
|
||||
block: "end",
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const animationFrameId = requestAnimationFrame(() => {
|
||||
parent?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
});
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}, [lastMessage, scrolledByUser, isChatActive]);
|
||||
|
||||
return {
|
||||
lastMessage,
|
||||
lastMessageRef,
|
||||
isChatActive,
|
||||
lastResponseMessages,
|
||||
previousMessages,
|
||||
};
|
||||
};
|
||||
134
apps/web/src/modules/common/ai/thread/index.tsx
Normal file
134
apps/web/src/modules/common/ai/thread/index.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { Role } from "@turbostarter/ai/chat/types";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
|
||||
|
||||
import { AnalyzingImage } from "./analyzing-image";
|
||||
import { useThreadLayout } from "./hooks/use-thread-layout";
|
||||
import { ThreadMessage } from "./message";
|
||||
|
||||
import type { ThreadMessageComponents } from "./message";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
interface ThreadProps<MESSAGE extends UIMessage> {
|
||||
readonly messages: MESSAGE[];
|
||||
readonly initialMessages?: MESSAGE[];
|
||||
readonly status: string;
|
||||
readonly error?: Error | null;
|
||||
readonly regenerate?: () => Promise<void>;
|
||||
readonly className?: string;
|
||||
readonly components: ThreadMessageComponents<MESSAGE>;
|
||||
readonly footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Thread = <MESSAGE extends UIMessage>({
|
||||
messages,
|
||||
initialMessages,
|
||||
status,
|
||||
error,
|
||||
regenerate,
|
||||
className,
|
||||
components,
|
||||
footer,
|
||||
}: ThreadProps<MESSAGE>) => {
|
||||
const { t } = useTranslation("common");
|
||||
const isReloading = useRef(false);
|
||||
|
||||
const {
|
||||
lastMessage,
|
||||
lastMessageRef,
|
||||
isChatActive,
|
||||
previousMessages,
|
||||
lastResponseMessages,
|
||||
} = useThreadLayout({ messages, initialMessages });
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
messages.at(-1)?.role === Role.USER &&
|
||||
status === "ready" &&
|
||||
!isReloading.current
|
||||
) {
|
||||
isReloading.current = true;
|
||||
void regenerate?.().finally(() => {
|
||||
isReloading.current = false;
|
||||
});
|
||||
}
|
||||
}, [regenerate, messages, status]);
|
||||
|
||||
const renderMessage = useCallback(
|
||||
(message: MESSAGE) => {
|
||||
return (
|
||||
<ThreadMessage.Message
|
||||
message={message}
|
||||
key={message.id}
|
||||
status={status}
|
||||
components={components}
|
||||
{...(message.id === lastMessage?.id && { ref: lastMessageRef })}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[lastMessage?.id, lastMessageRef, status, components],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"@container/thread h-full w-full pt-12 pb-4 md:pt-14",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="px-5">
|
||||
{previousMessages.map(renderMessage)}
|
||||
<div
|
||||
className={cn("mx-auto flex w-full max-w-3xl flex-col", {
|
||||
"min-h-[calc(100vh-4rem)] md:min-h-[calc(100vh-5.5rem)]":
|
||||
isChatActive,
|
||||
})}
|
||||
>
|
||||
{lastResponseMessages.map(renderMessage)}
|
||||
{["submitted", "streaming"].includes(status) && (
|
||||
<div className="relative py-4 md:px-4">
|
||||
{status === "submitted" &&
|
||||
messages.at(-1)?.role === Role.USER &&
|
||||
messages
|
||||
.at(-1)
|
||||
?.parts.some(
|
||||
(part) =>
|
||||
part.type === "file" && part.mediaType.startsWith("image"),
|
||||
) ? (
|
||||
<AnalyzingImage />
|
||||
) : (
|
||||
<Icons.Loader className="text-muted-foreground size-5 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{footer}
|
||||
{error && (
|
||||
<div className="relative pb-4 @lg/thread:px-2 @xl/thread:px-4">
|
||||
<div className="bg-destructive/10 dark:bg-destructive/40 flex w-fit flex-wrap items-center gap-3 rounded-xl p-5 py-3">
|
||||
<p className="text-destructive dark:text-foreground">
|
||||
{t("error.general")}
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-auto gap-2"
|
||||
onClick={() => regenerate?.()}
|
||||
>
|
||||
<Icons.RotateCw className="size-4" />
|
||||
{t("reload")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full pb-[calc(var(--composer-height)+20px)]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
66
apps/web/src/modules/common/ai/thread/message.tsx
Normal file
66
apps/web/src/modules/common/ai/thread/message.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Controls } from "./controls";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
export type ThreadMessageComponents<MESSAGE extends UIMessage> = Record<
|
||||
string,
|
||||
React.ComponentType<ThreadMessageProps<MESSAGE>>
|
||||
>;
|
||||
|
||||
export interface ThreadMessageProps<T extends UIMessage = UIMessage> {
|
||||
readonly status: string;
|
||||
readonly message: T;
|
||||
readonly ref?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const Message = <MESSAGE extends UIMessage>(
|
||||
props: ThreadMessageProps<MESSAGE> & {
|
||||
components: ThreadMessageComponents<MESSAGE>;
|
||||
},
|
||||
) => {
|
||||
const role = props.message.role;
|
||||
|
||||
const isSupportedRole = (
|
||||
role: string,
|
||||
): role is keyof typeof props.components => {
|
||||
return role in props.components;
|
||||
};
|
||||
|
||||
if (!isSupportedRole(role)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Component = props.components[role];
|
||||
|
||||
if (!Component) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
const Layout = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative mx-auto flex w-full max-w-3xl scroll-mb-[calc(var(--composer-height,140px)+36px)] flex-col justify-center gap-1 py-4 @md/thread:px-1 @lg/thread:px-2 @xl/thread:px-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThreadMessage = {
|
||||
Layout,
|
||||
Message,
|
||||
Controls,
|
||||
};
|
||||
399
apps/web/src/modules/common/avatar-form.tsx
Normal file
399
apps/web/src/modules/common/avatar-form.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation, useMutationState } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { FormProvider, useForm, useFormContext } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
interface AvatarFormProps {
|
||||
readonly id: string;
|
||||
readonly image?: string | null;
|
||||
readonly update: (
|
||||
image: string | null,
|
||||
) => Promise<{ error: { message?: string } | null }>;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
const mutations = {
|
||||
upload: {
|
||||
mutationKey: ["avatar", "upload"],
|
||||
mutationFn: async ({
|
||||
avatar,
|
||||
id,
|
||||
image,
|
||||
update,
|
||||
}: AvatarFormProps & { avatar?: File }) => {
|
||||
const extension = avatar?.type.split("/").pop();
|
||||
const uuid = crypto.randomUUID();
|
||||
const path = `avatars/${id}-${uuid}.${extension}`;
|
||||
|
||||
const { url: uploadUrl } = await handle(api.storage.upload.$get)({
|
||||
query: { path },
|
||||
});
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: avatar,
|
||||
headers: {
|
||||
"Content-Type": avatar?.type ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const { url: publicUrl } = await handle(api.storage.public.$get)({
|
||||
query: { path },
|
||||
});
|
||||
|
||||
const { error } = await update(publicUrl);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return { publicUrl, oldImage: image };
|
||||
},
|
||||
},
|
||||
remove: {
|
||||
mutationKey: ["avatar", "remove"],
|
||||
mutationFn: async ({ image, update }: Omit<AvatarFormProps, "id">) => {
|
||||
const path = image?.split("/").pop();
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { url: deleteUrl } = await handle(api.storage.delete.$get)({
|
||||
query: { path: `avatars/${path}` },
|
||||
});
|
||||
|
||||
const { error } = await update(null);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
void fetch(deleteUrl, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const useAvatarFormSchema = () => {
|
||||
const { t, i18n } = useTranslation("validation");
|
||||
|
||||
return z.object({
|
||||
avatar: z
|
||||
.custom<FileList>()
|
||||
.refine(
|
||||
(files) => files.length === 1,
|
||||
t("error.file.maxCount", { count: 1 }),
|
||||
)
|
||||
.transform((files) => files[0])
|
||||
.refine(
|
||||
(file) => (file?.size ?? 0) <= MAX_FILE_SIZE,
|
||||
t("error.tooBig.file.inclusive", {
|
||||
maximum: MAX_FILE_SIZE,
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(file) => ACCEPTED_IMAGE_TYPES.includes(file?.type ?? ""),
|
||||
t("error.file.type", {
|
||||
types: new Intl.ListFormat(i18n.language, {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
}).format(ACCEPTED_IMAGE_TYPES.map((t) => t.replace("image/", "."))),
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
interface AvatarFormContextValue extends AvatarFormProps {
|
||||
previewUrl: string | null;
|
||||
setPreviewUrl: (previewUrl: string | null) => void;
|
||||
}
|
||||
|
||||
const AvatarFormContext = createContext<AvatarFormContextValue | null>(null);
|
||||
|
||||
const useAvatarFormContext = () => {
|
||||
const context = useContext(AvatarFormContext);
|
||||
if (!context) {
|
||||
throw new Error("useAvatarFormContext must be used within a AvatarForm!");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const AvatarForm = ({
|
||||
id,
|
||||
image,
|
||||
|
||||
update,
|
||||
children,
|
||||
}: AvatarFormProps & {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [previewUrl, setPreviewUrl] = useState(image ?? null);
|
||||
const avatarSchema = useAvatarFormSchema();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(avatarSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<AvatarFormContext.Provider
|
||||
value={{ id, image, update, previewUrl, setPreviewUrl }}
|
||||
>
|
||||
<FormProvider {...form}>{children}</FormProvider>
|
||||
</AvatarFormContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarFormInput = ({
|
||||
className,
|
||||
children,
|
||||
onUpload,
|
||||
disabled,
|
||||
...props
|
||||
}: React.ComponentProps<"input"> & { onUpload?: () => void }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { image, setPreviewUrl, id, update } = useAvatarFormContext();
|
||||
const avatarSchema = useAvatarFormSchema();
|
||||
|
||||
const { register, handleSubmit, reset } =
|
||||
useFormContext<z.infer<typeof avatarSchema>>();
|
||||
|
||||
const upload = useMutation({
|
||||
...mutations.upload,
|
||||
onError: (error) => {
|
||||
setPreviewUrl(image ?? null);
|
||||
toast.error(error.message || t("error.general"));
|
||||
},
|
||||
onSuccess: async ({ publicUrl, oldImage }) => {
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.src = publicUrl;
|
||||
img.onload = resolve;
|
||||
});
|
||||
|
||||
if (oldImage) {
|
||||
const oldPath = oldImage.split("/").pop();
|
||||
if (oldPath) {
|
||||
const { url: deleteUrl } = await handle(api.storage.delete.$get)({
|
||||
query: { path: `avatars/${oldPath}` },
|
||||
});
|
||||
void fetch(deleteUrl, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
|
||||
onUpload?.();
|
||||
},
|
||||
});
|
||||
|
||||
const [removeStatus] = useMutationState({
|
||||
filters: { mutationKey: mutations.remove.mutationKey },
|
||||
select: (mutation) => mutation.state.status,
|
||||
});
|
||||
|
||||
const onSubmit = (data: z.infer<typeof avatarSchema>) => {
|
||||
upload.mutate({
|
||||
...data,
|
||||
id,
|
||||
image,
|
||||
update,
|
||||
});
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"group",
|
||||
{
|
||||
"cursor-pointer": !disabled,
|
||||
"cursor-not-allowed opacity-50": disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<input
|
||||
{...register("avatar", {
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = avatarSchema.safeParse({
|
||||
avatar: e.target.files,
|
||||
});
|
||||
|
||||
if (isValid.success) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
|
||||
void handleSubmit(onSubmit)();
|
||||
},
|
||||
})}
|
||||
type="file"
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
||||
className="sr-only"
|
||||
aria-label={t("update")}
|
||||
onClick={(e) => "value" in e.target && (e.target.value = "")}
|
||||
disabled={disabled ?? (upload.isPending || removeStatus === "pending")}
|
||||
{...props}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarFormPreview = ({
|
||||
className,
|
||||
fallback,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Avatar> & { fallback?: React.ReactNode }) => {
|
||||
const { previewUrl } = useAvatarFormContext();
|
||||
const { formState } = useFormContext();
|
||||
|
||||
const mutationStatues = useMutationState({
|
||||
filters: {
|
||||
predicate: (mutation) =>
|
||||
[mutations.upload.mutationKey, mutations.remove.mutationKey].includes(
|
||||
mutation.options.mutationKey as string[],
|
||||
),
|
||||
},
|
||||
select: (mutation) => mutation.state.status,
|
||||
});
|
||||
|
||||
const hasError =
|
||||
formState.errors.avatar ??
|
||||
mutationStatues.some((status) => status === "error");
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
className={cn(
|
||||
`relative size-20 transition-all ${
|
||||
hasError
|
||||
? "ring-destructive ring-offset-background ring-2 ring-offset-2"
|
||||
: "group-focus-within:ring-primary group-focus-within:ring-offset-background group-focus-within:ring-2 group-focus-within:ring-offset-2"
|
||||
}`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{previewUrl && <AvatarImage src={previewUrl} />}
|
||||
{mutationStatues.some((status) => status === "pending") && (
|
||||
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-full backdrop-blur-sm">
|
||||
<Icons.Loader2 className="text-muted-foreground size-7 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<AvatarFallback>
|
||||
{fallback ?? <Icons.UserRound className="size-10" />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarFormRemoveButton = ({
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button> & { onRemove?: () => void }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { image, update, previewUrl, setPreviewUrl } = useAvatarFormContext();
|
||||
|
||||
const { clearErrors } = useFormContext();
|
||||
const [uploadStatus] = useMutationState({
|
||||
filters: { mutationKey: mutations.upload.mutationKey },
|
||||
select: (mutation) => mutation.state.status,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
...mutations.remove,
|
||||
onMutate: () => {
|
||||
setPreviewUrl(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setPreviewUrl(null);
|
||||
onRemove?.();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
previewUrl &&
|
||||
uploadStatus !== "pending" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"bg-background dark:bg-background hover:bg-muted dark:hover:bg-muted absolute -top-1 -right-1 size-6 rounded-full",
|
||||
className,
|
||||
)}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
clearErrors();
|
||||
remove.mutate({
|
||||
image,
|
||||
update,
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Icons.X className="size-3.5" />
|
||||
<span className="sr-only">{t("remove")}</span>
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarFormErrorMessage = (props: React.ComponentProps<"span">) => {
|
||||
const _avatarSchema = useAvatarFormSchema();
|
||||
const { formState } = useFormContext<z.infer<typeof _avatarSchema>>();
|
||||
|
||||
if (!formState.errors.avatar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("text-destructive text-xs", props.className)}
|
||||
{...props}
|
||||
>
|
||||
{formState.errors.avatar.message}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
AvatarForm,
|
||||
AvatarFormInput,
|
||||
AvatarFormPreview,
|
||||
AvatarFormRemoveButton,
|
||||
AvatarFormErrorMessage,
|
||||
};
|
||||
142
apps/web/src/modules/common/destructive-action-dialog.tsx
Normal file
142
apps/web/src/modules/common/destructive-action-dialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@turbostarter/ui-web/accordion";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@turbostarter/ui-web/alert-dialog";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
interface ImpactItem {
|
||||
id: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface CascadeImpact {
|
||||
label: string;
|
||||
count: number;
|
||||
items: ImpactItem[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
interface DestructiveActionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
impacts: CascadeImpact[];
|
||||
isLoading?: boolean;
|
||||
isExecuting?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
export function DestructiveActionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
impacts,
|
||||
isLoading = false,
|
||||
isExecuting = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmLabel = "Delete",
|
||||
}: DestructiveActionDialogProps) {
|
||||
const hasImpacts = impacts.length > 0;
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Icons.Loader2 className="h-4 w-4 animate-spin" />
|
||||
Checking impact...
|
||||
</div>
|
||||
) : hasImpacts ? (
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icons.AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium text-destructive">
|
||||
This will also delete:
|
||||
</p>
|
||||
<Accordion type="multiple" className="mt-2">
|
||||
{impacts.map((impact) => (
|
||||
<AccordionItem
|
||||
key={impact.label}
|
||||
value={impact.label}
|
||||
className="border-b-0"
|
||||
>
|
||||
<AccordionTrigger className="py-1.5 text-sm text-muted-foreground hover:no-underline">
|
||||
<span>
|
||||
{impact.count} {impact.label}
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-2 pt-0">
|
||||
<ul className="space-y-0.5 text-xs text-muted-foreground">
|
||||
{impact.items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="truncate pl-2"
|
||||
title={item.displayName}
|
||||
>
|
||||
• {item.displayName}
|
||||
</li>
|
||||
))}
|
||||
{impact.hasMore && (
|
||||
<li className="pl-2 italic">
|
||||
+{impact.count - impact.items.length} more...
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel} disabled={isExecuting}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading || isExecuting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<Icons.Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
confirmLabel
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
123
apps/web/src/modules/common/hooks/use-ai-error.tsx
Normal file
123
apps/web/src/modules/common/hooks/use-ai-error.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { isAPIError } from "@turbostarter/api/utils";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
const InsufficientCredits = () => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const list = [
|
||||
t("credits.description", { count: 5 }),
|
||||
t("credits.description2"),
|
||||
t("credits.description3"),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Icons.BanknoteX className="size-5" />
|
||||
<div className="flex flex-col gap-6">
|
||||
<span className="leading-tight font-medium">
|
||||
{t("error.insufficientCredits")}
|
||||
</span>
|
||||
|
||||
<ul className="flex flex-col gap-2">
|
||||
{list.map((item) => (
|
||||
<li key={item} className="flex items-center gap-2.5">
|
||||
<div className="bg-success size-2 rounded-full"></div>
|
||||
<span className="leading-tight">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href="https://turbostarter.dev/ai#pricing"
|
||||
target="_blank"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"group/button gap-2",
|
||||
)}
|
||||
>
|
||||
{t("credits.cta")}{" "}
|
||||
<Icons.ArrowRight className="size-4 transition-all group-hover/button:translate-x-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RateLimit = () => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const list = [
|
||||
t("rateLimit.description"),
|
||||
t("rateLimit.description2"),
|
||||
t("rateLimit.description3"),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Icons.ClockAlert className="size-5" />
|
||||
<div className="flex flex-col gap-6">
|
||||
<span className="leading-tight font-medium">
|
||||
{t("error.rateLimit")}
|
||||
</span>
|
||||
|
||||
<ul className="flex flex-col gap-2">
|
||||
{list.map((item) => (
|
||||
<li key={item} className="flex items-center gap-2.5">
|
||||
<div className="bg-success size-2 rounded-full"></div>
|
||||
<span className="leading-tight">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href="https://turbostarter.dev/ai#pricing"
|
||||
target="_blank"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"group/button gap-2",
|
||||
)}
|
||||
>
|
||||
{t("rateLimit.cta")}{" "}
|
||||
<Icons.ArrowRight className="size-4 transition-all group-hover/button:translate-x-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const parseError = (error: Error) => {
|
||||
try {
|
||||
const parsed = JSON.parse(error.message);
|
||||
return parsed;
|
||||
} catch {
|
||||
return error.message;
|
||||
}
|
||||
};
|
||||
|
||||
export const useAIError = () => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const onError = (error: Error) => {
|
||||
console.error(error);
|
||||
const parsed = parseError(error);
|
||||
|
||||
if (!isAPIError(parsed)) {
|
||||
return toast.error(error.message || t("error.general"));
|
||||
}
|
||||
|
||||
if (parsed.code === "error.insufficientCredits") {
|
||||
return toast(<InsufficientCredits />);
|
||||
}
|
||||
|
||||
if (parsed.code === "error.rateLimit") {
|
||||
return toast(<RateLimit />);
|
||||
}
|
||||
};
|
||||
|
||||
return { onError };
|
||||
};
|
||||
32
apps/web/src/modules/common/hooks/use-copy-to-clipboard.ts
Normal file
32
apps/web/src/modules/common/hooks/use-copy-to-clipboard.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
type CopiedValue = string | null;
|
||||
|
||||
type CopyFn = (text: string) => Promise<boolean>;
|
||||
|
||||
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
|
||||
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
|
||||
|
||||
const copy: CopyFn = useCallback(async (text) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!navigator?.clipboard) {
|
||||
logger.warn("Clipboard not supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to save to clipboard then save it in the state if worked
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedText(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn("Copy failed", error);
|
||||
setCopiedText(null);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [copiedText, copy];
|
||||
}
|
||||
46
apps/web/src/modules/common/hooks/use-copy.tsx
Normal file
46
apps/web/src/modules/common/hooks/use-copy.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type CopiedValue = string | null;
|
||||
type CopyFn = (text: string) => Promise<boolean>;
|
||||
|
||||
export function useCopy() {
|
||||
const [text, setText] = useState<CopiedValue>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
if (copied) {
|
||||
timeoutId = setTimeout(() => {
|
||||
setCopied(false);
|
||||
setText(null);
|
||||
}, 3000);
|
||||
}
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [copied]);
|
||||
|
||||
const copy: CopyFn = useCallback(async (text) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!navigator?.clipboard) {
|
||||
console.warn("Clipboard not supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setText(text);
|
||||
setCopied(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("Copy failed", error);
|
||||
setText(null);
|
||||
setCopied(false);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
copied,
|
||||
copy,
|
||||
text,
|
||||
};
|
||||
}
|
||||
90
apps/web/src/modules/common/hooks/use-data-table/common.ts
Normal file
90
apps/web/src/modules/common/hooks/use-data-table/common.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createParser } from "nuqs/server";
|
||||
import * as z from "zod";
|
||||
|
||||
import { sortSchema } from "@turbostarter/shared/schema";
|
||||
|
||||
import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table";
|
||||
|
||||
const PAGE_KEY = "page";
|
||||
const PER_PAGE_KEY = "perPage";
|
||||
const SORT_KEY = "sort";
|
||||
const ARRAY_SEPARATOR = ",";
|
||||
const DEBOUNCE_MS = 500;
|
||||
const THROTTLE_MS = 50;
|
||||
|
||||
interface CommonProps {
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
const getFiltersFromColumnFilters = <TData>({
|
||||
columnFilters,
|
||||
previousColumnFilters,
|
||||
filterableColumns,
|
||||
}: {
|
||||
columnFilters: ColumnFiltersState;
|
||||
previousColumnFilters?: ColumnFiltersState;
|
||||
filterableColumns: ColumnDef<TData>[];
|
||||
}) => {
|
||||
const filterUpdates = columnFilters.reduce<
|
||||
Record<string, string | string[] | null>
|
||||
>((acc, filter) => {
|
||||
if (filterableColumns.find((column) => column.id === filter.id)) {
|
||||
acc[filter.id] = filter.value as string | string[];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
for (const prevFilter of previousColumnFilters ?? []) {
|
||||
if (!columnFilters.some((filter) => filter.id === prevFilter.id)) {
|
||||
filterUpdates[prevFilter.id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return filterUpdates;
|
||||
};
|
||||
|
||||
const getSortingStateParser = (columnIds?: string[] | Set<string>) => {
|
||||
const validKeys = columnIds
|
||||
? columnIds instanceof Set
|
||||
? columnIds
|
||||
: new Set(columnIds)
|
||||
: null;
|
||||
|
||||
return createParser({
|
||||
parse: (value) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const result = z.array(sortSchema).safeParse(parsed);
|
||||
|
||||
if (!result.success) return null;
|
||||
|
||||
if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
serialize: (value) => JSON.stringify(value),
|
||||
eq: (a, b) =>
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(item, index) =>
|
||||
item.id === b[index]?.id && item.desc === b[index].desc,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
PAGE_KEY,
|
||||
PER_PAGE_KEY,
|
||||
SORT_KEY,
|
||||
ARRAY_SEPARATOR,
|
||||
DEBOUNCE_MS,
|
||||
THROTTLE_MS,
|
||||
getFiltersFromColumnFilters,
|
||||
getSortingStateParser,
|
||||
type CommonProps,
|
||||
};
|
||||
38
apps/web/src/modules/common/hooks/use-data-table/index.ts
Normal file
38
apps/web/src/modules/common/hooks/use-data-table/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useLocalDataTable } from "./local";
|
||||
import { useSearchParamsDataTable } from "./search-params";
|
||||
|
||||
import type { UseLocalDataTableProps } from "./local";
|
||||
import type { UseSearchParamsDataTableProps } from "./search-params";
|
||||
import type { useQuery } from "@tanstack/react-query";
|
||||
import type { UseQueryOptions } from "@tanstack/react-query";
|
||||
import type { useReactTable } from "@tanstack/react-table";
|
||||
|
||||
export function useDataTable<
|
||||
TData,
|
||||
TQueryFnData extends { data?: TData[]; total?: number },
|
||||
TQuery extends UseQueryOptions<TQueryFnData>,
|
||||
>(
|
||||
props: UseLocalDataTableProps<TData, TQueryFnData, TQuery>,
|
||||
): {
|
||||
table: ReturnType<typeof useReactTable<TData>>;
|
||||
query: ReturnType<typeof useQuery<TQueryFnData>>;
|
||||
};
|
||||
export function useDataTable<TData>(
|
||||
props: UseSearchParamsDataTableProps<TData>,
|
||||
): { table: ReturnType<typeof useReactTable<TData>> };
|
||||
|
||||
export function useDataTable<
|
||||
TData,
|
||||
TQueryFnData extends { data?: TData[]; total?: number },
|
||||
TQuery extends UseQueryOptions<TQueryFnData>,
|
||||
>(
|
||||
props:
|
||||
| UseLocalDataTableProps<TData, TQueryFnData, TQuery>
|
||||
| UseSearchParamsDataTableProps<TData>,
|
||||
) {
|
||||
if (props.persistance === "local") {
|
||||
return useLocalDataTable(props);
|
||||
}
|
||||
return useSearchParamsDataTable(props);
|
||||
}
|
||||
189
apps/web/src/modules/common/hooks/use-data-table/local.ts
Normal file
189
apps/web/src/modules/common/hooks/use-data-table/local.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getFacetedUniqueValues,
|
||||
getFacetedRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
getFilteredRowModel,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getFacetedMinMaxValues,
|
||||
} from "@tanstack/react-table";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useDebounceCallback } from "@turbostarter/shared/hooks";
|
||||
|
||||
import { DEBOUNCE_MS, getFiltersFromColumnFilters } from "./common";
|
||||
|
||||
import type { CommonProps } from "./common";
|
||||
import type { UseQueryOptions } from "@tanstack/react-query";
|
||||
import type {
|
||||
ColumnFiltersState,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
Updater,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import type { TableOptions } from "@tanstack/react-table";
|
||||
|
||||
export interface UseLocalDataTableProps<
|
||||
TData,
|
||||
TQueryFnData extends { data?: TData[]; pageCount?: number },
|
||||
TQuery extends UseQueryOptions<TQueryFnData>,
|
||||
> extends CommonProps,
|
||||
Omit<TableOptions<TData>, "data" | "state" | "getCoreRowModel"> {
|
||||
persistance: "local";
|
||||
query: (params: {
|
||||
page: number;
|
||||
perPage: number;
|
||||
sorting: SortingState;
|
||||
filters: Record<string, string | string[] | null>;
|
||||
}) => TQuery;
|
||||
}
|
||||
|
||||
export function useLocalDataTable<
|
||||
TData,
|
||||
TQueryFnData extends { data?: TData[]; total?: number },
|
||||
TQuery extends UseQueryOptions<TQueryFnData>,
|
||||
>(props: UseLocalDataTableProps<TData, TQueryFnData, TQuery>) {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
|
||||
props.initialState?.rowSelection ?? {},
|
||||
);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>(
|
||||
props.initialState?.sorting ?? [],
|
||||
);
|
||||
|
||||
const [page, setPage] = useState(
|
||||
(props.initialState?.pagination?.pageIndex ?? 0) + 1,
|
||||
);
|
||||
const [perPage, setPerPage] = useState(
|
||||
props.initialState?.pagination?.pageSize ?? 10,
|
||||
);
|
||||
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(
|
||||
props.initialState?.columnFilters ?? [],
|
||||
);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
props.initialState?.columnVisibility ?? {},
|
||||
);
|
||||
|
||||
const filterableColumns = useMemo(() => {
|
||||
return props.columns.filter((column) => column.enableColumnFilter);
|
||||
}, [props.columns]);
|
||||
|
||||
const [filters, setFilters] = useState<
|
||||
Record<string, string | string[] | null>
|
||||
>(getFiltersFromColumnFilters({ columnFilters, filterableColumns }));
|
||||
|
||||
const debouncedSetFilters = useDebounceCallback(
|
||||
(values: Record<string, string | string[] | null>) => {
|
||||
setPage(1);
|
||||
setFilters(values);
|
||||
},
|
||||
DEBOUNCE_MS,
|
||||
);
|
||||
const pagination: PaginationState = useMemo(
|
||||
() => ({
|
||||
pageIndex: page - 1,
|
||||
pageSize: perPage,
|
||||
}),
|
||||
[page, perPage],
|
||||
);
|
||||
|
||||
const onPaginationChange = useCallback(
|
||||
(updaterOrValue: Updater<PaginationState>) => {
|
||||
const newPagination =
|
||||
typeof updaterOrValue === "function"
|
||||
? updaterOrValue(pagination)
|
||||
: updaterOrValue;
|
||||
setPage(newPagination.pageIndex + 1);
|
||||
setPerPage(newPagination.pageSize);
|
||||
},
|
||||
[pagination, setPage, setPerPage],
|
||||
);
|
||||
|
||||
const onSortingChange = useCallback(
|
||||
(updaterOrValue: Updater<SortingState>) => {
|
||||
setSorting(
|
||||
typeof updaterOrValue === "function"
|
||||
? updaterOrValue(sorting)
|
||||
: updaterOrValue,
|
||||
);
|
||||
},
|
||||
[sorting, setSorting],
|
||||
);
|
||||
|
||||
const onColumnFiltersChange = useCallback(
|
||||
(updaterOrValue: Updater<ColumnFiltersState>) => {
|
||||
setColumnFilters((prev) => {
|
||||
const next =
|
||||
typeof updaterOrValue === "function"
|
||||
? updaterOrValue(prev)
|
||||
: updaterOrValue;
|
||||
|
||||
debouncedSetFilters(
|
||||
getFiltersFromColumnFilters({
|
||||
columnFilters: next,
|
||||
previousColumnFilters: prev,
|
||||
filterableColumns,
|
||||
}),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[debouncedSetFilters, filterableColumns],
|
||||
);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
page,
|
||||
perPage,
|
||||
sorting,
|
||||
filters,
|
||||
}),
|
||||
[page, perPage, sorting, filters],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
...props.query(params),
|
||||
enabled: !!props.query,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const pageCount = useMemo(() => {
|
||||
return Math.ceil((query.data?.total ?? 0) / perPage);
|
||||
}, [query.data?.total, perPage]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: query.data?.data ?? [],
|
||||
pageCount,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnFilters,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onPaginationChange,
|
||||
onSortingChange,
|
||||
onColumnFiltersChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
getFacetedMinMaxValues: getFacetedMinMaxValues(),
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
...props,
|
||||
});
|
||||
|
||||
return { table, query };
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
getFacetedUniqueValues,
|
||||
getFacetedRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
getFilteredRowModel,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getFacetedMinMaxValues,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
parseAsArrayOf,
|
||||
parseAsString,
|
||||
parseAsInteger,
|
||||
useQueryState,
|
||||
useQueryStates,
|
||||
} from "nuqs";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useDebounceCallback } from "@turbostarter/shared/hooks";
|
||||
|
||||
import {
|
||||
ARRAY_SEPARATOR,
|
||||
DEBOUNCE_MS,
|
||||
getFiltersFromColumnFilters,
|
||||
PAGE_KEY,
|
||||
PER_PAGE_KEY,
|
||||
SORT_KEY,
|
||||
THROTTLE_MS,
|
||||
} from "./common";
|
||||
import { getSortingStateParser } from "./common";
|
||||
|
||||
import type { CommonProps } from "./common";
|
||||
import type {
|
||||
ColumnFiltersState,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
Updater,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import type { TableOptions } from "@tanstack/react-table";
|
||||
import type { Parser, UseQueryStateOptions } from "nuqs";
|
||||
|
||||
export interface UseSearchParamsDataTableProps<TData>
|
||||
extends CommonProps,
|
||||
Omit<TableOptions<TData>, "state" | "getCoreRowModel"> {
|
||||
persistance: "searchParams";
|
||||
history?: "push" | "replace";
|
||||
throttleMs?: number;
|
||||
clearOnDefault?: boolean;
|
||||
enableAdvancedFilter?: boolean;
|
||||
scroll?: boolean;
|
||||
shallow?: boolean;
|
||||
startTransition?: React.TransitionStartFunction;
|
||||
}
|
||||
|
||||
export function useSearchParamsDataTable<TData>(
|
||||
props: UseSearchParamsDataTableProps<TData>,
|
||||
) {
|
||||
const {
|
||||
columns,
|
||||
initialState,
|
||||
history = "replace",
|
||||
debounceMs = DEBOUNCE_MS,
|
||||
throttleMs = THROTTLE_MS,
|
||||
clearOnDefault = false,
|
||||
scroll = false,
|
||||
shallow = true,
|
||||
startTransition,
|
||||
...tableProps
|
||||
} = props;
|
||||
|
||||
const queryStateOptions = useMemo<
|
||||
Omit<UseQueryStateOptions<string>, "parse">
|
||||
>(
|
||||
() => ({
|
||||
history,
|
||||
scroll,
|
||||
shallow,
|
||||
throttleMs,
|
||||
debounceMs,
|
||||
clearOnDefault,
|
||||
startTransition,
|
||||
}),
|
||||
[
|
||||
history,
|
||||
scroll,
|
||||
shallow,
|
||||
throttleMs,
|
||||
debounceMs,
|
||||
clearOnDefault,
|
||||
startTransition,
|
||||
],
|
||||
);
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
|
||||
initialState?.rowSelection ?? {},
|
||||
);
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
initialState?.columnVisibility ?? {},
|
||||
);
|
||||
|
||||
const [page, setPage] = useQueryState(
|
||||
PAGE_KEY,
|
||||
parseAsInteger.withOptions(queryStateOptions).withDefault(1),
|
||||
);
|
||||
const [perPage, setPerPage] = useQueryState(
|
||||
PER_PAGE_KEY,
|
||||
parseAsInteger
|
||||
.withOptions(queryStateOptions)
|
||||
.withDefault(initialState?.pagination?.pageSize ?? 10),
|
||||
);
|
||||
|
||||
const pagination: PaginationState = useMemo(() => {
|
||||
return {
|
||||
pageIndex: page - 1,
|
||||
pageSize: perPage,
|
||||
};
|
||||
}, [page, perPage]);
|
||||
|
||||
const onPaginationChange = useCallback(
|
||||
(updaterOrValue: Updater<PaginationState>) => {
|
||||
const newPagination =
|
||||
typeof updaterOrValue === "function"
|
||||
? updaterOrValue(pagination)
|
||||
: updaterOrValue;
|
||||
void setPage(newPagination.pageIndex + 1);
|
||||
void setPerPage(newPagination.pageSize);
|
||||
},
|
||||
[pagination, setPage, setPerPage],
|
||||
);
|
||||
|
||||
const columnIds = useMemo(() => {
|
||||
return new Set(columns.map((column) => column.id).filter(Boolean));
|
||||
}, [columns]);
|
||||
|
||||
const [sorting, setSorting] = useQueryState(
|
||||
SORT_KEY,
|
||||
getSortingStateParser(columnIds)
|
||||
.withOptions(queryStateOptions)
|
||||
.withDefault(initialState?.sorting ?? []),
|
||||
);
|
||||
|
||||
const onSortingChange = useCallback(
|
||||
(updaterOrValue: Updater<SortingState>) => {
|
||||
void setSorting(
|
||||
typeof updaterOrValue === "function"
|
||||
? updaterOrValue(sorting)
|
||||
: updaterOrValue,
|
||||
);
|
||||
},
|
||||
[sorting, setSorting],
|
||||
);
|
||||
|
||||
const filterableColumns = useMemo(
|
||||
() => columns.filter((column) => column.enableColumnFilter),
|
||||
[columns],
|
||||
);
|
||||
|
||||
const filterParsers = useMemo(() => {
|
||||
return filterableColumns.reduce<
|
||||
Record<string, Parser<string> | Parser<string[]>>
|
||||
>((acc, column) => {
|
||||
if (column.meta && "options" in column.meta && column.meta.options) {
|
||||
acc[column.id ?? ""] = parseAsArrayOf(
|
||||
parseAsString,
|
||||
ARRAY_SEPARATOR,
|
||||
).withOptions(queryStateOptions);
|
||||
} else {
|
||||
acc[column.id ?? ""] = parseAsString.withOptions(queryStateOptions);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}, [filterableColumns, queryStateOptions]);
|
||||
|
||||
const [filters, setFilters] = useQueryStates(filterParsers);
|
||||
|
||||
const debouncedSetFilters = useDebounceCallback((values: typeof filters) => {
|
||||
void setPage(1);
|
||||
void setFilters(values);
|
||||
}, debounceMs);
|
||||
|
||||
const initialColumnFilters: ColumnFiltersState = useMemo(() => {
|
||||
return Object.entries(filters).reduce<ColumnFiltersState>(
|
||||
(filters, [key, value]) => {
|
||||
if (value !== null) {
|
||||
const processedValue = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === "string" && /[^a-zA-Z0-9]/.test(value)
|
||||
? value.split(/[^a-zA-Z0-9]+/).filter(Boolean)
|
||||
: [value];
|
||||
|
||||
filters.push({
|
||||
id: key,
|
||||
value: processedValue,
|
||||
});
|
||||
}
|
||||
return filters;
|
||||
},
|
||||
[],
|
||||
);
|
||||
}, [filters]);
|
||||
|
||||
const [columnFilters, setColumnFilters] =
|
||||
useState<ColumnFiltersState>(initialColumnFilters);
|
||||
|
||||
const onColumnFiltersChange = useCallback(
|
||||
(updaterOrValue: Updater<ColumnFiltersState>) => {
|
||||
setColumnFilters((prev) => {
|
||||
const next =
|
||||
typeof updaterOrValue === "function"
|
||||
? updaterOrValue(prev)
|
||||
: updaterOrValue;
|
||||
|
||||
debouncedSetFilters(
|
||||
getFiltersFromColumnFilters({
|
||||
columnFilters: next,
|
||||
previousColumnFilters: prev,
|
||||
filterableColumns,
|
||||
}),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[debouncedSetFilters, filterableColumns],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
initialState,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnFilters,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onPaginationChange,
|
||||
onSortingChange,
|
||||
onColumnFiltersChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
getFacetedMinMaxValues: getFacetedMinMaxValues(),
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
...tableProps,
|
||||
});
|
||||
|
||||
return { table };
|
||||
}
|
||||
154
apps/web/src/modules/common/hooks/use-destructive-action.ts
Normal file
154
apps/web/src/modules/common/hooks/use-destructive-action.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
|
||||
interface ImpactItem {
|
||||
id: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface CascadeImpact {
|
||||
label: string;
|
||||
count: number;
|
||||
items: ImpactItem[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
interface PreviewResponse {
|
||||
userFacingImpacts: CascadeImpact[];
|
||||
totalAffected: number;
|
||||
}
|
||||
|
||||
interface UseDestructiveActionOptions<TParams> {
|
||||
/** Function to call for preview (returns impact counts) */
|
||||
previewFn: (params: TParams) => Promise<PreviewResponse>;
|
||||
/** Function to call for actual deletion */
|
||||
executeFn: (params: TParams) => Promise<void>;
|
||||
/** Callback on successful deletion */
|
||||
onSuccess?: () => void;
|
||||
/** Callback on error */
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface UseDestructiveActionReturn<TParams> {
|
||||
/** Initiate the destructive action (shows preview dialog) */
|
||||
initiate: (params: TParams) => Promise<void>;
|
||||
/** Confirm and execute the action */
|
||||
confirm: () => Promise<void>;
|
||||
/** Cancel the action */
|
||||
cancel: () => void;
|
||||
/** Whether the dialog is open */
|
||||
isOpen: boolean;
|
||||
/** Whether preview is loading */
|
||||
isLoadingPreview: boolean;
|
||||
/** Whether deletion is executing */
|
||||
isExecuting: boolean;
|
||||
/** Cascade impacts to display */
|
||||
impacts: CascadeImpact[];
|
||||
/** The pending parameters (useful for displaying entity name) */
|
||||
pendingParams: TParams | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing destructive actions with cascade impact preview
|
||||
*
|
||||
* @example
|
||||
* const deleteAction = useDestructiveAction({
|
||||
* previewFn: async ({ id }) => {
|
||||
* const res = await api.connections[":id"].$delete({
|
||||
* param: { id },
|
||||
* query: { orgId, preview: "true" },
|
||||
* });
|
||||
* return res.json();
|
||||
* },
|
||||
* executeFn: async ({ id }) => {
|
||||
* await api.connections[":id"].$delete({
|
||||
* param: { id },
|
||||
* query: { orgId },
|
||||
* });
|
||||
* },
|
||||
* onSuccess: () => {
|
||||
* queryClient.invalidateQueries({ queryKey: ["connections"] });
|
||||
* toast.success("Deleted successfully");
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* // In JSX:
|
||||
* <Button onClick={() => deleteAction.initiate({ id: connection.id })}>Delete</Button>
|
||||
* <DestructiveActionDialog
|
||||
* open={deleteAction.isOpen}
|
||||
* impacts={deleteAction.impacts}
|
||||
* isLoading={deleteAction.isLoadingPreview}
|
||||
* isExecuting={deleteAction.isExecuting}
|
||||
* onConfirm={deleteAction.confirm}
|
||||
* onCancel={deleteAction.cancel}
|
||||
* />
|
||||
*/
|
||||
export function useDestructiveAction<TParams>({
|
||||
previewFn,
|
||||
executeFn,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: UseDestructiveActionOptions<TParams>): UseDestructiveActionReturn<TParams> {
|
||||
const [pendingParams, setPendingParams] = useState<TParams | null>(null);
|
||||
const [impacts, setImpacts] = useState<CascadeImpact[]>([]);
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: previewFn,
|
||||
onSuccess: (data) => {
|
||||
setImpacts(data.userFacingImpacts);
|
||||
},
|
||||
onError: (error) => {
|
||||
// Still show dialog even if preview fails
|
||||
console.error("Preview failed:", error);
|
||||
setImpacts([]);
|
||||
},
|
||||
});
|
||||
|
||||
const executeMutation = useMutation({
|
||||
mutationFn: executeFn,
|
||||
onSuccess: () => {
|
||||
setPendingParams(null);
|
||||
setImpacts([]);
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||
},
|
||||
});
|
||||
|
||||
const initiate = useCallback(
|
||||
async (params: TParams) => {
|
||||
setPendingParams(params);
|
||||
setImpacts([]);
|
||||
await previewMutation.mutateAsync(params);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[previewMutation.mutateAsync],
|
||||
);
|
||||
|
||||
const confirm = useCallback(async () => {
|
||||
if (pendingParams) {
|
||||
await executeMutation.mutateAsync(pendingParams);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pendingParams, executeMutation.mutateAsync]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setPendingParams(null);
|
||||
setImpacts([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
initiate,
|
||||
confirm,
|
||||
cancel,
|
||||
isOpen: pendingParams !== null,
|
||||
isLoadingPreview: previewMutation.isPending,
|
||||
isExecuting: executeMutation.isPending,
|
||||
impacts,
|
||||
pendingParams,
|
||||
};
|
||||
}
|
||||
137
apps/web/src/modules/common/hooks/use-intersection-observer.ts
Normal file
137
apps/web/src/modules/common/hooks/use-intersection-observer.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface State {
|
||||
isIntersecting: boolean;
|
||||
entry?: IntersectionObserverEntry;
|
||||
}
|
||||
|
||||
interface UseIntersectionObserverOptions {
|
||||
root?: Element | Document | null;
|
||||
rootMargin?: string;
|
||||
threshold?: number | number[];
|
||||
freezeOnceVisible?: boolean;
|
||||
onChange?: (
|
||||
isIntersecting: boolean,
|
||||
entry: IntersectionObserverEntry,
|
||||
) => void;
|
||||
initialIsIntersecting?: boolean;
|
||||
}
|
||||
|
||||
type IntersectionReturn = [
|
||||
(node?: Element | null) => void,
|
||||
boolean,
|
||||
IntersectionObserverEntry | undefined,
|
||||
] & {
|
||||
ref: (node?: Element | null) => void;
|
||||
isIntersecting: boolean;
|
||||
entry?: IntersectionObserverEntry;
|
||||
};
|
||||
|
||||
export function useIntersectionObserver({
|
||||
threshold = 0,
|
||||
root = null,
|
||||
rootMargin = "0%",
|
||||
freezeOnceVisible = false,
|
||||
initialIsIntersecting = false,
|
||||
onChange,
|
||||
}: UseIntersectionObserverOptions = {}): IntersectionReturn {
|
||||
const [ref, setRef] = useState<Element | null>(null);
|
||||
|
||||
const [state, setState] = useState<State>(() => ({
|
||||
isIntersecting: initialIsIntersecting,
|
||||
entry: undefined,
|
||||
}));
|
||||
|
||||
const callbackRef =
|
||||
useRef<UseIntersectionObserverOptions["onChange"]>(undefined);
|
||||
|
||||
callbackRef.current = onChange;
|
||||
|
||||
const frozen = state.entry?.isIntersecting && freezeOnceVisible;
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure we have a ref to observe
|
||||
if (!ref) return;
|
||||
|
||||
// Ensure the browser supports the Intersection Observer API
|
||||
if (!("IntersectionObserver" in window)) return;
|
||||
|
||||
// Skip if frozen
|
||||
if (frozen) return;
|
||||
|
||||
let unobserve: (() => void) | undefined;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries: IntersectionObserverEntry[]): void => {
|
||||
const thresholds = Array.isArray(observer.thresholds)
|
||||
? observer.thresholds
|
||||
: [observer.thresholds];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const isIntersecting =
|
||||
entry.isIntersecting &&
|
||||
thresholds.some(
|
||||
(threshold) => entry.intersectionRatio >= (threshold as number),
|
||||
);
|
||||
|
||||
setState({ isIntersecting, entry });
|
||||
|
||||
if (callbackRef.current) {
|
||||
callbackRef.current(isIntersecting, entry);
|
||||
}
|
||||
|
||||
if (isIntersecting && freezeOnceVisible && unobserve) {
|
||||
unobserve();
|
||||
unobserve = undefined;
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold, root, rootMargin },
|
||||
);
|
||||
|
||||
observer.observe(ref);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
ref,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(threshold),
|
||||
root,
|
||||
rootMargin,
|
||||
frozen,
|
||||
freezeOnceVisible,
|
||||
]);
|
||||
|
||||
// ensures that if the observed element changes, the intersection observer is reinitialized
|
||||
const prevRef = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!ref &&
|
||||
state.entry?.target &&
|
||||
!freezeOnceVisible &&
|
||||
!frozen &&
|
||||
prevRef.current !== state.entry.target
|
||||
) {
|
||||
prevRef.current = state.entry.target;
|
||||
setState({ isIntersecting: initialIsIntersecting, entry: undefined });
|
||||
}
|
||||
}, [ref, state.entry, freezeOnceVisible, frozen, initialIsIntersecting]);
|
||||
|
||||
const result = [
|
||||
setRef,
|
||||
!!state.isIntersecting,
|
||||
state.entry,
|
||||
] as IntersectionReturn;
|
||||
|
||||
// Support object destructuring, by adding the specific values.
|
||||
result.ref = result[0];
|
||||
result.isIntersecting = result[1];
|
||||
result.entry = result[2];
|
||||
|
||||
return result;
|
||||
}
|
||||
12
apps/web/src/modules/common/i18n/actions.ts
Normal file
12
apps/web/src/modules/common/i18n/actions.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { config } from "@turbostarter/i18n";
|
||||
|
||||
import type { Locale } from "@turbostarter/i18n";
|
||||
|
||||
export const setLocaleCookie = async (locale: Locale) => {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(config.cookie, locale);
|
||||
};
|
||||
35
apps/web/src/modules/common/i18n/controls.tsx
Normal file
35
apps/web/src/modules/common/i18n/controls.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { getPathname } from "@turbostarter/i18n";
|
||||
import { LocaleCustomizer } from "@turbostarter/ui-web/i18n";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
|
||||
import { setLocaleCookie } from "./actions";
|
||||
|
||||
import type { Locale } from "@turbostarter/i18n";
|
||||
|
||||
export const I18nControls = () => {
|
||||
const router = useRouter();
|
||||
const path = usePathname();
|
||||
|
||||
const onChange = useCallback(
|
||||
async (locale: Locale) => {
|
||||
await setLocaleCookie(locale);
|
||||
router.push(
|
||||
getPathname({
|
||||
locale,
|
||||
path,
|
||||
defaultLocale: appConfig.locale,
|
||||
}),
|
||||
);
|
||||
router.refresh();
|
||||
},
|
||||
[path, router],
|
||||
);
|
||||
|
||||
return <LocaleCustomizer onChange={onChange} />;
|
||||
};
|
||||
200
apps/web/src/modules/common/image.tsx
Normal file
200
apps/web/src/modules/common/image.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { motion } from "motion/react";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
} from "@turbostarter/ui-web/dialog";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
export const Thumbnail = ({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof motion.button> & { index?: number }) => {
|
||||
return (
|
||||
<motion.button
|
||||
className={cn(
|
||||
"group/thumbnail relative cursor-pointer overflow-hidden rounded-lg",
|
||||
"ring-offset-background hover:ring-primary focus:ring-primary hover:ring-2 hover:ring-offset-2 focus:ring-2 focus:ring-offset-2",
|
||||
"transition-all duration-200",
|
||||
"bg-primary/5 dark:bg-primary/10 shadow-xs",
|
||||
className,
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: (index ?? 0) * 0.1 }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThumbnailImage = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ImgHTMLAttributes<HTMLImageElement>) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
className={cn(
|
||||
"h-full w-full object-cover object-center",
|
||||
"opacity-0 transition-all duration-500",
|
||||
loaded && "opacity-100",
|
||||
className,
|
||||
)}
|
||||
onLoad={() => {
|
||||
setLoaded(true);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{!loaded && <Skeleton className="absolute inset-0" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImageSource {
|
||||
url?: string | null;
|
||||
base64?: string | null;
|
||||
}
|
||||
|
||||
export const getImageSource = (image: ImageSource) => {
|
||||
if (image.url) {
|
||||
return image.url;
|
||||
}
|
||||
if (image.base64) {
|
||||
return `data:image/jpeg;base64,${image.base64}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
interface ViewerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
images: (ImageSource & { description?: string | null })[];
|
||||
selectedImage: number;
|
||||
setSelectedImage: (index: number) => void;
|
||||
}
|
||||
|
||||
export const Viewer = memo<ViewerProps>(
|
||||
({ open, onOpenChange, images, selectedImage, setSelectedImage }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const currentImage = images[selectedImage];
|
||||
|
||||
const navigatePrevious = useCallback(() => {
|
||||
if (images.length <= 1) return;
|
||||
setSelectedImage(
|
||||
selectedImage === 0 ? images.length - 1 : selectedImage - 1,
|
||||
);
|
||||
}, [images.length, selectedImage, setSelectedImage]);
|
||||
|
||||
const navigateNext = useCallback(() => {
|
||||
if (images.length <= 1) return;
|
||||
setSelectedImage(
|
||||
selectedImage === images.length - 1 ? 0 : selectedImage + 1,
|
||||
);
|
||||
}, [images.length, selectedImage, setSelectedImage]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!open || images.length <= 1) return;
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
navigatePrevious();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
navigateNext();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, selectedImage, images.length, navigatePrevious, navigateNext]);
|
||||
|
||||
if (!currentImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="h-full w-full max-w-full border-none bg-transparent p-0 shadow-none sm:max-w-full"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="relative flex grow flex-col gap-4">
|
||||
<div className="flex grow items-center justify-center">
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
className="group flex h-full cursor-pointer items-center justify-center p-2 backdrop-blur-sm transition-all hover:backdrop-blur-md md:p-3"
|
||||
onClick={navigatePrevious}
|
||||
aria-label={t("previous")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"bg-background/50 text-muted-foreground group-hover:bg-accent group-hover:text-accent-foreground size-10 border-0 p-0 backdrop-blur-md md:-left-6 md:size-12",
|
||||
)}
|
||||
>
|
||||
<Icons.ChevronLeft className="size-6 md:size-7" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="relative flex w-full grow items-center justify-center px-2">
|
||||
<motion.img
|
||||
key={getImageSource(currentImage)} // Use a stable key like the source
|
||||
src={getImageSource(currentImage)}
|
||||
alt={currentImage.description ?? ""}
|
||||
className="h-full max-h-[70vh] w-full rounded-lg object-contain"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
fetchPriority="high"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
className="group flex h-full cursor-pointer items-center justify-center p-2 backdrop-blur-sm transition-all hover:backdrop-blur-md md:p-3"
|
||||
onClick={navigateNext}
|
||||
aria-label={t("next")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"bg-background/50 text-muted-foreground group-hover:bg-accent group-hover:text-accent-foreground size-10 border-0 p-0 backdrop-blur-md md:-left-6 md:size-12",
|
||||
)}
|
||||
>
|
||||
<Icons.ChevronRight className="size-6 md:size-7" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentImage.description && (
|
||||
<div className="absolute right-3 bottom-4 left-3 md:bottom-6 lg:bottom-8">
|
||||
<p className="bg-background/75 mx-auto max-w-3xl rounded-2xl border px-6 py-4 text-center text-xs leading-normal backdrop-blur-md backdrop-saturate-150 md:text-sm">
|
||||
{currentImage.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogClose className="absolute top-3 right-3 md:top-3.5 md:right-3.5">
|
||||
<Icons.X />
|
||||
<span className="sr-only">{t("close")}</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Viewer.displayName = "Viewer";
|
||||
37
apps/web/src/modules/common/layout/base.tsx
Normal file
37
apps/web/src/modules/common/layout/base.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Geist_Mono, Geist } from "next/font/google";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
|
||||
const sans = Geist({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
const mono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-mono",
|
||||
weight: ["300", "400", "500"],
|
||||
});
|
||||
|
||||
interface BaseLayoutProps {
|
||||
readonly locale: string;
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BaseLayout = ({ children, locale }: BaseLayoutProps) => {
|
||||
return (
|
||||
<html lang={locale} className={cn(sans.variable, mono.variable)}>
|
||||
<body
|
||||
suppressHydrationWarning
|
||||
className="bg-background text-foreground flex min-h-screen flex-col items-center justify-center font-sans antialiased"
|
||||
data-theme={appConfig.theme.color}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
17
apps/web/src/modules/common/layout/credits/api.ts
Normal file
17
apps/web/src/modules/common/layout/credits/api.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
export const queries = {
|
||||
get: (params: { id: string }) => ({
|
||||
queryKey: ["credits", params.id],
|
||||
queryFn: () => handle(api.ai.credits.$get)(),
|
||||
}),
|
||||
};
|
||||
|
||||
export const mutations = {};
|
||||
|
||||
export const credits = {
|
||||
queries,
|
||||
mutations,
|
||||
} as const;
|
||||
93
apps/web/src/modules/common/layout/credits/index.tsx
Normal file
93
apps/web/src/modules/common/layout/credits/index.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import NumberFlow from "@number-flow/react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import {
|
||||
getCreditsLevel,
|
||||
getCreditsProgress,
|
||||
} from "@turbostarter/ai/credits/utils";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
import { credits } from "./api";
|
||||
|
||||
export const useCredits = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
const query = useQuery({
|
||||
...credits.queries.get({ id: data?.user.id ?? "" }),
|
||||
enabled: !!data?.user.id,
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries(
|
||||
credits.queries.get({ id: data?.user.id ?? "" }),
|
||||
);
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @tanstack/query/no-rest-destructuring
|
||||
...query,
|
||||
invalidate,
|
||||
};
|
||||
};
|
||||
|
||||
export const Credits = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const { data: credits } = useCredits();
|
||||
|
||||
if (typeof credits !== "number") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const level = getCreditsLevel(credits);
|
||||
const progress = getCreditsProgress(credits);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn("mx-2 mt-2 flex flex-col overflow-hidden rounded-md", {
|
||||
"bg-success/10": level === "high",
|
||||
"bg-yellow-500/10": level === "medium",
|
||||
"bg-destructive/10": level === "low",
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 py-2.5">
|
||||
<div
|
||||
className={cn("size-2.5 animate-pulse rounded-full", {
|
||||
"bg-success": level === "high",
|
||||
"bg-yellow-500": level === "medium",
|
||||
"bg-destructive": level === "low",
|
||||
})}
|
||||
></div>
|
||||
<span className={cn("text-foreground text-sm font-medium", {})}>
|
||||
<NumberFlow value={credits} format={{ style: "decimal" }} />{" "}
|
||||
{t("creditsLeft")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn("relative h-1 w-full", {
|
||||
"bg-success/35": level === "high",
|
||||
"bg-yellow-500/35": level === "medium",
|
||||
"bg-destructive/35": level === "low",
|
||||
})}
|
||||
>
|
||||
<motion.div
|
||||
className={cn("absolute top-0 left-0 h-1 w-full origin-left", {
|
||||
"bg-success": level === "high",
|
||||
"bg-yellow-500": level === "medium",
|
||||
"bg-destructive": level === "low",
|
||||
})}
|
||||
initial={{
|
||||
scaleX: 0,
|
||||
}}
|
||||
animate={{
|
||||
scaleX: progress,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
17
apps/web/src/modules/common/layout/credits/server.ts
Normal file
17
apps/web/src/modules/common/layout/credits/server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/server";
|
||||
import { getQueryClient } from "~/lib/query/server";
|
||||
|
||||
import { credits } from "./api";
|
||||
|
||||
export const prefetchCredits = async (id: string) => {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
...credits.queries.get({ id }),
|
||||
queryFn: handle(api.ai.credits.$get),
|
||||
});
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
176
apps/web/src/modules/common/layout/dashboard/action-bar.tsx
Normal file
176
apps/web/src/modules/common/layout/dashboard/action-bar.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Fragment } from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@turbostarter/ui-web/breadcrumb";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Separator } from "@turbostarter/ui-web/separator";
|
||||
import { SidebarTrigger } from "@turbostarter/ui-web/sidebar";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
const ROOT_KEY = "home";
|
||||
|
||||
const indexSchema = z.object({
|
||||
index: z.string(),
|
||||
});
|
||||
|
||||
const hasIndex = (value: unknown): value is z.infer<typeof indexSchema> => {
|
||||
return indexSchema.safeParse(value).success;
|
||||
};
|
||||
|
||||
const getDisplayKey = (rawKey: string, hasPrevious: boolean) => {
|
||||
if (rawKey === "index") return hasPrevious ? null : ROOT_KEY;
|
||||
return rawKey;
|
||||
};
|
||||
const isSkippedKey = (key: string) => ["user", "organization"].includes(key);
|
||||
|
||||
const addCrumb = (
|
||||
trail: { key: string; path?: string }[],
|
||||
rawKey: string,
|
||||
path?: string,
|
||||
) => {
|
||||
const displayKey = getDisplayKey(rawKey, trail.length > 0);
|
||||
if (!displayKey || isSkippedKey(rawKey)) return trail;
|
||||
return [...trail, { key: displayKey, path }];
|
||||
};
|
||||
|
||||
const getPath = (
|
||||
obj: unknown,
|
||||
target: string,
|
||||
current: { key: string; path?: string }[] = [],
|
||||
): { key: string; path?: string }[] | null => {
|
||||
if (!obj || typeof obj !== "object") return null;
|
||||
|
||||
for (const [rawKey, value] of Object.entries(
|
||||
obj as Record<string, unknown>,
|
||||
)) {
|
||||
if (typeof value === "string") {
|
||||
if (value === target) return addCrumb(current, rawKey, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === "function") {
|
||||
const candidates = target.split("/").filter(Boolean);
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const result = (value as (arg: string) => unknown)(candidate);
|
||||
if (typeof result === "string") {
|
||||
if (result === target) return addCrumb(current, rawKey, result);
|
||||
} else if (result && typeof result === "object") {
|
||||
const parentPath = addCrumb(
|
||||
current,
|
||||
rawKey,
|
||||
hasIndex(result) ? result.index : undefined,
|
||||
);
|
||||
const found = getPath(result, target, parentPath);
|
||||
if (found) return found;
|
||||
}
|
||||
} catch {
|
||||
// Ignore callable errors and continue trying other candidates
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const parentPath = addCrumb(
|
||||
current,
|
||||
rawKey,
|
||||
hasIndex(value) ? value.index : undefined,
|
||||
);
|
||||
const found = getPath(value, target, parentPath);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const DashboardActionBar = () => {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const pathname = usePathname();
|
||||
|
||||
const rawPath = getPath(pathsConfig, pathname);
|
||||
const path =
|
||||
rawPath?.length === 1 ? [{ ...rawPath[0], key: ROOT_KEY }] : rawPath;
|
||||
|
||||
const last = path?.at(-1);
|
||||
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center justify-between gap-2 px-4 transition-[width,height] ease-linear md:px-6 lg:px-7">
|
||||
<div className="flex items-center gap-2 pr-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
{path ? (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{path.length > 1 &&
|
||||
path.slice(1, -1).map((item, index, array) => (
|
||||
<Fragment key={item.key}>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<TurboLink href={item.path ?? "#"}>
|
||||
{isKey(item.key, i18n, "common")
|
||||
? t(item.key)
|
||||
: item.key}
|
||||
</TurboLink>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{index < array.length - 1 && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{last && (
|
||||
<>
|
||||
{path.length > 2 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>
|
||||
{isKey(last.key, i18n, "common")
|
||||
? t(last.key)
|
||||
: last.key}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<a
|
||||
href="https://github.com/turbostarter"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className={buttonVariants({ variant: "ghost", size: "icon" })}
|
||||
>
|
||||
<Icons.Github className="size-5" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://discord.gg/KjpK2uk3JP"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className={buttonVariants({ variant: "ghost", size: "icon" })}
|
||||
>
|
||||
<Icons.Discord className="size-5" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
40
apps/web/src/modules/common/layout/dashboard/header.tsx
Normal file
40
apps/web/src/modules/common/layout/dashboard/header.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
export const DashboardHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"header">) => {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-center justify-between gap-4 py-2 md:gap-6 lg:gap-10",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const DashboardHeaderTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"h1">) => {
|
||||
return (
|
||||
<h1
|
||||
className={cn("text-3xl font-bold tracking-tighter", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const DashboardHeaderDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) => {
|
||||
return (
|
||||
<p
|
||||
className={cn("text-muted-foreground text-sm text-pretty", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
17
apps/web/src/modules/common/layout/dashboard/inset.tsx
Normal file
17
apps/web/src/modules/common/layout/dashboard/inset.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { SidebarInset } from "@turbostarter/ui-web/sidebar";
|
||||
|
||||
import { DashboardActionBar } from "./action-bar";
|
||||
import { ScrollContainer } from "./scroll-container";
|
||||
|
||||
export const DashboardInset = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<SidebarInset className="relative flex h-dvh flex-col sm:h-[calc(100dvh-1rem)]">
|
||||
<DashboardActionBar />
|
||||
<ScrollContainer>
|
||||
{children}
|
||||
</ScrollContainer>
|
||||
</SidebarInset>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
interface ScrollContainerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScrollContainer({ children, className }: ScrollContainerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [_canScrollUp, setCanScrollUp] = useState(false);
|
||||
const [_canScrollDown, setCanScrollDown] = useState(false);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||
setCanScrollUp(scrollTop > 1);
|
||||
setCanScrollDown(scrollTop + clientHeight < scrollHeight - 1);
|
||||
}, []);
|
||||
|
||||
// Check on mount, resize, and content changes
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Initial check
|
||||
updateScrollState();
|
||||
|
||||
// Watch for size changes
|
||||
const observer = new ResizeObserver(updateScrollState);
|
||||
observer.observe(el);
|
||||
|
||||
// Also observe children for content changes
|
||||
const mutationObserver = new MutationObserver(updateScrollState);
|
||||
mutationObserver.observe(el, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, [updateScrollState]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex-1 overflow-hidden", className)}>
|
||||
{/* Scroll content - shadows removed, handled by individual components */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={updateScrollState}
|
||||
className="h-full overflow-auto"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
apps/web/src/modules/common/layout/dashboard/settings-card.tsx
Normal file
135
apps/web/src/modules/common/layout/dashboard/settings-card.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@turbostarter/ui-web/card";
|
||||
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
|
||||
const settingsCardVariant = cva("bg-background h-fit w-full overflow-hidden", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "dark:border-input",
|
||||
destructive: "border-destructive/25",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
const SettingsCardContext = createContext<
|
||||
{
|
||||
disabled: boolean;
|
||||
} & VariantProps<typeof settingsCardVariant>
|
||||
>({
|
||||
disabled: false,
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
const SettingsCard = ({
|
||||
className,
|
||||
variant,
|
||||
disabled = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Card> &
|
||||
VariantProps<typeof settingsCardVariant> & { disabled?: boolean }) => (
|
||||
<SettingsCardContext.Provider value={{ disabled, variant }}>
|
||||
<Card
|
||||
className={cn(
|
||||
settingsCardVariant({ variant: disabled ? "default" : variant }),
|
||||
"pb-0",
|
||||
{
|
||||
"text-muted-foreground cursor-not-allowed": disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</SettingsCardContext.Provider>
|
||||
);
|
||||
|
||||
const SettingsCardHeader = CardHeader;
|
||||
|
||||
const SettingsCardTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CardTitle>) => (
|
||||
<CardTitle className={cn("text-xl", className)} {...props} />
|
||||
);
|
||||
|
||||
const SettingsCardDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CardDescription>) => {
|
||||
const { disabled } = useContext(SettingsCardContext);
|
||||
|
||||
return (
|
||||
<CardDescription
|
||||
className={cn(
|
||||
"pb-1.5 text-sm",
|
||||
{
|
||||
"text-foreground": !disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsCardContent = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CardContent>) => {
|
||||
return <CardContent className={cn("-mt-4", className)} {...props} />;
|
||||
};
|
||||
|
||||
const settingsCardFooterVariant = cva(
|
||||
"flex min-h-14 cursor-auto justify-between gap-10 border-t py-3 text-sm leading-tight [.border-t]:pt-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-accent text-muted-foreground dark:bg-accent/75",
|
||||
destructive:
|
||||
"border-t-destructive/25 bg-destructive/15 dark:bg-destructive/20 border-t",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const SettingsCardFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CardFooter>) => {
|
||||
const { variant, disabled } = useContext(SettingsCardContext);
|
||||
return (
|
||||
<CardFooter
|
||||
className={cn(
|
||||
settingsCardFooterVariant({ variant: disabled ? "default" : variant }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
SettingsCard,
|
||||
SettingsCardHeader,
|
||||
SettingsCardContent,
|
||||
SettingsCardFooter,
|
||||
SettingsCardTitle,
|
||||
SettingsCardDescription,
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
|
||||
import { SidebarMenuButton, useSidebar } from "@turbostarter/ui-web/sidebar";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
interface SidebarLinkProps
|
||||
extends React.ComponentProps<typeof SidebarMenuButton> {
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const SidebarLink = memo<SidebarLinkProps>(
|
||||
({ href, children, ...props }) => {
|
||||
const { setOpenMobile } = useSidebar();
|
||||
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
|
||||
const normalizedPathname = pathname.replace(
|
||||
`/${params.locale?.toString()}`,
|
||||
"",
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={
|
||||
[
|
||||
pathsConfig.admin.index,
|
||||
pathsConfig.dashboard.user.index,
|
||||
...(params.organization
|
||||
? [
|
||||
pathsConfig.dashboard.organization(
|
||||
params.organization.toString(),
|
||||
).index,
|
||||
]
|
||||
: []),
|
||||
].includes(href)
|
||||
? normalizedPathname === href
|
||||
: normalizedPathname.startsWith(href)
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<TurboLink href={href} onClick={() => setOpenMobile(false)}>
|
||||
{children}
|
||||
</TurboLink>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
},
|
||||
);
|
||||
117
apps/web/src/modules/common/layout/dashboard/sidebar.tsx
Normal file
117
apps/web/src/modules/common/layout/dashboard/sidebar.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { isKey } from "@turbostarter/i18n";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@turbostarter/ui-web/sidebar";
|
||||
import { SidebarMenu } from "@turbostarter/ui-web/sidebar";
|
||||
import {
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
} from "@turbostarter/ui-web/sidebar";
|
||||
import { Sidebar } from "@turbostarter/ui-web/sidebar";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { AccountSwitcher } from "~/modules/organization/account-switcher";
|
||||
import { UserNavigation } from "~/modules/user/user-navigation";
|
||||
|
||||
import { SidebarLink } from "./sidebar-link";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type { Icon } from "@turbostarter/ui-web/icons";
|
||||
|
||||
interface DashboardSidebarProps {
|
||||
readonly user: User;
|
||||
readonly menu: {
|
||||
label: string;
|
||||
items: {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: Icon;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export const DashboardSidebar = memo<DashboardSidebarProps>(
|
||||
async ({ user, menu }) => {
|
||||
const { t, i18n } = await getTranslation({ ns: "common" });
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
variant="inset"
|
||||
className="top-(--banner-height) h-[calc(100svh-var(--banner-height))]"
|
||||
>
|
||||
<SidebarHeader>
|
||||
<AccountSwitcher user={user} />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{menu.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel className="uppercase">
|
||||
{isKey(group.label, i18n, "common")
|
||||
? t(group.label)
|
||||
: group.label}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const title = isKey(item.title, i18n, "common")
|
||||
? t(item.title)
|
||||
: item.title;
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarLink href={item.href} tooltip={title}>
|
||||
<item.icon />
|
||||
<span>{title}</span>
|
||||
</SidebarLink>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
|
||||
<SidebarGroup className="mt-auto">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarLink
|
||||
href={pathsConfig.marketing.contact}
|
||||
tooltip={t("support")}
|
||||
>
|
||||
<Icons.LifeBuoy />
|
||||
<span>{t("support")}</span>
|
||||
</SidebarLink>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarLink
|
||||
href={pathsConfig.marketing.contact}
|
||||
tooltip={t("feedback")}
|
||||
>
|
||||
<Icons.MessageCircle />
|
||||
<span>{t("feedback")}</span>
|
||||
</SidebarLink>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<UserNavigation user={user} />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
},
|
||||
);
|
||||
22
apps/web/src/modules/common/layout/header.tsx
Normal file
22
apps/web/src/modules/common/layout/header.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { SidebarTrigger } from "@turbostarter/ui-web/sidebar";
|
||||
|
||||
export const Header = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"bg-background absolute top-0 left-0 z-20 flex h-12 w-full items-center justify-between gap-1 p-2 md:h-14 md:rounded-t-lg md:p-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SidebarTrigger />
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
519
apps/web/src/modules/common/layout/sidebar/content.tsx
Normal file
519
apps/web/src/modules/common/layout/sidebar/content.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@turbostarter/ui-web/sidebar";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { APPS } from "~/lib/constants";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
const FREE_TOOLS = [
|
||||
{
|
||||
title: "envin",
|
||||
href: "https://envin.turbostarter.dev",
|
||||
icon: Icons.Shrub,
|
||||
},
|
||||
{
|
||||
title: "ideasGenerator",
|
||||
href: "https://www.turbostarter.dev/ideas",
|
||||
icon: Icons.Lightbulb,
|
||||
},
|
||||
{
|
||||
title: "extro",
|
||||
href: "https://git.new/extro",
|
||||
icon: Icons.Puzzle,
|
||||
},
|
||||
{
|
||||
title: "emojai",
|
||||
href: "https://chromewebstore.google.com/detail/emojai/gnnjegdgbplhcoadniflbacadnmlepoa",
|
||||
icon: Icons.Smile,
|
||||
},
|
||||
{
|
||||
title: "syncreads",
|
||||
href: "https://syncreads.com",
|
||||
icon: Icons.RefreshCcw,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const OTHER = [
|
||||
{
|
||||
title: "home",
|
||||
href: "https://turbostarter.dev/ai",
|
||||
icon: Icons.Home,
|
||||
},
|
||||
{
|
||||
title: "documentation",
|
||||
href: "https://turbostarter.dev/ai/docs",
|
||||
icon: Icons.LibraryBig,
|
||||
},
|
||||
{
|
||||
title: "affiliates",
|
||||
href: "https://turbostarter.lemonsqueezy.com/affiliates",
|
||||
icon: Icons.BadgeDollarSign,
|
||||
},
|
||||
{
|
||||
title: "blog",
|
||||
href: "https://turbostarter.dev/blog",
|
||||
icon: Icons.Newspaper,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const DEV = [
|
||||
{
|
||||
title: "demos",
|
||||
href: pathsConfig.demo.index,
|
||||
icon: Icons.LayoutDashboard,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const sections = [
|
||||
{ label: "apps", items: APPS },
|
||||
{ label: "dev", items: DEV },
|
||||
{ label: "freeTools", items: FREE_TOOLS },
|
||||
{ label: "other", items: OTHER },
|
||||
] as const;
|
||||
|
||||
export const Content = () => {
|
||||
const { t } = useTranslation(["common", "ai"]);
|
||||
const pathname = usePathname();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarContent>
|
||||
{sections.map((section) => (
|
||||
<SidebarGroup key={section.label}>
|
||||
<SidebarGroupLabel className="uppercase">
|
||||
{t(section.label)}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{section.items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={
|
||||
(section.label === "apps" || section.label === "dev") &&
|
||||
pathname.startsWith(item.href)
|
||||
}
|
||||
>
|
||||
<TurboLink
|
||||
href={item.href}
|
||||
target={
|
||||
item.href.startsWith("http") ? "_blank" : undefined
|
||||
}
|
||||
onClick={() => setOpenMobile(false)}
|
||||
>
|
||||
<item.icon className="size-4" />
|
||||
{section.label === "apps"
|
||||
? t(`ai:${item.title}.title` as "ai:chat.title" | "ai:image.title" | "ai:tts.title" | "ai:pdf.title" | "ai:agent.title")
|
||||
: t(item.title as "home" | "documentation" | "affiliates" | "blog" | "envin" | "ideasGenerator" | "extro" | "emojai" | "syncreads" | "demos" | "dev")}
|
||||
</TurboLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
|
||||
<div className="flex flex-col gap-2 px-3 pb-4">
|
||||
<AppStoreButton
|
||||
size="lg"
|
||||
href="https://apps.apple.com/app/id6754575325"
|
||||
/>
|
||||
<GooglePlayButton
|
||||
size="lg"
|
||||
href="https://play.google.com/store/apps/details?id=com.turbostarter.ai"
|
||||
/>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
);
|
||||
};
|
||||
|
||||
const GooglePlayButton = ({
|
||||
size = "md",
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
|
||||
return (
|
||||
<a
|
||||
aria-label="Get it on Google Play"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
className={cn(
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground flex cursor-pointer items-center justify-center rounded-lg border py-0.5",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width={size === "md" ? 135 : 149}
|
||||
height={size === "md" ? 40 : 44}
|
||||
viewBox="0 0 135 40"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M68.136 21.7511C65.784 21.7511 63.867 23.5401 63.867 26.0041C63.867 28.4531 65.784 30.2571 68.136 30.2571C70.489 30.2571 72.406 28.4531 72.406 26.0041C72.405 23.5401 70.488 21.7511 68.136 21.7511ZM68.136 28.5831C66.847 28.5831 65.736 27.5201 65.736 26.0051C65.736 24.4741 66.848 23.4271 68.136 23.4271C69.425 23.4271 70.536 24.4741 70.536 26.0051C70.536 27.5191 69.425 28.5831 68.136 28.5831ZM58.822 21.7511C56.47 21.7511 54.553 23.5401 54.553 26.0041C54.553 28.4531 56.47 30.2571 58.822 30.2571C61.175 30.2571 63.092 28.4531 63.092 26.0041C63.092 23.5401 61.175 21.7511 58.822 21.7511ZM58.822 28.5831C57.533 28.5831 56.422 27.5201 56.422 26.0051C56.422 24.4741 57.534 23.4271 58.822 23.4271C60.111 23.4271 61.222 24.4741 61.222 26.0051C61.223 27.5191 60.111 28.5831 58.822 28.5831ZM47.744 23.0571V24.8611H52.062C51.933 25.8761 51.595 26.6171 51.079 27.1321C50.451 27.7601 49.468 28.4531 47.744 28.4531C45.086 28.4531 43.008 26.3101 43.008 23.6521C43.008 20.9941 45.086 18.8511 47.744 18.8511C49.178 18.8511 50.225 19.4151 50.998 20.1401L52.271 18.8671C51.191 17.8361 49.758 17.0471 47.744 17.0471C44.103 17.0471 41.042 20.0111 41.042 23.6521C41.042 27.2931 44.103 30.2571 47.744 30.2571C49.709 30.2571 51.192 29.6121 52.351 28.4041C53.543 27.2121 53.914 25.5361 53.914 24.1831C53.914 23.7651 53.882 23.3781 53.817 23.0561H47.744V23.0571ZM93.052 24.4581C92.698 23.5081 91.618 21.7511 89.411 21.7511C87.22 21.7511 85.399 23.4751 85.399 26.0041C85.399 28.3881 87.204 30.2571 89.62 30.2571C91.569 30.2571 92.697 29.0651 93.165 28.3721L91.715 27.4051C91.232 28.1141 90.571 28.5811 89.62 28.5811C88.67 28.5811 87.993 28.1461 87.558 27.2921L93.245 24.9401L93.052 24.4581ZM87.252 25.8761C87.204 24.2321 88.525 23.3951 89.476 23.3951C90.217 23.3951 90.845 23.7661 91.055 24.2971L87.252 25.8761ZM82.629 30.0001H84.497V17.4991H82.629V30.0001ZM79.567 22.7021H79.503C79.084 22.2021 78.278 21.7511 77.264 21.7511C75.137 21.7511 73.188 23.6201 73.188 26.0211C73.188 28.4051 75.137 30.2581 77.264 30.2581C78.279 30.2581 79.084 29.8071 79.503 29.2921H79.567V29.9041C79.567 31.5311 78.697 32.4011 77.296 32.4011C76.152 32.4011 75.443 31.5801 75.153 30.8871L73.526 31.5641C73.993 32.6911 75.233 34.0771 77.296 34.0771C79.487 34.0771 81.34 32.7881 81.34 29.6461V22.0101H79.568V22.7021H79.567ZM77.425 28.5831C76.136 28.5831 75.057 27.5031 75.057 26.0211C75.057 24.5221 76.136 23.4271 77.425 23.4271C78.697 23.4271 79.696 24.5221 79.696 26.0211C79.696 27.5031 78.697 28.5831 77.425 28.5831ZM101.806 17.4991H97.335V30.0001H99.2V25.2641H101.805C103.873 25.2641 105.907 23.7671 105.907 21.3821C105.907 18.9971 103.874 17.4991 101.806 17.4991ZM101.854 23.5241H99.2V19.2391H101.854C103.249 19.2391 104.041 20.3941 104.041 21.3821C104.041 22.3501 103.249 23.5241 101.854 23.5241ZM113.386 21.7291C112.035 21.7291 110.636 22.3241 110.057 23.6431L111.713 24.3341C112.067 23.6431 112.727 23.4171 113.418 23.4171C114.383 23.4171 115.364 23.9961 115.38 25.0251V25.1541C115.042 24.9611 114.318 24.6721 113.434 24.6721C111.649 24.6721 109.831 25.6531 109.831 27.4861C109.831 29.1591 111.295 30.2361 112.935 30.2361C114.189 30.2361 114.881 29.6731 115.315 29.0131H115.379V29.9781H117.181V25.1851C117.182 22.9671 115.524 21.7291 113.386 21.7291ZM113.16 28.5801C112.55 28.5801 111.697 28.2741 111.697 27.5181C111.697 26.5531 112.759 26.1831 113.676 26.1831C114.495 26.1831 114.882 26.3601 115.38 26.6011C115.235 27.7601 114.238 28.5801 113.16 28.5801ZM123.743 22.0021L121.604 27.4221H121.54L119.32 22.0021H117.31L120.639 29.5771L118.741 33.7911H120.687L125.818 22.0021H123.743ZM106.937 30.0001H108.802V17.4991H106.937V30.0001Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M47.418 10.2429C47.418 11.0809 47.1701 11.7479 46.673 12.2459C46.109 12.8379 45.3731 13.1339 44.4691 13.1339C43.6031 13.1339 42.8661 12.8339 42.2611 12.2339C41.6551 11.6329 41.3521 10.8889 41.3521 10.0009C41.3521 9.11194 41.6551 8.36794 42.2611 7.76794C42.8661 7.16694 43.6031 6.86694 44.4691 6.86694C44.8991 6.86694 45.3101 6.95094 45.7001 7.11794C46.0911 7.28594 46.404 7.50894 46.6381 7.78794L46.111 8.31594C45.714 7.84094 45.167 7.60394 44.468 7.60394C43.836 7.60394 43.29 7.82594 42.829 8.26994C42.368 8.71394 42.1381 9.29094 42.1381 9.99994C42.1381 10.7089 42.368 11.2859 42.829 11.7299C43.29 12.1739 43.836 12.3959 44.468 12.3959C45.138 12.3959 45.6971 12.1729 46.1441 11.7259C46.4341 11.4349 46.602 11.0299 46.647 10.5109H44.468V9.78994H47.375C47.405 9.94694 47.418 10.0979 47.418 10.2429Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M52.0281 7.737H49.2961V9.639H51.7601V10.36H49.2961V12.262H52.0281V13H48.5251V7H52.0281V7.737Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M55.279 13H54.508V7.737H52.832V7H56.955V7.737H55.279V13Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path d="M59.938 13V7H60.709V13H59.938Z" fill="var(--foreground)" />
|
||||
<path
|
||||
d="M64.1281 13H63.3572V7.737H61.6812V7H65.8042V7.737H64.1281V13Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M73.6089 12.225C73.0189 12.831 72.2859 13.134 71.4089 13.134C70.5319 13.134 69.7989 12.831 69.2099 12.225C68.6199 11.619 68.3259 10.877 68.3259 9.99999C68.3259 9.12299 68.6199 8.38099 69.2099 7.77499C69.7989 7.16899 70.5319 6.86499 71.4089 6.86499C72.2809 6.86499 73.0129 7.16999 73.6049 7.77899C74.1969 8.38799 74.4929 9.12799 74.4929 9.99999C74.4929 10.877 74.1979 11.619 73.6089 12.225ZM69.7789 11.722C70.2229 12.172 70.7659 12.396 71.4089 12.396C72.0519 12.396 72.5959 12.171 73.0389 11.722C73.4829 11.272 73.7059 10.698 73.7059 9.99999C73.7059 9.30199 73.4829 8.72799 73.0389 8.27799C72.5959 7.82799 72.0519 7.60399 71.4089 7.60399C70.7659 7.60399 70.2229 7.82899 69.7789 8.27799C69.3359 8.72799 69.1129 9.30199 69.1129 9.99999C69.1129 10.698 69.3359 11.272 69.7789 11.722Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M75.5749 13V7H76.513L79.429 11.667H79.4619L79.429 10.511V7H80.1999V13H79.3949L76.344 8.106H76.3109L76.344 9.262V13H75.5749Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M47.418 10.2429C47.418 11.0809 47.1701 11.7479 46.673 12.2459C46.109 12.8379 45.3731 13.1339 44.4691 13.1339C43.6031 13.1339 42.8661 12.8339 42.2611 12.2339C41.6551 11.6329 41.3521 10.8889 41.3521 10.0009C41.3521 9.11194 41.6551 8.36794 42.2611 7.76794C42.8661 7.16694 43.6031 6.86694 44.4691 6.86694C44.8991 6.86694 45.3101 6.95094 45.7001 7.11794C46.0911 7.28594 46.404 7.50894 46.6381 7.78794L46.111 8.31594C45.714 7.84094 45.167 7.60394 44.468 7.60394C43.836 7.60394 43.29 7.82594 42.829 8.26994C42.368 8.71394 42.1381 9.29094 42.1381 9.99994C42.1381 10.7089 42.368 11.2859 42.829 11.7299C43.29 12.1739 43.836 12.3959 44.468 12.3959C45.138 12.3959 45.6971 12.1729 46.1441 11.7259C46.4341 11.4349 46.602 11.0299 46.647 10.5109H44.468V9.78994H47.375C47.405 9.94694 47.418 10.0979 47.418 10.2429Z"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth="0.2"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
<path
|
||||
d="M52.0281 7.737H49.2961V9.639H51.7601V10.36H49.2961V12.262H52.0281V13H48.5251V7H52.0281V7.737Z"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth="0.2"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
<path
|
||||
d="M55.279 13H54.508V7.737H52.832V7H56.955V7.737H55.279V13Z"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth="0.2"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
<path
|
||||
d="M59.938 13V7H60.709V13H59.938Z"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth="0.2"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
<path
|
||||
d="M64.1281 13H63.3572V7.737H61.6812V7H65.8042V7.737H64.1281V13Z"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth="0.2"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
<path
|
||||
d="M73.6089 12.225C73.0189 12.831 72.2859 13.134 71.4089 13.134C70.5319 13.134 69.7989 12.831 69.2099 12.225C68.6199 11.619 68.3259 10.877 68.3259 9.99999C68.3259 9.12299 68.6199 8.38099 69.2099 7.77499C69.7989 7.16899 70.5319 6.86499 71.4089 6.86499C72.2809 6.86499 73.0129 7.16999 73.6049 7.77899C74.1969 8.38799 74.4929 9.12799 74.4929 9.99999C74.4929 10.877 74.1979 11.619 73.6089 12.225ZM69.7789 11.722C70.2229 12.172 70.7659 12.396 71.4089 12.396C72.0519 12.396 72.5959 12.171 73.0389 11.722C73.4829 11.272 73.7059 10.698 73.7059 9.99999C73.7059 9.30199 73.4829 8.72799 73.0389 8.27799C72.5959 7.82799 72.0519 7.60399 71.4089 7.60399C70.7659 7.60399 70.2229 7.82899 69.7789 8.27799C69.3359 8.72799 69.1129 9.30199 69.1129 9.99999C69.1129 10.698 69.3359 11.272 69.7789 11.722Z"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth="0.2"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
<path
|
||||
d="M75.5749 13V7H76.513L79.429 11.667H79.4619L79.429 10.511V7H80.1999V13H79.3949L76.344 8.106H76.3109L76.344 9.262V13H75.5749Z"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth="0.2"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
<g filter="url(#filter0_ii_1303_2188)">
|
||||
<path
|
||||
d="M10.4361 7.53803C10.1451 7.84603 9.97314 8.32403 9.97314 8.94303V31.059C9.97314 31.679 10.1451 32.156 10.4361 32.464L10.5101 32.536L22.8991 20.147V20.001V19.855L10.5101 7.46503L10.4361 7.53803Z"
|
||||
fill="url(#paint0_linear_1303_2188)"
|
||||
/>
|
||||
<path
|
||||
d="M27.0279 24.278L22.8989 20.147V20.001V19.855L27.0289 15.725L27.1219 15.778L32.0149 18.558C33.4119 19.352 33.4119 20.651 32.0149 21.446L27.1219 24.226L27.0279 24.278Z"
|
||||
fill="url(#paint1_linear_1303_2188)"
|
||||
/>
|
||||
<g filter="url(#filter1_i_1303_2188)">
|
||||
<path
|
||||
d="M27.122 24.225L22.898 20.001L10.436 32.464C10.896 32.952 11.657 33.012 12.514 32.526L27.122 24.225Z"
|
||||
fill="url(#paint2_linear_1303_2188)"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M27.122 15.777L12.514 7.47701C11.657 6.99001 10.896 7.05101 10.436 7.53901L22.899 20.002L27.122 15.777Z"
|
||||
fill="url(#paint3_linear_1303_2188)"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_ii_1303_2188"
|
||||
x="9.97314"
|
||||
y="7.14093"
|
||||
width="23.0894"
|
||||
height="25.7207"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="-0.15" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect1_innerShadow_1303_2188"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="0.15" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="effect1_innerShadow_1303_2188"
|
||||
result="effect2_innerShadow_1303_2188"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_i_1303_2188"
|
||||
x="10.436"
|
||||
y="20.001"
|
||||
width="16.686"
|
||||
height="12.8607"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="-0.15" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect1_innerShadow_1303_2188"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_1303_2188"
|
||||
x1="21.8009"
|
||||
y1="8.70903"
|
||||
x2="5.01895"
|
||||
y2="25.491"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#00A0FF" />
|
||||
<stop offset="0.0066" stopColor="#00A1FF" />
|
||||
<stop offset="0.2601" stopColor="#00BEFF" />
|
||||
<stop offset="0.5122" stopColor="#00D2FF" />
|
||||
<stop offset="0.7604" stopColor="#00DFFF" />
|
||||
<stop offset="1" stopColor="#00E3FF" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_1303_2188"
|
||||
x1="33.8334"
|
||||
y1="20.001"
|
||||
x2="9.63753"
|
||||
y2="20.001"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FFE000" />
|
||||
<stop offset="0.4087" stopColor="#FFBD00" />
|
||||
<stop offset="0.7754" stopColor="#FFA500" />
|
||||
<stop offset="1" stopColor="#FF9C00" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_1303_2188"
|
||||
x1="24.8281"
|
||||
y1="22.2949"
|
||||
x2="2.06964"
|
||||
y2="45.0534"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF3A44" />
|
||||
<stop offset="1" stopColor="#C31162" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_1303_2188"
|
||||
x1="7.29743"
|
||||
y1="0.176806"
|
||||
x2="17.4597"
|
||||
y2="10.3391"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#32A071" />
|
||||
<stop offset="0.0685" stopColor="#2DA771" />
|
||||
<stop offset="0.4762" stopColor="#15CF74" />
|
||||
<stop offset="0.8009" stopColor="#06E775" />
|
||||
<stop offset="1" stopColor="#00F076" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const AppStoreButton = ({
|
||||
size = "md",
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
|
||||
return (
|
||||
<a
|
||||
aria-label="Download on the App Store"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
className={cn(
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground flex cursor-pointer items-center justify-center rounded-lg border py-px",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width={size === "md" ? 120 : 132}
|
||||
height={size === "md" ? 40 : 44}
|
||||
viewBox="0 0 120 40"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M81.5257 19.2009V21.4919H80.0896V22.9944H81.5257V28.0994C81.5257 29.8425 82.3143 30.5398 84.2981 30.5398C84.6468 30.5398 84.9788 30.4983 85.2693 30.4485V28.9626C85.0203 28.9875 84.8626 29.0041 84.5887 29.0041C83.7005 29.0041 83.3104 28.5891 83.3104 27.6428V22.9944H85.2693V21.4919H83.3104V19.2009H81.5257Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M90.3232 30.6643C92.9628 30.6643 94.5815 28.8962 94.5815 25.9661C94.5815 23.0525 92.9545 21.2761 90.3232 21.2761C87.6835 21.2761 86.0566 23.0525 86.0566 25.9661C86.0566 28.8962 87.6752 30.6643 90.3232 30.6643ZM90.3232 29.0789C88.7709 29.0789 87.8994 27.9416 87.8994 25.9661C87.8994 24.0071 88.7709 22.8616 90.3232 22.8616C91.8671 22.8616 92.747 24.0071 92.747 25.9661C92.747 27.9333 91.8671 29.0789 90.3232 29.0789Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M95.9664 30.49H97.7511V25.1526C97.7511 23.8826 98.7056 23.0276 100.059 23.0276C100.374 23.0276 100.905 23.0857 101.055 23.1355V21.3757C100.864 21.3259 100.524 21.301 100.258 21.301C99.0792 21.301 98.0748 21.9485 97.8175 22.8367H97.6846V21.4504H95.9664V30.49Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M105.486 22.7952C106.806 22.7952 107.669 23.7165 107.711 25.136H103.145C103.245 23.7248 104.166 22.7952 105.486 22.7952ZM107.702 28.0496C107.37 28.7551 106.632 29.1453 105.552 29.1453C104.125 29.1453 103.203 28.1409 103.145 26.5554V26.4558H109.529V25.8332C109.529 22.9944 108.009 21.2761 105.494 21.2761C102.946 21.2761 101.327 23.1106 101.327 25.9993C101.327 28.8879 102.913 30.6643 105.503 30.6643C107.57 30.6643 109.014 29.6682 109.421 28.0496H107.702Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M69.8221 27.1518C69.9598 29.3715 71.8095 30.7911 74.5626 30.7911C77.505 30.7911 79.3462 29.3027 79.3462 26.9281C79.3462 25.0612 78.2966 24.0287 75.7499 23.4351L74.382 23.0996C72.7645 22.721 72.1106 22.2134 72.1106 21.3272C72.1106 20.2088 73.1259 19.4775 74.6487 19.4775C76.0941 19.4775 77.0921 20.1916 77.2727 21.3358H79.1483C79.0365 19.2452 77.1953 17.774 74.6745 17.774C71.9644 17.774 70.1576 19.2452 70.1576 21.4563C70.1576 23.2802 71.1815 24.3643 73.427 24.8891L75.0272 25.2763C76.6705 25.6634 77.3932 26.2312 77.3932 27.1776C77.3932 28.2789 76.2575 29.079 74.7089 29.079C73.0484 29.079 71.8955 28.3305 71.7321 27.1518H69.8221Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M51.3348 21.301C50.1063 21.301 49.0437 21.9153 48.4959 22.9446H48.3631V21.4504H46.6448V33.4949H48.4295V29.1204H48.5706C49.0437 30.0749 50.0647 30.6394 51.3514 30.6394C53.6341 30.6394 55.0867 28.8381 55.0867 25.9661C55.0867 23.094 53.6341 21.301 51.3348 21.301ZM50.8284 29.0373C49.3343 29.0373 48.3963 27.8586 48.3963 25.9744C48.3963 24.0818 49.3343 22.9031 50.8367 22.9031C52.3475 22.9031 53.2522 24.0569 53.2522 25.9661C53.2522 27.8835 52.3475 29.0373 50.8284 29.0373Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M61.3316 21.301C60.103 21.301 59.0405 21.9153 58.4927 22.9446H58.3599V21.4504H56.6416V33.4949H58.4263V29.1204H58.5674C59.0405 30.0749 60.0615 30.6394 61.3482 30.6394C63.6309 30.6394 65.0835 28.8381 65.0835 25.9661C65.0835 23.094 63.6309 21.301 61.3316 21.301ZM60.8252 29.0373C59.3311 29.0373 58.3931 27.8586 58.3931 25.9744C58.3931 24.0818 59.3311 22.9031 60.8335 22.9031C62.3443 22.9031 63.249 24.0569 63.249 25.9661C63.249 27.8835 62.3443 29.0373 60.8252 29.0373Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M43.4428 30.49H45.4905L41.008 18.0751H38.9346L34.4521 30.49H36.431L37.5752 27.1948H42.3072L43.4428 30.49ZM39.8724 20.3292H40.0186L41.8168 25.5774H38.0656L39.8724 20.3292Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M35.6514 8.71094V14.7H37.8137C39.5984 14.7 40.6318 13.6001 40.6318 11.6868C40.6318 9.80249 39.5901 8.71094 37.8137 8.71094H35.6514ZM36.5811 9.55762H37.71C38.9509 9.55762 39.6855 10.3462 39.6855 11.6992C39.6855 13.073 38.9634 13.8533 37.71 13.8533H36.5811V9.55762Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M43.7969 14.7871C45.1167 14.7871 45.9261 13.9031 45.9261 12.438C45.9261 10.9812 45.1126 10.093 43.7969 10.093C42.4771 10.093 41.6636 10.9812 41.6636 12.438C41.6636 13.9031 42.4729 14.7871 43.7969 14.7871ZM43.7969 13.9944C43.0208 13.9944 42.585 13.4258 42.585 12.438C42.585 11.4585 43.0208 10.8857 43.7969 10.8857C44.5689 10.8857 45.0088 11.4585 45.0088 12.438C45.0088 13.4216 44.5689 13.9944 43.7969 13.9944Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M52.8182 10.1802H51.9259L51.1207 13.6292H51.0501L50.1205 10.1802H49.2655L48.3358 13.6292H48.2694L47.4601 10.1802H46.5553L47.8004 14.7H48.7176L49.6473 11.3713H49.7179L50.6517 14.7H51.5772L52.8182 10.1802Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M53.8458 14.7H54.7382V12.0562C54.7382 11.3506 55.1574 10.9106 55.8173 10.9106C56.4772 10.9106 56.7926 11.2717 56.7926 11.998V14.7H57.685V11.7739C57.685 10.699 57.1288 10.093 56.1203 10.093C55.4396 10.093 54.9914 10.396 54.7714 10.8982H54.705V10.1802H53.8458V14.7Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M59.0903 14.7H59.9826V8.41626H59.0903V14.7Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M63.3386 14.7871C64.6584 14.7871 65.4678 13.9031 65.4678 12.438C65.4678 10.9812 64.6543 10.093 63.3386 10.093C62.0188 10.093 61.2053 10.9812 61.2053 12.438C61.2053 13.9031 62.0146 14.7871 63.3386 14.7871ZM63.3386 13.9944C62.5625 13.9944 62.1267 13.4258 62.1267 12.438C62.1267 11.4585 62.5625 10.8857 63.3386 10.8857C64.1106 10.8857 64.5505 11.4585 64.5505 12.438C64.5505 13.4216 64.1106 13.9944 63.3386 13.9944Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M68.1265 14.0234C67.6409 14.0234 67.2881 13.7869 67.2881 13.3801C67.2881 12.9817 67.5704 12.77 68.1929 12.7285L69.2969 12.658V13.0356C69.2969 13.5959 68.7989 14.0234 68.1265 14.0234ZM67.8982 14.7747C68.4917 14.7747 68.9856 14.5173 69.2554 14.0649H69.326V14.7H70.1851V11.6121C70.1851 10.6575 69.5459 10.093 68.4129 10.093C67.3877 10.093 66.6573 10.5911 66.566 11.3672H67.4292C67.5289 11.0476 67.8733 10.865 68.3714 10.865C68.9815 10.865 69.2969 11.1348 69.2969 11.6121V12.0022L68.0726 12.0728C66.9976 12.1392 66.3916 12.6082 66.3916 13.4216C66.3916 14.2476 67.0267 14.7747 67.8982 14.7747Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M73.2132 14.7747C73.8358 14.7747 74.3629 14.48 74.6327 13.9861H74.7032V14.7H75.5582V8.41626H74.6659V10.8982H74.5995C74.3546 10.4001 73.8316 10.1055 73.2132 10.1055C72.0719 10.1055 71.3373 11.0103 71.3373 12.438C71.3373 13.8699 72.0636 14.7747 73.2132 14.7747ZM73.4664 10.9065C74.2135 10.9065 74.6825 11.5 74.6825 12.4421C74.6825 13.3884 74.2176 13.9736 73.4664 13.9736C72.711 13.9736 72.2586 13.3967 72.2586 12.438C72.2586 11.4875 72.7152 10.9065 73.4664 10.9065Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M81.3447 14.7871C82.6645 14.7871 83.4738 13.9031 83.4738 12.438C83.4738 10.9812 82.6604 10.093 81.3447 10.093C80.0249 10.093 79.2114 10.9812 79.2114 12.438C79.2114 13.9031 80.0207 14.7871 81.3447 14.7871ZM81.3447 13.9944C80.5686 13.9944 80.1328 13.4258 80.1328 12.438C80.1328 11.4585 80.5686 10.8857 81.3447 10.8857C82.1166 10.8857 82.5566 11.4585 82.5566 12.438C82.5566 13.4216 82.1166 13.9944 81.3447 13.9944Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M84.655 14.7H85.5474V12.0562C85.5474 11.3506 85.9666 10.9106 86.6265 10.9106C87.2864 10.9106 87.6018 11.2717 87.6018 11.998V14.7H88.4941V11.7739C88.4941 10.699 87.938 10.093 86.9294 10.093C86.2488 10.093 85.8005 10.396 85.5806 10.8982H85.5142V10.1802H84.655V14.7Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M92.6039 9.05542V10.2009H91.8858V10.9521H92.6039V13.5046C92.6039 14.3762 92.9981 14.7249 93.9901 14.7249C94.1644 14.7249 94.3304 14.7041 94.4757 14.6792V13.9363C94.3512 13.9487 94.2723 13.957 94.1353 13.957C93.6913 13.957 93.4962 13.7495 93.4962 13.2764V10.9521H94.4757V10.2009H93.4962V9.05542H92.6039Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M95.6735 14.7H96.5658V12.0603C96.5658 11.3755 96.9726 10.9148 97.703 10.9148C98.3339 10.9148 98.6701 11.28 98.6701 12.0022V14.7H99.5624V11.7822C99.5624 10.7073 98.9689 10.0972 98.006 10.0972C97.3253 10.0972 96.848 10.4001 96.6281 10.9065H96.5575V8.41626H95.6735V14.7Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M102.781 10.8525C103.441 10.8525 103.873 11.3132 103.894 12.0229H101.611C101.661 11.3174 102.122 10.8525 102.781 10.8525ZM103.89 13.4797C103.724 13.8325 103.354 14.0276 102.815 14.0276C102.101 14.0276 101.64 13.5254 101.611 12.7327V12.6829H104.803V12.3716C104.803 10.9521 104.043 10.093 102.786 10.093C101.511 10.093 100.702 11.0103 100.702 12.4546C100.702 13.8989 101.495 14.7871 102.79 14.7871C103.823 14.7871 104.545 14.2891 104.749 13.4797H103.89Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M24.769 20.3008C24.7907 18.6198 25.6934 17.0292 27.1256 16.1488C26.2221 14.8584 24.7088 14.0403 23.1344 13.9911C21.4552 13.8148 19.8272 14.9959 18.9715 14.9959C18.0992 14.9959 16.7817 14.0086 15.363 14.0378C13.5137 14.0975 11.7898 15.1489 10.8901 16.7656C8.95607 20.1141 10.3987 25.0351 12.2513 27.7417C13.1782 29.0671 14.2615 30.5475 15.6789 30.495C17.066 30.4375 17.584 29.6105 19.2583 29.6105C20.9171 29.6105 21.4031 30.495 22.8493 30.4616C24.3377 30.4375 25.2754 29.1304 26.1698 27.7925C26.8358 26.8481 27.3483 25.8044 27.6882 24.7C25.9391 23.9602 24.771 22.2 24.769 20.3008Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
<path
|
||||
d="M22.0373 12.2111C22.8489 11.2369 23.2487 9.98469 23.1518 8.72046C21.912 8.85068 20.7668 9.44324 19.9443 10.3801C19.14 11.2954 18.7214 12.5255 18.8006 13.7415C20.0408 13.7542 21.2601 13.1777 22.0373 12.2111Z"
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
54
apps/web/src/modules/common/layout/sidebar/footer.tsx
Normal file
54
apps/web/src/modules/common/layout/sidebar/footer.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
SidebarFooter,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@turbostarter/ui-web/sidebar";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
import { Credits } from "../credits";
|
||||
|
||||
export function Footer() {
|
||||
const { data } = authClient.useSession();
|
||||
const name = data?.user.name ?? "Anonymous";
|
||||
const email = data?.user.email ?? "...but maybe not at all?";
|
||||
const image = data?.user.image ?? `https://avatar.vercel.sh/${data?.user.id}`;
|
||||
|
||||
return (
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<Credits />
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={image} alt={name} />
|
||||
<AvatarFallback>
|
||||
{name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{name}</span>
|
||||
<span className="truncate text-xs">{email}</span>
|
||||
</div>
|
||||
<Icons.EllipsisVertical className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
);
|
||||
}
|
||||
19
apps/web/src/modules/common/layout/sidebar/header.tsx
Normal file
19
apps/web/src/modules/common/layout/sidebar/header.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { SidebarHeader } from "@turbostarter/ui-web/sidebar";
|
||||
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
export const Header = () => {
|
||||
return (
|
||||
<SidebarHeader>
|
||||
<TurboLink
|
||||
target="_blank"
|
||||
href="https://turbostarter.dev/ai"
|
||||
className="flex items-center gap-3 p-2 transition-[padding] group-data-[collapsible=icon]:p-0.5"
|
||||
>
|
||||
<Icons.Logo className="text-primary h-8 transition-[width,height]" />
|
||||
<Icons.LogoText className="text-foreground h-4 group-data-[collapsible=icon]:hidden" />
|
||||
</TurboLink>
|
||||
</SidebarHeader>
|
||||
);
|
||||
};
|
||||
21
apps/web/src/modules/common/layout/sidebar/index.tsx
Normal file
21
apps/web/src/modules/common/layout/sidebar/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { Sidebar } from "@turbostarter/ui-web/sidebar";
|
||||
|
||||
import { Content } from "./content";
|
||||
import { Footer } from "./footer";
|
||||
import { Header } from "./header";
|
||||
|
||||
export function AppsSidebar({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar variant="inset" {...props}>
|
||||
<Header />
|
||||
<Content />
|
||||
<Footer />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
45
apps/web/src/modules/common/markdown/code.tsx
Normal file
45
apps/web/src/modules/common/markdown/code.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import ShikiHighlighter from "react-shiki";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { Element } from "react-shiki";
|
||||
|
||||
interface CodeHighlightProps {
|
||||
className?: string | undefined;
|
||||
children?: ReactNode | undefined;
|
||||
node?: Element | undefined;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export const CodeHighlight = ({
|
||||
inline = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeHighlightProps) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const match = className?.match(/language-(\w+)/);
|
||||
const language = match ? match[1] : undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
const code = String(children).trim();
|
||||
|
||||
return !inline ? (
|
||||
<ShikiHighlighter
|
||||
language={language}
|
||||
theme={`github-${resolvedTheme === "dark" ? "dark" : "light"}`}
|
||||
{...props}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-none border-y @md:rounded-lg @md:border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{code}
|
||||
</ShikiHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
52
apps/web/src/modules/common/markdown/memoized-markdown.tsx
Normal file
52
apps/web/src/modules/common/markdown/memoized-markdown.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import "katex/dist/katex.min.css";
|
||||
import { marked } from "marked";
|
||||
import { memo, useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { rehypeInlineCodeProperty } from "react-shiki";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
|
||||
import { CodeHighlight } from "./code";
|
||||
import { preprocessMarkdown } from "./utils";
|
||||
|
||||
function parseMarkdownIntoBlocks(markdown: string): string[] {
|
||||
const tokens = marked.lexer(markdown);
|
||||
return tokens.map((token) => token.raw);
|
||||
}
|
||||
|
||||
const MemoizedMarkdownBlock = memo(
|
||||
({ content }: { content: string }) => {
|
||||
const processedContent = preprocessMarkdown(content);
|
||||
return (
|
||||
<ReactMarkdown
|
||||
rehypePlugins={[rehypeRaw, rehypeKatex, rehypeInlineCodeProperty]}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
components={{
|
||||
code: CodeHighlight,
|
||||
}}
|
||||
>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.content !== nextProps.content) return false;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock";
|
||||
|
||||
export const MemoizedMarkdown = memo<{ content: string; id: string }>(
|
||||
({ content, id }) => {
|
||||
const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]);
|
||||
|
||||
return blocks.map((block, index) => (
|
||||
<MemoizedMarkdownBlock content={block} key={`${id}-block_${index}`} />
|
||||
));
|
||||
},
|
||||
);
|
||||
|
||||
MemoizedMarkdown.displayName = "MemoizedMarkdown";
|
||||
15
apps/web/src/modules/common/markdown/utils/index.ts
Normal file
15
apps/web/src/modules/common/markdown/utils/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const preprocessLaTeX = (content: string) => {
|
||||
const blockProcessedContent = content.replace(
|
||||
/\\\[([\s\S]*?)\\\]/g,
|
||||
(_, equation) => `$$${equation}$$`,
|
||||
);
|
||||
const inlineProcessedContent = blockProcessedContent.replace(
|
||||
/\\\(([\s\S]*?)\\\)/g,
|
||||
(_, equation) => `$${equation}$`,
|
||||
);
|
||||
return inlineProcessedContent;
|
||||
};
|
||||
|
||||
export const preprocessMarkdown = (markdown: string) => {
|
||||
return preprocessLaTeX(markdown);
|
||||
};
|
||||
13
apps/web/src/modules/common/mdx.tsx
Normal file
13
apps/web/src/modules/common/mdx.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { MDXContent } from "@content-collections/mdx/react";
|
||||
|
||||
interface MdxProps {
|
||||
readonly mdx: string;
|
||||
}
|
||||
|
||||
export const Mdx = ({ mdx }: MdxProps) => {
|
||||
return (
|
||||
<div className="prose dark:prose-invert prose-headings:font-semibold py-6">
|
||||
<MDXContent code={mdx} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
apps/web/src/modules/common/prose.tsx
Normal file
20
apps/web/src/modules/common/prose.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
export const Prose = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose dark:prose-invert prose-p:opacity-95 prose-strong:opacity-100 prose-pre:-mx-5 prose-pre:rounded-none prose-pre:bg-transparent prose-pre:p-0 @md:prose-pre:mx-0 wrap-break-word",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
22
apps/web/src/modules/common/stopwatch.tsx
Normal file
22
apps/web/src/modules/common/stopwatch.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface StopwatchProps {
|
||||
startTime: Date;
|
||||
}
|
||||
|
||||
export function Stopwatch({ startTime }: StopwatchProps) {
|
||||
const [elapsed, setElapsed] = useState(dayjs().diff(dayjs(startTime)));
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setElapsed(dayjs().diff(dayjs(startTime)));
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [startTime]);
|
||||
|
||||
const value = +(elapsed / 1000).toFixed(1);
|
||||
|
||||
return value;
|
||||
}
|
||||
157
apps/web/src/modules/common/theme.tsx
Normal file
157
apps/web/src/modules/common/theme.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { useBreakpoint } from "@turbostarter/ui-web";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
DrawerContent,
|
||||
} from "@turbostarter/ui-web/drawer";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
} from "@turbostarter/ui-web/popover";
|
||||
import { ThemeCustomizer } from "@turbostarter/ui-web/theme";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
import { useThemeConfig } from "~/lib/providers/theme";
|
||||
|
||||
import type { ThemeConfig, ThemeMode } from "@turbostarter/ui";
|
||||
|
||||
const Trigger = (props: React.ComponentProps<typeof Button>) => {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("theme.customization.label")}
|
||||
{...props}
|
||||
>
|
||||
<Icons.PaintBucket className="text-primary size-5" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const Customizer = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const { config, setConfig } = useThemeConfig();
|
||||
const { setTheme: setMode, theme: mode } = useTheme();
|
||||
|
||||
const onChange = (config: ThemeConfig) => {
|
||||
setConfig(config);
|
||||
setMode(config.mode);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start">
|
||||
<div className="space-y-1 pr-2">
|
||||
<div className="leading-none font-semibold tracking-tight">
|
||||
{t("theme.customization.title")}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("theme.customization.description")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
onChange(appConfig.theme);
|
||||
}}
|
||||
>
|
||||
<Icons.Undo2 className="size-4" />
|
||||
<span className="sr-only">{t("reset")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<ThemeCustomizer
|
||||
defaultConfig={appConfig.theme}
|
||||
config={{
|
||||
...config,
|
||||
mode: (mode as ThemeMode | undefined) ?? appConfig.theme.mode,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThemeControlsProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const isDesktop = useBreakpoint("md");
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-88 space-y-4 rounded-lg p-6"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onFocusOutside={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Customizer />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent className="p-6 pt-0">
|
||||
<div className="space-y-4 pt-4">
|
||||
<Customizer />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThemeControls = () => {
|
||||
return (
|
||||
<ThemeControlsProvider>
|
||||
<Trigger />
|
||||
</ThemeControlsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Simple theme toggle for AI apps
|
||||
export function ThemeSwitcher() {
|
||||
const { t } = useTranslation("common");
|
||||
const { setTheme: setMode, theme: mode } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setMode(mode === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group relative"
|
||||
onClick={toggleTheme}
|
||||
aria-label={t("theme.toggle")}
|
||||
>
|
||||
<Icons.Sun className="text-muted-foreground group-hover:text-foreground size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Icons.Moon className="text-muted-foreground group-hover:text-foreground absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/modules/common/themed-image.tsx
Normal file
23
apps/web/src/modules/common/themed-image.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { preload } from "react-dom";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export const ThemedImage = ({
|
||||
light,
|
||||
dark,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof Image>, "src"> & {
|
||||
light: string;
|
||||
dark: string;
|
||||
}) => {
|
||||
preload(light, { as: "image" });
|
||||
preload(dark, { as: "image" });
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return <Image src={resolvedTheme === "dark" ? dark : light} {...props} />;
|
||||
};
|
||||
29
apps/web/src/modules/common/toast.tsx
Normal file
29
apps/web/src/modules/common/toast.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
|
||||
import { ThemeMode } from "@turbostarter/ui";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = ThemeMode.SYSTEM } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
52
apps/web/src/modules/common/turbo-link.tsx
Normal file
52
apps/web/src/modules/common/turbo-link.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type TurboLinkProps = ComponentProps<typeof Link>;
|
||||
|
||||
export const TurboLink = ({
|
||||
onMouseEnter,
|
||||
onPointerEnter,
|
||||
onTouchStart,
|
||||
onFocus,
|
||||
children,
|
||||
...props
|
||||
}: TurboLinkProps) => {
|
||||
const router = useRouter();
|
||||
const strHref =
|
||||
typeof props.href === "string" ? props.href : props.href.href;
|
||||
|
||||
const conditionalPrefetch = () => {
|
||||
if (strHref) {
|
||||
void router.prefetch(strHref);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
{...props}
|
||||
prefetch={false}
|
||||
onMouseEnter={(e) => {
|
||||
conditionalPrefetch();
|
||||
onMouseEnter?.(e);
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
conditionalPrefetch();
|
||||
onPointerEnter?.(e);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
conditionalPrefetch();
|
||||
onTouchStart?.(e);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
conditionalPrefetch();
|
||||
onFocus?.(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user