Compare commits

...

7 Commits

13 changed files with 283 additions and 177 deletions

View 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)
}
}

View 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)
}
}

View File

@ -1,6 +1,6 @@
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'
const uploadResponseSchema = z.object({

View File

@ -8,6 +8,9 @@ const subscriptionResponseSchema = z.object({
id: z.string(),
code: z.string(),
name: z.string(),
length: z.number().optional(),
price: z.number().optional(),
status: z.number().optional(),
}),
),
})

View File

@ -127,7 +127,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive('bold')}
title="Bold"
>
<BoldIcon />
<BoldIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleItalic().run()}
@ -137,7 +137,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive('italic')}
title="Italic"
>
<ItalicIcon />
<ItalicIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleStrike().run()}
@ -147,7 +147,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive('strike')}
title="Strike"
>
<StrikethroughIcon />
<StrikethroughIcon className="size-4" />
</EditorButton>
<div className="relative">
<EditorButton
@ -159,7 +159,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={true}
disabled={disabled}
>
<SwatchIcon />
<SwatchIcon className="size-4" />
</EditorButton>
{isOpenColor && (
<div
@ -191,7 +191,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
<Bars3BottomLeftIcon />
<Bars3BottomLeftIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
@ -202,7 +202,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
<Bars3Icon />
<Bars3Icon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
@ -213,7 +213,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
<Bars3BottomRightIcon />
<Bars3BottomRightIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
@ -224,7 +224,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive({ textAlign: 'justify' })}
title="Align Justify"
>
<Bars4Icon />
<Bars4Icon className="size-4" />
</EditorButton>
</div>
<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"
disabled={disabled}
>
<H1Icon />
<H1Icon className="size-4" />
</EditorButton>
<EditorButton
onClick={() =>
@ -246,7 +246,7 @@ export const EditorMenuBar = (properties: TProperties) => {
title="Heading 2"
disabled={disabled}
>
<H2Icon />
<H2Icon className="size-4" />
</EditorButton>
<EditorButton
onClick={() =>
@ -256,7 +256,7 @@ export const EditorMenuBar = (properties: TProperties) => {
title="Heading 3"
disabled={disabled}
>
<H3Icon />
<H3Icon className="size-4" />
</EditorButton>
{/* <EditorButton
onClick={() => editor.chain().focus().setParagraph().run()}
@ -272,7 +272,7 @@ export const EditorMenuBar = (properties: TProperties) => {
title="Bullet List"
disabled={disabled}
>
<ListBulletIcon />
<ListBulletIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
@ -280,7 +280,7 @@ export const EditorMenuBar = (properties: TProperties) => {
title="Ordered List"
disabled={disabled}
>
<NumberedListIcon />
<NumberedListIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
@ -288,7 +288,7 @@ export const EditorMenuBar = (properties: TProperties) => {
title="Code Block"
disabled={disabled}
>
<CodeBracketIcon />
<CodeBracketIcon className="size-4" />
</EditorButton>
</div>
{/* <div className="flex items-start gap-1 px-1">
@ -334,7 +334,7 @@ export const EditorMenuBar = (properties: TProperties) => {
title="Insert Image"
disabled={disabled}
>
<PhotoIcon />
<PhotoIcon className="size-4" />
</EditorButton>
{isOpenImage && (
<div
@ -380,14 +380,14 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive('link')}
title="Set Link"
>
<LinkIcon />
<LinkIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().unsetLink().run()}
disabled={disabled || !editor.isActive('link')}
title="Unset Link"
>
<LinkSlashIcon />
<LinkSlashIcon className="size-4" />
</EditorButton>
</div>
<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()}
title="Undo"
>
<ArrowUturnLeftIcon />
<ArrowUturnLeftIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().redo().run()}
disabled={disabled || !editor.can().chain().focus().redo().run()}
title="Redo"
>
<ArrowUturnRightIcon />
<ArrowUturnRightIcon className="size-4" />
</EditorButton>
</div>
</div>
@ -413,7 +413,7 @@ export const EditorMenuBar = (properties: TProperties) => {
onClick={() => setIsPlainHTML(true)}
title="Switch to Plain Text"
>
<DocumentTextIcon />
<DocumentTextIcon className="size-4" />
</EditorButton>
</div>
</div>

View File

@ -1,15 +1,11 @@
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'
import type { PropsWithChildren } from 'react'
import { useAdminContext } from '~/contexts/admin'
import { FormUpload } from './form-upload'
import { DialogUpload } from './dialog-upload'
import { Navbar } from './navbar'
import { Sidebar } from './sidebar'
export const AdminDashboardLayout = (properties: PropsWithChildren) => {
const { children } = properties
const { isUploadOpen, setIsUploadOpen } = useAdminContext()
return (
<div className="flex flex-col">
<Navbar />
@ -18,27 +14,7 @@ export const AdminDashboardLayout = (properties: PropsWithChildren) => {
<div className="min-h-[calc(100dvh-80px)] flex-1 p-8">{children}</div>
</div>
<Dialog
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>
<DialogUpload />
</div>
)
}

View 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>
)
}

View File

@ -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>
)
}

View File

@ -60,7 +60,9 @@ export const ContentsPage = () => {
2: (_value: unknown, _type: unknown, data: TNewsResponse) => (
<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>
),
3: (value: string) => <span className="text-sm">{value}</span>,

View File

@ -1,3 +1,4 @@
import { Field, Label, Select } from '@headlessui/react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useState } from 'react'
import { useFetcher, useNavigate } from 'react-router'
@ -13,6 +14,9 @@ export const createSubscribePlanSchema = z.object({
id: z.string().optional(),
name: z.string().min(3, 'Nama minimal 3 karakter'),
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>
type TProperties = {
@ -31,6 +35,9 @@ export const FormSubscribePlanPage = (properties: TProperties) => {
id: subscribePlanData?.id || undefined,
code: subscribePlanData?.code || '',
name: subscribePlanData?.name || '',
length: subscribePlanData?.length || undefined,
price: subscribePlanData?.price || undefined,
status: subscribePlanData?.status || undefined,
},
})
const [error, setError] = useState<string>()
@ -99,6 +106,41 @@ export const FormSubscribePlanPage = (properties: TProperties) => {
Save
</Button>
</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>
</RemixFormProvider>
</div>

View File

@ -29,7 +29,7 @@ export const action = async ({ request }: Route.ActionArgs) => {
return data({ success: false, errors, defaultValues }, { status: 400 })
}
const { data: tagsData } = await createSubscribePlanRequest({
const { data: subscribePlanData } = await createSubscribePlanRequest({
accessToken: staffToken,
payload,
})
@ -37,7 +37,7 @@ export const action = async ({ request }: Route.ActionArgs) => {
return data(
{
success: true,
tagsData,
subscribePlanData,
},
{
status: 200,

View File

@ -5,8 +5,10 @@ import { XiorError } from 'xior'
import { updateSubscribePlanRequest } from '~/apis/admin/update-subscribe-plan'
import { handleCookie } from '~/libs/cookies'
import type { TSubscribePlanSchema } from '~/pages/form-subscriptions-plan'
import { createTagSchema } from '~/pages/form-tag'
import {
createSubscribePlanSchema,
type TSubscribePlanSchema,
} from '~/pages/form-subscriptions-plan'
import type { Route } from './+types/actions.register'
@ -19,7 +21,7 @@ export const action = async ({ request }: Route.ActionArgs) => {
receivedValues: defaultValues,
} = await getValidatedFormData<TSubscribePlanSchema>(
request,
zodResolver(createTagSchema),
zodResolver(createSubscribePlanSchema),
false,
)
@ -27,7 +29,7 @@ export const action = async ({ request }: Route.ActionArgs) => {
return data({ success: false, errors, defaultValues }, { status: 400 })
}
const { data: tagData } = await updateSubscribePlanRequest({
const { data: subscribePlanData } = await updateSubscribePlanRequest({
accessToken: staffToken,
payload,
})
@ -35,7 +37,7 @@ export const action = async ({ request }: Route.ActionArgs) => {
return data(
{
success: true,
tagData,
subscribePlanData,
},
{
status: 200,

View File

@ -4,7 +4,7 @@ import { getValidatedFormData } from 'remix-hook-form'
import { XiorError } from 'xior'
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 type { Route } from './+types/actions.register'