Fixed creation of users + better frontend handling of permissions

This commit is contained in:
cdricms
2025-03-06 17:34:52 +01:00
parent 3c6038bce1
commit 7cb633b4c6
46 changed files with 1511 additions and 909 deletions

View File

@@ -0,0 +1,684 @@
"use client";
import type { Editor } from "@tiptap/react";
import {
AlignCenter,
AlignJustifyIcon,
AlignLeft,
AlignRight,
Bold,
Heading1,
Heading2,
Heading3,
ImageIcon,
Italic,
LinkIcon,
List,
ListOrdered,
Minus,
Pilcrow,
Quote,
Strikethrough,
Underline,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Level } from "@tiptap/extension-heading";
import { Separator } from "@/components/ui/separator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { SizeOption } from "./extensions/marks";
interface EditorMenuProps {
editor: Editor | null;
}
const sizeOptions: SizeOption[] = ["xs", "sm", "base", "lg", "xl"];
const tailwindColors = [
{ value: "default", label: "Default" }, // Changed from '' to "default"
{ value: "gray-500", label: "Gray" },
{ value: "red-500", label: "Red" },
{ value: "yellow-500", label: "Yellow" },
{ value: "green-500", label: "Green" },
{ value: "blue-500", label: "Blue" },
{ value: "indigo-500", label: "Indigo" },
{ value: "purple-500", label: "Purple" },
{ value: "pink-500", label: "Pink" },
];
export function EditorMenu({ editor }: EditorMenuProps) {
if (!editor) {
return null;
}
return (
<TooltipProvider delayDuration={0}>
<div className="flex flex-wrap gap-2 rounded-t-lg border bg-background p-1">
<div className="flex flex-wrap gap-2">
{/* Existing formatting toggles */}
<ToggleGroup
type="multiple"
size="sm"
className="flex flex-wrap gap-1"
>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="bold"
aria-label="Toggle bold"
aria-selected={editor.isActive("bold")}
onClick={() =>
editor
.chain()
.focus()
.toggleBold()
.run()
}
>
<Bold className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Bold</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="italic"
aria-label="Toggle italic"
aria-selected={editor.isActive("italic")}
onClick={() =>
editor
.chain()
.focus()
.toggleItalic()
.run()
}
>
<Italic className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Italic</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="underline"
aria-label="Toggle underline"
aria-selected={editor.isActive("underline")}
onClick={() =>
editor
.chain()
.focus()
.toggleUnderline()
.run()
}
>
<Underline className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Underline</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="strike"
aria-label="Toggle strikethrough"
aria-selected={editor.isActive("strike")}
onClick={() =>
editor
.chain()
.focus()
.toggleStrike()
.run()
}
>
<Strikethrough className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Strikethrough</TooltipContent>
</Tooltip>
</ToggleGroup>
<Separator orientation="vertical" className="h-8" />
{/* Font Size Selector */}
<Select
value={
editor.isActive("fontSize")
? editor.getAttributes("fontSize").size ||
"base"
: "base"
}
onValueChange={(value: SizeOption) => {
editor
.chain()
.focus()
.setFontSize({ size: value })
.run();
}}
>
<SelectTrigger className="h-8 w-[100px]">
<SelectValue placeholder="Size" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sizeOptions.map((size) => (
<SelectItem key={size} value={size}>
{size.toUpperCase()}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{/* Text Color Selector */}
{/*<Select
value={
editor.isActive("textColor")
? editor.getAttributes("textColor").color ||
"default"
: "default"
}
onValueChange={(value) => {
if (value === "default") {
editor.chain().focus().unsetTextColor().run();
} else {
editor
.chain()
.focus()
.setTextColor({ color: value })
.run();
}
}}
>
<SelectTrigger className="h-8 w-[100px]">
<SelectValue placeholder="Color" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{tailwindColors.map(({ value, label }) => (
<SelectItem key={value} value={value}>
<div className="flex items-center gap-2">
<span
className={`h-3 w-3 rounded-full ${value === "default" ? "bg-gray-200" : `bg-${value}`}`}
/>
{label}
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select> */}
<Separator orientation="vertical" className="h-8" />
{/* Heading Selector */}
<Select
value={getActiveHeading(editor)}
onValueChange={(value) => {
if (value === "paragraph") {
editor.chain().focus().setParagraph().run();
} else {
editor
.chain()
.focus()
.toggleHeading({
level: Number.parseInt(value) as Level,
})
.run();
}
}}
>
<SelectTrigger className="h-8 w-[130px]">
<SelectValue placeholder="Paragraph" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="paragraph">
<div className="flex items-center gap-2">
<Pilcrow className="h-4 w-4" />
<span>Paragraph</span>
</div>
</SelectItem>
<SelectItem value="1">
<div className="flex items-center gap-2">
<Heading1 className="h-4 w-4" />
<span>Heading 1</span>
</div>
</SelectItem>
<SelectItem value="2">
<div className="flex items-center gap-2">
<Heading2 className="h-4 w-4" />
<span>Heading 2</span>
</div>
</SelectItem>
<SelectItem value="3">
<div className="flex items-center gap-2">
<Heading3 className="h-4 w-4" />
<span>Heading 3</span>
</div>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Separator orientation="vertical" className="h-8" />
{/* Alignment Controls */}
<ToggleGroup
type="single"
size="sm"
className="flex flex-wrap gap-1"
>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="left"
aria-label="Align left"
aria-selected={editor.isActive({
textAlign: "left",
})}
onClick={() =>
editor
.chain()
.focus()
.setTextAlign("left")
.run()
}
>
<AlignLeft className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Align left</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="center"
aria-label="Align center"
aria-selected={editor.isActive({
textAlign: "center",
})}
onClick={() =>
editor
.chain()
.focus()
.setTextAlign("center")
.run()
}
>
<AlignCenter className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Align center</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="right"
aria-label="Align right"
aria-selected={editor.isActive({
textAlign: "right",
})}
onClick={() =>
editor
.chain()
.focus()
.setTextAlign("right")
.run()
}
>
<AlignRight className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Align right</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="justify"
aria-label="Justify"
aria-selected={editor.isActive({
textAlign: "justify",
})}
onClick={() =>
editor
.chain()
.focus()
.setTextAlign("justify")
.run()
}
>
<AlignJustifyIcon className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Align justify</TooltipContent>
</Tooltip>
</ToggleGroup>
<Separator orientation="vertical" className="h-8" />
{/* List and Blockquote Controls */}
<ToggleGroup
type="multiple"
size="sm"
className="flex flex-wrap gap-1"
>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="bulletList"
aria-label="Toggle bullet list"
aria-selected={editor.isActive(
"bulletList",
)}
onClick={() =>
editor
.chain()
.focus()
.toggleBulletList()
.run()
}
>
<List className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Bullet list</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="orderedList"
aria-label="Toggle ordered list"
aria-selected={editor.isActive(
"orderedList",
)}
onClick={() =>
editor
.chain()
.focus()
.toggleOrderedList()
.run()
}
>
<ListOrdered className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Ordered list</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="blockquote"
aria-label="Toggle blockquote"
aria-selected={editor.isActive(
"blockquote",
)}
onClick={() =>
editor
.chain()
.focus()
.toggleBlockquote()
.run()
}
>
<Quote className="h-4 w-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Blockquote</TooltipContent>
</Tooltip>
</ToggleGroup>
<Separator orientation="vertical" className="h-8" />
{/* Additional Controls */}
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() =>
editor
.chain()
.focus()
.setHorizontalRule()
.run()
}
>
<Minus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Horizontal rule</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() =>
editor
.chain()
.focus()
.clearNodes()
.unsetAllMarks()
.run()
}
>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Clear formatting</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-8" />
<Tooltip>
<TooltipTrigger asChild>
<LinkPopover editor={editor} />
</TooltipTrigger>
<TooltipContent>Add link</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ImageDialog editor={editor} />
</TooltipTrigger>
<TooltipContent>Add image</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</TooltipProvider>
);
}
// Rest of the components remain unchanged
function getActiveHeading(editor: Editor) {
if (editor.isActive("heading", { level: 1 })) return "1";
if (editor.isActive("heading", { level: 2 })) return "2";
if (editor.isActive("heading", { level: 3 })) return "3";
return "paragraph";
}
const LinkPopover: React.FC<{ editor: Editor }> = ({ editor }) => {
const [linkUrl, setLinkUrl] = useState("");
const [linkNewTab, setLinkNewTab] = useState(false);
const [isLinkOpen, setIsLinkOpen] = useState(false);
const setLink = () => {
if (linkUrl) {
editor
.chain()
.focus()
.setLink({
href: linkUrl,
target: linkNewTab ? "_blank" : null,
})
.run();
} else {
editor.chain().focus().unsetLink().run();
}
setIsLinkOpen(false);
setLinkUrl("");
setLinkNewTab(false);
};
return (
<Popover open={isLinkOpen} onOpenChange={setIsLinkOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={editor.isActive("link") ? "bg-accent" : ""}
>
<LinkIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="link">Link URL</Label>
<Input
id="link"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://example.com"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="new-tab"
checked={linkNewTab}
onCheckedChange={setLinkNewTab}
/>
<Label htmlFor="new-tab">Open in new tab</Label>
</div>
<div className="flex gap-2">
<Button onClick={setLink} className="flex-1">
Save
</Button>
{editor.isActive("link") && (
<Button
variant="outline"
onClick={() => {
editor.chain().focus().unsetLink().run();
setIsLinkOpen(false);
}}
>
Remove
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
};
const ImageDialog: React.FC<{ editor: Editor }> = ({ editor }) => {
const [isImageOpen, setIsImageOpen] = useState(false);
const [imageUrl, setImageUrl] = useState("");
const [imageAlt, setImageAlt] = useState("");
const addImage = () => {
if (imageUrl) {
editor
.chain()
.focus()
.setImage({
src: imageUrl,
alt: imageAlt,
})
.run();
}
setIsImageOpen(false);
setImageUrl("");
setImageAlt("");
};
return (
<Dialog open={isImageOpen} onOpenChange={setIsImageOpen}>
<Button
variant="outline"
size="sm"
onClick={() => setIsImageOpen(true)}
>
<ImageIcon className="h-4 w-4" />
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>Add image</DialogTitle>
<DialogDescription>
Insert an image by providing its URL and alt text.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="image-url">Image URL</Label>
<Input
id="image-url"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/image.jpg"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="image-alt">Alt text</Label>
<Input
id="image-alt"
value={imageAlt}
onChange={(e) => setImageAlt(e.target.value)}
placeholder="Description of the image"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsImageOpen(false)}
>
Cancel
</Button>
<Button onClick={addImage}>Add image</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,222 @@
// extensions/customMarks.ts
import {
Mark,
markInputRule,
markPasteRule,
mergeAttributes,
} from "@tiptap/core";
// Define available size options
const sizeOptions = {
xs: "text-xs",
sm: "text-sm",
base: "text-base",
lg: "text-lg",
xl: "text-xl",
} as const;
export type SizeOption = keyof typeof sizeOptions;
export interface FontSizeOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
fontSize: {
setFontSize: (attributes: { size: SizeOption }) => ReturnType;
toggleFontSize: (attributes: { size: SizeOption }) => ReturnType;
unsetFontSize: () => ReturnType;
};
}
}
export const fontSizeInputRegex = /(?:^|\s)(~((?:[^~]+))~)$/;
export const fontSizePasteRegex = /(?:^|\s)(~((?:[^~]+))~)/g;
export const FontSize = Mark.create<FontSizeOptions>({
name: "fontSize",
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
size: {
default: "base",
parseHTML: (element: HTMLElement) => {
const sizeClass = Object.entries(sizeOptions).find(
([, className]) =>
element.classList.contains(className),
)?.[0];
return sizeClass || "base";
},
renderHTML: (attributes: { size: SizeOption }) => ({
class: sizeOptions[attributes.size],
}),
},
};
},
parseHTML() {
return [
{
tag: "span",
getAttrs: (node: HTMLElement) => {
const hasSizeClass = Object.values(sizeOptions).some(
(className) => node.classList.contains(className),
);
return hasSizeClass ? {} : null;
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
setFontSize:
(attributes: { size: SizeOption }) =>
({ commands }) => {
return commands.setMark(this.name, attributes);
},
toggleFontSize:
(attributes: { size: SizeOption }) =>
({ commands }) => {
return commands.toggleMark(this.name, attributes);
},
unsetFontSize:
() =>
({ commands }) => {
return commands.unsetMark(this.name);
},
};
},
addKeyboardShortcuts() {
return {
"Mod-s": () => this.editor.commands.toggleFontSize({ size: "sm" }),
};
},
addInputRules() {
return [
markInputRule({
find: fontSizeInputRegex,
type: this.type,
getAttributes: { size: "sm" }, // Default size for input rule
}),
];
},
addPasteRules() {
return [
markPasteRule({
find: fontSizePasteRegex,
type: this.type,
getAttributes: { size: "sm" }, // Default size for paste rule
}),
];
},
});
// TextColor remains unchanged
export interface TextColorOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
textColor: {
setTextColor: (attributes: { color: string }) => ReturnType;
toggleTextColor: (attributes: { color: string }) => ReturnType;
unsetTextColor: () => ReturnType;
};
}
}
export const TextColor = Mark.create<TextColorOptions>({
name: "textColor",
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
color: {
default: null,
parseHTML: (element: HTMLElement) => {
const colorClass = Array.from(element.classList).find(
(cls) => cls.startsWith("text-"),
);
return colorClass ? colorClass.replace("text-", "") : null;
},
renderHTML: (attributes) => {
if (!attributes.color) return {};
// Use !important to override prose styles
return {
class: `text-${attributes.color} !text-${attributes.color}`,
};
},
},
};
},
parseHTML() {
return [
{
tag: "span",
getAttrs: (node: HTMLElement) => {
const colorClass = Array.from(node.classList).find((cls) =>
cls.startsWith("text-"),
);
return colorClass
? { color: colorClass.replace("text-", "") }
: null;
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
setTextColor:
(attributes: { color: string }) =>
({ commands }) => {
return commands.setMark(this.name, attributes);
},
toggleTextColor:
(attributes: { color: string }) =>
({ commands }) => {
return commands.toggleMark(this.name, attributes);
},
unsetTextColor:
() =>
({ commands }) => {
return commands.unsetMark(this.name);
},
};
},
addKeyboardShortcuts() {
return {
"Mod-Shift-c": () =>
this.editor.commands.toggleTextColor({ color: "gray-500" }),
};
},
});

View File

@@ -0,0 +1,109 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Card } from "@/components/ui/card";
import { Editor, EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import TTImage from "@tiptap/extension-image";
import TextAlign from "@tiptap/extension-text-align";
import Youtube from "@tiptap/extension-youtube";
import Dropcursor from "@tiptap/extension-dropcursor";
import { EditorMenu } from "./editor-menu";
import { FontSize, TextColor } from "./extensions/marks";
interface EditorProps {
content: string;
onChange?: (content: string) => void;
className?: string;
onTitleChange?: (t: string) => void;
}
export function LocalEditor({
content,
onChange,
className,
onTitleChange: setTitle,
}: EditorProps) {
const getTitle = (editor: Editor) => {
const firstNode = editor.state.doc.firstChild;
if (!firstNode) {
editor.commands.setNode("heading", {
level: 1,
content: [{ type: "text", text: "Titre" }],
});
}
if (
firstNode &&
!(firstNode.type.name === "heading" && firstNode.attrs.level === 1)
) {
setFirstLineAsH1(editor);
}
return firstNode?.textContent;
};
const setFirstLineAsH1 = (editor: Editor) => {
const firstNode = editor.state.doc.firstChild;
// Check if the first node is a paragraph and make it h1
if (firstNode && firstNode.type.name === "paragraph") {
editor.commands.setNode("heading", { level: 1 });
}
};
const editor = useEditor({
extensions: [
StarterKit,
FontSize,
TextColor,
Underline,
Link,
Youtube,
Dropcursor,
TTImage.configure({
HTMLAttributes: {
class: "max-w-full w-auto h-auto resize overflow-auto",
},
}),
TextAlign.configure({
alignments: ["left", "center", "right", "justify"],
defaultAlignment: "justify",
types: ["heading", "paragraph"],
}),
],
content,
immediatelyRender: false,
editorProps: {
attributes: {
class: "prose prose-sm sm:prose-base lg:prose-lg xl:prose-2xl m-5 focus:outline-none dark:prose-invert",
},
},
onCreate: ({ editor }) => {
const title = getTitle(editor);
setTitle?.(title ?? "");
},
onUpdate: ({ editor }) => {
onChange?.(editor.getHTML());
const title = getTitle(editor);
setTitle?.(title ?? "");
},
});
if (!editor) {
return null;
}
return (
<Card className={cn("border rounded-lg overflow-hidden", className)}>
<EditorMenu editor={editor} />
<EditorContent editor={editor} />
</Card>
);
}