enabled react-compiler; added color picker; menu bar sorted

This commit is contained in:
2025-03-08 15:20:27 +01:00
parent 66aadfd04d
commit 96b989799a
14 changed files with 361 additions and 122 deletions
+54 -33
View File
@@ -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>
);
+10 -6
View File
@@ -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
+22 -36
View File
@@ -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"}
+47 -28
View File
@@ -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;
}
+103
View File
@@ -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;
+15
View File
@@ -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
}
+1 -1
View File
@@ -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({
+1 -1
View File
@@ -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