adsdasd
This commit is contained in:
@@ -1,203 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { clientFetch } from "@/lib/api";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { ImageUploader } from "@/components/dashboard/image-uploader";
|
|
||||||
import { Loader2, PlusCircle } from "lucide-react";
|
|
||||||
import { PostStatus, ContentFormat, UserRole } from "@/lib/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
userRole: UserRole;
|
|
||||||
onCreated: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreatePostForm({ userRole, onCreated }: Props) {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [status, setStatus] = useState<PostStatus>(PostStatus.DRAFT);
|
|
||||||
const [contentFormat, setContentFormat] = useState<ContentFormat>(
|
|
||||||
ContentFormat.MARKDOWN
|
|
||||||
);
|
|
||||||
const [isFeatured, setIsFeatured] = useState("false");
|
|
||||||
|
|
||||||
// Controlled image state (ImageUploader is not a native input element)
|
|
||||||
const [featuredImageUrl, setFeaturedImageUrl] = useState("");
|
|
||||||
const [featuredImageAlt, setFeaturedImageAlt] = useState("");
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const fd = new FormData(e.currentTarget);
|
|
||||||
const body = {
|
|
||||||
title: fd.get("title"),
|
|
||||||
slug: fd.get("slug") || undefined,
|
|
||||||
excerpt: fd.get("excerpt") || undefined,
|
|
||||||
content: fd.get("content"),
|
|
||||||
contentFormat,
|
|
||||||
status: userRole === UserRole.ADMIN ? status : PostStatus.DRAFT,
|
|
||||||
isFeatured: isFeatured === "true",
|
|
||||||
categories: fd.get("categories") || undefined,
|
|
||||||
tags: fd.get("tags") || undefined,
|
|
||||||
// image values come from controlled state, not FormData
|
|
||||||
featuredImageUrl: featuredImageUrl || undefined,
|
|
||||||
featuredImageAlt: featuredImageAlt || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await clientFetch("/blog-posts", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
toast.success("Post created!");
|
|
||||||
(e.target as HTMLFormElement).reset();
|
|
||||||
// Reset controlled image state
|
|
||||||
setFeaturedImageUrl("");
|
|
||||||
setFeaturedImageAlt("");
|
|
||||||
onCreated();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "Failed to create post");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 rounded-xl border bg-muted/30 p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="font-semibold flex items-center gap-2">
|
|
||||||
<PlusCircle className="h-4 w-4" /> Create New Post
|
|
||||||
</h3>
|
|
||||||
{userRole !== UserRole.ADMIN && (
|
|
||||||
<span className="text-xs text-muted-foreground border rounded-full px-2 py-0.5">
|
|
||||||
Status forced to Draft (Manager)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="cp-title">Title *</Label>
|
|
||||||
<Input id="cp-title" name="title" placeholder="Post title" required />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="cp-slug">Slug (optional)</Label>
|
|
||||||
<Input id="cp-slug" name="slug" placeholder="custom-slug" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="cp-excerpt">Excerpt</Label>
|
|
||||||
<Textarea
|
|
||||||
id="cp-excerpt"
|
|
||||||
name="excerpt"
|
|
||||||
placeholder="Short description…"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="cp-content">Content *</Label>
|
|
||||||
<Textarea
|
|
||||||
id="cp-content"
|
|
||||||
name="content"
|
|
||||||
placeholder="Write your post content…"
|
|
||||||
rows={8}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Format</Label>
|
|
||||||
<Select
|
|
||||||
value={contentFormat}
|
|
||||||
onValueChange={(v) => setContentFormat(v as ContentFormat)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={ContentFormat.MARKDOWN}>Markdown</SelectItem>
|
|
||||||
<SelectItem value={ContentFormat.HTML}>HTML</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{userRole === UserRole.ADMIN && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Status</Label>
|
|
||||||
<Select
|
|
||||||
value={status}
|
|
||||||
onValueChange={(v) => setStatus(v as PostStatus)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={PostStatus.DRAFT}>Draft</SelectItem>
|
|
||||||
<SelectItem value={PostStatus.PUBLISHED}>Published</SelectItem>
|
|
||||||
<SelectItem value={PostStatus.ARCHIVED}>Archived</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Featured</Label>
|
|
||||||
<Select value={isFeatured} onValueChange={setIsFeatured}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="false">Not featured</SelectItem>
|
|
||||||
<SelectItem value="true">Featured</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="cp-categories">Categories (comma separated)</Label>
|
|
||||||
<Input
|
|
||||||
id="cp-categories"
|
|
||||||
name="categories"
|
|
||||||
placeholder="backend,api"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="cp-tags">Tags (comma separated)</Label>
|
|
||||||
<Input id="cp-tags" name="tags" placeholder="rest,pagination" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Featured image upload ─────────────────────────────────────────── */}
|
|
||||||
<ImageUploader
|
|
||||||
value={featuredImageUrl}
|
|
||||||
onChange={setFeaturedImageUrl}
|
|
||||||
altValue={featuredImageAlt}
|
|
||||||
onAltChange={setFeaturedImageAlt}
|
|
||||||
label="Featured Image"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button type="submit" disabled={loading}>
|
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Create Post
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
373
frontend/app/dashboard/components/post-sheet.tsx
Normal file
373
frontend/app/dashboard/components/post-sheet.tsx
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { clientFetch } from "@/lib/api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { BlogPost, PostStatus, ContentFormat, UserRole } from "@/lib/types";
|
||||||
|
import { TiptapEditor } from "@/components/dashboard/tiptap-editor";
|
||||||
|
import { ImageUploader } from "@/components/dashboard/image-uploader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Loader2, FileText, Settings2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** If provided, the sheet is in edit mode. Otherwise, create mode. */
|
||||||
|
post?: BlogPost | null;
|
||||||
|
userRole: UserRole;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultStatus = (role: UserRole): PostStatus =>
|
||||||
|
role === UserRole.ADMIN ? PostStatus.PUBLISHED : PostStatus.DRAFT;
|
||||||
|
|
||||||
|
export function PostSheet({ open, onOpenChange, post, userRole, onSuccess }: Props) {
|
||||||
|
const isEdit = !!post;
|
||||||
|
|
||||||
|
/* ── form state ─────────────────────────────────────────────────────────── */
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [excerpt, setExcerpt] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [contentFormat, setContentFormat] = useState<ContentFormat>(ContentFormat.HTML);
|
||||||
|
const [status, setStatus] = useState<PostStatus>(defaultStatus(userRole));
|
||||||
|
const [isFeatured, setIsFeatured] = useState(false);
|
||||||
|
const [categories, setCategories] = useState("");
|
||||||
|
const [tags, setTags] = useState("");
|
||||||
|
const [imageUrl, setImageUrl] = useState("");
|
||||||
|
const [imageAlt, setImageAlt] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
/* ── populate when sheet opens ──────────────────────────────────────────── */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (post) {
|
||||||
|
setTitle(post.title);
|
||||||
|
setSlug(post.slug);
|
||||||
|
setExcerpt(post.excerpt ?? "");
|
||||||
|
setContent(post.content ?? "");
|
||||||
|
setContentFormat(post.contentFormat);
|
||||||
|
setStatus(post.status);
|
||||||
|
setIsFeatured(post.isFeatured);
|
||||||
|
setCategories(post.categories.join(","));
|
||||||
|
setTags(post.tags.join(","));
|
||||||
|
setImageUrl(post.featuredImageUrl ?? "");
|
||||||
|
setImageAlt(post.featuredImageAlt ?? "");
|
||||||
|
} else {
|
||||||
|
setTitle("");
|
||||||
|
setSlug("");
|
||||||
|
setExcerpt("");
|
||||||
|
setContent("");
|
||||||
|
setContentFormat(ContentFormat.HTML);
|
||||||
|
setStatus(defaultStatus(userRole));
|
||||||
|
setIsFeatured(false);
|
||||||
|
setCategories("");
|
||||||
|
setTags("");
|
||||||
|
setImageUrl("");
|
||||||
|
setImageAlt("");
|
||||||
|
}
|
||||||
|
}, [open, post, userRole]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!content || content === "<p></p>") {
|
||||||
|
toast.error("Content cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
title: title.trim(),
|
||||||
|
slug: slug.trim() || undefined,
|
||||||
|
excerpt: excerpt.trim() || undefined,
|
||||||
|
content,
|
||||||
|
contentFormat,
|
||||||
|
status: userRole !== UserRole.ADMIN ? PostStatus.DRAFT : status,
|
||||||
|
isFeatured,
|
||||||
|
categories: categories.trim() || undefined,
|
||||||
|
tags: tags.trim() || undefined,
|
||||||
|
featuredImageUrl: imageUrl || undefined,
|
||||||
|
featuredImageAlt: imageAlt || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (isEdit && post) {
|
||||||
|
await clientFetch(`/blog-posts/${post.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
toast.success("Post updated!");
|
||||||
|
} else {
|
||||||
|
await clientFetch("/blog-posts", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
toast.success("Post created!");
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor: Record<PostStatus, string> = {
|
||||||
|
[PostStatus.PUBLISHED]: "text-emerald-600",
|
||||||
|
[PostStatus.DRAFT]: "text-zinc-500",
|
||||||
|
[PostStatus.ARCHIVED]: "text-amber-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
{/* Wide sheet — takes 60% of screen on large displays */}
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
className="w-full sm:max-w-2xl lg:max-w-3xl flex flex-col p-0 gap-0"
|
||||||
|
>
|
||||||
|
{/* ── Sheet header ──────────────────────────────────────────────────── */}
|
||||||
|
<SheetHeader className="px-6 py-4 border-b shrink-0">
|
||||||
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{isEdit ? "Edit Post" : "New Post"}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
{isEdit
|
||||||
|
? `Editing "${post?.title}"`
|
||||||
|
: "Fill in the details below and hit Publish (or save as Draft)."}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{/* ── Scrollable form body ──────────────────────────────────────────── */}
|
||||||
|
<form
|
||||||
|
id="post-sheet-form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex-1 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div className="px-6 py-5 space-y-5">
|
||||||
|
|
||||||
|
{/* Title + Slug */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ps-title">
|
||||||
|
Title <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ps-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="My awesome post"
|
||||||
|
required
|
||||||
|
className="text-base font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ps-slug" className="text-muted-foreground text-xs">
|
||||||
|
Slug <span className="text-muted-foreground/60">(auto-generated if empty)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ps-slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
|
placeholder="my-awesome-post"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Content editor */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>
|
||||||
|
Content <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<TiptapEditor
|
||||||
|
value={content}
|
||||||
|
onChange={setContent}
|
||||||
|
placeholder="Write your post content here…"
|
||||||
|
minHeight="340px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Excerpt */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ps-excerpt">Excerpt</Label>
|
||||||
|
<Textarea
|
||||||
|
id="ps-excerpt"
|
||||||
|
value={excerpt}
|
||||||
|
onChange={(e) => setExcerpt(e.target.value)}
|
||||||
|
placeholder="A short description shown in post listings…"
|
||||||
|
rows={2}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Settings row */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
|
||||||
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
|
Post settings
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{/* Status — admin only */}
|
||||||
|
{userRole === UserRole.ADMIN ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(v) => setStatus(v as PostStatus)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={PostStatus.DRAFT}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-zinc-400" />
|
||||||
|
Draft
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={PostStatus.PUBLISHED}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
Published
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={PostStatus.ARCHIVED}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-amber-500" />
|
||||||
|
Archived
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-muted-foreground">Status</Label>
|
||||||
|
<div className="flex h-9 items-center text-sm text-muted-foreground border rounded-md px-3">
|
||||||
|
Saved as Draft (Manager)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Format */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Content format</Label>
|
||||||
|
<Select
|
||||||
|
value={contentFormat}
|
||||||
|
onValueChange={(v) => setContentFormat(v as ContentFormat)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ContentFormat.HTML}>HTML (Tiptap)</SelectItem>
|
||||||
|
<SelectItem value={ContentFormat.MARKDOWN}>Markdown</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories + Tags */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ps-cats">Categories</Label>
|
||||||
|
<Input
|
||||||
|
id="ps-cats"
|
||||||
|
value={categories}
|
||||||
|
onChange={(e) => setCategories(e.target.value)}
|
||||||
|
placeholder="tech, tutorials"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ps-tags">Tags</Label>
|
||||||
|
<Input
|
||||||
|
id="ps-tags"
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
placeholder="nextjs, react"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured toggle */}
|
||||||
|
{userRole === UserRole.ADMIN && (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Featured post</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Shown in the hero/featured section on the home page
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isFeatured}
|
||||||
|
onCheckedChange={setIsFeatured}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Featured image */}
|
||||||
|
<ImageUploader
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={setImageUrl}
|
||||||
|
altValue={imageAlt}
|
||||||
|
onAltChange={setImageAlt}
|
||||||
|
label="Featured Image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* ── Sticky footer ────────────────────────────────────────────────── */}
|
||||||
|
<div className="border-t px-6 py-4 flex items-center justify-between gap-3 bg-background shrink-0">
|
||||||
|
{isEdit && (
|
||||||
|
<span className={`text-xs font-medium capitalize ${statusColor[status]}`}>
|
||||||
|
● {status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="post-sheet-form" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{isEdit ? "Save changes" : status === PostStatus.PUBLISHED ? "Publish" : "Save Draft"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,19 +3,11 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { clientFetch } from "@/lib/api";
|
import { clientFetch } from "@/lib/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { BlogPost, PostStatus, ContentFormat, UserRole } from "@/lib/types";
|
import { BlogPost, PostStatus, UserRole } from "@/lib/types";
|
||||||
import { CreatePostForm } from "./create-post-form";
|
import { PostSheet } from "./post-sheet";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -27,8 +19,16 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { ImageUploader } from "@/components/dashboard/image-uploader";
|
import {
|
||||||
import { Eye, Loader2, Pencil, Trash2, X, Check, ExternalLink } from "lucide-react";
|
Eye,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
PlusCircle,
|
||||||
|
ExternalLink,
|
||||||
|
Star,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -36,17 +36,30 @@ interface Props {
|
|||||||
userRole: UserRole;
|
userRole: UserRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-post edit image state so each inline form has its own uploader values. */
|
const statusMeta: Record<PostStatus, { label: string; dot: string; row: string }> = {
|
||||||
interface EditImageState {
|
[PostStatus.PUBLISHED]: {
|
||||||
url: string;
|
label: "Published",
|
||||||
alt: string;
|
dot: "bg-emerald-500",
|
||||||
}
|
row: "border-l-emerald-400",
|
||||||
|
},
|
||||||
|
[PostStatus.DRAFT]: {
|
||||||
|
label: "Draft",
|
||||||
|
dot: "bg-zinc-400",
|
||||||
|
row: "border-l-zinc-300",
|
||||||
|
},
|
||||||
|
[PostStatus.ARCHIVED]: {
|
||||||
|
label: "Archived",
|
||||||
|
dot: "bg-amber-400",
|
||||||
|
row: "border-l-amber-400",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function PostsTable({ initialPosts, userRole }: Props) {
|
export function PostsTable({ initialPosts, userRole }: Props) {
|
||||||
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [editImage, setEditImage] = useState<EditImageState>({ url: "", alt: "" });
|
const [editingPost, setEditingPost] = useState<BlogPost | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const refreshPosts = useCallback(async () => {
|
const refreshPosts = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -59,16 +72,18 @@ export function PostsTable({ initialPosts, userRole }: Props) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startEditing = (post: BlogPost) => {
|
const openCreate = () => {
|
||||||
setEditingId(post.id);
|
setEditingPost(null);
|
||||||
setEditImage({
|
setSheetOpen(true);
|
||||||
url: post.featuredImageUrl || "",
|
};
|
||||||
alt: post.featuredImageAlt || "",
|
|
||||||
});
|
const openEdit = (post: BlogPost) => {
|
||||||
|
setEditingPost(post);
|
||||||
|
setSheetOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
setLoading(true);
|
setDeletingId(id);
|
||||||
try {
|
try {
|
||||||
await clientFetch(`/blog-posts/${id}`, { method: "DELETE" });
|
await clientFetch(`/blog-posts/${id}`, { method: "DELETE" });
|
||||||
toast.success("Post deleted.");
|
toast.success("Post deleted.");
|
||||||
@@ -76,249 +91,203 @@ export function PostsTable({ initialPosts, userRole }: Props) {
|
|||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err instanceof Error ? err.message : "Delete failed");
|
toast.error(err instanceof Error ? err.message : "Delete failed");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setDeletingId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (
|
const canEdit = userRole === UserRole.ADMIN;
|
||||||
e: React.FormEvent<HTMLFormElement>,
|
const canCreate = userRole === UserRole.ADMIN || userRole === UserRole.MANAGER;
|
||||||
id: string
|
|
||||||
) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const fd = new FormData(e.currentTarget);
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
title: fd.get("title"),
|
|
||||||
slug: fd.get("slug") || undefined,
|
|
||||||
excerpt: fd.get("excerpt") || undefined,
|
|
||||||
content: fd.get("content"),
|
|
||||||
contentFormat: fd.get("contentFormat"),
|
|
||||||
status: fd.get("status"),
|
|
||||||
isFeatured: fd.get("isFeatured") === "true",
|
|
||||||
categories: fd.get("categories") || undefined,
|
|
||||||
tags: fd.get("tags") || undefined,
|
|
||||||
// Image comes from controlled ImageUploader state
|
|
||||||
featuredImageUrl: editImage.url || undefined,
|
|
||||||
featuredImageAlt: editImage.alt || undefined,
|
|
||||||
};
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await clientFetch(`/blog-posts/${id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
toast.success("Post updated!");
|
|
||||||
setEditingId(null);
|
|
||||||
await refreshPosts();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "Update failed");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusColor: Record<PostStatus, string> = {
|
const filtered = search.trim()
|
||||||
[PostStatus.PUBLISHED]: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
? posts.filter(
|
||||||
[PostStatus.DRAFT]: "bg-zinc-100 text-zinc-700 border-zinc-200",
|
(p) =>
|
||||||
[PostStatus.ARCHIVED]: "bg-amber-100 text-amber-800 border-amber-200",
|
p.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
};
|
p.slug.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: posts;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<>
|
||||||
{/* Create form for ADMIN/MANAGER */}
|
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
|
||||||
{(userRole === UserRole.ADMIN || userRole === UserRole.MANAGER) && (
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
<CreatePostForm userRole={userRole} onCreated={refreshPosts} />
|
<div className="relative">
|
||||||
)}
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search posts…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-8 h-8 w-56 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{canCreate && (
|
||||||
|
<Button size="sm" onClick={openCreate} className="gap-1.5">
|
||||||
|
<PlusCircle className="h-4 w-4" />
|
||||||
|
New Post
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Posts list */}
|
{/* ── Posts list ────────────────────────────────────────────────────── */}
|
||||||
{posts.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">
|
<div className="rounded-xl border border-dashed p-10 text-center text-sm text-muted-foreground">
|
||||||
No posts yet.
|
{search
|
||||||
|
? "No posts match your search."
|
||||||
|
: "No posts yet. Create your first post!"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="rounded-xl border overflow-hidden divide-y">
|
||||||
{posts.map((post) => (
|
{filtered.map((post) => {
|
||||||
<article
|
const meta = statusMeta[post.status];
|
||||||
key={post.id}
|
return (
|
||||||
className="rounded-xl border bg-card p-4 shadow-sm"
|
<div
|
||||||
>
|
key={post.id}
|
||||||
{editingId === post.id && userRole === UserRole.ADMIN ? (
|
className={`flex items-start gap-3 px-4 py-3.5 border-l-4 bg-card hover:bg-muted/30 transition-colors ${meta.row}`}
|
||||||
/* ── Edit form ─────────────────────────────────────────── */
|
>
|
||||||
<form
|
{/* Thumbnail */}
|
||||||
onSubmit={(e) => handleUpdate(e, post.id)}
|
{post.featuredImageUrl ? (
|
||||||
className="space-y-3"
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
>
|
<img
|
||||||
<div className="flex justify-between items-center">
|
src={post.featuredImageUrl}
|
||||||
<span className="font-semibold text-sm">Editing post</span>
|
alt={post.featuredImageAlt || ""}
|
||||||
<Button
|
className="h-12 w-20 rounded-md object-cover shrink-0 border hidden sm:block"
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEditingId(null)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<Input name="title" defaultValue={post.title} required placeholder="Title" />
|
|
||||||
<Input name="slug" defaultValue={post.slug} placeholder="Slug" />
|
|
||||||
</div>
|
|
||||||
<Textarea name="excerpt" defaultValue={post.excerpt} placeholder="Excerpt" rows={2} />
|
|
||||||
<Textarea name="content" defaultValue={post.content} placeholder="Content" rows={6} required />
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
|
||||||
<Select name="contentFormat" defaultValue={post.contentFormat}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={ContentFormat.MARKDOWN}>Markdown</SelectItem>
|
|
||||||
<SelectItem value={ContentFormat.HTML}>HTML</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select name="status" defaultValue={post.status}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={PostStatus.DRAFT}>Draft</SelectItem>
|
|
||||||
<SelectItem value={PostStatus.PUBLISHED}>Published</SelectItem>
|
|
||||||
<SelectItem value={PostStatus.ARCHIVED}>Archived</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select name="isFeatured" defaultValue={String(post.isFeatured)}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="false">Not featured</SelectItem>
|
|
||||||
<SelectItem value="true">Featured</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<Input name="categories" defaultValue={post.categories.join(",")} placeholder="Categories" />
|
|
||||||
<Input name="tags" defaultValue={post.tags.join(",")} placeholder="Tags" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Image upload (replaces the old URL text inputs) ── */}
|
|
||||||
<ImageUploader
|
|
||||||
value={editImage.url}
|
|
||||||
onChange={(url) => setEditImage((prev) => ({ ...prev, url }))}
|
|
||||||
altValue={editImage.alt}
|
|
||||||
onAltChange={(alt) => setEditImage((prev) => ({ ...prev, alt }))}
|
|
||||||
label="Featured Image"
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="h-12 w-20 rounded-md bg-muted shrink-0 hidden sm:flex items-center justify-center">
|
||||||
<Button type="button" variant="ghost" onClick={() => setEditingId(null)}>
|
<span className="text-xs text-muted-foreground/50">No img</span>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={loading}>
|
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
<Check className="mr-1 h-4 w-4" /> Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
) : (
|
|
||||||
/* ── Post card view ─────────────────────────────────────── */
|
{/* Main content */}
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="space-y-1 min-w-0 flex-1">
|
<div className="flex flex-wrap items-center gap-1.5 mb-0.5">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<span
|
||||||
<Link
|
className={`inline-block h-1.5 w-1.5 rounded-full ${meta.dot}`}
|
||||||
href={`/blog/${post.slug}`}
|
/>
|
||||||
className="font-semibold hover:text-primary transition-colors"
|
<span className="text-xs text-muted-foreground">
|
||||||
target="_blank"
|
{meta.label}
|
||||||
>
|
</span>
|
||||||
{post.title}
|
{post.isFeatured && (
|
||||||
<ExternalLink className="inline-block ml-1 h-3 w-3" />
|
<Star className="h-3 w-3 text-amber-500 fill-amber-500" />
|
||||||
</Link>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
|
<Link
|
||||||
|
href={`/blog/${post.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
className="font-semibold text-sm hover:text-primary transition-colors line-clamp-1"
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
<ExternalLink className="inline-block ml-1 h-2.5 w-2.5 opacity-50" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
/{post.slug}
|
/{post.slug}
|
||||||
</p>
|
</span>
|
||||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${statusColor[post.status]}`}>
|
<Eye className="h-3 w-3" /> {post.views.toLocaleString()}
|
||||||
{post.status}
|
</span>
|
||||||
|
{post.author && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{post.author.name || post.author.email}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
|
)}
|
||||||
<Eye className="h-3 w-3" /> {post.views.toLocaleString()}
|
</div>
|
||||||
</span>
|
|
||||||
{post.isFeatured && (
|
{(post.categories.length > 0 || post.tags.length > 0) && (
|
||||||
<Badge variant="secondary" className="text-xs">⭐ featured</Badge>
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
)}
|
{post.categories.map((c) => (
|
||||||
{post.author && (
|
<Badge
|
||||||
|
key={c}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs h-4 px-1.5"
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{post.tags.slice(0, 3).map((t) => (
|
||||||
|
<Badge
|
||||||
|
key={t}
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs h-4 px-1.5"
|
||||||
|
>
|
||||||
|
#{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{post.tags.length > 3 && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
by {post.author.name || post.author.email}
|
+{post.tags.length - 3}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{post.excerpt && (
|
)}
|
||||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
|
||||||
{post.excerpt}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
|
||||||
{post.categories.map((c) => (
|
|
||||||
<Badge key={c} variant="secondary" className="text-xs">{c}</Badge>
|
|
||||||
))}
|
|
||||||
{post.tags.map((t) => (
|
|
||||||
<Badge key={t} variant="outline" className="text-xs">#{t}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail if image exists */}
|
|
||||||
{post.featuredImageUrl && (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={post.featuredImageUrl}
|
|
||||||
alt={post.featuredImageAlt || ""}
|
|
||||||
className="mt-2 h-16 w-28 rounded-md object-cover border"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-2 shrink-0">
|
|
||||||
{userRole === UserRole.ADMIN && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => startEditing(post)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" className="text-destructive border-destructive/30 hover:bg-destructive/5">
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete post?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
"{post.title}" will be permanently deleted.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
onClick={() => handleDelete(post.id)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</article>
|
{/* Date */}
|
||||||
))}
|
<span className="text-xs text-muted-foreground shrink-0 hidden md:block">
|
||||||
|
{new Date(post.updatedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => openEdit(post)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete post?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
"{post.title}" will be permanently deleted.
|
||||||
|
This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => handleDelete(post.id)}
|
||||||
|
disabled={deletingId === post.id}
|
||||||
|
>
|
||||||
|
{deletingId === post.id && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
{/* ── Post create/edit sheet ─────────────────────────────────────────── */}
|
||||||
|
<PostSheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={setSheetOpen}
|
||||||
|
post={editingPost}
|
||||||
|
userRole={userRole}
|
||||||
|
onSuccess={refreshPosts}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { User, UserRole } from "@/lib/types";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -14,55 +15,78 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Loader2, Pencil, X, Check } from "lucide-react";
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Loader2, Pencil, Check, Users2 } from "lucide-react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialUsers: User[];
|
initialUsers: User[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleColor: Record<UserRole, string> = {
|
const roleMeta: Record<UserRole, { label: string; classes: string }> = {
|
||||||
[UserRole.ADMIN]: "bg-red-100 text-red-800 border-red-200",
|
[UserRole.ADMIN]: {
|
||||||
[UserRole.MANAGER]: "bg-amber-100 text-amber-800 border-amber-200",
|
label: "Admin",
|
||||||
[UserRole.MEMBER]: "bg-blue-100 text-blue-700 border-blue-200",
|
classes: "bg-red-100 text-red-800 border-red-200 dark:bg-red-950 dark:text-red-300",
|
||||||
|
},
|
||||||
|
[UserRole.MANAGER]: {
|
||||||
|
label: "Manager",
|
||||||
|
classes: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300",
|
||||||
|
},
|
||||||
|
[UserRole.MEMBER]: {
|
||||||
|
label: "Member",
|
||||||
|
classes: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function Avatar({ name, email }: { name?: string; email: string }) {
|
||||||
|
const letter = (name || email).charAt(0).toUpperCase();
|
||||||
|
return (
|
||||||
|
<div className="h-9 w-9 shrink-0 rounded-full bg-primary/10 flex items-center justify-center text-sm font-semibold text-primary select-none">
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function UsersTable({ initialUsers }: Props) {
|
export function UsersTable({ initialUsers }: Props) {
|
||||||
const [users, setUsers] = useState<User[]>(initialUsers);
|
const [users, setUsers] = useState<User[]>(initialUsers);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editRole, setEditRole] = useState<UserRole>(UserRole.MEMBER);
|
const [editRole, setEditRole] = useState<UserRole>(UserRole.MEMBER);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const startEdit = (user: User) => {
|
const openEdit = (user: User) => {
|
||||||
setEditingId(user.id);
|
setEditingUser(user);
|
||||||
setEditName(user.name || "");
|
setEditName(user.name || "");
|
||||||
setEditRole(user.role);
|
setEditRole(user.role);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEdit = () => {
|
const closeEdit = () => setEditingUser(null);
|
||||||
setEditingId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async (user: User) => {
|
const handleUpdate = async () => {
|
||||||
|
if (!editingUser) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Update name if changed
|
let updated = editingUser;
|
||||||
let updated = user;
|
|
||||||
if (editName !== (user.name || "")) {
|
if (editName.trim() !== (editingUser.name || "")) {
|
||||||
const res = await clientFetch<{ success: boolean; user: User }>(
|
const res = await clientFetch<{ success: boolean; user: User }>(
|
||||||
`/users/${user.id}`,
|
`/users/${editingUser.id}`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ name: editName || undefined }),
|
body: JSON.stringify({ name: editName.trim() || undefined }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
updated = res.user;
|
updated = res.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update role if changed
|
if (editRole !== editingUser.role) {
|
||||||
if (editRole !== user.role) {
|
|
||||||
const res = await clientFetch<{ success: boolean; user: User }>(
|
const res = await clientFetch<{ success: boolean; user: User }>(
|
||||||
`/users/${user.id}/role`,
|
`/users/${editingUser.id}/role`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ role: editRole }),
|
body: JSON.stringify({ role: editRole }),
|
||||||
@@ -72,8 +96,10 @@ export function UsersTable({ initialUsers }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success("User updated.");
|
toast.success("User updated.");
|
||||||
setUsers((prev) => prev.map((u) => (u.id === user.id ? updated : u)));
|
setUsers((prev) =>
|
||||||
setEditingId(null);
|
prev.map((u) => (u.id === editingUser.id ? updated : u))
|
||||||
|
);
|
||||||
|
closeEdit();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err instanceof Error ? err.message : "Update failed");
|
toast.error(err instanceof Error ? err.message : "Update failed");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -81,111 +107,156 @@ export function UsersTable({ initialUsers }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-dashed p-10 text-center text-sm text-muted-foreground">
|
||||||
|
No users found.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
{users.length === 0 ? (
|
<div className="rounded-xl border overflow-hidden divide-y">
|
||||||
<div className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">
|
{users.map((user) => {
|
||||||
No users found.
|
const role = roleMeta[user.role];
|
||||||
</div>
|
return (
|
||||||
) : (
|
<div
|
||||||
users.map((user) => (
|
key={user.id}
|
||||||
<div
|
className="flex items-center gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors"
|
||||||
key={user.id}
|
>
|
||||||
className="rounded-xl border bg-card p-4 shadow-sm flex flex-wrap items-center gap-3"
|
<Avatar name={user.name} email={user.email} />
|
||||||
>
|
|
||||||
{editingId === user.id ? (
|
<div className="flex-1 min-w-0">
|
||||||
/* ── Edit row ─────────────────────────────────────────────── */
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
<p className="text-sm font-medium truncate">
|
||||||
<Input
|
{user.name || (
|
||||||
className="h-8 w-40"
|
<span className="italic text-muted-foreground">
|
||||||
placeholder="Name"
|
No name
|
||||||
value={editName}
|
</span>
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={editRole}
|
|
||||||
onValueChange={(v) => setEditRole(v as UserRole)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-32">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={UserRole.MEMBER}>MEMBER</SelectItem>
|
|
||||||
<SelectItem value={UserRole.MANAGER}>MANAGER</SelectItem>
|
|
||||||
<SelectItem value={UserRole.ADMIN}>ADMIN</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleUpdate(user)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</p>
|
||||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* ── View row ─────────────────────────────────────────────── */
|
|
||||||
<>
|
|
||||||
<div className="flex flex-1 flex-wrap items-center gap-3 min-w-0">
|
|
||||||
{/* Avatar placeholder */}
|
|
||||||
<div className="h-8 w-8 shrink-0 rounded-full bg-muted flex items-center justify-center text-xs font-semibold uppercase">
|
|
||||||
{(user.name || user.email).charAt(0)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{user.name ?? (
|
|
||||||
<span className="italic text-muted-foreground">
|
|
||||||
No name
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{user.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${roleColor[user.role]}`}
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${role.classes}`}
|
||||||
>
|
>
|
||||||
{user.role}
|
{role.label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{!user.isActive && (
|
{!user.isActive && (
|
||||||
<Badge
|
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||||
variant="outline"
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
Inactive
|
Inactive
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground shrink-0">
|
|
||||||
Joined {new Date(user.createdAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
<span className="text-xs text-muted-foreground shrink-0 hidden sm:block">
|
||||||
<Button
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
variant="outline"
|
</span>
|
||||||
size="sm"
|
|
||||||
onClick={() => startEdit(user)}
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<Pencil className="h-4 w-4" />
|
size="sm"
|
||||||
</Button>
|
className="h-7 w-7 p-0 shrink-0"
|
||||||
</>
|
onClick={() => openEdit(user)}
|
||||||
)}
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Edit sheet ───────────────────────────────────────────────────── */}
|
||||||
|
<Sheet open={!!editingUser} onOpenChange={(o) => !o && closeEdit()}>
|
||||||
|
<SheetContent side="right" className="sm:max-w-sm">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
<Users2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
Edit User
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>{editingUser?.email}</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4 px-1">
|
||||||
|
{/* Avatar preview */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center text-lg font-semibold text-primary">
|
||||||
|
{(editName || editingUser?.email || "?").charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{editName || "No name"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{editingUser?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="eu-name">Display name</Label>
|
||||||
|
<Input
|
||||||
|
id="eu-name"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
placeholder="Full name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Role</Label>
|
||||||
|
<Select
|
||||||
|
value={editRole}
|
||||||
|
onValueChange={(v) => setEditRole(v as UserRole)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={UserRole.MEMBER}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
||||||
|
Member
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={UserRole.MANAGER}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-amber-500" />
|
||||||
|
Manager
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={UserRole.ADMIN}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-red-500" />
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{editRole === UserRole.ADMIN
|
||||||
|
? "Full access: create, edit, delete posts and manage users."
|
||||||
|
: editRole === UserRole.MANAGER
|
||||||
|
? "Can create posts (saved as draft for review)."
|
||||||
|
: "Read-only access to the dashboard."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button variant="outline" className="flex-1" onClick={closeEdit} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1" onClick={handleUpdate} disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
</SheetContent>
|
||||||
)}
|
</Sheet>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { apiFetch, ApiError } from "@/lib/api";
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
import { BlogPost, User, UserRole, CurrentUser } from "@/lib/types";
|
import { BlogPost, User, UserRole, CurrentUser, PostStatus } from "@/lib/types";
|
||||||
import { PostsTable } from "./components/posts-table";
|
import { PostsTable } from "./components/posts-table";
|
||||||
import { UsersTable } from "./components/users-table";
|
import { UsersTable } from "./components/users-table";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Card,
|
FileText,
|
||||||
CardContent,
|
Users,
|
||||||
CardDescription,
|
Eye,
|
||||||
CardHeader,
|
TrendingUp,
|
||||||
CardTitle,
|
CheckCircle2,
|
||||||
} from "@/components/ui/card";
|
Clock,
|
||||||
import { Badge } from "@/components/ui/badge";
|
Archive,
|
||||||
import { Separator } from "@/components/ui/separator";
|
} from "lucide-react";
|
||||||
import { LayoutDashboard, Users, FileText, ShieldAlert } from "lucide-react";
|
|
||||||
|
|
||||||
// ── Decode JWT payload without verification (public payload info only) ─────────
|
// ── JWT decode (no verification — display only) ────────────────────────────
|
||||||
function decodeJwtPayload(token: string): CurrentUser | null {
|
function decodeJwtPayload(token: string): CurrentUser | null {
|
||||||
try {
|
try {
|
||||||
const base64Payload = token.split(".")[1];
|
const base64Payload = token.split(".")[1];
|
||||||
@@ -26,179 +27,217 @@ function decodeJwtPayload(token: string): CurrentUser | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleColor: Record<UserRole, string> = {
|
const rolePill: Record<UserRole, string> = {
|
||||||
[UserRole.ADMIN]: "bg-red-100 text-red-800 border-red-200",
|
[UserRole.ADMIN]: "bg-red-100 text-red-700 border-red-200",
|
||||||
[UserRole.MANAGER]: "bg-amber-100 text-amber-800 border-amber-200",
|
[UserRole.MANAGER]: "bg-amber-100 text-amber-700 border-amber-200",
|
||||||
[UserRole.MEMBER]: "bg-blue-100 text-blue-700 border-blue-200",
|
[UserRole.MEMBER]: "bg-blue-100 text-blue-700 border-blue-200",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Stat card ──────────────────────────────────────────────────────────────
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
icon: React.ElementType;
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
sub?: string;
|
||||||
|
accent?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className={`text-3xl font-bold tracking-tight ${accent ?? ""}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{sub && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{sub}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-9 w-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
|
<Icon className="h-4.5 w-4.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
// ── Auth check ─────────────────────────────────────────────────────────────
|
/* Auth */
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const accessToken = cookieStore.get("accessToken")?.value;
|
const accessToken = cookieStore.get("accessToken")?.value;
|
||||||
|
if (!accessToken) redirect("/auth");
|
||||||
if (!accessToken) {
|
|
||||||
redirect("/auth");
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUser = decodeJwtPayload(accessToken);
|
const currentUser = decodeJwtPayload(accessToken);
|
||||||
if (!currentUser) {
|
if (!currentUser) redirect("/auth");
|
||||||
redirect("/auth");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fetch posts ────────────────────────────────────────────────────────────
|
/* Posts */
|
||||||
let posts: BlogPost[] = [];
|
let posts: BlogPost[] = [];
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<{ posts: BlogPost[]; total: number }>(
|
const data = await apiFetch<{ posts: BlogPost[]; total: number }>(
|
||||||
"/blog-posts?pageSize=50"
|
"/blog-posts?pageSize=100"
|
||||||
);
|
);
|
||||||
posts = data.posts;
|
posts = data.posts;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 401) {
|
if (err instanceof ApiError && err.status === 401) redirect("/auth");
|
||||||
redirect("/auth");
|
|
||||||
}
|
|
||||||
// Non-fatal — show empty list
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fetch users (ADMIN only) ───────────────────────────────────────────────
|
/* Users (ADMIN only) */
|
||||||
let users: User[] = [];
|
let users: User[] = [];
|
||||||
if (currentUser.role === UserRole.ADMIN) {
|
if (currentUser.role === UserRole.ADMIN) {
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<{ users: User[]; total: number }>(
|
const data = await apiFetch<{ users: User[]; total: number }>(
|
||||||
"/users?pageSize=50"
|
"/users?pageSize=100"
|
||||||
);
|
);
|
||||||
users = data.users;
|
users = data.users;
|
||||||
} catch {
|
} catch { /* non-fatal */ }
|
||||||
// Non-fatal — show empty list
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
const published = posts.filter((p) => p.status === PostStatus.PUBLISHED).length;
|
||||||
|
const drafts = posts.filter((p) => p.status === PostStatus.DRAFT).length;
|
||||||
|
const archived = posts.filter((p) => p.status === PostStatus.ARCHIVED).length;
|
||||||
|
const totalViews = posts.reduce((s, p) => s + (p.views ?? 0), 0);
|
||||||
|
|
||||||
const displayName = currentUser.name || currentUser.email;
|
const displayName = currentUser.name || currentUser.email;
|
||||||
|
const isAdmin = currentUser.role === UserRole.ADMIN;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="min-h-screen bg-muted/20">
|
||||||
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
{/* ── Top header bar ──────────────────────────────────────────────────── */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="border-b bg-background">
|
||||||
<div className="flex items-center gap-3">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<LayoutDashboard className="h-6 w-6 text-muted-foreground" />
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
<h1 className="text-xl font-bold">Dashboard</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Manage your blog content and users
|
Welcome back, <span className="font-medium text-foreground">{displayName}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground">{displayName}</span>
|
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${roleColor[currentUser.role as UserRole]}`}
|
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold ${rolePill[currentUser.role as UserRole]}`}
|
||||||
>
|
>
|
||||||
{currentUser.role}
|
{currentUser.role}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Stats strip ─────────────────────────────────────────────────────── */}
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 space-y-6">
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<FileText className="h-8 w-8 text-muted-foreground/60" />
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-bold">{posts.length}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Total posts</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<FileText className="h-8 w-8 text-emerald-500/60" />
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-bold">
|
|
||||||
{posts.filter((p) => p.status === "published").length}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Published</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<FileText className="h-8 w-8 text-zinc-400/60" />
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-bold">
|
|
||||||
{posts.filter((p) => p.status === "draft").length}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Drafts</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
{/* ── Stats grid ────────────────────────────────────────────────────── */}
|
||||||
|
<div className={`grid gap-4 ${isAdmin ? "sm:grid-cols-2 lg:grid-cols-4" : "sm:grid-cols-3"}`}>
|
||||||
{/* ── Posts section ───────────────────────────────────────────────────── */}
|
<StatCard
|
||||||
<section className="space-y-4">
|
icon={FileText}
|
||||||
<div className="flex items-center gap-2">
|
label="Total posts"
|
||||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
value={posts.length}
|
||||||
<h2 className="text-xl font-semibold">Posts</h2>
|
sub={`${published} published`}
|
||||||
<Badge variant="secondary" className="text-xs">
|
/>
|
||||||
{posts.length}
|
<StatCard
|
||||||
</Badge>
|
icon={CheckCircle2}
|
||||||
|
label="Published"
|
||||||
|
value={published}
|
||||||
|
accent="text-emerald-600"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Drafts"
|
||||||
|
value={drafts}
|
||||||
|
accent="text-zinc-500"
|
||||||
|
/>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<StatCard
|
||||||
|
icon={Eye}
|
||||||
|
label="Total views"
|
||||||
|
value={totalViews.toLocaleString()}
|
||||||
|
sub="across all posts"
|
||||||
|
accent="text-primary"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PostsTable initialPosts={posts} userRole={currentUser.role as UserRole} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Users section (ADMIN only) ───────────────────────────────────────── */}
|
{/* Second row for admin — archived + users */}
|
||||||
{currentUser.role === UserRole.ADMIN && (
|
{isAdmin && (
|
||||||
<>
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Separator />
|
<StatCard
|
||||||
<section className="space-y-4">
|
icon={Archive}
|
||||||
<div className="flex items-center gap-2">
|
label="Archived"
|
||||||
<Users className="h-5 w-5 text-muted-foreground" />
|
value={archived}
|
||||||
<h2 className="text-xl font-semibold">Users</h2>
|
accent="text-amber-600"
|
||||||
<Badge variant="secondary" className="text-xs">
|
/>
|
||||||
{users.length}
|
<StatCard
|
||||||
</Badge>
|
icon={Users}
|
||||||
</div>
|
label="Total users"
|
||||||
{users.length === 0 ? (
|
value={users.length}
|
||||||
<Card>
|
sub={`${users.filter((u) => u.isActive).length} active`}
|
||||||
<CardHeader>
|
/>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<StatCard
|
||||||
<ShieldAlert className="h-4 w-4" /> No users loaded
|
icon={TrendingUp}
|
||||||
</CardTitle>
|
label="Avg. views / post"
|
||||||
<CardDescription>
|
value={posts.length ? Math.round(totalViews / posts.length).toLocaleString() : "—"}
|
||||||
Unable to fetch user list. Check your backend connection.
|
/>
|
||||||
</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
)}
|
||||||
</Card>
|
|
||||||
) : (
|
{/* ── Tabs ──────────────────────────────────────────────────────────── */}
|
||||||
<UsersTable initialUsers={users} />
|
<Tabs defaultValue="posts" className="space-y-4">
|
||||||
|
<TabsList className={isAdmin ? "" : "w-auto"}>
|
||||||
|
<TabsTrigger value="posts" className="gap-1.5">
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
Posts
|
||||||
|
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground ml-0.5">
|
||||||
|
{posts.length}
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
{isAdmin && (
|
||||||
|
<TabsTrigger value="users" className="gap-1.5">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
Users
|
||||||
|
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground ml-0.5">
|
||||||
|
{users.length}
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
</section>
|
</TabsList>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Member notice ───────────────────────────────────────────────────── */}
|
{/* Posts tab */}
|
||||||
{currentUser.role === UserRole.MEMBER && (
|
<TabsContent value="posts" className="mt-0">
|
||||||
<Card className="border-dashed">
|
<PostsTable
|
||||||
<CardHeader>
|
initialPosts={posts}
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
userRole={currentUser.role as UserRole}
|
||||||
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
|
/>
|
||||||
Read-only access
|
</TabsContent>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
{/* Users tab (admin only) */}
|
||||||
You have the <strong>MEMBER</strong> role. You can view posts but
|
{isAdmin && (
|
||||||
cannot create, edit, or delete them. Contact an admin to upgrade
|
<TabsContent value="users" className="mt-0">
|
||||||
your role.
|
<UsersTable initialUsers={users} />
|
||||||
</CardDescription>
|
</TabsContent>
|
||||||
</CardHeader>
|
)}
|
||||||
</Card>
|
</Tabs>
|
||||||
)}
|
|
||||||
|
{/* Member read-only notice */}
|
||||||
|
{currentUser.role === UserRole.MEMBER && (
|
||||||
|
<Card className="border-dashed border-muted-foreground/30 bg-muted/20">
|
||||||
|
<CardContent className="py-4 px-5">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-semibold text-foreground">Read-only access.</span>{" "}
|
||||||
|
You can view posts but cannot create, edit, or delete them. Contact an admin to upgrade your role.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,4 +124,67 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tiptap editor content styles ─────────────────────────────────────────── */
|
||||||
|
.tiptap-content .ProseMirror {
|
||||||
|
outline: none;
|
||||||
|
min-height: inherit;
|
||||||
|
}
|
||||||
|
.tiptap-content .ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: oklch(0.556 0 0);
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.tiptap-content .ProseMirror > * + * { margin-top: 0.65em; }
|
||||||
|
.tiptap-content .ProseMirror h1 { font-size: 1.6rem; font-weight: 700; line-height: 1.2; }
|
||||||
|
.tiptap-content .ProseMirror h2 { font-size: 1.3rem; font-weight: 700; line-height: 1.3; }
|
||||||
|
.tiptap-content .ProseMirror h3 { font-size: 1.1rem; font-weight: 600; line-height: 1.4; }
|
||||||
|
.tiptap-content .ProseMirror strong { font-weight: 700; }
|
||||||
|
.tiptap-content .ProseMirror em { font-style: italic; }
|
||||||
|
.tiptap-content .ProseMirror u { text-decoration: underline; }
|
||||||
|
.tiptap-content .ProseMirror s { text-decoration: line-through; }
|
||||||
|
.tiptap-content .ProseMirror mark { background-color: oklch(0.97 0.18 95); border-radius: 0.15em; padding: 0 0.15em; }
|
||||||
|
.tiptap-content .ProseMirror ul { list-style-type: disc; padding-left: 1.4em; }
|
||||||
|
.tiptap-content .ProseMirror ol { list-style-type: decimal; padding-left: 1.4em; }
|
||||||
|
.tiptap-content .ProseMirror li > p { margin: 0; }
|
||||||
|
.tiptap-content .ProseMirror blockquote {
|
||||||
|
border-left: 3px solid oklch(0.708 0 0);
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
color: oklch(0.556 0 0);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.tiptap-content .ProseMirror code {
|
||||||
|
background: oklch(0.97 0 0);
|
||||||
|
border-radius: 0.25em;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
font-size: 0.875em;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
.tiptap-content .ProseMirror pre {
|
||||||
|
background: oklch(0.145 0 0);
|
||||||
|
color: oklch(0.922 0 0);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
padding: 0.85em 1.1em;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.tiptap-content .ProseMirror pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.tiptap-content .ProseMirror hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid oklch(0.922 0 0);
|
||||||
|
margin: 1.25em 0;
|
||||||
|
}
|
||||||
|
.dark .tiptap-content .ProseMirror p.is-editor-empty:first-child::before { color: oklch(0.708 0 0); }
|
||||||
|
.dark .tiptap-content .ProseMirror mark { background-color: oklch(0.75 0.15 90 / 0.35); }
|
||||||
|
.dark .tiptap-content .ProseMirror code { background: oklch(0.269 0 0); }
|
||||||
|
.dark .tiptap-content .ProseMirror pre { background: oklch(0.1 0 0); }
|
||||||
|
.dark .tiptap-content .ProseMirror blockquote { border-left-color: oklch(0.556 0 0); color: oklch(0.708 0 0); }
|
||||||
|
.dark .tiptap-content .ProseMirror hr { border-top-color: oklch(1 0 0 / 10%); }
|
||||||
243
frontend/components/dashboard/tiptap-editor.tsx
Normal file
243
frontend/components/dashboard/tiptap-editor.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import Underline from "@tiptap/extension-underline";
|
||||||
|
import Highlight from "@tiptap/extension-highlight";
|
||||||
|
import TextAlign from "@tiptap/extension-text-align";
|
||||||
|
import Link from "@tiptap/extension-link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
UnderlineIcon,
|
||||||
|
Strikethrough,
|
||||||
|
Highlighter,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Quote,
|
||||||
|
Code,
|
||||||
|
Code2,
|
||||||
|
AlignLeft,
|
||||||
|
AlignCenter,
|
||||||
|
AlignRight,
|
||||||
|
Minus,
|
||||||
|
Undo2,
|
||||||
|
Redo2,
|
||||||
|
Link2,
|
||||||
|
Link2Off,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string;
|
||||||
|
onChange: (html: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
minHeight?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TiptapEditor({
|
||||||
|
value = "",
|
||||||
|
onChange,
|
||||||
|
placeholder = "Write your post content here…",
|
||||||
|
minHeight = "320px",
|
||||||
|
}: Props) {
|
||||||
|
const editor = useEditor({
|
||||||
|
immediatelyRender: false,
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
bulletList: { keepMarks: true, keepAttributes: false },
|
||||||
|
orderedList: { keepMarks: true, keepAttributes: false },
|
||||||
|
}),
|
||||||
|
Underline,
|
||||||
|
Highlight.configure({ multicolor: false }),
|
||||||
|
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||||
|
Placeholder.configure({ placeholder }),
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: { class: "text-primary underline underline-offset-2" },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: value,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
onChange(editor.getHTML());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync external value changes (e.g. when edit form opens with existing content)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const current = editor.getHTML();
|
||||||
|
if (value !== current) {
|
||||||
|
editor.commands.setContent(value || "", { emitUpdate: false });
|
||||||
|
}
|
||||||
|
}, [value, editor]);
|
||||||
|
|
||||||
|
const setLink = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const prev = editor.getAttributes("link").href as string | undefined;
|
||||||
|
const url = window.prompt("URL", prev ?? "https://");
|
||||||
|
if (url === null) return; // cancelled
|
||||||
|
if (url === "") {
|
||||||
|
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||||
|
} else {
|
||||||
|
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
||||||
|
}
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
|
const btn = (active: boolean) =>
|
||||||
|
`h-7 w-7 p-0 ${active ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted/60"}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-background overflow-hidden">
|
||||||
|
{/* ── Toolbar ─────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5 border-b bg-muted/30 p-1.5">
|
||||||
|
{/* History */}
|
||||||
|
<Button type="button" variant="ghost" size="sm" className={btn(false)}
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
disabled={!editor.can().undo()}>
|
||||||
|
<Undo2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className={btn(false)}
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
disabled={!editor.can().redo()}>
|
||||||
|
<Redo2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||||
|
|
||||||
|
{/* Headings */}
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("heading", { level: 1 }))}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>
|
||||||
|
<Heading1 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("heading", { level: 2 }))}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
|
||||||
|
<Heading2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("heading", { level: 3 }))}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
|
||||||
|
<Heading3 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||||
|
|
||||||
|
{/* Inline marks */}
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("bold"))}
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||||
|
<Bold className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("italic"))}
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||||
|
<Italic className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("underline"))}
|
||||||
|
onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
||||||
|
<UnderlineIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("strike"))}
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||||
|
<Strikethrough className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("highlight"))}
|
||||||
|
onClick={() => editor.chain().focus().toggleHighlight().run()}>
|
||||||
|
<Highlighter className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("code"))}
|
||||||
|
onClick={() => editor.chain().focus().toggleCode().run()}>
|
||||||
|
<Code className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||||
|
|
||||||
|
{/* Link */}
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("link"))}
|
||||||
|
onClick={setLink}>
|
||||||
|
<Link2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
{editor.isActive("link") && (
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(false)}
|
||||||
|
onClick={() => editor.chain().focus().unsetLink().run()}>
|
||||||
|
<Link2Off className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||||
|
|
||||||
|
{/* Lists */}
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("bulletList"))}
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||||
|
<List className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("orderedList"))}
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||||
|
<ListOrdered className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("blockquote"))}
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||||
|
<Quote className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive("codeBlock"))}
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
|
||||||
|
<Code2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||||
|
|
||||||
|
{/* Alignment */}
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive({ textAlign: "left" }))}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("left").run()}>
|
||||||
|
<AlignLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive({ textAlign: "center" }))}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("center").run()}>
|
||||||
|
<AlignCenter className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className={btn(editor.isActive({ textAlign: "right" }))}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("right").run()}>
|
||||||
|
<AlignRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<Button type="button" variant="ghost" size="sm" className={btn(false)}
|
||||||
|
onClick={() => editor.chain().focus().setHorizontalRule().run()}>
|
||||||
|
<Minus className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Editor area ─────────────────────────────────────────────────────── */}
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className="tiptap-content px-4 py-3 text-sm focus-within:outline-none"
|
||||||
|
style={{ minHeight }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
841
frontend/package-lock.json
generated
841
frontend/package-lock.json
generated
@@ -9,6 +9,16 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tiptap/extension-character-count": "^3.20.0",
|
||||||
|
"@tiptap/extension-highlight": "^3.20.0",
|
||||||
|
"@tiptap/extension-image": "^3.20.0",
|
||||||
|
"@tiptap/extension-link": "^3.20.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.20.0",
|
||||||
|
"@tiptap/extension-text-align": "^3.20.0",
|
||||||
|
"@tiptap/extension-underline": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0",
|
||||||
|
"@tiptap/react": "^3.20.0",
|
||||||
|
"@tiptap/starter-kit": "^3.20.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
@@ -3452,6 +3462,12 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@remirror/core-constants": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -3784,6 +3800,505 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/core": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/pm": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bold": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bubble-menu": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-character-count": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-WxE0HgntJfkpaCy7u7ANL7jwqygSIu1wc7eKL78sp1jr0QeyQYj5Addq7h//fpr7OI9+V8v55tM2+qd8RiI77Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code-block": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-document": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-dropcursor": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-floating-menu": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0",
|
||||||
|
"@tiptap/core": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-hard-break": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-heading": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-highlight": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-ANL1wFz0s1ScNR4uBfO0s6Sz+qqGp2u6ynrCVk6TCT3d10CQ+gD1gSDTrVRC3CtlMKtHHH4fYrFAq284+J0gKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-image": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-0t7HYncV0kYEQS79NFczxdlZoZ8zu8X4VavDqt+mbSAUKRq3gCvgtZ5Zyd778sNmtmbz3arxkEYMIVou2swD0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-italic": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.3.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-keymap": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-ordered-list": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-paragraph": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-placeholder": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-ZhYD3L5m16ydSe2z8vqz+RdtAG/iOQaFHHedFct70tKRoLqi2ajF5kgpemu8DwpaRTcyiCN4G99J/+MqehKNjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-strike": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text-align": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-4s0r+bovtH6yeGDUD+Ui8j5WOV5koB5P6AuzOMqoLwaFGRSkKf64ly6DXjjmjIgnYCLZN/XO6llaQKVVdvad2g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-underline": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extensions": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/pm": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-changeset": "^2.3.0",
|
||||||
|
"prosemirror-collab": "^1.3.1",
|
||||||
|
"prosemirror-commands": "^1.6.2",
|
||||||
|
"prosemirror-dropcursor": "^1.8.1",
|
||||||
|
"prosemirror-gapcursor": "^1.3.2",
|
||||||
|
"prosemirror-history": "^1.4.1",
|
||||||
|
"prosemirror-inputrules": "^1.4.0",
|
||||||
|
"prosemirror-keymap": "^1.2.2",
|
||||||
|
"prosemirror-markdown": "^1.13.1",
|
||||||
|
"prosemirror-menu": "^1.2.4",
|
||||||
|
"prosemirror-model": "^1.24.1",
|
||||||
|
"prosemirror-schema-basic": "^1.2.3",
|
||||||
|
"prosemirror-schema-list": "^1.5.0",
|
||||||
|
"prosemirror-state": "^1.4.3",
|
||||||
|
"prosemirror-tables": "^1.6.4",
|
||||||
|
"prosemirror-trailing-node": "^3.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2",
|
||||||
|
"prosemirror-view": "^1.38.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/react": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-jFLNzkmn18zqefJwPje0PPd9VhZ7Oy28YHiSvSc7YpBnQIbuN/HIxZ2lrOsKyEHta0WjRZjfU5X1pGxlbcGwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"fast-equals": "^5.3.3",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tiptap/extension-bubble-menu": "^3.20.0",
|
||||||
|
"@tiptap/extension-floating-menu": "^3.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0",
|
||||||
|
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/starter-kit": {
|
||||||
|
"version": "3.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.0.tgz",
|
||||||
|
"integrity": "sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "^3.20.0",
|
||||||
|
"@tiptap/extension-blockquote": "^3.20.0",
|
||||||
|
"@tiptap/extension-bold": "^3.20.0",
|
||||||
|
"@tiptap/extension-bullet-list": "^3.20.0",
|
||||||
|
"@tiptap/extension-code": "^3.20.0",
|
||||||
|
"@tiptap/extension-code-block": "^3.20.0",
|
||||||
|
"@tiptap/extension-document": "^3.20.0",
|
||||||
|
"@tiptap/extension-dropcursor": "^3.20.0",
|
||||||
|
"@tiptap/extension-gapcursor": "^3.20.0",
|
||||||
|
"@tiptap/extension-hard-break": "^3.20.0",
|
||||||
|
"@tiptap/extension-heading": "^3.20.0",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^3.20.0",
|
||||||
|
"@tiptap/extension-italic": "^3.20.0",
|
||||||
|
"@tiptap/extension-link": "^3.20.0",
|
||||||
|
"@tiptap/extension-list": "^3.20.0",
|
||||||
|
"@tiptap/extension-list-item": "^3.20.0",
|
||||||
|
"@tiptap/extension-list-keymap": "^3.20.0",
|
||||||
|
"@tiptap/extension-ordered-list": "^3.20.0",
|
||||||
|
"@tiptap/extension-paragraph": "^3.20.0",
|
||||||
|
"@tiptap/extension-strike": "^3.20.0",
|
||||||
|
"@tiptap/extension-text": "^3.20.0",
|
||||||
|
"@tiptap/extension-underline": "^3.20.0",
|
||||||
|
"@tiptap/extensions": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ts-morph/common": {
|
"node_modules/@ts-morph/common": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
||||||
@@ -3897,6 +4412,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/markdown-it": {
|
||||||
|
"version": "14.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||||
|
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^5",
|
||||||
|
"@types/mdurl": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.33",
|
"version": "20.19.33",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||||
@@ -3911,7 +4448,6 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -3921,7 +4457,6 @@
|
|||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@@ -3934,6 +4469,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/validate-npm-package-name": {
|
"node_modules/@types/validate-npm-package-name": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||||
@@ -4641,7 +5182,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/aria-hidden": {
|
"node_modules/aria-hidden": {
|
||||||
@@ -5398,6 +5938,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -5429,7 +5975,6 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
@@ -5800,6 +6345,18 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/env-paths": {
|
"node_modules/env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
@@ -6018,7 +6575,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -6601,6 +7157,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-equals": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||||
@@ -8483,6 +9048,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -8591,6 +9171,23 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-it": {
|
||||||
|
"version": "14.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||||
|
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1",
|
||||||
|
"entities": "^4.4.0",
|
||||||
|
"linkify-it": "^5.0.0",
|
||||||
|
"mdurl": "^2.0.0",
|
||||||
|
"punycode.js": "^2.3.1",
|
||||||
|
"uc.micro": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/marked": {
|
"node_modules/marked": {
|
||||||
"version": "17.0.3",
|
"version": "17.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz",
|
||||||
@@ -8613,6 +9210,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
@@ -9302,6 +9905,12 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/orderedmap": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/outvariant": {
|
"node_modules/outvariant": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
|
||||||
@@ -9619,6 +10228,201 @@
|
|||||||
"react-is": "^16.13.1"
|
"react-is": "^16.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prosemirror-changeset": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-collab": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-commands": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-dropcursor": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0",
|
||||||
|
"prosemirror-view": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-gapcursor": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.0.0",
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-history": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.31.0",
|
||||||
|
"rope-sequence": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-inputrules": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-keymap": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"w3c-keyname": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^14.0.0",
|
||||||
|
"markdown-it": "^14.0.0",
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-menu": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crelt": "^1.0.0",
|
||||||
|
"prosemirror-commands": "^1.0.0",
|
||||||
|
"prosemirror-history": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-model": {
|
||||||
|
"version": "1.25.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"orderedmap": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-basic": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-list": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-state": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-tables": {
|
||||||
|
"version": "1.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||||
|
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.4",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-transform": "^1.10.5",
|
||||||
|
"prosemirror-view": "^1.41.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-trailing-node": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@remirror/core-constants": "3.0.0",
|
||||||
|
"escape-string-regexp": "^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prosemirror-model": "^1.22.1",
|
||||||
|
"prosemirror-state": "^1.4.2",
|
||||||
|
"prosemirror-view": "^1.33.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-transform": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-view": {
|
||||||
|
"version": "1.41.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
|
||||||
|
"integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.20.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -9643,6 +10447,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -10037,6 +10850,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rope-sequence": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/router": {
|
"node_modules/router": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
@@ -11289,6 +12108,12 @@
|
|||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||||
@@ -11512,6 +12337,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/web-streams-polyfill": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,16 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tiptap/extension-character-count": "^3.20.0",
|
||||||
|
"@tiptap/extension-highlight": "^3.20.0",
|
||||||
|
"@tiptap/extension-image": "^3.20.0",
|
||||||
|
"@tiptap/extension-link": "^3.20.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.20.0",
|
||||||
|
"@tiptap/extension-text-align": "^3.20.0",
|
||||||
|
"@tiptap/extension-underline": "^3.20.0",
|
||||||
|
"@tiptap/pm": "^3.20.0",
|
||||||
|
"@tiptap/react": "^3.20.0",
|
||||||
|
"@tiptap/starter-kit": "^3.20.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user