From 1a1d8cc20983f0fd7a239b4b1c8a78375e750d86 Mon Sep 17 00:00:00 2001 From: Ardeman Date: Wed, 5 Mar 2025 17:57:52 +0800 Subject: [PATCH 01/16] feat: implement getTags API and integrate with content creation form --- app/apis/common/get-tags.ts | 25 ++++ app/components/ui/combobox.tsx | 26 +++- app/components/ui/input.tsx | 6 +- app/components/ui/text-editor.tsx | 1 + app/components/ui/title-dashboard.tsx | 2 +- app/pages/contents-create/index.tsx | 175 ++++++++++++++++++++----- app/pages/dashboard-contents/index.tsx | 2 +- app/routes/_admin.lg-admin.tsx | 6 + 8 files changed, 203 insertions(+), 40 deletions(-) create mode 100644 app/apis/common/get-tags.ts diff --git a/app/apis/common/get-tags.ts b/app/apis/common/get-tags.ts new file mode 100644 index 0000000..bf6c36b --- /dev/null +++ b/app/apis/common/get-tags.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' + +import { HttpServer, type THttpServer } from '~/libs/http-server' + +const tagSchema = z.object({ + data: z.array( + z.object({ + id: z.string(), + code: z.string(), + name: z.string(), + }), + ), +}) + +export type TTagSchema = z.infer + +export const getTags = async (parameters?: THttpServer) => { + try { + const { data } = await HttpServer(parameters).get(`/api/tag`) + return tagSchema.parse(data) + } catch (error) { + // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject + return Promise.reject(error) + } +} diff --git a/app/components/ui/combobox.tsx b/app/components/ui/combobox.tsx index 1e8c9a2..d4e662c 100644 --- a/app/components/ui/combobox.tsx +++ b/app/components/ui/combobox.tsx @@ -35,13 +35,26 @@ type TInputProperties = ComponentProps< rules?: RegisterOptions placeholder?: string options?: TComboboxOption[] + labelClassName?: string + containerClassName?: string } export const Combobox = >( properties: TInputProperties, ) => { - const { id, label, name, rules, disabled, placeholder, options, ...rest } = - properties + const { + id, + label, + name, + rules, + disabled, + placeholder, + options, + className, + labelClassName, + containerClassName, + ...rest + } = properties const { control, formState: { errors }, @@ -58,11 +71,11 @@ export const Combobox = >( return ( - { const editor = useEditor({ extensions: [StarterKit], + immediatelyRender: false, content: '

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quis lobortis nisl cursus bibendum sit nulla accumsan sodales ornare. At urna viverra non suspendisse neque, lorem. Pretium condimentum pellentesque gravida id etiam sit sed arcu euismod. Rhoncus proin orci duis scelerisque molestie cursus tincidunt aliquam.

', }) diff --git a/app/components/ui/title-dashboard.tsx b/app/components/ui/title-dashboard.tsx index 9a0c16d..8d1ef35 100644 --- a/app/components/ui/title-dashboard.tsx +++ b/app/components/ui/title-dashboard.tsx @@ -4,7 +4,7 @@ type TitleDashboardProperties = { export const TitleDashboard = (properties: TitleDashboardProperties) => { const { title } = properties return ( -
+

{title}

