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:
82
apps/web/src/modules/image/composer/aspect-selector.tsx
Normal file
82
apps/web/src/modules/image/composer/aspect-selector.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { AspectRatio } from "@turbostarter/ai/image/types";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectPortal,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
|
||||
const icons = {
|
||||
[AspectRatio.SQUARE]: Icons.Square,
|
||||
[AspectRatio.STANDARD]: Icons.Square,
|
||||
[AspectRatio.LANDSCAPE]: Icons.RectangleHorizontal,
|
||||
[AspectRatio.PORTRAIT]: Icons.RectangleVertical,
|
||||
};
|
||||
|
||||
interface AspectSelectorProps<T extends FieldValues> {
|
||||
readonly control: Control<T>;
|
||||
readonly name: Path<T>;
|
||||
readonly options: readonly {
|
||||
readonly id: AspectRatio;
|
||||
readonly value: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const AspectSelector = <T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
options,
|
||||
}: AspectSelectorProps<T>) => {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="max-w-24 min-w-0 @sm:max-w-none">
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent align="end">
|
||||
{options.map(({ id, value }) => {
|
||||
const Icon = icons[id];
|
||||
|
||||
return (
|
||||
<SelectItem key={id} value={id}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
<span className="hidden @lg:inline">
|
||||
{t(
|
||||
id.toLowerCase() as Lowercase<
|
||||
keyof typeof AspectRatio
|
||||
>,
|
||||
)}{" "}
|
||||
</span>
|
||||
<span>{`(${value})`}</span>
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
67
apps/web/src/modules/image/composer/image-count-selector.tsx
Normal file
67
apps/web/src/modules/image/composer/image-count-selector.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectPortal,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
|
||||
interface ImageCountSelectorProps<T extends FieldValues> {
|
||||
readonly control: Control<T>;
|
||||
readonly name: Path<T>;
|
||||
readonly min?: number;
|
||||
readonly max?: number;
|
||||
}
|
||||
|
||||
export const ImageCountSelector = <T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
min = 1,
|
||||
max = 5,
|
||||
}: ImageCountSelectorProps<T>) => {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0 gap-0">
|
||||
<FormControl>
|
||||
<Select
|
||||
value={`${field.value}`}
|
||||
onValueChange={(value) => field.onChange(parseInt(value, 10))}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.Image className="size-4 shrink-0" />
|
||||
<SelectValue />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent align="end">
|
||||
{Array.from({ length: max - min + 1 }, (_, i) => i + min).map(
|
||||
(count) => (
|
||||
<SelectItem key={count} value={count.toString()}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
103
apps/web/src/modules/image/composer/index.tsx
Normal file
103
apps/web/src/modules/image/composer/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/image/constants";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { Composer } from "~/modules/common/ai/composer";
|
||||
import { ModelSelector } from "~/modules/common/ai/composer/model-selector";
|
||||
|
||||
import { AspectSelector } from "./aspect-selector";
|
||||
import { ImageCountSelector } from "./image-count-selector";
|
||||
import { useComposer } from "./use-composer";
|
||||
|
||||
interface ImageComposerProps {
|
||||
id?: string;
|
||||
prompt?: string;
|
||||
reset?: () => void;
|
||||
}
|
||||
|
||||
export const ImageComposer = ({
|
||||
id,
|
||||
prompt: initialPrompt,
|
||||
reset,
|
||||
}: ImageComposerProps) => {
|
||||
const { t } = useTranslation(["ai", "common"]);
|
||||
const { form, model, onSubmit } = useComposer({ id });
|
||||
|
||||
const prompt = form.watch("prompt");
|
||||
|
||||
useEffect(() => {
|
||||
if (initialPrompt) {
|
||||
form.setValue("prompt", initialPrompt);
|
||||
form.setFocus("prompt");
|
||||
reset?.();
|
||||
}
|
||||
}, [initialPrompt, form, reset]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<Composer.Form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Composer.Input className="pb-12">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="prompt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Composer.Textarea
|
||||
{...field}
|
||||
autoFocus
|
||||
maxLength={5_000}
|
||||
placeholder={t("image.composer.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
return form.handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex w-full gap-1.5 overflow-hidden border-2 border-transparent p-2 @[480px]/input:p-3">
|
||||
<div className="flex max-w-full grow gap-px">
|
||||
<ImageCountSelector control={form.control} name="options.count" />
|
||||
<AspectSelector
|
||||
control={form.control}
|
||||
name="options.aspectRatio"
|
||||
options={MODELS.find((m) => m.id === model)?.dimensions ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
control={form.control}
|
||||
name="options.model"
|
||||
options={MODELS}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="ml-auto shrink-0 rounded-full"
|
||||
disabled={!prompt.trim()}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
<Icons.ImagePlay className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Composer.Input>
|
||||
</Composer.Form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
121
apps/web/src/modules/image/composer/use-composer.tsx
Normal file
121
apps/web/src/modules/image/composer/use-composer.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/image/constants";
|
||||
import { imageGenerationSchema } from "@turbostarter/ai/image/schema";
|
||||
import { useDebounceCallback } from "@turbostarter/shared/hooks";
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
import { useImageGeneration } from "~/modules/image/use-image-generation";
|
||||
|
||||
import type {
|
||||
ImageGenerationOptionsPayload,
|
||||
ImageGenerationPayload,
|
||||
} from "@turbostarter/ai/image/schema";
|
||||
import type { WatchObserver } from "react-hook-form";
|
||||
|
||||
interface ImageComposerState {
|
||||
prompt: string;
|
||||
options: ImageGenerationOptionsPayload;
|
||||
setPrompt: (prompt: string) => void;
|
||||
setOptions: (options: Partial<ImageGenerationOptionsPayload>) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
model: MODELS[0].id,
|
||||
aspectRatio: MODELS[0].dimensions[0].id,
|
||||
count: 1,
|
||||
};
|
||||
|
||||
const useImageComposerStore = create<ImageComposerState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
prompt: "",
|
||||
options: DEFAULT_OPTIONS,
|
||||
setPrompt: (prompt) => set({ prompt }),
|
||||
setOptions: (options) =>
|
||||
set((state) => ({
|
||||
options: { ...state.options, ...options },
|
||||
})),
|
||||
reset: () =>
|
||||
set({
|
||||
prompt: "",
|
||||
options: DEFAULT_OPTIONS,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "image-options",
|
||||
partialize: (state) => ({ options: state.options }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
interface UseComposerProps {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const useComposer = ({ id: passedId }: UseComposerProps = {}) => {
|
||||
const { prompt, options, setPrompt, setOptions, reset } =
|
||||
useImageComposerStore();
|
||||
const currentId = useRef(passedId);
|
||||
|
||||
const { createGeneration } = useImageGeneration({
|
||||
id: currentId.current,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentId.current !== passedId) {
|
||||
reset();
|
||||
currentId.current = passedId ?? generateId();
|
||||
}
|
||||
}, [passedId, reset]);
|
||||
|
||||
const newForm = useForm({
|
||||
resolver: zodResolver(imageGenerationSchema),
|
||||
defaultValues: {
|
||||
prompt,
|
||||
options,
|
||||
},
|
||||
});
|
||||
const contextForm = useFormContext<ImageGenerationPayload>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const form = contextForm ?? newForm;
|
||||
|
||||
const model = form.watch("options.model");
|
||||
|
||||
const sync: WatchObserver<ImageGenerationPayload> = useCallback(
|
||||
(values) => {
|
||||
setPrompt(values.prompt ?? "");
|
||||
setOptions(values.options ?? DEFAULT_OPTIONS);
|
||||
},
|
||||
[setOptions, setPrompt],
|
||||
);
|
||||
|
||||
const debouncedSync = useDebounceCallback(sync, 500, {
|
||||
leading: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch(debouncedSync);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, debouncedSync]);
|
||||
|
||||
const onSubmit = (input: ImageGenerationPayload) => {
|
||||
form.resetField("prompt");
|
||||
createGeneration.mutate(input);
|
||||
};
|
||||
|
||||
return {
|
||||
form,
|
||||
model,
|
||||
prompt,
|
||||
onSubmit,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user