feat: implement getTags API and integrate with content creation form

This commit is contained in:
Ardeman 2025-03-05 17:57:52 +08:00
parent aca894729e
commit 1a1d8cc209
8 changed files with 203 additions and 40 deletions

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

View File

@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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