feat: implement getTags API and integrate with content creation form
This commit is contained in:
parent
aca894729e
commit
1a1d8cc209
25
app/apis/common/get-tags.ts
Normal file
25
app/apis/common/get-tags.ts
Normal file
@ -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<typeof tagSchema>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -35,13 +35,26 @@ type TInputProperties<T extends FieldValues> = ComponentProps<
|
||||
rules?: RegisterOptions
|
||||
placeholder?: string
|
||||
options?: TComboboxOption[]
|
||||
labelClassName?: string
|
||||
containerClassName?: string
|
||||
}
|
||||
|
||||
export const Combobox = <TFormValues extends Record<string, unknown>>(
|
||||
properties: TInputProperties<TFormValues>,
|
||||
) => {
|
||||
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 = <TFormValues extends Record<string, unknown>>(
|
||||
|
||||
return (
|
||||
<Field
|
||||
className="relative"
|
||||
className={twMerge('relative', containerClassName)}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
>
|
||||
<Label className="mb-1 block text-gray-700">
|
||||
<Label className={twMerge('mb-1 block text-gray-700', labelClassName)}>
|
||||
{label} {error && <span className="text-red-500">{error.message}</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
@ -82,7 +95,10 @@ export const Combobox = <TFormValues extends Record<string, unknown>>(
|
||||
placeholder={placeholder}
|
||||
displayValue={(option: TComboboxOption) => option?.name}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="focus:inheriten h-[42px] w-full rounded-md border border-[#DFDFDF] p-2"
|
||||
className={twMerge(
|
||||
'focus:inheriten h-[42px] w-full rounded-md border border-[#DFDFDF] p-2',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
<ComboboxButton className="group absolute inset-y-0 right-0 px-2.5">
|
||||
<ChevronDownIcon className="size-4 fill-gray-500" />
|
||||
|
||||
@ -35,6 +35,7 @@ export const Input = <TFormValues extends Record<string, unknown>>(
|
||||
type = 'text',
|
||||
placeholder,
|
||||
disabled,
|
||||
className,
|
||||
...rest
|
||||
} = properties
|
||||
const [inputType, setInputType] = useState(type)
|
||||
@ -57,7 +58,10 @@ export const Input = <TFormValues extends Record<string, unknown>>(
|
||||
</Label>
|
||||
<HeadlessInput
|
||||
type={inputType}
|
||||
className="h-[42px] w-full rounded-md border border-[#DFDFDF] p-2"
|
||||
className={twMerge(
|
||||
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2',
|
||||
className,
|
||||
)}
|
||||
placeholder={inputType === 'password' ? '******' : placeholder}
|
||||
{...register(name, rules)}
|
||||
{...rest}
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
const DefaultTextEditor = () => {
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit],
|
||||
immediatelyRender: false,
|
||||
content:
|
||||
'<p>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.</p>',
|
||||
})
|
||||
|
||||
@ -4,7 +4,7 @@ type TitleDashboardProperties = {
|
||||
export const TitleDashboard = (properties: TitleDashboardProperties) => {
|
||||
const { title } = properties
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<div className="container">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">{title}</h1>
|
||||
</div>
|
||||
|
||||
@ -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<typeof contentSchema>
|
||||
|
||||
export const CreateContentsPage = () => {
|
||||
const fetcher = useFetcher()
|
||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_admin.lg-admin')
|
||||
const categories = loaderData?.categoriesData
|
||||
const tags = loaderData?.tagsData
|
||||
const [error, setError] = useState<string>()
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
|
||||
const formMethods = useRemixForm<TContentSchema>({
|
||||
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 (
|
||||
<div className="relative">
|
||||
<TitleDashboard title="Buat Artikel" />
|
||||
<div className="mb-8 flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
||||
<div className="w-[400px]">
|
||||
<Field>
|
||||
<Label className="mb-2 block text-sm font-medium">Pilih Tags</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Cari Tags"
|
||||
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<SearchIcon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<RemixFormProvider {...formMethods}>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
onSubmit={handleSubmit}
|
||||
action="/actions/admin/contents/create"
|
||||
className="space-y-4"
|
||||
>
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 capitalize">{error}</div>
|
||||
)}
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<Combobox
|
||||
multiple
|
||||
id="categories"
|
||||
name="categories"
|
||||
label="Kategori"
|
||||
placeholder={
|
||||
watchCategories
|
||||
? watchCategories.map((category) => 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"
|
||||
/>
|
||||
<Combobox
|
||||
multiple
|
||||
id="tags"
|
||||
name="tags"
|
||||
label="Tags"
|
||||
placeholder={
|
||||
watchTags
|
||||
? watchTags.map((tag) => 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"
|
||||
/>
|
||||
<Input
|
||||
id="live_at"
|
||||
label="Tanggal Live"
|
||||
placeholder="Pilih Tanggal"
|
||||
name="live_at"
|
||||
type="date"
|
||||
className="border-0 bg-white shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||
/>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="text-md h-[42px] rounded-md"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="w-[235px]">
|
||||
<Field>
|
||||
<Label className="mb-2 block text-sm font-medium">Status</Label>
|
||||
<Select className="w-full rounded-lg bg-white p-2 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none">
|
||||
<option>Pilih Status</option>
|
||||
<option>Aktif</option>
|
||||
<option>Nonaktif</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<section>
|
||||
<DefaultTextEditor />
|
||||
</section>
|
||||
</fetcher.Form>
|
||||
</RemixFormProvider>
|
||||
|
||||
<section>
|
||||
<DefaultTextEditor />
|
||||
</section>
|
||||
<DevTool control={control} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -91,7 +91,7 @@ export const ContentsPage = () => {
|
||||
<Button
|
||||
as={Link}
|
||||
to="/lg-admin/contents/create"
|
||||
className="text-md rounded-md"
|
||||
className="text-md h-[42px] rounded-md"
|
||||
size="lg"
|
||||
>
|
||||
Create New
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { Outlet, redirect } from 'react-router'
|
||||
|
||||
import { getStaff } from '~/apis/admin/get-staff'
|
||||
import { getCategories } from '~/apis/common/get-categories'
|
||||
import { getTags } from '~/apis/common/get-tags'
|
||||
import { AUTH_PAGES } from '~/configs/pages'
|
||||
import { AdminProvider } from '~/contexts/admin'
|
||||
import { AdminDefaultLayout } from '~/layouts/admin/default'
|
||||
@ -28,9 +30,13 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
})
|
||||
staffData = data
|
||||
}
|
||||
const { data: categoriesData } = await getCategories()
|
||||
const { data: tagsData } = await getTags()
|
||||
|
||||
return {
|
||||
staffData,
|
||||
categoriesData,
|
||||
tagsData,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user