enabled react-compiler; added color picker; menu bar sorted
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import "./styles.css";
|
||||
import { Article } from "@/server/db/schema";
|
||||
|
||||
import React from "react";
|
||||
@@ -18,37 +17,44 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { articleSchema } from "@/lib/validation/zod/article";
|
||||
import { debounce } from "@/lib/utils";
|
||||
import { cn, debounce } from "@/lib/utils";
|
||||
import { updateArticle } from "@/server/actions/article";
|
||||
import Editor from "./editor";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default ({ server_article }: { server_article: Article }) => {
|
||||
const form = useForm<z.infer<typeof articleSchema>>({
|
||||
resolver: zodResolver(articleSchema),
|
||||
defaultValues: {
|
||||
title: server_article?.title ?? "",
|
||||
content:
|
||||
server_article?.content ??
|
||||
`<h2>
|
||||
const defaultValues = {
|
||||
title: server_article?.title ?? "",
|
||||
content:
|
||||
server_article?.content ??
|
||||
`<h2>
|
||||
Hey bearbeite mich!
|
||||
</h2>`,
|
||||
},
|
||||
};
|
||||
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const form = useForm<z.infer<typeof articleSchema>>({
|
||||
resolver: zodResolver(articleSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
// 2. Define a submit handler.
|
||||
async function onSubmit(values: z.infer<typeof articleSchema>) {
|
||||
setLoading(true);
|
||||
await updateArticle(values, server_article.id);
|
||||
setLoading(false);
|
||||
form.reset(values);
|
||||
}
|
||||
const debouncedSubmit = React.useCallback(
|
||||
debounce(() => {
|
||||
form.handleSubmit(onSubmit)();
|
||||
}, 1000),
|
||||
}, 3000),
|
||||
[form],
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
@@ -56,6 +62,7 @@ export default ({ server_article }: { server_article: Article }) => {
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<TextareaAutosize
|
||||
className="w-full resize-none text-4xl font-bold focus-visible:outline-none"
|
||||
value={field.value}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
@@ -67,28 +74,42 @@ export default ({ server_article }: { server_article: Article }) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex w-full gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Editor
|
||||
editorProviderProps={{
|
||||
editorProps: { attributes: { class: "min-h-64" } },
|
||||
content: field.value,
|
||||
onUpdate: (value) => {
|
||||
field.onChange(value.editor.getHTML());
|
||||
debouncedSubmit();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Editor
|
||||
editorProviderProps={{
|
||||
content: field.value,
|
||||
onUpdate: (value) => {
|
||||
field.onChange(value.editor.getHTML());
|
||||
debouncedSubmit();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-4 h-max w-full max-w-md rounded-md border-t-2 bg-muted p-4",
|
||||
loading && "border-t-blue-600",
|
||||
)}
|
||||
>
|
||||
<Badge>
|
||||
{!form.formState.isDirty && !loading
|
||||
? "gespeichert"
|
||||
: "nicht gespeichert"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import "./styles.css";
|
||||
import React from "react";
|
||||
import { EditorProvider, EditorProviderProps } from "@tiptap/react";
|
||||
import { MenuBar } from "./menu-bar";
|
||||
@@ -12,12 +14,14 @@ function Editor({
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<EditorProvider
|
||||
immediatelyRender={false}
|
||||
extensions={extensions}
|
||||
slotBefore={!readOnly && <MenuBar />}
|
||||
{...editorProviderProps}
|
||||
/>
|
||||
<div className="rounded-md bg-gradient-to-b from-muted to-background p-2">
|
||||
<EditorProvider
|
||||
immediatelyRender={false}
|
||||
extensions={extensions}
|
||||
slotBefore={!readOnly && <MenuBar />}
|
||||
{...editorProviderProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ export const extensions = [
|
||||
/* @ts-ignore */
|
||||
TextStyle.configure({ types: [ListItem.name] }),
|
||||
StarterKit.configure({
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: {},
|
||||
bulletList: {
|
||||
keepMarks: true,
|
||||
keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import ColorPickerPopover from "@/components/color-picker/color-picker-popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RGBAToHexA } from "@/lib/utils";
|
||||
import { useCurrentEditor } from "@tiptap/react";
|
||||
import {
|
||||
BoldIcon,
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
SeparatorHorizontalIcon,
|
||||
StrikethroughIcon,
|
||||
TextIcon,
|
||||
TypeIcon,
|
||||
UndoIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -28,7 +32,10 @@ export const MenuBar = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="control-group my-4">
|
||||
<div className="menu-bar mb-4 flex items-center justify-between space-y-2">
|
||||
{/* <div className="flex w-full items-center justify-between">
|
||||
|
||||
</div> */}
|
||||
<div className="Button-group flex items-center gap-1">
|
||||
<Button
|
||||
size={"icon"}
|
||||
@@ -44,7 +51,9 @@ export const MenuBar = () => {
|
||||
variant={"outline"}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||
className={editor.isActive("italic") ? "is-active" : ""}
|
||||
className={
|
||||
editor.isActive("italic") ? "bg-foreground text-background" : ""
|
||||
}
|
||||
>
|
||||
<ItalicIcon className="size-4" />
|
||||
</Button>
|
||||
@@ -57,7 +66,7 @@ export const MenuBar = () => {
|
||||
>
|
||||
<StrikethroughIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
{/* <Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
@@ -65,7 +74,7 @@ export const MenuBar = () => {
|
||||
className={editor.isActive("code") ? "is-active" : ""}
|
||||
>
|
||||
<CodeIcon className="size-4" />
|
||||
</Button>
|
||||
</Button> */}
|
||||
|
||||
<Button
|
||||
variant={"outline"}
|
||||
@@ -73,7 +82,7 @@ export const MenuBar = () => {
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
className={editor.isActive("paragraph") ? "is-active" : ""}
|
||||
>
|
||||
<TextIcon className="size-4" />
|
||||
<TypeIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -152,14 +161,14 @@ export const MenuBar = () => {
|
||||
>
|
||||
<ListOrderedIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
{/* <Button
|
||||
variant={"outline"}
|
||||
size={"icon"}
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
className={editor.isActive("codeBlock") ? "is-active" : ""}
|
||||
>
|
||||
<CodeSquareIcon className="size-4" />
|
||||
</Button>
|
||||
</Button> */}
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"icon"}
|
||||
@@ -175,35 +184,12 @@ export const MenuBar = () => {
|
||||
>
|
||||
<SeparatorHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => editor.chain().focus().setHardBreak().run()}
|
||||
>
|
||||
Hard Break
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => editor.chain().focus().setColor("#958DF1").run()}
|
||||
className={
|
||||
editor.isActive("textStyle", { color: "#958DF1" })
|
||||
? "is-active"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
Purple
|
||||
</Button>
|
||||
{/* <Button
|
||||
variant={"outline"}
|
||||
onClick={() => editor.chain().focus().unsetAllMarks().run()}
|
||||
>
|
||||
Formatierung aufheben
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => editor.chain().focus().clearNodes().run()}
|
||||
>
|
||||
Clear nodes
|
||||
</Button> */}
|
||||
<ColorPickerPopover
|
||||
// initialColor={editor.getAttributes("textStyle").color}
|
||||
onInput={(color) => editor.chain().focus().setColor(color).run()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"icon"}
|
||||
|
||||
@@ -7,16 +7,28 @@
|
||||
}
|
||||
|
||||
/* List styles */
|
||||
.tiptap ul,
|
||||
/* .tiptap ul,
|
||||
.tiptap ol {
|
||||
padding: 0 1rem;
|
||||
margin: 1.25rem 1rem 1.25rem 0.4rem;
|
||||
}
|
||||
} */
|
||||
|
||||
.tiptap ul li p,
|
||||
.tiptap ol li p {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
@apply my-1;
|
||||
}
|
||||
|
||||
.tiptap ul,
|
||||
ol {
|
||||
@apply ml-4 p-1;
|
||||
}
|
||||
|
||||
.tiptap ul {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
.tiptap ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
/* Heading styles */
|
||||
@@ -26,42 +38,38 @@
|
||||
.tiptap h4,
|
||||
.tiptap h5,
|
||||
.tiptap h6 {
|
||||
@apply my-2;
|
||||
line-height: 1.1;
|
||||
margin-top: 2.5rem;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.tiptap h1,
|
||||
.tiptap h2 {
|
||||
margin-top: 3.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tiptap h1 {
|
||||
font-size: 1.4rem;
|
||||
@apply my-4;
|
||||
}
|
||||
|
||||
.tiptap h2 {
|
||||
font-size: 1.2rem;
|
||||
@apply text-4xl;
|
||||
}
|
||||
|
||||
.tiptap h3 {
|
||||
font-size: 1.1rem;
|
||||
@apply text-3xl;
|
||||
}
|
||||
|
||||
.tiptap h4,
|
||||
.tiptap h5,
|
||||
.tiptap h4 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
.tiptap h5 {
|
||||
@apply text-xl;
|
||||
}
|
||||
.tiptap h6 {
|
||||
font-size: 1rem;
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
/* Code and preformatted text styles */
|
||||
.tiptap code {
|
||||
background-color: var(--purple-light);
|
||||
border-radius: 0.4rem;
|
||||
color: var(--black);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25em 0.3em;
|
||||
/* .tiptap code {
|
||||
@apply bg-foreground p-1 text-background;
|
||||
}
|
||||
|
||||
.tiptap pre {
|
||||
@@ -78,18 +86,29 @@
|
||||
color: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
} */
|
||||
|
||||
/* Blockquote styles */
|
||||
.tiptap blockquote {
|
||||
border-left: 3px solid var(--gray-3);
|
||||
@apply m-6 border-l-2 pl-4;
|
||||
/* border-left: 3px solid var(--gray-3);
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 1rem;
|
||||
padding-left: 1rem; */
|
||||
}
|
||||
|
||||
/* Horizontal rule styles */
|
||||
.tiptap hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--gray-2);
|
||||
margin: 2rem 0;
|
||||
@apply my-1;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply rounded-md bg-muted-foreground/25;
|
||||
}
|
||||
|
||||
.menu-bar button {
|
||||
@apply duration-0;
|
||||
}
|
||||
|
||||
.menu-bar .is-active {
|
||||
@apply border-primary;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import ColorPicker, { type ColorPickerProps } from ".";
|
||||
import { Button } from "../ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
|
||||
export default function ColorPickerPopover(props: ColorPickerProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button style={{ backgroundColor: props.initialColor }} className="">
|
||||
color
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<ColorPicker {...props} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.color-picker .react-colorful {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
}
|
||||
.color-picker .react-colorful__saturation {
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.color-picker .react-colorful__hue {
|
||||
height: 40px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
.color-picker .react-colorful__hue-pointer {
|
||||
width: 12px;
|
||||
height: inherit;
|
||||
border-radius: 0;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
import "./color-picker.css";
|
||||
|
||||
import React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
import { Input } from "../ui/input";
|
||||
import { debounce } from "@/lib/utils";
|
||||
|
||||
const STORAGE_KEY = "savedColors";
|
||||
const MAX_COLORS = 12;
|
||||
const SELECT_DEBOUNCE = 500;
|
||||
|
||||
export type ColorPickerProps = {
|
||||
onInput?: (color: string) => void;
|
||||
initialColor?: string;
|
||||
};
|
||||
|
||||
function ColorPicker({ onInput, initialColor }: ColorPickerProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [customColors, setCustomColors] = useState<string[]>([]);
|
||||
const [color, setColor] = useState(initialColor ?? "#ff0000");
|
||||
|
||||
// Load colors from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (initialColor?.length) setColor(initialColor);
|
||||
const storageColors = localStorage.getItem(STORAGE_KEY);
|
||||
const storedColors = storageColors ? JSON.parse(storageColors) : [];
|
||||
if (storedColors.length) {
|
||||
setCustomColors(storedColors);
|
||||
if (!initialColor?.length) setColor(storedColors[0]);
|
||||
setMounted(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistColor = (newColor: string) => {
|
||||
if (newColor.length < 2) return;
|
||||
if (customColors[0] === newColor) return; // Prevent duplicate consecutive colors
|
||||
|
||||
const updatedColors = [
|
||||
newColor,
|
||||
...customColors.filter((c) => c !== newColor),
|
||||
].slice(0, MAX_COLORS);
|
||||
console.log(updatedColors);
|
||||
|
||||
setCustomColors(updatedColors);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedColors));
|
||||
};
|
||||
|
||||
const selectColor = (newColor: string) => {
|
||||
persistColor(newColor);
|
||||
onInput?.(newColor);
|
||||
};
|
||||
|
||||
const selectColorDebounced = debounce((newColor: string) => {
|
||||
selectColor(newColor);
|
||||
}, SELECT_DEBOUNCE);
|
||||
|
||||
const handleColorChange = (newColor: string, skipDebounce = false) => {
|
||||
setColor(newColor);
|
||||
if (skipDebounce)
|
||||
selectColor(newColor); // Delayed save
|
||||
else selectColorDebounced(newColor);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="color-picker flex flex-col items-center gap-4">
|
||||
{/* React Colorful Picker */}
|
||||
<HexColorPicker
|
||||
color={color}
|
||||
onChange={handleColorChange}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Default Input (Native Color Picker) */}
|
||||
<div className="flex w-full gap-2">
|
||||
<div
|
||||
className="size-8 rounded-md border"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<Input
|
||||
className="h-8"
|
||||
value={color}
|
||||
onInput={(e) => handleColorChange(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Display Recent Colors */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{customColors.map((col) => (
|
||||
<button
|
||||
key={col}
|
||||
className="size-8 rounded border"
|
||||
style={{ backgroundColor: col }}
|
||||
onClick={() => handleColorChange(col, true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -20,3 +20,18 @@ export function debounce<T extends (...args: any[]) => void>(
|
||||
timeoutId = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
export function RGBAToHexA(rgba: string, forceRemoveAlpha = false) {
|
||||
return (
|
||||
"#" +
|
||||
rgba
|
||||
.replace(/^rgba?\(|\s+|\)$/g, "") // Get's rgba / rgb string values
|
||||
.split(",") // splits them at ","
|
||||
.filter((string, index) => !forceRemoveAlpha || index !== 3)
|
||||
.map((string) => parseFloat(string)) // Converts them to numbers
|
||||
.map((number, index) => (index === 3 ? Math.round(number * 255) : number)) // Converts alpha to 255 number
|
||||
.map((number) => number.toString(16)) // Converts numbers to hex
|
||||
.map((string) => (string.length === 1 ? "0" + string : string)) // Adds 0 when length of one number is 1
|
||||
.join("")
|
||||
); // Puts the array to togehter to a string
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function updateArticle(
|
||||
articleId,
|
||||
});
|
||||
// if (!result[0]?.id?.length) return false;
|
||||
// return revalidatePath(`/artikel/${result[0].id}/edit`);
|
||||
// return revalidatePath(`/artikel/${result[0]?.slug}/edit`);
|
||||
}
|
||||
export async function deleteArticle(articleId: string) {
|
||||
const result = await api.article.delete({
|
||||
|
||||
@@ -86,7 +86,7 @@ export const articleRouter = createTRPCRouter({
|
||||
.set(input.article)
|
||||
.where(eq(articles.id, input.articleId))
|
||||
.returning({
|
||||
id: articles.id,
|
||||
slug: articles.slug,
|
||||
});
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
|
||||
Reference in New Issue
Block a user