diff --git a/app/apis/admin/get-users.ts b/app/apis/admin/get-users.ts index 56d17e7..db411c4 100644 --- a/app/apis/admin/get-users.ts +++ b/app/apis/admin/get-users.ts @@ -26,9 +26,7 @@ const usersResponseSchema = z.object({ data: z.array(userResponseSchema), }) -export type TSubscribePlanRespon = z.infer export type TUserResponse = z.infer -export type TSubscribeResponse = z.infer export const getUsers = async (parameters: THttpServer) => { try { diff --git a/app/apis/admin/upload-file.ts b/app/apis/admin/upload-file.ts new file mode 100644 index 0000000..e94f376 --- /dev/null +++ b/app/apis/admin/upload-file.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' + +import type { TUploadSchema } from '~/layouts/admin/form-upload' +import { HttpServer, type THttpServer } from '~/libs/http-server' + +const uploadResponseSchema = z.object({ + data: z.object({ + message: z.string(), + data: z.object({ + file_path: z.string(), + file_url: z.string(), + }), + }), +}) + +type TParameter = { + payload: TUploadSchema & { + file: File + } +} & THttpServer + +export const uploadFileRequest = async (parameters: TParameter) => { + const { payload, ...restParameters } = parameters + const formdata = new FormData() + formdata.append('file', payload.file) + + try { + const { data } = await HttpServer(restParameters).post( + '/api/file', + formdata, + ) + return uploadResponseSchema.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-news-by-slug.ts b/app/apis/common/get-news-by-slug.ts index a46f417..72c4c6d 100644 --- a/app/apis/common/get-news-by-slug.ts +++ b/app/apis/common/get-news-by-slug.ts @@ -14,7 +14,9 @@ type TParameters = { export const getNewsBySlug = async (parameters: TParameters) => { const { slug, ...restParameters } = parameters try { - const { data } = await HttpServer(restParameters).get(`/api/news/${slug}`) + const { data } = await HttpServer(restParameters).get( + `/api/news/${encodeURIComponent(slug)}`, + ) return dataResponseSchema.parse(data) } catch (error) { // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject diff --git a/app/components/icons/chevron-double.tsx b/app/components/icons/chevron-double.tsx deleted file mode 100644 index db9cadf..0000000 --- a/app/components/icons/chevron-double.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { JSX, SVGProps } from 'react' - -/** - * Note: `ChevronDoubleIcon` default mengarah ke kiri. - * Gunakan class `rotate-xx` untuk mengubah arah ikon. - */ -export const ChevronDoubleIcon = ( - properties: JSX.IntrinsicAttributes & SVGProps, -) => { - return ( - - - - ) -} diff --git a/app/components/text-editor/editor-menubar.tsx b/app/components/text-editor/editor-menubar.tsx index 8e3c31b..09395f2 100644 --- a/app/components/text-editor/editor-menubar.tsx +++ b/app/components/text-editor/editor-menubar.tsx @@ -1,3 +1,4 @@ +import { Input } from '@headlessui/react' import { ArrowUturnLeftIcon, ArrowUturnRightIcon, @@ -6,6 +7,7 @@ import { Bars3Icon, Bars4Icon, BoldIcon, + CloudArrowUpIcon, CodeBracketIcon, DocumentTextIcon, H1Icon, @@ -27,9 +29,12 @@ import { useState, useRef, useCallback, + useEffect, } from 'react' import { HexColorInput, HexColorPicker } from 'react-colorful' +import { Button } from '~/components/ui/button' +import { useAdminContext } from '~/contexts/admin' import { useClickOutside } from '~/hooks/use-click-outside' import { isHexCompatible, rgbToHex } from '~/utils/color' @@ -43,16 +48,30 @@ type TProperties = { } export const EditorMenuBar = (properties: TProperties) => { - const { - editor, - setIsPlainHTML, - // category, - disabled = false, - } = properties - // const [isOpenImage, setIsOpenImage] = useState(false) + const { editor, setIsPlainHTML, category, disabled = false } = properties + const { setIsUploadOpen, uploadedFile, setUploadedFile, isUploadOpen } = + useAdminContext() + const [isOpenImage, setIsOpenImage] = useState(false) + const [imageUrl, setImageUrl] = useState('') const [isOpenColor, setIsOpenColor] = useState(false) const popover = useRef(null) - const close = useCallback(() => setIsOpenColor(false), []) + const close = useCallback(() => { + setIsOpenColor(false) + setIsOpenImage(false) + if (imageUrl) { + addImage(imageUrl) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [imageUrl]) + + useEffect(() => { + if (uploadedFile && isUploadOpen === category) { + addImage(uploadedFile) + setUploadedFile(undefined) + setIsUploadOpen(undefined) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uploadedFile]) useClickOutside(popover, close) @@ -96,167 +115,150 @@ export const EditorMenuBar = (properties: TProperties) => { } } - 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" + > + + +
editor.chain().focus().toggleBold().run()} - disabled={ - disabled || !editor.can().chain().focus().toggleBold().run() - } - isActive={editor.isActive('bold')} - title="Bold" + onClick={() => setIsOpenColor(true)} + title="Text Color" + style={{ + color: rgbColor, + }} + isActive={true} + disabled={disabled} > - + - 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 && ( +
- - - {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().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" @@ -264,32 +266,32 @@ export const EditorMenuBar = (properties: TProperties) => { > */} - 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().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')} @@ -306,7 +308,7 @@ export const EditorMenuBar = (properties: TProperties) => {
*/} - {/*
+ {/*
editor.chain().focus().setHardBreak().run()} title="Hard Break" @@ -325,89 +327,96 @@ export const EditorMenuBar = (properties: TProperties) => {
*/} -
+
+
toggleUpload()} + onClick={() => setIsOpenImage(true)} 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" - > - - + {isOpenImage && ( +
+
+ { + setImageUrl(event.target.value) + }} + className="z-10 flex h-[42px] w-full rounded-lg border-0 bg-white p-2 pr-8 text-sm shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none focus-visible:outline-0 disabled:bg-gray-100" + /> + +
+
+ )}
+ 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" + > + +
-
-
- setIsPlainHTML(true)} - title="Switch to Plain Text" - > - - -
+
+ 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" + > + +
- - {/* - setIsOpenImage(false), - onSave: (file) => { - addImage(file) - setIsOpenImage(false) - }, - category: category, - maxFileSize: 300, - selectedFile: '', - }} - > - - - */} - +
+
+ setIsPlainHTML(true)} + title="Switch to Plain Text" + > + + +
+
+
) } diff --git a/app/components/text-editor/index.tsx b/app/components/text-editor/index.tsx index 98669a4..dad6cf1 100644 --- a/app/components/text-editor/index.tsx +++ b/app/components/text-editor/index.tsx @@ -140,7 +140,7 @@ export const TextEditor = >( editor={editor} id={id ?? generatedId} className={twMerge( - 'prose prose-headings:my-0.5 prose-p:my-0.5 max-h-96 max-w-none cursor-text overflow-y-auto rounded-b-md p-2', + 'prose prose-headings:my-0.5 prose-p:my-0.5 max-w-none cursor-text overflow-y-auto rounded-b-md p-2', inputClassName, )} onClick={() => editor?.commands.focus()} diff --git a/app/components/ui/input-file.tsx b/app/components/ui/input-file.tsx index b3570cc..29d6f00 100644 --- a/app/components/ui/input-file.tsx +++ b/app/components/ui/input-file.tsx @@ -1,6 +1,6 @@ import { Field, Label, Input as HeadlessInput } from '@headlessui/react' import { CloudArrowUpIcon } from '@heroicons/react/20/solid' -import { type ComponentProps, type ReactNode } from 'react' +import { useEffect, type ComponentProps, type ReactNode } from 'react' import { get, type FieldError, @@ -11,7 +11,7 @@ import { import { useRemixFormContext } from 'remix-hook-form' import { twMerge } from 'tailwind-merge' -import { useAdminContext } from '~/contexts/admin' +import { useAdminContext, type TUpload } from '~/contexts/admin' import { Button } from './button' @@ -42,15 +42,26 @@ export const InputFile = >( labelClassName, ...restProperties } = properties - const { setIsUploadOpen } = useAdminContext() + const { setIsUploadOpen, uploadedFile, setUploadedFile, isUploadOpen } = + useAdminContext() const { register, formState: { errors }, + setValue, } = useRemixFormContext() const error: FieldError = get(errors, name) + useEffect(() => { + if (uploadedFile && isUploadOpen === name) { + setValue(name as string, uploadedFile) + setUploadedFile(undefined) + setIsUploadOpen(undefined) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uploadedFile]) + return ( >( size="fit" className="absolute right-3 h-[42px]" onClick={() => { - setIsUploadOpen('featured_image') + setIsUploadOpen(name as TUpload) }} > diff --git a/app/components/ui/pagination.tsx b/app/components/ui/pagination.tsx deleted file mode 100644 index 0dd37da..0000000 --- a/app/components/ui/pagination.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react' - -import { ChevronIcon } from '~/components/icons/chevron' -import { ChevronDoubleIcon } from '~/components/icons/chevron-double' - -type PaginationProperties = { - currentPage: number - totalPages: number - onPageChange: (page: number) => void -} - -export const Pagination: React.FC = ({ - currentPage = 1, - totalPages, - onPageChange, -}) => { - const renderPageNumbers = () => { - const pages = [] - for (let index = 1; index <= totalPages; index++) { - pages.push( - , - ) - } - return pages - } - - return ( -
- - - - {renderPageNumbers()} - - - -
- ) -} diff --git a/app/contexts/admin.tsx b/app/contexts/admin.tsx index 7e093a1..c64ccc9 100644 --- a/app/contexts/admin.tsx +++ b/app/contexts/admin.tsx @@ -6,17 +6,19 @@ import { type Dispatch, type SetStateAction, } from 'react' +import { z } from 'zod' -type TUpload = - | 'featured_image' - | 'ads' - | 'content' - | 'profile_picture' - | undefined +export const uploadCategorySchema = z + .enum(['featured_image', 'ads', 'content', 'profile_picture']) + .optional() + +export type TUpload = z.infer type AdminContextProperties = { isUploadOpen: TUpload setIsUploadOpen: Dispatch> + uploadedFile?: string + setUploadedFile: Dispatch> } const AdminContext = createContext( @@ -25,12 +27,15 @@ const AdminContext = createContext( export const AdminProvider = ({ children }: PropsWithChildren) => { const [isUploadOpen, setIsUploadOpen] = useState() + const [uploadedFile, setUploadedFile] = useState() return ( {children} diff --git a/app/layouts/admin/dashboard.tsx b/app/layouts/admin/dashboard.tsx index 74e7858..642918b 100644 --- a/app/layouts/admin/dashboard.tsx +++ b/app/layouts/admin/dashboard.tsx @@ -3,6 +3,7 @@ import type { PropsWithChildren } from 'react' import { useAdminContext } from '~/contexts/admin' +import { FormUpload } from './form-upload' import { Navbar } from './navbar' import { Sidebar } from './sidebar' @@ -34,7 +35,7 @@ export const AdminDashboardLayout = (properties: PropsWithChildren) => { transition className="max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg" > - Upload di mari {isUploadOpen} +
diff --git a/app/layouts/admin/form-upload.tsx b/app/layouts/admin/form-upload.tsx new file mode 100644 index 0000000..425e6c7 --- /dev/null +++ b/app/layouts/admin/form-upload.tsx @@ -0,0 +1,122 @@ +import { Button, Input } from '@headlessui/react' +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, useState, type ChangeEvent } from 'react' +import { useFetcher } from 'react-router' +import { RemixFormProvider, useRemixForm } from 'remix-hook-form' +import { z } from 'zod' + +import { uploadCategorySchema, useAdminContext } from '~/contexts/admin' + +export const uploadSchema = z.object({ + file: z.instanceof(File), + category: uploadCategorySchema, +}) + +export type TUploadSchema = z.infer + +export const FormUpload = () => { + const { isUploadOpen, setUploadedFile } = useAdminContext() + const fetcher = useFetcher() + const [disabled, setDisabled] = useState(false) + const [error, setError] = useState() + const maxFileSize = 10 * 1024 // 10MB + + const formMethods = useRemixForm({ + mode: 'onSubmit', + fetcher, + resolver: zodResolver(uploadSchema), + }) + + const { handleSubmit, register, setValue } = formMethods + + useEffect(() => { + if (!fetcher.data?.success) { + setError(fetcher.data?.message) + setDisabled(false) + return + } + + setUploadedFile(fetcher.data.uploadData.data.file_url) + + setDisabled(true) + setError(undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher]) + + const handleChange = async function (event: ChangeEvent) { + event.preventDefault() + if (event.target.files && event.target.files[0]) { + const files: File[] = [...event.target.files] + + onChange(files, event) + } + } + + const onChange = async function ( + files: File[], + event: ChangeEvent, + ) { + const file = files[0] + const img = new Image() + + if (!file.type.startsWith('image/')) { + setError('Please upload an image file.') + return + } + + if (file.size > maxFileSize * 1024) { + setError(`File size is too big!`) + return + } + + img.addEventListener('load', () => { + handleFiles(event) + }) + + img.src = URL.createObjectURL(file) + } + + const handleFiles = (event: ChangeEvent) => { + const files = event.target.files + if (files && files.length > 0) { + const file = files[0] + setValue('file', file) + } + } + + return ( + + + {error && ( +
{error}
+ )} + + + +
+
+ ) +} diff --git a/app/layouts/admin/menu.ts b/app/layouts/admin/menu.ts index e898d93..1ca8692 100644 --- a/app/layouts/admin/menu.ts +++ b/app/layouts/admin/menu.ts @@ -1,4 +1,8 @@ -import { ClipboardDocumentCheckIcon, TagIcon } from '@heroicons/react/20/solid' +import { + ClipboardDocumentCheckIcon, + DocumentCurrencyDollarIcon, + TagIcon, +} from '@heroicons/react/20/solid' import type { SVGProps } from 'react' import { ChartIcon } from '~/components/icons/chart' @@ -62,7 +66,7 @@ export const MENU: TMenu[] = [ { title: 'Subscribe Plan', url: '/lg-admin/subscribe-plan', - icon: TagIcon, + icon: DocumentCurrencyDollarIcon, }, ], }, diff --git a/app/pages/dashboard-contents/index.tsx b/app/pages/dashboard-contents/index.tsx index fb13630..487ac0c 100644 --- a/app/pages/dashboard-contents/index.tsx +++ b/app/pages/dashboard-contents/index.tsx @@ -83,7 +83,7 @@ export const ContentsPage = () => { 7: (value: string) => (