This commit is contained in:
2026-02-19 05:19:09 +09:00
parent 49e8081453
commit dcbb3a0670
9 changed files with 2113 additions and 717 deletions

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
&quot;{post.title}&quot; 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>
&quot;{post.title}&quot; 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}
/>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -125,3 +125,66 @@
@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%); }

View 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>
);
}

View File

@@ -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",

View File

@@ -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",