diff --git a/app/pages/contents-create/index.tsx b/app/pages/contents-create/index.tsx index d6a684d..7471ada 100644 --- a/app/pages/contents-create/index.tsx +++ b/app/pages/contents-create/index.tsx @@ -1,45 +1,156 @@ -import { Field, Input, Label, Select } from '@headlessui/react' +import { DevTool } from '@hookform/devtools' +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, useState } from 'react' +import { useFetcher, useRouteLoaderData } from 'react-router' +import { RemixFormProvider, useRemixForm } from 'remix-hook-form' +import { z } from 'zod' -import { SearchIcon } from '~/components/icons/search' +import { Button } from '~/components/ui/button' +import { Combobox } from '~/components/ui/combobox' +import { Input } from '~/components/ui/input' import DefaultTextEditor from '~/components/ui/text-editor' import { TitleDashboard } from '~/components/ui/title-dashboard' +import type { loader } from '~/routes/_admin.lg-admin' + +export const contentSchema = z.object({ + categories: z + .array( + z + .object({ + id: z.string(), + code: z.string(), + name: z.string(), + }) + .optional() + .nullable(), + ) + .refine((data) => !!data, { + message: 'Please select a category', + }), + tags: z.array( + z + .object({ + id: z.string(), + code: z.string(), + name: z.string(), + }) + .optional() + .nullable(), + ), + title: z.string().min(1, { + message: 'Title is required', + }), + content: z.string().min(1, { + message: 'Content is required', + }), + featured_image: z.string().optional(), + is_premium: z.boolean().optional(), + live_at: z.string().min(1, { + message: 'Tanggal live is required', + }), +}) + +export type TContentSchema = z.infer export const CreateContentsPage = () => { + const fetcher = useFetcher() + const loaderData = useRouteLoaderData('routes/_admin.lg-admin') + const categories = loaderData?.categoriesData + const tags = loaderData?.tagsData + const [error, setError] = useState() + const [disabled, setDisabled] = useState(false) + + const formMethods = useRemixForm({ + mode: 'onSubmit', + fetcher, + resolver: zodResolver(contentSchema), + }) + + const { handleSubmit, control, watch } = formMethods + const watchCategories = watch('categories') + const watchTags = watch('tags') + + useEffect(() => { + if (!fetcher.data?.success) { + setError(fetcher.data?.message) + setDisabled(false) + return + } + + setDisabled(true) + setError(undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher]) + return (
-
-
- - -
- -
- -
-
-
-
+ + + {error && ( +
{error}
+ )} +
+ category?.name).join(', ') + : 'Pilih Kategori' + } + options={categories} + className="border-0 bg-white shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none" + labelClassName="text-sm font-medium text-[#363636]" + containerClassName="flex-1" + /> + tag?.name).join(', ') + : 'Pilih Tags' + } + options={tags} + className="border-0 bg-white shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none" + labelClassName="text-sm font-medium text-[#363636]" + containerClassName="flex-1" + /> + + +
-
- - - - -
-
+
+ +
+ + -
- -
+
) } diff --git a/app/pages/dashboard-contents/index.tsx b/app/pages/dashboard-contents/index.tsx index b6df7b2..7ba58fe 100644 --- a/app/pages/dashboard-contents/index.tsx +++ b/app/pages/dashboard-contents/index.tsx @@ -91,7 +91,7 @@ export const ContentsPage = () => { + ) +} diff --git a/app/components/text-editor/editor-menubar.tsx b/app/components/text-editor/editor-menubar.tsx new file mode 100644 index 0000000..a588219 --- /dev/null +++ b/app/components/text-editor/editor-menubar.tsx @@ -0,0 +1,432 @@ +import { + ArrowUturnLeftIcon, + ArrowUturnRightIcon, + Bars3BottomLeftIcon, + Bars3BottomRightIcon, + Bars3Icon, + Bars4Icon, + BoldIcon, + CodeBracketIcon, + DocumentTextIcon, + H1Icon, + H2Icon, + H3Icon, + ItalicIcon, + LinkIcon, + LinkSlashIcon, + ListBulletIcon, + MoonIcon, + NumberedListIcon, + PhotoIcon, + StrikethroughIcon, + SunIcon, + SwatchIcon, +} from '@heroicons/react/20/solid' +import type { Editor } from '@tiptap/react' +import { + type SetStateAction, + type Dispatch, + useState, + useRef, + useCallback, +} from 'react' +import { HexColorInput, HexColorPicker } from 'react-colorful' + +import { useClickOutside } from '~/hooks/use-click-outside' +import { isHexCompatible, rgbToHex } from '~/utils/color' + +import { EditorButton } from './editor-button' + +type TProperties = { + editor: Editor | null + setIsPlainHTML: Dispatch> + category: string + darkMode: boolean + setDarkMode: Dispatch> + disabled?: boolean +} + +export const EditorMenuBar = (properties: TProperties) => { + const { + editor, + setIsPlainHTML, + // category, + darkMode, + setDarkMode, + disabled = false, + } = properties + // const [isOpenImage, setIsOpenImage] = useState(false) + const [isOpenColor, setIsOpenColor] = useState(false) + const popover = useRef(null) + const close = useCallback(() => setIsOpenColor(false), []) + + useClickOutside(popover, close) + + const setLink = useCallback(() => { + const previousUrl = editor?.getAttributes('link').href + const url = globalThis.prompt('URL', previousUrl) + + // cancelled + if (url === null) { + return + } + + // empty + if (url === '') { + editor?.chain().focus().extendMarkRange('link').unsetLink().run() + + return + } + + // update link + editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run() + }, [editor]) + + if (!editor) { + return + } + + const addImage = (url: string) => { + if (url) { + editor.chain().focus().setImage({ src: url }).run() + } + } + + const currentColor: string = editor.getAttributes('textStyle').color + const rgbColor = isHexCompatible(currentColor) + ? currentColor + : rgbToHex(currentColor) + const handleChangeColor = (selectedColor: string) => { + if (selectedColor.length === 7) { + editor.chain().focus().setColor(selectedColor).run() + } + } + + const uploadEnabled = false + const toggleUpload = () => { + if (uploadEnabled) { + // setIsOpenImage(true) + } else { + const urlImage = globalThis.prompt('URL') + if (urlImage) { + addImage(urlImage) + } + } + } + + const toggleDark = () => { + setDarkMode(!darkMode) + // localStorage.setItem(editorKey, JSON.stringify(!darkMode)) + } + + return ( + <> +
+
+
+ editor.chain().focus().toggleBold().run()} + disabled={ + disabled || !editor.can().chain().focus().toggleBold().run() + } + isActive={editor.isActive('bold')} + title="Bold" + > + + + editor.chain().focus().toggleItalic().run()} + disabled={ + disabled || !editor.can().chain().focus().toggleItalic().run() + } + isActive={editor.isActive('italic')} + title="Italic" + > + + + editor.chain().focus().toggleStrike().run()} + disabled={ + disabled || !editor.can().chain().focus().toggleStrike().run() + } + isActive={editor.isActive('strike')} + title="Strike" + > + + +
+ setIsOpenColor(true)} + title="Text Color" + style={{ + color: rgbColor, + }} + isActive={true} + disabled={disabled} + > + + + {isOpenColor && ( +
+ +
+ +
+
+ )} +
+ editor.chain().focus().setTextAlign('left').run()} + disabled={ + disabled || + !editor.can().chain().focus().setTextAlign('left').run() + } + isActive={editor.isActive({ textAlign: 'left' })} + title="Align Left" + > + + + + editor.chain().focus().setTextAlign('center').run() + } + disabled={ + disabled || + !editor.can().chain().focus().setTextAlign('center').run() + } + isActive={editor.isActive({ textAlign: 'center' })} + title="Align Center" + > + + + editor.chain().focus().setTextAlign('right').run()} + disabled={ + disabled || + !editor.can().chain().focus().setTextAlign('right').run() + } + isActive={editor.isActive({ textAlign: 'right' })} + title="Align Right" + > + + + + editor.chain().focus().setTextAlign('justify').run() + } + disabled={ + disabled || + !editor.can().chain().focus().setTextAlign('justify').run() + } + isActive={editor.isActive({ textAlign: 'justify' })} + title="Align Justify" + > + + +
+
+ + editor.chain().focus().toggleHeading({ level: 1 }).run() + } + isActive={editor.isActive('heading', { level: 1 })} + title="Heading 1" + disabled={disabled} + > + + + + editor.chain().focus().toggleHeading({ level: 2 }).run() + } + isActive={editor.isActive('heading', { level: 2 })} + title="Heading 2" + disabled={disabled} + > + + + + editor.chain().focus().toggleHeading({ level: 3 }).run() + } + isActive={editor.isActive('heading', { level: 3 })} + title="Heading 3" + disabled={disabled} + > + + + {/* editor.chain().focus().setParagraph().run()} + isActive={editor.isActive('paragraph')} + title="Paragraph" + disabled={disabled} + > + + */} + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive('bulletList')} + title="Bullet List" + disabled={disabled} + > + + + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive('orderedList')} + title="Ordered List" + disabled={disabled} + > + + + editor.chain().focus().toggleCodeBlock().run()} + isActive={editor.isActive('codeBlock')} + title="Code Block" + disabled={disabled} + > + + +
+ {/*
+ editor.chain().focus().toggleBlockquote().run()} + isActive={editor.isActive('blockquote')} + title="Blockquote" + disabled={disabled} + > + + + editor.chain().focus().setHorizontalRule().run()} + title="Horizontal Rule" + disabled={disabled} + > + + +
*/} + {/*
+ editor.chain().focus().setHardBreak().run()} + title="Hard Break" + disabled={disabled} + > + + + { + editor.chain().focus().unsetAllMarks().run() + editor.chain().focus().clearNodes().run() + }} + title="Clear Format" + disabled={disabled} + > + + +
*/} +
+ toggleUpload()} + title="Insert Image" + disabled={disabled} + > + + + setLink()} + disabled={ + disabled || + !editor + .can() + .chain() + .focus() + .extendMarkRange('link') + .setLink({ href: '' }) + .run() + } + isActive={editor.isActive('link')} + title="Set Link" + > + + + editor.chain().focus().unsetLink().run()} + disabled={disabled || !editor.isActive('link')} + title="Unset Link" + > + + +
+
+ editor.chain().focus().undo().run()} + disabled={disabled || !editor.can().chain().focus().undo().run()} + title="Undo" + > + + + editor.chain().focus().redo().run()} + disabled={disabled || !editor.can().chain().focus().redo().run()} + title="Redo" + > + + + toggleDark()} + title={darkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'} + isActive={true} + > + {darkMode ? : } + +
+
+
+
+ setIsPlainHTML(true)} + title="Switch to Plain Text" + isActive={true} + > + + +
+
+
+ + {/* + setIsOpenImage(false), + onSave: (file) => { + addImage(file) + setIsOpenImage(false) + }, + category: category, + maxFileSize: 300, + selectedFile: '', + }} + > + + + */} + + ) +} diff --git a/app/components/text-editor/editor-textarea.tsx b/app/components/text-editor/editor-textarea.tsx new file mode 100644 index 0000000..a3b6019 --- /dev/null +++ b/app/components/text-editor/editor-textarea.tsx @@ -0,0 +1,56 @@ +import { CodeBracketSquareIcon } from '@heroicons/react/20/solid' +import MonacoEditor from '@monaco-editor/react' +import type { Dispatch, SetStateAction } from 'react' +import { Controller } from 'react-hook-form' +import { useRemixFormContext } from 'remix-hook-form' + +import { EditorButton } from './editor-button' + +type TProperties = { + name: string + disabled?: boolean + setIsPlainHTML: Dispatch> +} + +// const MonacoEditor = dynamic(() => import('@monaco-editor/react'), { +// ssr: false, +// }) + +export const EditorTextArea = (properties: TProperties) => { + const { setIsPlainHTML, name, disabled = false } = properties + + const { control } = useRemixFormContext() + return ( + <> +
+
+ setIsPlainHTML(false)} + title="Switch to Rich Text" + isActive={true} + > + + +
+
+ ( + { + field.onChange(newValue) + }} + value={field.value} + options={{ + readOnly: disabled, + wordWrap: 'on', + }} + className="mb-1 h-96 max-w-none overflow-y-auto rounded-[0_0_5px_5px] border border-[#D2D2D2] p-1" + /> + )} + /> + + ) +} diff --git a/app/components/text-editor/index.tsx b/app/components/text-editor/index.tsx new file mode 100644 index 0000000..9a2aedf --- /dev/null +++ b/app/components/text-editor/index.tsx @@ -0,0 +1,151 @@ +import { Color } from '@tiptap/extension-color' +import Highlight from '@tiptap/extension-highlight' +import Image from '@tiptap/extension-image' +import Link from '@tiptap/extension-link' +import TextAlign from '@tiptap/extension-text-align' +import TextStyle from '@tiptap/extension-text-style' +import { EditorContent, useEditor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { useEffect, useId, useState } from 'react' +import { + get, + type FieldValues, + type Path, + type RegisterOptions, +} from 'react-hook-form' +import { useRemixFormContext } from 'remix-hook-form' +import { twMerge } from 'tailwind-merge' + +import { EditorMenuBar } from './editor-menubar' +import { EditorTextArea } from './editor-textarea' + +type TProperties = { + id?: string + name: Path + label?: string + placeholder?: string + labelClassName?: string + className?: string + inputClassName?: string + rules?: RegisterOptions + disabled?: boolean + isRequired?: boolean + category: string +} + +export const TextEditor = >( + properties: TProperties, +) => { + const { + id, + label, + name, + labelClassName, + isRequired, + className, + inputClassName, + category, + disabled = false, + } = properties + + const [isPlainHTML, setIsPlainHTML] = useState(false) + const [init, setInit] = useState(true) + const [darkMode, setDarkMode] = useState(false) + const generatedId = useId() + + const { + setValue, + watch, + formState: { errors }, + } = useRemixFormContext() + + const watchContent = watch(name) + const error = get(errors, name) + + const editor = useEditor({ + editable: !disabled, + extensions: [ + StarterKit, + Highlight, + Image.configure({ + inline: true, + }), + TextStyle, + Color.configure({ + types: ['textStyle'], + }), + Link.configure({ + openOnClick: false, + }), + TextAlign.configure({ + types: ['heading', 'paragraph'], + }), + ], + content: watchContent, + onUpdate: ({ editor }) => { + setValue(name, editor.getHTML() as any) // eslint-disable-line @typescript-eslint/no-explicit-any + }, + }) + useEffect(() => { + if ( + watchContent && + watchContent.length > 0 && + editor && + (isPlainHTML || init) + ) { + editor.commands.setContent(watchContent) + setInit(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [watchContent]) + + return ( +
+ {label && ( + + )} + + {isPlainHTML ? ( + + ) : ( + <> + + editor?.commands.focus()} + /> + + )} + + {error && ( +

+ {error?.message?.toString()} +

+ )} +
+ ) +} diff --git a/app/components/ui/input.tsx b/app/components/ui/input.tsx index 8c29ae8..78c0bfb 100644 --- a/app/components/ui/input.tsx +++ b/app/components/ui/input.tsx @@ -22,6 +22,7 @@ type TInputProperties = Omit< label?: ReactNode name: Path rules?: RegisterOptions + containerClassName?: string } export const Input = >( @@ -36,6 +37,7 @@ export const Input = >( placeholder, disabled, className, + containerClassName, ...rest } = properties const [inputType, setInputType] = useState(type) @@ -49,7 +51,7 @@ export const Input = >( return ( diff --git a/app/hooks/use-click-outside.ts b/app/hooks/use-click-outside.ts new file mode 100644 index 0000000..47672e8 --- /dev/null +++ b/app/hooks/use-click-outside.ts @@ -0,0 +1,27 @@ +import { useEffect, type RefObject } from 'react' + +type Event = MouseEvent | TouchEvent + +export const useClickOutside = ( + reference: RefObject, + handler: (event: Event) => void, +) => { + useEffect(() => { + const listener = (event: Event) => { + const element = reference?.current + if (!element || element.contains((event?.target as Node) || undefined)) { + return + } + + handler(event) + } + + document.addEventListener('mousedown', listener) + document.addEventListener('touchstart', listener) + + return () => { + document.removeEventListener('mousedown', listener) + document.removeEventListener('touchstart', listener) + } + }, [reference, handler]) +} diff --git a/app/pages/contents-create/index.tsx b/app/pages/contents-create/index.tsx index 7471ada..6f31ff9 100644 --- a/app/pages/contents-create/index.tsx +++ b/app/pages/contents-create/index.tsx @@ -5,10 +5,10 @@ import { useFetcher, useRouteLoaderData } from 'react-router' import { RemixFormProvider, useRemixForm } from 'remix-hook-form' import { z } from 'zod' +import { TextEditor } from '~/components/text-editor' import { Button } from '~/components/ui/button' import { Combobox } from '~/components/ui/combobox' import { Input } from '~/components/ui/input' -import DefaultTextEditor from '~/components/ui/text-editor' import { TitleDashboard } from '~/components/ui/title-dashboard' import type { loader } from '~/routes/_admin.lg-admin' @@ -38,10 +38,10 @@ export const contentSchema = z.object({ .nullable(), ), title: z.string().min(1, { - message: 'Title is required', + message: 'Judul is required', }), content: z.string().min(1, { - message: 'Content is required', + message: 'Konten is required', }), featured_image: z.string().optional(), is_premium: z.boolean().optional(), @@ -95,6 +95,24 @@ export const CreateContentsPage = () => { {error && (
{error}
)} +
+ + +
{
-
- -
+ diff --git a/app/utils/color.ts b/app/utils/color.ts new file mode 100644 index 0000000..73962a1 --- /dev/null +++ b/app/utils/color.ts @@ -0,0 +1,39 @@ +export const isHexCompatible = (hexColor?: string): boolean => { + if (hexColor === undefined) { + return true + } + + const hexColorRegex = /^#([\dA-Fa-f]{6}|[\dA-Fa-f]{3})$/ + return hexColorRegex.test(hexColor) +} + +export const rgbToHex = (rgb: string): string => { + // Extract the integers by matching against a regex + const result = rgb.match(/\d+/g) + + if (!result) { + return '#000000' // Set default color to #000000 if the RGB string is invalid + } + + const [red, green, blue] = result.map(Number) + + // Ensure the values are valid + if ( + red < 0 || + red > 255 || + green < 0 || + green > 255 || + blue < 0 || + blue > 255 + ) { + return '#000000' // Set default color to #000000 if the RGB values are invalid + } + + // Convert each component to hex + const redHex = red.toString(16).padStart(2, '0') + const greenHex = green.toString(16).padStart(2, '0') + const blueHex = blue.toString(16).padStart(2, '0') + + // Return the combined string + return `#${redHex}${greenHex}${blueHex}` +} diff --git a/package.json b/package.json index 63e5659..707b41b 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,16 @@ "@headlessui/react": "^2.2.0", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^4.1.1", + "@monaco-editor/react": "^4.7.0", "@react-router/fs-routes": "^7.1.3", "@react-router/node": "^7.1.3", "@react-router/serve": "^7.1.3", + "@tiptap/extension-color": "^2.11.5", + "@tiptap/extension-highlight": "^2.11.5", + "@tiptap/extension-image": "^2.11.5", + "@tiptap/extension-link": "^2.11.5", + "@tiptap/extension-text-align": "^2.11.5", + "@tiptap/extension-text-style": "^2.11.5", "@tiptap/react": "^2.11.5", "@tiptap/starter-kit": "^2.11.5", "chart.js": "^4.4.8", @@ -32,6 +39,7 @@ "jose": "^6.0.8", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", + "react-colorful": "^5.6.1", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-router": "^7.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e3ff9d..c41056b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@hookform/resolvers': specifier: ^4.1.1 version: 4.1.1(react-hook-form@7.54.2(react@19.0.0)) + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@react-router/fs-routes': specifier: ^7.1.3 version: 7.1.3(@react-router/dev@7.1.3(@react-router/serve@7.1.3(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.7.3))(@types/node@20.17.16)(babel-plugin-macros@3.1.0)(lightningcss@1.29.1)(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.7.3)(vite@5.4.14(@types/node@20.17.16)(lightningcss@1.29.1)))(typescript@5.7.3) @@ -26,6 +29,24 @@ importers: '@react-router/serve': specifier: ^7.1.3 version: 7.1.3(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.7.3) + '@tiptap/extension-color': + specifier: ^2.11.5 + version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/extension-text-style@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))) + '@tiptap/extension-highlight': + specifier: ^2.11.5 + version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5)) + '@tiptap/extension-image': + specifier: ^2.11.5 + version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5)) + '@tiptap/extension-link': + specifier: ^2.11.5 + version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/pm@2.11.5) + '@tiptap/extension-text-align': + specifier: ^2.11.5 + version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5)) + '@tiptap/extension-text-style': + specifier: ^2.11.5 + version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5)) '@tiptap/react': specifier: ^2.11.5 version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/pm@2.11.5)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -62,6 +83,9 @@ importers: react-chartjs-2: specifier: ^5.3.0 version: 5.3.0(chart.js@4.4.8)(react@19.0.0) + react-colorful: + specifier: ^5.6.1 + version: 5.6.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) @@ -708,6 +732,16 @@ packages: '@mjackson/node-fetch-server@0.2.0': resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} + '@monaco-editor/loader@1.5.0': + resolution: {integrity: sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1474,6 +1508,12 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 + '@tiptap/extension-color@2.11.5': + resolution: {integrity: sha512-9gZF6EIpfOJYUt1TtFY37e8iqwKcOmBl8CkFaxq+4mWVvYd2D7KbA0r4tYTxSO0fOBJ5fA/1qJrpvgRlyocp/A==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-text-style': ^2.7.0 + '@tiptap/extension-document@2.11.5': resolution: {integrity: sha512-7I4BRTpIux2a0O2qS3BDmyZ5LGp3pszKbix32CmeVh7lN9dV7W5reDqtJJ9FCZEEF+pZ6e1/DQA362dflwZw2g==} peerDependencies: @@ -1507,6 +1547,11 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 + '@tiptap/extension-highlight@2.11.5': + resolution: {integrity: sha512-VBZfT869L9CiTLF8qr+3FBUtJcmlyUTECORNo0ceEiNDg4H6V9uNPwaROMXrWiQCc+DYVCOkx541QrXwNMzxlg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-history@2.11.5': resolution: {integrity: sha512-b+wOS33Dz1azw6F1i9LFTEIJ/gUui0Jwz5ZvmVDpL2ZHBhq1Ui0/spTT+tuZOXq7Y/uCbKL8Liu4WoedIvhboQ==} peerDependencies: @@ -1519,11 +1564,22 @@ packages: '@tiptap/core': ^2.7.0 '@tiptap/pm': ^2.7.0 + '@tiptap/extension-image@2.11.5': + resolution: {integrity: sha512-HbUq9AL8gb8eSuQfY/QKkvMc66ZFN/b6jvQAILGArNOgalUfGizoC6baKTJShaExMSPjBZlaAHtJiQKPaGRHaA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-italic@2.11.5': resolution: {integrity: sha512-9VGfb2/LfPhQ6TjzDwuYLRvw0A6VGbaIp3F+5Mql8XVdTBHb2+rhELbyhNGiGVR78CaB/EiKb6dO9xu/tBWSYA==} peerDependencies: '@tiptap/core': ^2.7.0 + '@tiptap/extension-link@2.11.5': + resolution: {integrity: sha512-4Iu/aPzevbYpe50xDI0ZkqRa6nkZ9eF270Ue2qaF3Ab47nehj+9Jl78XXzo8+LTyFMnrETI73TAs1aC/IGySeQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/extension-list-item@2.11.5': resolution: {integrity: sha512-Mp5RD/pbkfW1vdc6xMVxXYcta73FOwLmblQlFNn/l/E5/X1DUSA4iGhgDDH4EWO3swbs03x2f7Zka/Xoj3+WLg==} peerDependencies: @@ -1544,6 +1600,11 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 + '@tiptap/extension-text-align@2.11.5': + resolution: {integrity: sha512-Ei0zDpH5N9EV59ogydK4HTKa4lCPicCsQllM5n/Nf2tUJPir3aiYxzJ73FzhComD4Hpo1ANYnmssBhy8QeoPZA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/extension-text-style@2.11.5': resolution: {integrity: sha512-YUmYl0gILSd/u/ZkOmNxjNXVw+mu8fpC2f8G4I4tLODm0zCx09j9DDEJXSrM5XX72nxJQqtSQsCpNKnL0hfeEQ==} peerDependencies: @@ -3173,6 +3234,9 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkifyjs@4.2.0: + resolution: {integrity: sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw==} + lint-staged@15.4.3: resolution: {integrity: sha512-FoH1vOeouNh1pw+90S+cnuoFwRfUD9ijY2GKy5h7HS3OR7JVir2N2xrsa0+Twc1B7cW72L+88geG5cW4wIhn7g==} engines: {node: '>=18.12.0'} @@ -3337,6 +3401,9 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + monaco-editor@0.52.2: + resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} + morgan@1.10.0: resolution: {integrity: sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==} engines: {node: '>= 0.8.0'} @@ -3791,6 +3858,12 @@ packages: chart.js: ^4.1.1 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-colorful@5.6.1: + resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-d3-tree@3.6.2: resolution: {integrity: sha512-1ExQlmEnv5iOw9XfZ3EcESDjzGXVKPAmyDJTJbvVfiwkplZtP7CcNEY0tKZf4XSW0FzYJf4aFXprGJen+95yuw==} peerDependencies: @@ -4135,6 +4208,9 @@ packages: stable-hash@0.0.4: resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -5214,6 +5290,17 @@ snapshots: '@mjackson/node-fetch-server@0.2.0': {} + '@monaco-editor/loader@1.5.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@monaco-editor/loader': 1.5.0 + monaco-editor: 0.52.2 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5948,6 +6035,11 @@ snapshots: dependencies: '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) + '@tiptap/extension-color@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/extension-text-style@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5)))': + dependencies: + '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) + '@tiptap/extension-text-style': 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5)) + '@tiptap/extension-document@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))': dependencies: '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) @@ -5976,6 +6068,10 @@ snapshots: dependencies: '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) + '@tiptap/extension-highlight@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))': + dependencies: + '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) + '@tiptap/extension-history@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/pm@2.11.5)': dependencies: '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) @@ -5986,10 +6082,20 @@ snapshots: '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) '@tiptap/pm': 2.11.5 + '@tiptap/extension-image@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))': + dependencies: + '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) + '@tiptap/extension-italic@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))': dependencies: '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) + '@tiptap/extension-link@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/pm@2.11.5)': + dependencies: + '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) + '@tiptap/pm': 2.11.5 + linkifyjs: 4.2.0 + '@tiptap/extension-list-item@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))': dependencies: '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) @@ -6006,6 +6112,10 @@ snapshots: dependencies: '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) + '@tiptap/extension-text-align@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))': + dependencies: + '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) + '@tiptap/extension-text-style@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))': dependencies: '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) @@ -7882,6 +7992,8 @@ snapshots: dependencies: uc.micro: 2.1.0 + linkifyjs@4.2.0: {} + lint-staged@15.4.3: dependencies: chalk: 5.4.1 @@ -8028,6 +8140,8 @@ snapshots: minipass@7.1.2: {} + monaco-editor@0.52.2: {} + morgan@1.10.0: dependencies: basic-auth: 2.0.1 @@ -8457,6 +8571,11 @@ snapshots: chart.js: 4.4.8 react: 19.0.0 + react-colorful@5.6.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-d3-tree@3.6.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@bkrem/react-transition-group': 1.3.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -8876,6 +8995,8 @@ snapshots: stable-hash@0.0.4: {} + state-local@1.0.7: {} + statuses@2.0.1: {} stream-shift@1.0.3: {} From 894698c5d669a2ff1d2b5f14f014a2d349098c38 Mon Sep 17 00:00:00 2001 From: Ardeman Date: Thu, 6 Mar 2025 03:53:15 +0800 Subject: [PATCH 05/16] feat: enhance TextEditor and Input components with improved styling and error handling --- app/app.css | 4 ++ app/components/text-editor/editor-menubar.tsx | 2 +- app/components/text-editor/index.tsx | 38 +++++------ app/components/ui/input.tsx | 4 +- app/pages/contents-create/index.tsx | 7 +- app/routes/actions.admin.contents.create.ts | 65 +++++++++++++++++++ 6 files changed, 97 insertions(+), 23 deletions(-) create mode 100644 app/routes/actions.admin.contents.create.ts diff --git a/app/app.css b/app/app.css index 99be541..ef30239 100644 --- a/app/app.css +++ b/app/app.css @@ -19,6 +19,10 @@ html, body { } +.ProseMirror-focused { + @apply outline-none; +} + table.dataTable thead > tr { border-bottom: 2px solid #c2c2c2; } diff --git a/app/components/text-editor/editor-menubar.tsx b/app/components/text-editor/editor-menubar.tsx index a588219..ac7d570 100644 --- a/app/components/text-editor/editor-menubar.tsx +++ b/app/components/text-editor/editor-menubar.tsx @@ -121,7 +121,7 @@ export const EditorMenuBar = (properties: TProperties) => { return ( <> -
+
= { labelClassName?: string className?: string inputClassName?: string + containerClassName?: string rules?: RegisterOptions disabled?: boolean isRequired?: boolean @@ -41,11 +44,11 @@ export const TextEditor = >( label, name, labelClassName, - isRequired, className, inputClassName, category, disabled = false, + containerClassName, } = properties const [isPlainHTML, setIsPlainHTML] = useState(false) @@ -60,7 +63,7 @@ export const TextEditor = >( } = useRemixFormContext() const watchContent = watch(name) - const error = get(errors, name) + const error: FieldError = get(errors, name) const editor = useEditor({ editable: !disabled, @@ -100,15 +103,16 @@ export const TextEditor = >( }, [watchContent]) return ( -
+ {label && ( - + )} {isPlainHTML ? ( @@ -118,7 +122,7 @@ export const TextEditor = >( disabled={disabled} /> ) : ( - <> +
>( editor={editor} id={id ?? generatedId} className={twMerge( - 'prose mb-1 max-h-96 max-w-none cursor-text overflow-y-auto rounded-[0_0_5px_5px] border border-[#D2D2D2] px-4 py-1', + 'prose-invert max-h-96 max-w-none cursor-text overflow-y-auto rounded-[0_0_5px_5px] border border-[#D2D2D2] p-2', darkMode ? 'bg-[#00000055]' : '', inputClassName, )} onClick={() => editor?.commands.focus()} /> - +
)} - - {error && ( -

- {error?.message?.toString()} -

- )} -
+ ) } diff --git a/app/components/ui/input.tsx b/app/components/ui/input.tsx index 78c0bfb..b8b22bb 100644 --- a/app/components/ui/input.tsx +++ b/app/components/ui/input.tsx @@ -23,6 +23,7 @@ type TInputProperties = Omit< name: Path rules?: RegisterOptions containerClassName?: string + labelClassName?: string } export const Input = >( @@ -38,6 +39,7 @@ export const Input = >( disabled, className, containerClassName, + labelClassName, ...rest } = properties const [inputType, setInputType] = useState(type) @@ -55,7 +57,7 @@ export const Input = >( disabled={disabled} id={id} > -
@@ -151,6 +153,7 @@ export const CreateContentsPage = () => { name="live_at" type="date" className="border-0 bg-white shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none" + labelClassName="text-sm font-medium text-[#363636]" />
{ className="border-0 bg-white shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none" labelClassName="text-sm font-medium text-[#363636]" /> -
Date: Thu, 6 Mar 2025 08:18:36 +0800 Subject: [PATCH 09/16] feat: add Switch component for premium content toggle in CreateContentsPage --- app/components/ui/combobox.tsx | 4 +- app/components/ui/switch.tsx | 80 +++++++++++++++++++++++++++++ app/pages/contents-create/index.tsx | 17 ++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 app/components/ui/switch.tsx diff --git a/app/components/ui/combobox.tsx b/app/components/ui/combobox.tsx index d4e662c..d0fb882 100644 --- a/app/components/ui/combobox.tsx +++ b/app/components/ui/combobox.tsx @@ -26,7 +26,7 @@ type TComboboxOption = { id: string } -type TInputProperties = ComponentProps< +type TComboboxProperties = ComponentProps< typeof HeadlessCombobox > & { id: string @@ -40,7 +40,7 @@ type TInputProperties = ComponentProps< } export const Combobox = >( - properties: TInputProperties, + properties: TComboboxProperties, ) => { const { id, diff --git a/app/components/ui/switch.tsx b/app/components/ui/switch.tsx new file mode 100644 index 0000000..a6a6ffc --- /dev/null +++ b/app/components/ui/switch.tsx @@ -0,0 +1,80 @@ +import { Field, Label, Switch as HeadlessSwitch } from '@headlessui/react' +import { type ReactNode } from 'react' +import { + Controller, + get, + type FieldError, + type FieldValues, + type Path, + type RegisterOptions, +} from 'react-hook-form' +import { useRemixFormContext } from 'remix-hook-form' +import { twMerge } from 'tailwind-merge' + +type TSwitchProperties = { + id: string + label?: ReactNode + name: Path + rules?: RegisterOptions + containerClassName?: string + labelClassName?: string + className?: string + inputClassName?: string +} + +export const Switch = >( + properties: TSwitchProperties, +) => { + const { + id, + label, + name, + rules, + containerClassName, + labelClassName, + className, + inputClassName, + } = properties + + const { + control, + formState: { errors }, + } = useRemixFormContext() + + const error: FieldError = get(errors, name) + + return ( + + + ( +
+ { + field.onChange(checked) + }} + className={twMerge( + 'group relative flex h-7 w-14 cursor-pointer rounded-full bg-black/10 p-1 shadow transition-colors duration-200 ease-in-out focus:outline-none data-[checked]:bg-black/10 data-[focus]:outline-1 data-[focus]:outline-white', + className, + )} + > + +
+ )} + /> +
+ ) +} diff --git a/app/pages/contents-create/index.tsx b/app/pages/contents-create/index.tsx index b7a984e..d5c082c 100644 --- a/app/pages/contents-create/index.tsx +++ b/app/pages/contents-create/index.tsx @@ -9,6 +9,7 @@ import { TextEditor } from '~/components/text-editor' import { Button } from '~/components/ui/button' import { Combobox } from '~/components/ui/combobox' import { Input } from '~/components/ui/input' +import { Switch } from '~/components/ui/switch' import { TitleDashboard } from '~/components/ui/title-dashboard' import type { loader } from '~/routes/_admin.lg-admin' @@ -67,6 +68,15 @@ export const CreateContentsPage = () => { mode: 'onSubmit', fetcher, resolver: zodResolver(contentSchema), + defaultValues: { + categories: [], + tags: [], + title: '', + content: '', + featured_image: '', + is_premium: false, + live_at: '', + }, }) const { handleSubmit, control, watch } = formMethods @@ -167,6 +177,13 @@ export const CreateContentsPage = () => { className="border-0 bg-white shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none" labelClassName="text-sm font-medium text-[#363636]" /> +
Date: Thu, 6 Mar 2025 08:20:39 +0800 Subject: [PATCH 10/16] refactor: remove Select component from UI library --- app/components/ui/select.tsx | 67 ------------------------------------ 1 file changed, 67 deletions(-) delete mode 100644 app/components/ui/select.tsx diff --git a/app/components/ui/select.tsx b/app/components/ui/select.tsx deleted file mode 100644 index ad22f82..0000000 --- a/app/components/ui/select.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Field, Label, Select as HeadlessSelect } from '@headlessui/react' -import { type ComponentProps, type ReactNode } from 'react' -import { - get, - type FieldError, - type FieldValues, - type Path, - type RegisterOptions, -} from 'react-hook-form' -import { useRemixFormContext } from 'remix-hook-form' - -type TInputProperties = Omit< - ComponentProps<'select'>, - 'size' -> & { - id: string - label?: ReactNode - name: Path - rules?: RegisterOptions - placeholder?: string - options?: { - code: string - name: string - id: string - }[] -} - -export const Select = >( - properties: TInputProperties, -) => { - const { id, label, name, rules, disabled, placeholder, options, ...rest } = - properties - - const { - register, - formState: { errors }, - } = useRemixFormContext() - - const error: FieldError = get(errors, name) - - return ( - - - - - {options?.map(({ id, name }) => ( - - ))} - - - ) -} From a7c5da8d9c256e91a0ae2ec5fdbd3976bec664ef Mon Sep 17 00:00:00 2001 From: Ardeman Date: Thu, 6 Mar 2025 08:45:24 +0800 Subject: [PATCH 11/16] style: update button and switch components for improved UI consistency --- app/components/text-editor/editor-button.tsx | 4 +-- app/components/ui/switch.tsx | 2 +- app/pages/dashboard-contents/index.tsx | 35 ++------------------ 3 files changed, 5 insertions(+), 36 deletions(-) diff --git a/app/components/text-editor/editor-button.tsx b/app/components/text-editor/editor-button.tsx index 4712e8d..ba239a9 100644 --- a/app/components/text-editor/editor-button.tsx +++ b/app/components/text-editor/editor-button.tsx @@ -21,8 +21,8 @@ export const EditorButton = (properties: TProperties) => { onClick={onClick} disabled={disabled} className={twMerge( - 'flex h-6 w-8 items-center justify-center rounded-md text-xl hover:!bg-[#FCB017] disabled:cursor-not-allowed disabled:text-slate-400 disabled:opacity-50', - isActive ? 'bg-[#FCB01755]' : '', + 'flex h-6 w-8 items-center justify-center rounded-md p-2 hover:bg-[#2E2F7C] hover:text-white disabled:cursor-not-allowed disabled:bg-[#2E2F7C]/50 disabled:text-white disabled:hover:bg-[#2E2F7C]/50', + isActive ? 'bg-[#2E2F7C]/10' : '', className, )} style={style} diff --git a/app/components/ui/switch.tsx b/app/components/ui/switch.tsx index a6a6ffc..c79df4c 100644 --- a/app/components/ui/switch.tsx +++ b/app/components/ui/switch.tsx @@ -63,7 +63,7 @@ export const Switch = >( field.onChange(checked) }} className={twMerge( - 'group relative flex h-7 w-14 cursor-pointer rounded-full bg-black/10 p-1 shadow transition-colors duration-200 ease-in-out focus:outline-none data-[checked]:bg-black/10 data-[focus]:outline-1 data-[focus]:outline-white', + 'group relative flex h-7 w-14 cursor-pointer rounded-full bg-[#2E2F7C]/10 p-1 shadow transition-colors duration-200 ease-in-out focus:outline-none data-[checked]:bg-[#2E2F7C]/90 data-[focus]:outline-1 data-[focus]:outline-white', className, )} > diff --git a/app/pages/dashboard-contents/index.tsx b/app/pages/dashboard-contents/index.tsx index 7ba58fe..c6238d7 100644 --- a/app/pages/dashboard-contents/index.tsx +++ b/app/pages/dashboard-contents/index.tsx @@ -1,9 +1,7 @@ -import { Field, Input, Label, Select } from '@headlessui/react' import DT from 'datatables.net-dt' import DataTable from 'datatables.net-react' import { Link } from 'react-router' -import { SearchIcon } from '~/components/icons/search' import { Button } from '~/components/ui/button' import { UiTable } from '~/components/ui/table' import { TitleDashboard } from '~/components/ui/title-dashboard' @@ -57,37 +55,8 @@ export const ContentsPage = () => { return (
-
-
-
- - -
- -
- -
-
-
-
- -
- - - - -
-
+
+
{/* TODO: Filter */}
@@ -398,7 +380,6 @@ export const EditorMenuBar = (properties: TProperties) => { setIsPlainHTML(true)} title="Switch to Plain Text" - isActive={true} > diff --git a/app/components/text-editor/editor-textarea.tsx b/app/components/text-editor/editor-textarea.tsx index a3b6019..b84b24a 100644 --- a/app/components/text-editor/editor-textarea.tsx +++ b/app/components/text-editor/editor-textarea.tsx @@ -27,7 +27,6 @@ export const EditorTextArea = (properties: TProperties) => { setIsPlainHTML(false)} title="Switch to Rich Text" - isActive={true} > diff --git a/app/components/text-editor/index.tsx b/app/components/text-editor/index.tsx index 22d8cd0..205db26 100644 --- a/app/components/text-editor/index.tsx +++ b/app/components/text-editor/index.tsx @@ -53,7 +53,6 @@ export const TextEditor = >( const [isPlainHTML, setIsPlainHTML] = useState(false) const [init, setInit] = useState(true) - const [darkMode, setDarkMode] = useState(false) const generatedId = useId() const { @@ -129,16 +128,13 @@ export const TextEditor = >( category={category} editor={editor} setIsPlainHTML={setIsPlainHTML} - darkMode={darkMode} - setDarkMode={setDarkMode} /> editor?.commands.focus()} From d27c068f39b3905b3967ec14020cada9af8db794 Mon Sep 17 00:00:00 2001 From: Ardeman Date: Thu, 6 Mar 2025 09:01:26 +0800 Subject: [PATCH 14/16] style: update class names for improved consistency in text editor and switch components --- app/components/text-editor/editor-menubar.tsx | 2 +- app/components/text-editor/index.tsx | 4 ++-- app/components/ui/switch.tsx | 4 ++-- app/pages/contents-create/index.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/text-editor/editor-menubar.tsx b/app/components/text-editor/editor-menubar.tsx index cf3c540..8e3c31b 100644 --- a/app/components/text-editor/editor-menubar.tsx +++ b/app/components/text-editor/editor-menubar.tsx @@ -110,7 +110,7 @@ export const EditorMenuBar = (properties: TProperties) => { return ( <> -
+
>( disabled={disabled} /> ) : ( -
+
>( editor={editor} id={id ?? generatedId} className={twMerge( - 'prose prose-headings:my-0 prose-p:my-0 max-h-96 max-w-none cursor-text overflow-y-auto rounded-[0_0_5px_5px] border border-[#D2D2D2] p-2', + 'prose prose-headings:my-0 prose-p:my-0 max-h-96 max-w-none cursor-text overflow-y-auto rounded-b-md p-2', inputClassName, )} onClick={() => editor?.commands.focus()} diff --git a/app/components/ui/switch.tsx b/app/components/ui/switch.tsx index c79df4c..da7aaaa 100644 --- a/app/components/ui/switch.tsx +++ b/app/components/ui/switch.tsx @@ -56,7 +56,7 @@ export const Switch = >( control={control} rules={rules} render={({ field }) => ( -
+
{ @@ -64,7 +64,7 @@ export const Switch = >( }} className={twMerge( 'group relative flex h-7 w-14 cursor-pointer rounded-full bg-[#2E2F7C]/10 p-1 shadow transition-colors duration-200 ease-in-out focus:outline-none data-[checked]:bg-[#2E2F7C]/90 data-[focus]:outline-1 data-[focus]:outline-white', - className, + inputClassName, )} > { name="is_premium" label="Premium" labelClassName="text-sm font-medium text-[#363636]" - inputClassName="h-[42px]" + className="h-[42px]" />
From 703e3478303a120cdf80f98331e5fb7bb12ff753 Mon Sep 17 00:00:00 2001 From: Ardeman Date: Thu, 6 Mar 2025 09:30:53 +0800 Subject: [PATCH 15/16] feat: implement news fetching API and integrate with dashboard contents --- app/apis/admin/get-news.ts | 48 +++++++++++++++++++ app/pages/dashboard-contents/data.ts | 4 +- app/pages/dashboard-contents/index.tsx | 34 +++++++------ .../_admin.lg-admin._dashboard.contents.tsx | 12 +++++ 4 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 app/apis/admin/get-news.ts diff --git a/app/apis/admin/get-news.ts b/app/apis/admin/get-news.ts new file mode 100644 index 0000000..f0e6cbb --- /dev/null +++ b/app/apis/admin/get-news.ts @@ -0,0 +1,48 @@ +import { z } from 'zod' + +import { HttpServer, type THttpServer } from '~/libs/http-server' + +const newsSchema = z.object({ + data: z.array( + z.object({ + id: z.string(), + title: z.string(), + content: z.string(), + categories: z.array( + z.object({ + id: z.string(), + name: z.string(), + code: z.string(), + created_at: z.string(), + updated_at: z.string(), + }), + ), + tags: z.array( + z.object({ + id: z.string(), + name: z.string(), + code: z.string(), + created_at: z.string(), + updated_at: z.string(), + }), + ), + is_premium: z.boolean(), + slug: z.string(), + featured_image: z.string(), + author_id: z.string(), + live_at: z.string(), + created_at: z.string(), + updated_at: z.string(), + }), + ), +}) + +export const getNews = async (parameters: THttpServer) => { + try { + const { data } = await HttpServer(parameters).get(`/api/news`) + return newsSchema.parse(data) + } catch (error) { + // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject + return Promise.reject(error) + } +} diff --git a/app/pages/dashboard-contents/data.ts b/app/pages/dashboard-contents/data.ts index 5c79f59..93a4c2c 100644 --- a/app/pages/dashboard-contents/data.ts +++ b/app/pages/dashboard-contents/data.ts @@ -1,4 +1,4 @@ -type TContens = { +type TContents = { id: number createdAt: string author: string @@ -8,7 +8,7 @@ type TContens = { status: string } -export const CONTENTS: TContens[] = [ +export const CONTENTS: TContents[] = [ { id: 1, createdAt: '24/10/2024', diff --git a/app/pages/dashboard-contents/index.tsx b/app/pages/dashboard-contents/index.tsx index c6238d7..3d40b19 100644 --- a/app/pages/dashboard-contents/index.tsx +++ b/app/pages/dashboard-contents/index.tsx @@ -1,35 +1,39 @@ import DT from 'datatables.net-dt' import DataTable from 'datatables.net-react' -import { Link } from 'react-router' +import { Link, useRouteLoaderData } from 'react-router' import { Button } from '~/components/ui/button' import { UiTable } from '~/components/ui/table' import { TitleDashboard } from '~/components/ui/title-dashboard' - -import { CONTENTS } from './data' +import type { loader } from '~/routes/_admin.lg-admin._dashboard.contents' export const ContentsPage = () => { + const loaderData = useRouteLoaderData( + 'routes/_admin.lg-admin._dashboard.contents', + ) + const newsData = loaderData?.newsData + DataTable.use(DT) - const dataTable = CONTENTS + const dataTable = newsData const dataColumns = [ { title: 'No', data: 'id' }, - { title: 'Tanggal Kontent', data: 'createdAt' }, - { title: 'Nama Penulis', data: 'author' }, + { title: 'Tanggal Konten', data: 'created_at' }, + { title: 'Nama Penulis', data: 'author_id' }, { title: 'Judul', data: 'title' }, - { title: 'Kategori', data: 'category' }, + // { title: 'Kategori', data: 'category' }, { title: 'Tags', - data: 'tags', + data: 'is_premium', render: (value: string) => { - return value === 'Normal' - ? `${value}` - : `${value}` + return value + ? `Premium` + : `Normal` }, }, - { - title: 'Action', - data: 'id', - }, + // { + // title: 'Action', + // data: 'id', + // }, ] const dataSlot = { 6: (value: string | number) => { diff --git a/app/routes/_admin.lg-admin._dashboard.contents.tsx b/app/routes/_admin.lg-admin._dashboard.contents.tsx index f0d4595..8d7ce89 100644 --- a/app/routes/_admin.lg-admin._dashboard.contents.tsx +++ b/app/routes/_admin.lg-admin._dashboard.contents.tsx @@ -1,4 +1,16 @@ +import { getNews } from '~/apis/admin/get-news' +import { handleCookie } from '~/libs/cookies' import { ContentsPage } from '~/pages/dashboard-contents' +import type { Route } from './+types/_admin.lg-admin._dashboard.contents' + +export const loader = async ({ request }: Route.LoaderArgs) => { + const { staffToken } = await handleCookie(request) + const { data: newsData } = await getNews({ + accessToken: staffToken, + }) + return { newsData } +} + const DashboardContentsLayout = () => export default DashboardContentsLayout From 57a20aa048efc4f364351b0033852e99f9dda7d2 Mon Sep 17 00:00:00 2001 From: Ardeman Date: Thu, 6 Mar 2025 09:39:40 +0800 Subject: [PATCH 16/16] style: update premium and normal status indicators for improved UI consistency --- app/pages/dashboard-contents/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/dashboard-contents/index.tsx b/app/pages/dashboard-contents/index.tsx index 3d40b19..9c3726d 100644 --- a/app/pages/dashboard-contents/index.tsx +++ b/app/pages/dashboard-contents/index.tsx @@ -26,8 +26,8 @@ export const ContentsPage = () => { data: 'is_premium', render: (value: string) => { return value - ? `Premium` - : `Normal` + ? `Premium` + : `Normal` }, }, // {