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 { clientFetch } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { BlogPost, PostStatus, ContentFormat, UserRole } from "@/lib/types";
|
||||
import { CreatePostForm } from "./create-post-form";
|
||||
import { BlogPost, PostStatus, UserRole } from "@/lib/types";
|
||||
import { PostSheet } from "./post-sheet";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -27,8 +19,16 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { ImageUploader } from "@/components/dashboard/image-uploader";
|
||||
import { Eye, Loader2, Pencil, Trash2, X, Check, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
Eye,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
PlusCircle,
|
||||
ExternalLink,
|
||||
Star,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Props {
|
||||
@@ -36,17 +36,30 @@ interface Props {
|
||||
userRole: UserRole;
|
||||
}
|
||||
|
||||
/** Per-post edit image state so each inline form has its own uploader values. */
|
||||
interface EditImageState {
|
||||
url: string;
|
||||
alt: string;
|
||||
}
|
||||
const statusMeta: Record<PostStatus, { label: string; dot: string; row: string }> = {
|
||||
[PostStatus.PUBLISHED]: {
|
||||
label: "Published",
|
||||
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) {
|
||||
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editImage, setEditImage] = useState<EditImageState>({ url: "", alt: "" });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [editingPost, setEditingPost] = useState<BlogPost | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const refreshPosts = useCallback(async () => {
|
||||
try {
|
||||
@@ -59,16 +72,18 @@ export function PostsTable({ initialPosts, userRole }: Props) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startEditing = (post: BlogPost) => {
|
||||
setEditingId(post.id);
|
||||
setEditImage({
|
||||
url: post.featuredImageUrl || "",
|
||||
alt: post.featuredImageAlt || "",
|
||||
});
|
||||
const openCreate = () => {
|
||||
setEditingPost(null);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (post: BlogPost) => {
|
||||
setEditingPost(post);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setLoading(true);
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await clientFetch(`/blog-posts/${id}`, { method: "DELETE" });
|
||||
toast.success("Post deleted.");
|
||||
@@ -76,249 +91,203 @@ export function PostsTable({ initialPosts, userRole }: Props) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : "Delete failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (
|
||||
e: React.FormEvent<HTMLFormElement>,
|
||||
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 canEdit = userRole === UserRole.ADMIN;
|
||||
const canCreate = userRole === UserRole.ADMIN || userRole === UserRole.MANAGER;
|
||||
|
||||
const statusColor: Record<PostStatus, string> = {
|
||||
[PostStatus.PUBLISHED]: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||
[PostStatus.DRAFT]: "bg-zinc-100 text-zinc-700 border-zinc-200",
|
||||
[PostStatus.ARCHIVED]: "bg-amber-100 text-amber-800 border-amber-200",
|
||||
};
|
||||
const filtered = search.trim()
|
||||
? posts.filter(
|
||||
(p) =>
|
||||
p.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.slug.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: posts;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Create form for ADMIN/MANAGER */}
|
||||
{(userRole === UserRole.ADMIN || userRole === UserRole.MANAGER) && (
|
||||
<CreatePostForm userRole={userRole} onCreated={refreshPosts} />
|
||||
)}
|
||||
<>
|
||||
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<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.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
No posts yet.
|
||||
{/* ── Posts list ────────────────────────────────────────────────────── */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-10 text-center text-sm text-muted-foreground">
|
||||
{search
|
||||
? "No posts match your search."
|
||||
: "No posts yet. Create your first post!"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{posts.map((post) => (
|
||||
<article
|
||||
key={post.id}
|
||||
className="rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
{editingId === post.id && userRole === UserRole.ADMIN ? (
|
||||
/* ── Edit form ─────────────────────────────────────────── */
|
||||
<form
|
||||
onSubmit={(e) => handleUpdate(e, post.id)}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold text-sm">Editing post</span>
|
||||
<Button
|
||||
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="rounded-xl border overflow-hidden divide-y">
|
||||
{filtered.map((post) => {
|
||||
const meta = statusMeta[post.status];
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`flex items-start gap-3 px-4 py-3.5 border-l-4 bg-card hover:bg-muted/30 transition-colors ${meta.row}`}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
{post.featuredImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={post.featuredImageUrl}
|
||||
alt={post.featuredImageAlt || ""}
|
||||
className="h-12 w-20 rounded-md object-cover shrink-0 border hidden sm:block"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="ghost" onClick={() => setEditingId(null)}>
|
||||
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 className="h-12 w-20 rounded-md bg-muted shrink-0 hidden sm:flex items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground/50">No img</span>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
/* ── Post card view ─────────────────────────────────────── */
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="font-semibold hover:text-primary transition-colors"
|
||||
target="_blank"
|
||||
>
|
||||
{post.title}
|
||||
<ExternalLink className="inline-block ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-0.5">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${meta.dot}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{meta.label}
|
||||
</span>
|
||||
{post.isFeatured && (
|
||||
<Star className="h-3 w-3 text-amber-500 fill-amber-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${statusColor[post.status]}`}>
|
||||
{post.status}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Eye className="h-3 w-3" /> {post.views.toLocaleString()}
|
||||
</span>
|
||||
{post.author && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{post.author.name || post.author.email}
|
||||
</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()}
|
||||
</span>
|
||||
{post.isFeatured && (
|
||||
<Badge variant="secondary" className="text-xs">⭐ featured</Badge>
|
||||
)}
|
||||
{post.author && (
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(post.categories.length > 0 || post.tags.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{post.categories.map((c) => (
|
||||
<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">
|
||||
by {post.author.name || post.author.email}
|
||||
+{post.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* ── 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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,55 +15,78 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 {
|
||||
initialUsers: User[];
|
||||
}
|
||||
|
||||
const roleColor: Record<UserRole, string> = {
|
||||
[UserRole.ADMIN]: "bg-red-100 text-red-800 border-red-200",
|
||||
[UserRole.MANAGER]: "bg-amber-100 text-amber-800 border-amber-200",
|
||||
[UserRole.MEMBER]: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
const roleMeta: Record<UserRole, { label: string; classes: string }> = {
|
||||
[UserRole.ADMIN]: {
|
||||
label: "Admin",
|
||||
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) {
|
||||
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 [editRole, setEditRole] = useState<UserRole>(UserRole.MEMBER);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const startEdit = (user: User) => {
|
||||
setEditingId(user.id);
|
||||
const openEdit = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setEditName(user.name || "");
|
||||
setEditRole(user.role);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
};
|
||||
const closeEdit = () => setEditingUser(null);
|
||||
|
||||
const handleUpdate = async (user: User) => {
|
||||
const handleUpdate = async () => {
|
||||
if (!editingUser) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
// Update name if changed
|
||||
let updated = user;
|
||||
if (editName !== (user.name || "")) {
|
||||
let updated = editingUser;
|
||||
|
||||
if (editName.trim() !== (editingUser.name || "")) {
|
||||
const res = await clientFetch<{ success: boolean; user: User }>(
|
||||
`/users/${user.id}`,
|
||||
`/users/${editingUser.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: editName || undefined }),
|
||||
body: JSON.stringify({ name: editName.trim() || undefined }),
|
||||
}
|
||||
);
|
||||
updated = res.user;
|
||||
}
|
||||
|
||||
// Update role if changed
|
||||
if (editRole !== user.role) {
|
||||
if (editRole !== editingUser.role) {
|
||||
const res = await clientFetch<{ success: boolean; user: User }>(
|
||||
`/users/${user.id}/role`,
|
||||
`/users/${editingUser.id}/role`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ role: editRole }),
|
||||
@@ -72,8 +96,10 @@ export function UsersTable({ initialUsers }: Props) {
|
||||
}
|
||||
|
||||
toast.success("User updated.");
|
||||
setUsers((prev) => prev.map((u) => (u.id === user.id ? updated : u)));
|
||||
setEditingId(null);
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === editingUser.id ? updated : u))
|
||||
);
|
||||
closeEdit();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : "Update failed");
|
||||
} 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 (
|
||||
<div className="space-y-2">
|
||||
{users.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
No users found.
|
||||
</div>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="rounded-xl border bg-card p-4 shadow-sm flex flex-wrap items-center gap-3"
|
||||
>
|
||||
{editingId === user.id ? (
|
||||
/* ── Edit row ─────────────────────────────────────────────── */
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="h-8 w-40"
|
||||
placeholder="Name"
|
||||
value={editName}
|
||||
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" />
|
||||
<>
|
||||
<div className="rounded-xl border overflow-hidden divide-y">
|
||||
{users.map((user) => {
|
||||
const role = roleMeta[user.role];
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<Avatar name={user.name} email={user.email} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{user.name || (
|
||||
<span className="italic text-muted-foreground">
|
||||
No name
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
</p>
|
||||
<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>
|
||||
|
||||
{!user.isActive && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
Inactive
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground shrink-0">
|
||||
Joined {new Date(user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => startEdit(user)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0 hidden sm:block">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
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 { UsersTable } from "./components/users-table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { LayoutDashboard, Users, FileText, ShieldAlert } from "lucide-react";
|
||||
FileText,
|
||||
Users,
|
||||
Eye,
|
||||
TrendingUp,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
|
||||
// ── Decode JWT payload without verification (public payload info only) ─────────
|
||||
// ── JWT decode (no verification — display only) ────────────────────────────
|
||||
function decodeJwtPayload(token: string): CurrentUser | null {
|
||||
try {
|
||||
const base64Payload = token.split(".")[1];
|
||||
@@ -26,179 +27,217 @@ function decodeJwtPayload(token: string): CurrentUser | null {
|
||||
}
|
||||
}
|
||||
|
||||
const roleColor: Record<UserRole, string> = {
|
||||
[UserRole.ADMIN]: "bg-red-100 text-red-800 border-red-200",
|
||||
[UserRole.MANAGER]: "bg-amber-100 text-amber-800 border-amber-200",
|
||||
const rolePill: Record<UserRole, string> = {
|
||||
[UserRole.ADMIN]: "bg-red-100 text-red-700 border-red-200",
|
||||
[UserRole.MANAGER]: "bg-amber-100 text-amber-700 border-amber-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() {
|
||||
// ── Auth check ─────────────────────────────────────────────────────────────
|
||||
/* Auth */
|
||||
const cookieStore = await cookies();
|
||||
const accessToken = cookieStore.get("accessToken")?.value;
|
||||
|
||||
if (!accessToken) {
|
||||
redirect("/auth");
|
||||
}
|
||||
if (!accessToken) redirect("/auth");
|
||||
|
||||
const currentUser = decodeJwtPayload(accessToken);
|
||||
if (!currentUser) {
|
||||
redirect("/auth");
|
||||
}
|
||||
if (!currentUser) redirect("/auth");
|
||||
|
||||
// ── Fetch posts ────────────────────────────────────────────────────────────
|
||||
/* Posts */
|
||||
let posts: BlogPost[] = [];
|
||||
try {
|
||||
const data = await apiFetch<{ posts: BlogPost[]; total: number }>(
|
||||
"/blog-posts?pageSize=50"
|
||||
"/blog-posts?pageSize=100"
|
||||
);
|
||||
posts = data.posts;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
redirect("/auth");
|
||||
}
|
||||
// Non-fatal — show empty list
|
||||
if (err instanceof ApiError && err.status === 401) redirect("/auth");
|
||||
}
|
||||
|
||||
// ── Fetch users (ADMIN only) ───────────────────────────────────────────────
|
||||
/* Users (ADMIN only) */
|
||||
let users: User[] = [];
|
||||
if (currentUser.role === UserRole.ADMIN) {
|
||||
try {
|
||||
const data = await apiFetch<{ users: User[]; total: number }>(
|
||||
"/users?pageSize=50"
|
||||
"/users?pageSize=100"
|
||||
);
|
||||
users = data.users;
|
||||
} catch {
|
||||
// Non-fatal — show empty list
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
/* 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 isAdmin = currentUser.role === UserRole.ADMIN;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<LayoutDashboard className="h-6 w-6 text-muted-foreground" />
|
||||
<div className="min-h-screen bg-muted/20">
|
||||
{/* ── Top header bar ──────────────────────────────────────────────────── */}
|
||||
<div className="border-b bg-background">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your blog content and users
|
||||
<h1 className="text-xl font-bold">Dashboard</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Welcome back, <span className="font-medium text-foreground">{displayName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{displayName}</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}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Stats strip ─────────────────────────────────────────────────────── */}
|
||||
<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>
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 space-y-6">
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Posts section ───────────────────────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-xl font-semibold">Posts</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{posts.length}
|
||||
</Badge>
|
||||
{/* ── Stats grid ────────────────────────────────────────────────────── */}
|
||||
<div className={`grid gap-4 ${isAdmin ? "sm:grid-cols-2 lg:grid-cols-4" : "sm:grid-cols-3"}`}>
|
||||
<StatCard
|
||||
icon={FileText}
|
||||
label="Total posts"
|
||||
value={posts.length}
|
||||
sub={`${published} published`}
|
||||
/>
|
||||
<StatCard
|
||||
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>
|
||||
<PostsTable initialPosts={posts} userRole={currentUser.role as UserRole} />
|
||||
</section>
|
||||
|
||||
{/* ── Users section (ADMIN only) ───────────────────────────────────────── */}
|
||||
{currentUser.role === UserRole.ADMIN && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-xl font-semibold">Users</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{users.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{users.length === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldAlert className="h-4 w-4" /> No users loaded
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Unable to fetch user list. Check your backend connection.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<UsersTable initialUsers={users} />
|
||||
{/* Second row for admin — archived + users */}
|
||||
{isAdmin && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatCard
|
||||
icon={Archive}
|
||||
label="Archived"
|
||||
value={archived}
|
||||
accent="text-amber-600"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Users}
|
||||
label="Total users"
|
||||
value={users.length}
|
||||
sub={`${users.filter((u) => u.isActive).length} active`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label="Avg. views / post"
|
||||
value={posts.length ? Math.round(totalViews / posts.length).toLocaleString() : "—"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tabs ──────────────────────────────────────────────────────────── */}
|
||||
<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 ───────────────────────────────────────────────────── */}
|
||||
{currentUser.role === UserRole.MEMBER && (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
|
||||
Read-only access
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
You have the <strong>MEMBER</strong> role. You can view posts but
|
||||
cannot create, edit, or delete them. Contact an admin to upgrade
|
||||
your role.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
{/* Posts tab */}
|
||||
<TabsContent value="posts" className="mt-0">
|
||||
<PostsTable
|
||||
initialPosts={posts}
|
||||
userRole={currentUser.role as UserRole}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Users tab (admin only) */}
|
||||
{isAdmin && (
|
||||
<TabsContent value="users" className="mt-0">
|
||||
<UsersTable initialUsers={users} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,4 +124,67 @@
|
||||
body {
|
||||
@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",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
@@ -3452,6 +3462,12 @@
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -3784,6 +3800,505 @@
|
||||
"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": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
||||
@@ -3897,6 +4412,28 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "20.19.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||
@@ -3911,7 +4448,6 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3921,7 +4457,6 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -3934,6 +4469,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -5429,7 +5975,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
@@ -5800,6 +6345,18 @@
|
||||
"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": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@@ -6018,7 +6575,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -6601,6 +7157,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||
@@ -8483,6 +9048,21 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -8591,6 +9171,23 @@
|
||||
"@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": {
|
||||
"version": "17.0.3",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz",
|
||||
@@ -8613,6 +9210,12 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
|
||||
@@ -9619,6 +10228,201 @@
|
||||
"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": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -9643,6 +10447,15 @@
|
||||
"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": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
@@ -10037,6 +10850,12 @@
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
@@ -11289,6 +12108,12 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
@@ -11512,6 +12337,12 @@
|
||||
"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": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
|
||||
Reference in New Issue
Block a user