- 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>
400 lines
10 KiB
TypeScript
400 lines
10 KiB
TypeScript
"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,
|
|
};
|