diff --git a/app/apis/admin/create-news.ts b/app/apis/admin/create-news.ts new file mode 100644 index 0000000..58dba05 --- /dev/null +++ b/app/apis/admin/create-news.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +import { HttpServer } from '~/libs/http-server' +import type { TContentSchema } from '~/pages/contents-create' + +const newsResponseSchema = z.object({ + data: z.object({ + Message: z.string(), + }), +}) + +type TParameter = { + accessToken: string + payload: TContentSchema +} + +export const createNewsRequest = async (parameters: TParameter) => { + const { accessToken, payload } = parameters + try { + const { categories, tags, ...restPayload } = payload + const transformedPayload = { + ...restPayload, + categories: categories.map((category) => category?.id), + tags: tags?.map((tag) => tag?.id), + live_at: new Date(payload?.live_at).toISOString(), + } + if (transformedPayload.tags?.length === 0) { + delete transformedPayload.tags + } + const { data } = await HttpServer({ accessToken }).post( + '/api/news/create', + transformedPayload, + ) + return newsResponseSchema.parse(data) + } catch (error) { + // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject + return Promise.reject(error) + } +} 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/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/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-button.tsx b/app/components/text-editor/editor-button.tsx new file mode 100644 index 0000000..cd51d13 --- /dev/null +++ b/app/components/text-editor/editor-button.tsx @@ -0,0 +1,34 @@ +import type { CSSProperties, MouseEventHandler, ReactNode } from 'react' +import { twMerge } from 'tailwind-merge' + +type TProperties = { + children: ReactNode + onClick: MouseEventHandler + disabled?: boolean + isActive?: boolean + className?: string + title: string + style?: CSSProperties +} + +export const EditorButton = (properties: TProperties) => { + const { children, onClick, disabled, className, isActive, title, style } = + properties + + return ( + + ) +} diff --git a/app/components/text-editor/editor-menubar.tsx b/app/components/text-editor/editor-menubar.tsx new file mode 100644 index 0000000..8e3c31b --- /dev/null +++ b/app/components/text-editor/editor-menubar.tsx @@ -0,0 +1,413 @@ +import { + ArrowUturnLeftIcon, + ArrowUturnRightIcon, + Bars3BottomLeftIcon, + Bars3BottomRightIcon, + Bars3Icon, + Bars4Icon, + BoldIcon, + CodeBracketIcon, + DocumentTextIcon, + H1Icon, + H2Icon, + H3Icon, + ItalicIcon, + LinkIcon, + LinkSlashIcon, + ListBulletIcon, + NumberedListIcon, + PhotoIcon, + StrikethroughIcon, + 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 + disabled?: boolean +} + +export const EditorMenuBar = (properties: TProperties) => { + const { + editor, + setIsPlainHTML, + // category, + 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) + } + } + } + + 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" + > + + +
+
+
+
+ setIsPlainHTML(true)} + title="Switch to Plain Text" + > + + +
+
+
+ + {/* + 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..b84b24a --- /dev/null +++ b/app/components/text-editor/editor-textarea.tsx @@ -0,0 +1,55 @@ +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" + > + + +
+
+ ( + { + 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..b1c014e --- /dev/null +++ b/app/components/text-editor/index.tsx @@ -0,0 +1,146 @@ +import { Field, Label } from '@headlessui/react' +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 FieldError, + 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 + containerClassName?: string + rules?: RegisterOptions + disabled?: boolean + isRequired?: boolean + category: string +} + +export const TextEditor = >( + properties: TProperties, +) => { + const { + id, + label, + name, + labelClassName, + className, + inputClassName, + category, + disabled = false, + containerClassName, + } = properties + + const [isPlainHTML, setIsPlainHTML] = useState(false) + const [init, setInit] = useState(true) + const generatedId = useId() + + const { + setValue, + watch, + formState: { errors }, + } = useRemixFormContext() + + const watchContent = watch(name) + const error: FieldError = 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'], + }), + ], + immediatelyRender: false, + 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()} + /> +
+ )} +
+ ) +} diff --git a/app/components/ui/combobox.tsx b/app/components/ui/combobox.tsx index 1e8c9a2..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 @@ -35,13 +35,26 @@ type TInputProperties = ComponentProps< rules?: RegisterOptions placeholder?: string options?: TComboboxOption[] + labelClassName?: string + containerClassName?: string } export const Combobox = >( - properties: TInputProperties, + properties: TComboboxProperties, ) => { - 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 ( -