Update + Delete articles

This commit is contained in:
cdricms
2025-02-25 17:49:37 +01:00
parent 793e3748f9
commit a3f716446c
16 changed files with 764 additions and 3357 deletions

View File

@@ -3,18 +3,25 @@ package blogs
import ( import (
"context" "context"
"net/http" "net/http"
"regexp"
core "fr.latosa-escrima/core" core "fr.latosa-escrima/core"
"fr.latosa-escrima/core/models" "fr.latosa-escrima/core/models"
) )
func HandleBlog(w http.ResponseWriter, r *http.Request) { func HandleBlog(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug") identifier := r.PathValue("identifier")
query := "slug = ?"
if regexp.
MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`).
MatchString(identifier) {
query = "blog_id = ?"
}
var blog models.Blog var blog models.Blog
_, err := core.DB.NewSelect(). _, err := core.DB.NewSelect().
Model(&blog). Model(&blog).
Where("slug = ?", slug). Where(query, identifier).
Relation("Author"). Relation("Author").
ScanAndCount(context.Background()) ScanAndCount(context.Background())
if err != nil { if err != nil {

View File

@@ -18,7 +18,17 @@ var BlogsRoutes = map[string]core.Handler{
Handler: blogs.HandleCategories, Handler: blogs.HandleCategories,
Middlewares: []core.Middleware{Methods("GET")}, Middlewares: []core.Middleware{Methods("GET")},
}, },
"/blogs/{slug}": { "/blogs/{identifier}": {
Handler: blogs.HandleBlog, Handler: blogs.HandleBlog,
Middlewares: []core.Middleware{Methods("GET")}}, Middlewares: []core.Middleware{Methods("GET")}},
"/blogs/{blog_uuid}/delete": {
Handler: blogs.HandleDelete,
Middlewares: []core.Middleware{Methods("DELETE"),
HasPermissions("blogs", "delete"), AuthJWT},
},
"/blogs/{blog_uuid}/update": {
Handler: blogs.HandleUpdate,
Middlewares: []core.Middleware{Methods("PATCH"),
HasPermissions("blogs", "update"), AuthJWT},
},
} }

View File

@@ -0,0 +1,27 @@
"use client";
import { useApi } from "@/hooks/use-api";
import { Blog } from "@/types/types";
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
const BlogEditor = dynamic(
() => import("@/components/article/edit").then((mod) => mod.default),
{
ssr: false,
loading: () => <Loader2 className="animate-spin" />,
},
);
export default function Page() {
const params = useParams<{ uuid: string }>();
const {
data: blog,
error,
mutate,
success,
isLoading,
} = useApi<Blog>(`/blogs/${params.uuid}`, {}, false, false);
return <BlogEditor blog={blog} />;
}

View File

@@ -1,184 +0,0 @@
"use client";
import EditableText from "@/components/editable-text";
import { LocalEditor } from "@/components/editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog";
import useApiMutation, { useApi } from "@/hooks/use-api";
import sluggify from "@/lib/sluggify";
import { Category, NewBlog } from "@/types/types";
import { useEffect, useMemo, useState } from "react";
import { DialogTitle } from "@radix-ui/react-dialog";
import { useToast } from "@/hooks/use-toast";
import {
ActionButton,
ActionButtonDefault,
ActionButtonError,
ActionButtonLoading,
ActionButtonSuccess,
} from "@/components/action-button";
import ComboBox from "@/components/ui/combobox";
export default function BlogEditor() {
const { toast } = useToast();
const [title, setTitle] = useState("");
const [imageUrl, setImageUrl] = useState<string>("/placeholder.svg");
const [category, setCategory] = useState("");
const { data } = useApi<Category[]>(
"/blogs/categories",
undefined,
false,
false,
);
const [categories, setCategories] = useState<string[]>([]);
useEffect(() => {
if (data) setCategories(data.map((c) => c.category) ?? []);
}, [data]);
const content = localStorage.getItem("blog_draft") ?? "";
useEffect(() => {
const localImage = localStorage.getItem("blog_draft_image");
setImageUrl(
localImage && localImage.length > 0
? localImage
: "/placeholder.svg",
);
}, []);
const [summary, setSummary] = useState("");
const slug = useMemo(() => sluggify(title), [title]);
const {
trigger: newBlog,
isMutating: isSending,
isSuccess,
error,
} = useApiMutation<undefined, NewBlog>(
"/blogs/new",
{},
"POST",
true,
false,
);
return (
<section className="m-10 flex flex-col gap-4">
<div className="flex flex-col gap-4">
<EditableText onChange={setTitle}>
<h1>
{title.length > 0 ? title : "Un titre doit-être fourni"}
</h1>
</EditableText>
<p className="italic">{slug}</p>
<Dialog>
<DialogTrigger>
<img
src={imageUrl}
alt="Blog cover"
className="w-full h-60 object-cover cursor-pointer rounded-lg"
/>
</DialogTrigger>
<DialogTitle></DialogTitle>
<DialogContent>
<Input
type="text"
placeholder="Enter image URL"
value={imageUrl}
onChange={(e) => {
setImageUrl(e.target.value);
localStorage.setItem(
"blog_draft_image",
e.currentTarget.value,
);
}}
/>
</DialogContent>
</Dialog>
<div className="flex gap-4">
<Input
type="text"
placeholder="Enter a summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
<ComboBox
value={category}
setValue={setCategory}
key={categories.join(",")}
elements={categories}
trigger={(value) => (
<Button>
{value ?? "Selectionner une catégorie"}
</Button>
)}
onSubmit={(value) => {
setCategories((prev) => {
if (prev.includes(value)) return prev;
return [...prev, value];
});
setCategory(value);
}}
>
{(Item, element) => (
<Item value={element} key={element} label={element}>
{element}
</Item>
)}
</ComboBox>
</div>
</div>
<div className="flex">
<div className="flex-1">
<LocalEditor content={content} setTitle={setTitle} />
</div>
</div>
<ActionButton
isLoading={isSending}
isSuccess={isSuccess}
error={error}
onClick={async () => {
try {
const blogContent = localStorage.getItem("blog_draft");
if (!blogContent) return;
if (title.length < 1) return;
const res = await newBlog({
title,
summary,
image: imageUrl,
slug,
content: blogContent,
category,
});
if (!res) {
toast({ title: "Aucune réponse du serveur." });
return;
}
if (res.status === "Error") {
toast({
title: "Erreur.",
content: "Une erreur est survenue.",
});
}
if (res.data) console.log(res.data);
return res;
} catch (error: any) {
toast({
title: "Erreur.",
content: "Une erreur est survenue.",
});
}
}}
>
<ActionButtonDefault>Publier</ActionButtonDefault>
<ActionButtonSuccess />
<ActionButtonError />
<ActionButtonLoading />
</ActionButton>
</section>
);
}

View File

@@ -3,10 +3,13 @@
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
const BlogEditor = dynamic(() => import("./new").then((mod) => mod.default), { const BlogEditor = dynamic(
ssr: false, () => import("@/components/article/edit").then((mod) => mod.default),
loading: () => <Loader2 className="animate-spin" />, {
}); ssr: false,
loading: () => <Loader2 className="animate-spin" />,
},
);
export default function Page() { export default function Page() {
return <BlogEditor />; return <BlogEditor />;

View File

@@ -1,6 +1,7 @@
"use server"; "use server";
import BlogArticle from "@/components/article"; import BlogArticle from "@/components/article";
import getMe from "@/lib/getMe";
import request from "@/lib/request"; import request from "@/lib/request";
import { Blog } from "@/types/types"; import { Blog } from "@/types/types";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
@@ -20,5 +21,7 @@ export default async function HistoryDetails({
return notFound(); return notFound();
} }
return <BlogArticle blog={blog.data} />; const me = await getMe();
return <BlogArticle blog={blog.data} user={me?.data} />;
} }

View File

@@ -3,8 +3,26 @@ import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CalendarIcon } from "lucide-react"; import { CalendarIcon } from "lucide-react";
import { Blog } from "@/types/types"; import { Blog } from "@/types/types";
import IUser from "@/interfaces/IUser";
import hasPermissions from "@/lib/hasPermissions";
import { Button } from "./ui/button";
import DeleteArticleButton from "./article/delete-button";
import Link from "next/link";
const BlogArticle: React.FC<{ blog: Blog; user?: IUser }> = ({
blog,
user,
}) => {
const UpdateButton = () => {
if (!user || !hasPermissions(user.roles, { blogs: ["update"] })) return;
return (
<Button variant="secondary">
<Link href={`/dashboard/blogs/${blog.blogID}`}>Modifier</Link>
</Button>
);
};
const BlogArticle: React.FC<{ blog: Blog }> = ({ blog }) => {
return ( return (
<article className="mx-auto max-w-3xl px-4 py-8 md:py-12"> <article className="mx-auto max-w-3xl px-4 py-8 md:py-12">
<div className="space-y-6"> <div className="space-y-6">
@@ -28,34 +46,38 @@ const BlogArticle: React.FC<{ blog: Blog }> = ({ blog }) => {
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex justify-between">
<Avatar> <div className="flex items-center gap-3">
<AvatarImage <Avatar>
src={`https://avatar.vercel.sh/blog.author.userId`} <AvatarImage
alt={`${blog.author.firstname} ${blog.author.lastname}`} src={`https://avatar.vercel.sh/blog.author.userId`}
/> alt={`${blog.author.firstname} ${blog.author.lastname}`}
<AvatarFallback> />
{( <AvatarFallback>
blog.author.firstname[0] + {(
blog.author.lastname[0] blog.author.firstname[0] +
).toUpperCase()} blog.author.lastname[0]
</AvatarFallback> ).toUpperCase()}
</Avatar> </AvatarFallback>
<div> </Avatar>
<p className="font-medium"> <div>
{blog.author.firstname} {blog.author.lastname} <p className="font-medium">
</p> {blog.author.firstname} {blog.author.lastname}
<p className="text-sm text-muted-foreground">idk</p> </p>
<p className="text-sm text-muted-foreground">idk</p>
</div>
</div>
<div className="flex gap-2">
<UpdateButton />
<DeleteArticleButton id={blog.blogID} user={user} />
</div> </div>
</div> </div>
<div className="relative aspect-video overflow-hidden rounded-lg"> <div className="relative aspect-video overflow-hidden rounded-lg">
<Image <img
src={blog.image ?? "/placeholder.svg"} src={blog.image ?? "/placeholder.svg"}
alt={blog.title} alt={blog.title}
className="object-cover" className="object-contain"
fill
priority
/> />
</div> </div>

View File

@@ -0,0 +1,97 @@
"use client";
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
import hasPermissions from "@/lib/hasPermissions";
import IUser from "@/interfaces/IUser";
import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
import request from "@/lib/request";
import { ApiResponse } from "@/types/types";
interface DeleteArticleButtonProps {
id: string;
user?: IUser;
}
const DeleteArticleButton: React.FC<DeleteArticleButtonProps> = ({
id,
user,
}) => {
if (!user || !hasPermissions(user.roles, { blogs: ["update"] })) {
return null;
}
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
await request(`/blogs/${id}/delete`, {
requiresAuth: true,
method: "DELETE",
});
// if (res.status === "Success") {
// toast({
// title: "Article supprimé",
// description: "L'article a été supprimé avec succès.",
// });
// setOpen(false); // Only close on success
// router.replace("/blogs");
// } else {
// toast({
// title: "Erreur",
// description: res?.message || "Une erreur est survenue.",
// variant: "destructive",
// });
// // Don't setOpen(false) here - keep dialog open on error
// }
// } catch (e: unknown) {
// toast({
// title: "Erreur",
// description:
// (e as Error)?.message || "Une erreur est survenue.",
// variant: "destructive",
// });
// // Don't setOpen(false) here - keep dialog open on exception
// } finally {
// setIsDeleting(false); // Just reset the loading state
// }
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Êtes-vous sûr de vouloir supprimer cet article ?
</AlertDialogTitle>
<AlertDialogDescription>
Cette action supprimera définitivement cet article.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<Button variant="destructive" onClick={handleDelete}>
Supprimer
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default DeleteArticleButton;

View File

@@ -0,0 +1,261 @@
"use client";
import EditableText from "@/components/editable-text";
import { LocalEditor } from "@/components/editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog";
import useApiMutation, { useApi } from "@/hooks/use-api";
import sluggify from "@/lib/sluggify";
import { Blog, Category, NewBlog } from "@/types/types";
import { useEffect, useMemo, useState } from "react";
import { DialogTitle } from "@radix-ui/react-dialog";
import { useToast } from "@/hooks/use-toast";
import {
ActionButton,
ActionButtonDefault,
ActionButtonError,
ActionButtonLoading,
ActionButtonSuccess,
} from "@/components/action-button";
import ComboBox from "@/components/ui/combobox";
export type BlogState = Partial<Blog> & {
[K in keyof Required<Blog>]?: Blog[K] | null; // Allows null for resetting or un-setting fields
};
export default function BlogEditor({ blog }: { blog?: Blog }) {
const { toast } = useToast();
const setItem = (b: BlogState) =>
blog
? localStorage.setItem(`draft_${blog.blogID}`, JSON.stringify(b))
: localStorage.setItem("draft", JSON.stringify(b));
const [draft, setDraft] = useState<BlogState>(() => {
if (blog) {
setItem(blog);
return blog;
}
const d = {
slug: "",
content: "",
title: "",
category: "",
image: "/placeholder.svg",
};
try {
const _draft = localStorage.getItem("draft");
if (_draft) {
const draft: BlogState = JSON.parse(_draft);
return draft;
}
} catch (e) {}
return d;
});
const handleChange =
(field: keyof BlogState) =>
(e: React.ChangeEvent<HTMLInputElement> | string) => {
const value = typeof e === "string" ? e : e.target.value;
setDraft((prev) => {
const n: typeof prev = {
...prev,
[field]: value,
};
setItem(n);
return n;
});
};
const { data } = useApi<Category[]>(
"/blogs/categories",
undefined,
false,
false,
);
const [categories, setCategories] = useState<string[]>(
draft.category ? [draft.category] : [],
);
useEffect(() => {
if (data) setCategories(data.map((c) => c.category) ?? []);
}, [data]);
const slug = useMemo(() => {
const slug = draft.title ? sluggify(draft.title) : "";
handleChange("slug")(slug);
return slug;
}, [draft.title]);
const newBlog = useApiMutation<undefined, NewBlog>(
"/blogs/new",
{},
"POST",
true,
false,
);
const updateBlog = useApiMutation(
`/blogs/${blog?.blogID}/update`,
{},
"PATCH",
true,
false,
);
const handleNewBlog = async () => {
try {
if (!draft.content) return;
if (!draft.title || draft.title.length < 1) return;
const res = await newBlog.trigger({
title: draft.title,
summary: draft.summary,
image: draft.image,
slug,
content: draft.content,
category: draft.category,
});
if (!res) {
toast({ title: "Aucune réponse du serveur." });
return;
}
if (res.status === "Error") {
toast({
title: "Erreur.",
content: "Une erreur est survenue.",
});
}
if (res.data) console.log(res.data);
return res;
} catch (error: any) {
toast({
title: "Erreur.",
content: "Une erreur est survenue.",
});
}
};
const handleUpdateBlog = async () => {
try {
if (!draft.content) return;
if (!draft.title || draft.title.length < 1) return;
const res = await updateBlog.trigger({
title: draft.title,
summary: draft.summary,
image: draft.image,
slug,
content: draft.content,
category: draft.category,
});
if (!res) {
toast({ title: "Aucune réponse du serveur." });
return;
}
if (res.status === "Error") {
toast({
title: "Erreur.",
content: "Une erreur est survenue.",
});
}
if (res.data) console.log(res.data);
return res;
} catch (error: any) {
toast({
title: "Erreur.",
content: "Une erreur est survenue.",
});
}
};
return (
<section className="m-10 flex flex-col gap-4">
<div className="flex flex-col gap-4">
<EditableText onChange={handleChange("title")}>
<h1>
{draft.title && draft.title.length > 0
? draft.title
: "Un titre doit-être fourni"}
</h1>
</EditableText>
<p className="italic">{slug}</p>
<Dialog>
<DialogTrigger>
<img
src={draft.image}
alt="Blog cover"
className="w-full h-60 object-cover cursor-pointer rounded-lg"
/>
</DialogTrigger>
<DialogTitle></DialogTitle>
<DialogContent>
<Input
type="text"
placeholder="Enter image URL"
value={draft.image}
onChange={handleChange("image")}
/>
</DialogContent>
</Dialog>
<div className="flex gap-4">
<Input
type="text"
placeholder="Enter a summary"
value={draft.summary}
onChange={handleChange("summary")}
/>
<ComboBox
value={draft.category ?? ""}
onValueChange={handleChange("category")}
key={categories.join(",")}
elements={categories}
trigger={(value) => (
<Button>
{value ?? "Selectionner une catégorie"}
</Button>
)}
onSubmit={(value) => {
setCategories((prev) => {
if (prev.includes(value)) return prev;
return [...prev, value];
});
handleChange("category")(value);
}}
>
{(Item, element) => (
<Item value={element} key={element} label={element}>
{element}
</Item>
)}
</ComboBox>
</div>
</div>
<div className="flex">
<div className="flex-1">
<LocalEditor
onChange={handleChange("content")}
content={draft.content ?? ""}
onTitleChange={handleChange("title")}
/>
</div>
</div>
<ActionButton
isLoading={newBlog.isMutating || updateBlog.isMutating}
isSuccess={newBlog.isSuccess || updateBlog.isSuccess}
error={newBlog.error || updateBlog.error}
onClick={blog ? handleUpdateBlog : handleNewBlog}
>
<ActionButtonDefault>
{blog ? "Modifier" : "Publier"}
</ActionButtonDefault>
<ActionButtonSuccess />
<ActionButtonError />
<ActionButtonLoading />
</ActionButton>
</section>
);
}

View File

@@ -18,14 +18,14 @@ interface EditorProps {
content: string; content: string;
onChange?: (content: string) => void; onChange?: (content: string) => void;
className?: string; className?: string;
setTitle?: React.Dispatch<React.SetStateAction<string>>; onTitleChange?: (t: string) => void;
} }
export function LocalEditor({ export function LocalEditor({
content, content,
onChange, onChange,
className, className,
setTitle, onTitleChange: setTitle,
}: EditorProps) { }: EditorProps) {
const getTitle = (editor: Editor) => { const getTitle = (editor: Editor) => {
const firstNode = editor.state.doc.firstChild; const firstNode = editor.state.doc.firstChild;
@@ -87,7 +87,7 @@ export function LocalEditor({
setTitle?.(title ?? ""); setTitle?.(title ?? "");
}, },
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
localStorage.setItem("blog_draft", editor.getHTML()); onChange?.(editor.getHTML());
const title = getTitle(editor); const title = getTitle(editor);
setTitle?.(title ?? ""); setTitle?.(title ?? "");
}, },

View File

@@ -0,0 +1,141 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -24,7 +24,7 @@ interface ComboBoxProps<T> {
trigger: (value?: string) => React.ReactNode; trigger: (value?: string) => React.ReactNode;
onSubmit?: (value: string) => void; onSubmit?: (value: string) => void;
value: string; value: string;
setValue: React.Dispatch<React.SetStateAction<string>>; onValueChange: (v: string) => void;
children: ( children: (
ItemComponent: ( ItemComponent: (
props: React.ComponentProps<typeof CommandItem> & { label: string }, props: React.ComponentProps<typeof CommandItem> & { label: string },
@@ -39,7 +39,7 @@ const ComboBox = <T,>({
children, children,
onSubmit, onSubmit,
value, value,
setValue, onValueChange,
}: ComboBoxProps<T>) => { }: ComboBoxProps<T>) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
@@ -47,7 +47,7 @@ const ComboBox = <T,>({
const handleSubmit = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleSubmit = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== "Enter") return; if (e.key !== "Enter") return;
e.preventDefault(); e.preventDefault();
setValue(searchValue); onValueChange(searchValue);
onSubmit?.(searchValue); onSubmit?.(searchValue);
}; };
@@ -82,7 +82,7 @@ const ComboBox = <T,>({
key={index} key={index}
value={elementValue ?? ""} value={elementValue ?? ""}
onSelect={(_value) => { onSelect={(_value) => {
setValue(_value); onValueChange(_value);
console.log(elementValue); console.log(elementValue);
setOpen(false); setOpen(false);
onSelect?.(_value); onSelect?.(_value);

View File

@@ -1,3 +0,0 @@
{
"unstable": ["unsafe-proto"]
}

3111
frontend/deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",
@@ -873,6 +874,57 @@
} }
} }
}, },
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz",
"integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dialog": "1.1.6",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
@@ -1057,25 +1109,25 @@
} }
}, },
"node_modules/@radix-ui/react-dialog": { "node_modules/@radix-ui/react-dialog": {
"version": "1.1.4", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz",
"integrity": "sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==", "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/primitive": "1.1.1", "@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1", "@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.3", "@radix-ui/react-dismissable-layer": "1.1.5",
"@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2",
"@radix-ui/react-id": "1.1.0", "@radix-ui/react-id": "1.1.0",
"@radix-ui/react-portal": "1.1.3", "@radix-ui/react-portal": "1.1.4",
"@radix-ui/react-presence": "1.1.2", "@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-slot": "1.1.1", "@radix-ui/react-slot": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1", "aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.1" "react-remove-scroll": "^2.6.3"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1092,21 +1144,102 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.1", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.1" "@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" "@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@types/react": { "@types/react": {
"optional": true "optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz",
"integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
"integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
} }
} }
}, },
@@ -7385,16 +7518,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.6.2", "version": "2.6.3",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz",
"integrity": "sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==", "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-remove-scroll-bar": "^2.3.7", "react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.1", "react-style-singleton": "^2.2.3",
"tslib": "^2.1.0", "tslib": "^2.1.0",
"use-callback-ref": "^1.3.3", "use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.2" "use-sidecar": "^1.1.3"
}, },
"engines": { "engines": {
"node": ">=10" "node": ">=10"

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",