Compare commits
7 Commits
dfd1a46694
...
ea6462f3ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea6462f3ea | ||
|
|
6f0a21dde6 | ||
|
|
07cced218e | ||
|
|
9a2d64eee4 | ||
|
|
438d1d40db | ||
|
|
9a5bddef6a | ||
|
|
f97c79aaa9 |
29
app/apis/admin/delete-subscribe-plan.ts
Normal file
29
app/apis/admin/delete-subscribe-plan.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||||
|
import type { TSubscribePlanSchema } from '~/pages/form-subscriptions-plan'
|
||||||
|
|
||||||
|
const subscribePlanResponseSchema = z.object({
|
||||||
|
data: z.object({
|
||||||
|
Message: z.string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
type TTSubscribePlanId = Pick<TSubscribePlanSchema, 'id'>
|
||||||
|
type TParameters = {
|
||||||
|
payload: TTSubscribePlanId
|
||||||
|
} & THttpServer
|
||||||
|
|
||||||
|
export const deleteSubscribePlanRequest = async (parameters: TParameters) => {
|
||||||
|
const { payload, ...restParameters } = parameters
|
||||||
|
const { id } = payload
|
||||||
|
try {
|
||||||
|
const { data } = await HttpServer(restParameters).delete(
|
||||||
|
`/api/subscribe-plan/${id}/delete`,
|
||||||
|
)
|
||||||
|
return subscribePlanResponseSchema.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/apis/admin/delete-tags.ts
Normal file
30
app/apis/admin/delete-tags.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||||
|
import type { TTagSchema } from '~/pages/form-tag'
|
||||||
|
|
||||||
|
const deleteTagsResponseSchema = z.object({
|
||||||
|
data: z.object({
|
||||||
|
Message: z.string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
type TTagsId = Pick<TTagSchema, 'id'>
|
||||||
|
type TParameters = {
|
||||||
|
payload: TTagsId
|
||||||
|
} & THttpServer
|
||||||
|
|
||||||
|
export type TDeleteTagsSchema = z.infer<typeof deleteTagsResponseSchema>
|
||||||
|
export const deleteTagsRequest = async (parameters: TParameters) => {
|
||||||
|
const { payload, ...restParameters } = parameters
|
||||||
|
const { id } = payload
|
||||||
|
try {
|
||||||
|
const { data } = await HttpServer(restParameters).delete(
|
||||||
|
`/api/tag/${id}/delete`,
|
||||||
|
)
|
||||||
|
return deleteTagsResponseSchema.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import type { TUploadSchema } from '~/layouts/admin/form-upload'
|
import type { TUploadSchema } from '~/layouts/admin/dialog-upload'
|
||||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||||
|
|
||||||
const uploadResponseSchema = z.object({
|
const uploadResponseSchema = z.object({
|
||||||
|
|||||||
@ -8,6 +8,9 @@ const subscriptionResponseSchema = z.object({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
length: z.number().optional(),
|
||||||
|
price: z.number().optional(),
|
||||||
|
status: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -127,7 +127,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
isActive={editor.isActive('bold')}
|
isActive={editor.isActive('bold')}
|
||||||
title="Bold"
|
title="Bold"
|
||||||
>
|
>
|
||||||
<BoldIcon />
|
<BoldIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
<EditorButton
|
<EditorButton
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
@ -137,7 +137,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
isActive={editor.isActive('italic')}
|
isActive={editor.isActive('italic')}
|
||||||
title="Italic"
|
title="Italic"
|
||||||
>
|
>
|
||||||
<ItalicIcon />
|
<ItalicIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
<EditorButton
|
<EditorButton
|
||||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
@ -147,7 +147,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
isActive={editor.isActive('strike')}
|
isActive={editor.isActive('strike')}
|
||||||
title="Strike"
|
title="Strike"
|
||||||
>
|
>
|
||||||
<StrikethroughIcon />
|
<StrikethroughIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<EditorButton
|
<EditorButton
|
||||||
@ -159,7 +159,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
isActive={true}
|
isActive={true}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<SwatchIcon />
|
<SwatchIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
{isOpenColor && (
|
{isOpenColor && (
|
||||||
<div
|
<div
|
||||||
@ -191,7 +191,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
isActive={editor.isActive({ textAlign: 'left' })}
|
isActive={editor.isActive({ textAlign: 'left' })}
|
||||||
title="Align Left"
|
title="Align Left"
|
||||||
>
|
>
|
||||||
<Bars3BottomLeftIcon />
|
<Bars3BottomLeftIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
<EditorButton
|
<EditorButton
|
||||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||||
@ -202,7 +202,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
isActive={editor.isActive({ textAlign: 'center' })}
|
isActive={editor.isActive({ textAlign: 'center' })}
|
||||||
title="Align Center"
|
title="Align Center"
|
||||||
>
|
>
|
||||||
<Bars3Icon />
|
<Bars3Icon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
<EditorButton
|
<EditorButton
|
||||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||||
@ -213,7 +213,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
isActive={editor.isActive({ textAlign: 'right' })}
|
isActive={editor.isActive({ textAlign: 'right' })}
|
||||||
title="Align Right"
|
title="Align Right"
|
||||||
>
|
>
|
||||||
<Bars3BottomRightIcon />
|
<Bars3BottomRightIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
<EditorButton
|
<EditorButton
|
||||||
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
||||||
@ -224,7 +224,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
isActive={editor.isActive({ textAlign: 'justify' })}
|
isActive={editor.isActive({ textAlign: 'justify' })}
|
||||||
title="Align Justify"
|
title="Align Justify"
|
||||||
>
|
>
|
||||||
<Bars4Icon />
|
<Bars4Icon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex max-w-[150px] flex-wrap items-start gap-1 px-1">
|
<div className="flex max-w-[150px] flex-wrap items-start gap-1 px-1">
|
||||||
@ -236,7 +236,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
title="Heading 1"
|
title="Heading 1"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<H1Icon />
|
<H1Icon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
<EditorButton
|
<EditorButton
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@ -246,7 +246,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
title="Heading 2"
|
title="Heading 2"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<H2Icon />
|
<H2Icon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
<EditorButton
|
<EditorButton
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@ -256,7 +256,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
title="Heading 3"
|
title="Heading 3"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<H3Icon />
|
<H3Icon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
{/* <EditorButton
|
{/* <EditorButton
|
||||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||||
@ -272,7 +272,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
title="Bullet List"
|
title="Bullet List"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<ListBulletIcon />
|
<ListBulletIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
<EditorButton
|
<EditorButton
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
@ -280,7 +280,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
title="Ordered List"
|
title="Ordered List"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<NumberedListIcon />
|
<NumberedListIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
<EditorButton
|
<EditorButton
|
||||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
@ -288,7 +288,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
title="Code Block"
|
title="Code Block"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<CodeBracketIcon />
|
<CodeBracketIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
</div>
|
</div>
|
||||||
{/* <div className="flex items-start gap-1 px-1">
|
{/* <div className="flex items-start gap-1 px-1">
|
||||||
@ -334,7 +334,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
title="Insert Image"
|
title="Insert Image"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<PhotoIcon />
|
<PhotoIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
{isOpenImage && (
|
{isOpenImage && (
|
||||||
<div
|
<div
|
||||||
@ -380,14 +380,14 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
isActive={editor.isActive('link')}
|
isActive={editor.isActive('link')}
|
||||||
title="Set Link"
|
title="Set Link"
|
||||||
>
|
>
|
||||||
<LinkIcon />
|
<LinkIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
<EditorButton
|
<EditorButton
|
||||||
onClick={() => editor.chain().focus().unsetLink().run()}
|
onClick={() => editor.chain().focus().unsetLink().run()}
|
||||||
disabled={disabled || !editor.isActive('link')}
|
disabled={disabled || !editor.isActive('link')}
|
||||||
title="Unset Link"
|
title="Unset Link"
|
||||||
>
|
>
|
||||||
<LinkSlashIcon />
|
<LinkSlashIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-1 px-1">
|
<div className="flex items-start gap-1 px-1">
|
||||||
@ -396,14 +396,14 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
disabled={disabled || !editor.can().chain().focus().undo().run()}
|
disabled={disabled || !editor.can().chain().focus().undo().run()}
|
||||||
title="Undo"
|
title="Undo"
|
||||||
>
|
>
|
||||||
<ArrowUturnLeftIcon />
|
<ArrowUturnLeftIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
<EditorButton
|
<EditorButton
|
||||||
onClick={() => editor.chain().focus().redo().run()}
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
disabled={disabled || !editor.can().chain().focus().redo().run()}
|
disabled={disabled || !editor.can().chain().focus().redo().run()}
|
||||||
title="Redo"
|
title="Redo"
|
||||||
>
|
>
|
||||||
<ArrowUturnRightIcon />
|
<ArrowUturnRightIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -413,7 +413,7 @@ export const EditorMenuBar = (properties: TProperties) => {
|
|||||||
onClick={() => setIsPlainHTML(true)}
|
onClick={() => setIsPlainHTML(true)}
|
||||||
title="Switch to Plain Text"
|
title="Switch to Plain Text"
|
||||||
>
|
>
|
||||||
<DocumentTextIcon />
|
<DocumentTextIcon className="size-4" />
|
||||||
</EditorButton>
|
</EditorButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'
|
|
||||||
import type { PropsWithChildren } from 'react'
|
import type { PropsWithChildren } from 'react'
|
||||||
|
|
||||||
import { useAdminContext } from '~/contexts/admin'
|
import { DialogUpload } from './dialog-upload'
|
||||||
|
|
||||||
import { FormUpload } from './form-upload'
|
|
||||||
import { Navbar } from './navbar'
|
import { Navbar } from './navbar'
|
||||||
import { Sidebar } from './sidebar'
|
import { Sidebar } from './sidebar'
|
||||||
|
|
||||||
export const AdminDashboardLayout = (properties: PropsWithChildren) => {
|
export const AdminDashboardLayout = (properties: PropsWithChildren) => {
|
||||||
const { children } = properties
|
const { children } = properties
|
||||||
const { isUploadOpen, setIsUploadOpen } = useAdminContext()
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
@ -18,27 +14,7 @@ export const AdminDashboardLayout = (properties: PropsWithChildren) => {
|
|||||||
<div className="min-h-[calc(100dvh-80px)] flex-1 p-8">{children}</div>
|
<div className="min-h-[calc(100dvh-80px)] flex-1 p-8">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<DialogUpload />
|
||||||
open={!!isUploadOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsUploadOpen(undefined)
|
|
||||||
}}
|
|
||||||
className="relative z-50"
|
|
||||||
transition
|
|
||||||
>
|
|
||||||
<DialogBackdrop
|
|
||||||
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
|
|
||||||
transition
|
|
||||||
/>
|
|
||||||
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
|
|
||||||
<DialogPanel
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<FormUpload />
|
|
||||||
</DialogPanel>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
143
app/layouts/admin/dialog-upload.tsx
Normal file
143
app/layouts/admin/dialog-upload.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { Dialog, DialogBackdrop, DialogPanel, 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 { Button } from '~/components/ui/button'
|
||||||
|
import { uploadCategorySchema, useAdminContext } from '~/contexts/admin'
|
||||||
|
|
||||||
|
export const uploadSchema = z.object({
|
||||||
|
file: z.instanceof(File),
|
||||||
|
category: uploadCategorySchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TUploadSchema = z.infer<typeof uploadSchema>
|
||||||
|
|
||||||
|
export const DialogUpload = () => {
|
||||||
|
const { isUploadOpen, setUploadedFile, setIsUploadOpen } = useAdminContext()
|
||||||
|
const fetcher = useFetcher()
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
const maxFileSize = 10 * 1024 // 10MB
|
||||||
|
|
||||||
|
const formMethods = useRemixForm<TUploadSchema>({
|
||||||
|
mode: 'onSubmit',
|
||||||
|
fetcher,
|
||||||
|
resolver: zodResolver(uploadSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { handleSubmit, register, setValue } = formMethods
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fetcher.data?.success) {
|
||||||
|
setError(fetcher.data?.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadedFile(fetcher.data.uploadData.data.file_url)
|
||||||
|
|
||||||
|
setError(undefined)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [fetcher])
|
||||||
|
|
||||||
|
const handleChange = async function (event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
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<HTMLInputElement>,
|
||||||
|
) {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
const files = event.target.files
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const file = files[0]
|
||||||
|
setValue('file', file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={!!isUploadOpen}
|
||||||
|
onClose={() => {
|
||||||
|
if (fetcher.state === 'idle') {
|
||||||
|
setIsUploadOpen(undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative z-50"
|
||||||
|
transition
|
||||||
|
>
|
||||||
|
<DialogBackdrop
|
||||||
|
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
|
||||||
|
transition
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
|
||||||
|
<DialogPanel
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<RemixFormProvider {...formMethods}>
|
||||||
|
<fetcher.Form
|
||||||
|
method="post"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-4"
|
||||||
|
action="/actions/admin/upload"
|
||||||
|
encType="multipart/form-data"
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-red-500 capitalize">{error}</div>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
id="input-file-upload"
|
||||||
|
accept="image/*"
|
||||||
|
className="h-[42px] w-full cursor-pointer rounded-md border border-[#DFDFDF] p-2"
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
id="input-file-upload-type"
|
||||||
|
value={isUploadOpen}
|
||||||
|
{...register('category')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={fetcher.state !== 'idle'}
|
||||||
|
isLoading={fetcher.state !== 'idle'}
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded-md py-2"
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</fetcher.Form>
|
||||||
|
</RemixFormProvider>
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,121 +0,0 @@
|
|||||||
import { 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 { Button } from '~/components/ui/button'
|
|
||||||
import { uploadCategorySchema, useAdminContext } from '~/contexts/admin'
|
|
||||||
|
|
||||||
export const uploadSchema = z.object({
|
|
||||||
file: z.instanceof(File),
|
|
||||||
category: uploadCategorySchema,
|
|
||||||
})
|
|
||||||
|
|
||||||
export type TUploadSchema = z.infer<typeof uploadSchema>
|
|
||||||
|
|
||||||
export const FormUpload = () => {
|
|
||||||
const { isUploadOpen, setUploadedFile } = useAdminContext()
|
|
||||||
const fetcher = useFetcher()
|
|
||||||
const [error, setError] = useState<string>()
|
|
||||||
const maxFileSize = 10 * 1024 // 10MB
|
|
||||||
|
|
||||||
const formMethods = useRemixForm<TUploadSchema>({
|
|
||||||
mode: 'onSubmit',
|
|
||||||
fetcher,
|
|
||||||
resolver: zodResolver(uploadSchema),
|
|
||||||
})
|
|
||||||
|
|
||||||
const { handleSubmit, register, setValue } = formMethods
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!fetcher.data?.success) {
|
|
||||||
setError(fetcher.data?.message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadedFile(fetcher.data.uploadData.data.file_url)
|
|
||||||
|
|
||||||
setError(undefined)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [fetcher])
|
|
||||||
|
|
||||||
const handleChange = async function (event: ChangeEvent<HTMLInputElement>) {
|
|
||||||
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<HTMLInputElement>,
|
|
||||||
) {
|
|
||||||
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<HTMLInputElement>) => {
|
|
||||||
const files = event.target.files
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
const file = files[0]
|
|
||||||
setValue('file', file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RemixFormProvider {...formMethods}>
|
|
||||||
<fetcher.Form
|
|
||||||
method="post"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="space-y-4"
|
|
||||||
action="/actions/admin/upload"
|
|
||||||
encType="multipart/form-data"
|
|
||||||
>
|
|
||||||
{error && (
|
|
||||||
<div className="text-sm text-red-500 capitalize">{error}</div>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
id="input-file-upload"
|
|
||||||
accept="image/*"
|
|
||||||
className="h-[42px] w-full cursor-pointer rounded-md border border-[#DFDFDF] p-2"
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
id="input-file-upload-type"
|
|
||||||
value={isUploadOpen}
|
|
||||||
{...register('category')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
disabled={fetcher.state !== 'idle'}
|
|
||||||
isLoading={fetcher.state !== 'idle'}
|
|
||||||
type="submit"
|
|
||||||
className="w-full rounded-md py-2"
|
|
||||||
>
|
|
||||||
Upload
|
|
||||||
</Button>
|
|
||||||
</fetcher.Form>
|
|
||||||
</RemixFormProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -60,7 +60,9 @@ export const ContentsPage = () => {
|
|||||||
2: (_value: unknown, _type: unknown, data: TNewsResponse) => (
|
2: (_value: unknown, _type: unknown, data: TNewsResponse) => (
|
||||||
<div>
|
<div>
|
||||||
<div>{data.author.name}</div>
|
<div>{data.author.name}</div>
|
||||||
<div className="text-sm text-[#7C7C7C]">ID: {data.id.slice(0, 8)}</div>
|
<div className="text-sm text-[#7C7C7C]">
|
||||||
|
ID: {data.author.id.slice(0, 8)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
3: (value: string) => <span className="text-sm">{value}</span>,
|
3: (value: string) => <span className="text-sm">{value}</span>,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { Field, Label, Select } from '@headlessui/react'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useFetcher, useNavigate } from 'react-router'
|
import { useFetcher, useNavigate } from 'react-router'
|
||||||
@ -13,6 +14,9 @@ export const createSubscribePlanSchema = z.object({
|
|||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
name: z.string().min(3, 'Nama minimal 3 karakter'),
|
name: z.string().min(3, 'Nama minimal 3 karakter'),
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
|
length: z.preprocess(Number, z.number().optional()),
|
||||||
|
price: z.preprocess(Number, z.number().optional()),
|
||||||
|
status: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
export type TSubscribePlanSchema = z.infer<typeof createSubscribePlanSchema>
|
export type TSubscribePlanSchema = z.infer<typeof createSubscribePlanSchema>
|
||||||
type TProperties = {
|
type TProperties = {
|
||||||
@ -31,6 +35,9 @@ export const FormSubscribePlanPage = (properties: TProperties) => {
|
|||||||
id: subscribePlanData?.id || undefined,
|
id: subscribePlanData?.id || undefined,
|
||||||
code: subscribePlanData?.code || '',
|
code: subscribePlanData?.code || '',
|
||||||
name: subscribePlanData?.name || '',
|
name: subscribePlanData?.name || '',
|
||||||
|
length: subscribePlanData?.length || undefined,
|
||||||
|
price: subscribePlanData?.price || undefined,
|
||||||
|
status: subscribePlanData?.status || undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [error, setError] = useState<string>()
|
const [error, setError] = useState<string>()
|
||||||
@ -99,6 +106,41 @@ export const FormSubscribePlanPage = (properties: TProperties) => {
|
|||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
|
<Input
|
||||||
|
id="length"
|
||||||
|
label="Length"
|
||||||
|
type="number"
|
||||||
|
placeholder="Masukkan Subscribe Plan Length (days)"
|
||||||
|
name="length"
|
||||||
|
className="border-0 bg-white shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||||
|
labelClassName="text-sm font-medium text-[#363636]"
|
||||||
|
containerClassName="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="price"
|
||||||
|
label="Price"
|
||||||
|
placeholder="Masukkan Price"
|
||||||
|
type="number"
|
||||||
|
name="price"
|
||||||
|
className="border-0 bg-white shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||||
|
labelClassName="text-sm font-medium text-[#363636]"
|
||||||
|
containerClassName="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field className={'flex-1'}>
|
||||||
|
<Label className="mb-2 block text-sm font-medium">Status</Label>
|
||||||
|
<Select
|
||||||
|
name="status"
|
||||||
|
id="status"
|
||||||
|
className="w-full rounded-lg bg-white p-2 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||||
|
>
|
||||||
|
<option disabled>Pilih Status</option>
|
||||||
|
<option value={1}>Aktif</option>
|
||||||
|
<option value={0}>Nonaktif</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
</fetcher.Form>
|
</fetcher.Form>
|
||||||
</RemixFormProvider>
|
</RemixFormProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export const action = async ({ request }: Route.ActionArgs) => {
|
|||||||
return data({ success: false, errors, defaultValues }, { status: 400 })
|
return data({ success: false, errors, defaultValues }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: tagsData } = await createSubscribePlanRequest({
|
const { data: subscribePlanData } = await createSubscribePlanRequest({
|
||||||
accessToken: staffToken,
|
accessToken: staffToken,
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
@ -37,7 +37,7 @@ export const action = async ({ request }: Route.ActionArgs) => {
|
|||||||
return data(
|
return data(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
tagsData,
|
subscribePlanData,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@ -5,8 +5,10 @@ import { XiorError } from 'xior'
|
|||||||
|
|
||||||
import { updateSubscribePlanRequest } from '~/apis/admin/update-subscribe-plan'
|
import { updateSubscribePlanRequest } from '~/apis/admin/update-subscribe-plan'
|
||||||
import { handleCookie } from '~/libs/cookies'
|
import { handleCookie } from '~/libs/cookies'
|
||||||
import type { TSubscribePlanSchema } from '~/pages/form-subscriptions-plan'
|
import {
|
||||||
import { createTagSchema } from '~/pages/form-tag'
|
createSubscribePlanSchema,
|
||||||
|
type TSubscribePlanSchema,
|
||||||
|
} from '~/pages/form-subscriptions-plan'
|
||||||
|
|
||||||
import type { Route } from './+types/actions.register'
|
import type { Route } from './+types/actions.register'
|
||||||
|
|
||||||
@ -19,7 +21,7 @@ export const action = async ({ request }: Route.ActionArgs) => {
|
|||||||
receivedValues: defaultValues,
|
receivedValues: defaultValues,
|
||||||
} = await getValidatedFormData<TSubscribePlanSchema>(
|
} = await getValidatedFormData<TSubscribePlanSchema>(
|
||||||
request,
|
request,
|
||||||
zodResolver(createTagSchema),
|
zodResolver(createSubscribePlanSchema),
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -27,7 +29,7 @@ export const action = async ({ request }: Route.ActionArgs) => {
|
|||||||
return data({ success: false, errors, defaultValues }, { status: 400 })
|
return data({ success: false, errors, defaultValues }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: tagData } = await updateSubscribePlanRequest({
|
const { data: subscribePlanData } = await updateSubscribePlanRequest({
|
||||||
accessToken: staffToken,
|
accessToken: staffToken,
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
@ -35,7 +37,7 @@ export const action = async ({ request }: Route.ActionArgs) => {
|
|||||||
return data(
|
return data(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
tagData,
|
subscribePlanData,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { getValidatedFormData } from 'remix-hook-form'
|
|||||||
import { XiorError } from 'xior'
|
import { XiorError } from 'xior'
|
||||||
|
|
||||||
import { uploadFileRequest } from '~/apis/admin/upload-file'
|
import { uploadFileRequest } from '~/apis/admin/upload-file'
|
||||||
import { uploadSchema, type TUploadSchema } from '~/layouts/admin/form-upload'
|
import { uploadSchema, type TUploadSchema } from '~/layouts/admin/dialog-upload'
|
||||||
import { handleCookie } from '~/libs/cookies'
|
import { handleCookie } from '~/libs/cookies'
|
||||||
|
|
||||||
import type { Route } from './+types/actions.register'
|
import type { Route } from './+types/actions.register'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user