Compare commits
No commits in common. "e0b68611bd9865c62add926e47e587bee6cfc38e" and "18098d63baeecbe35a13fd11ad267b6e652dab2b" have entirely different histories.
e0b68611bd
...
18098d63ba
@ -9,7 +9,7 @@ const authorSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
profile_picture: z.string(),
|
profile_picture: z.string(),
|
||||||
})
|
})
|
||||||
export const newsResponseSchema = z.object({
|
const newsResponseSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
import { newsResponseSchema } from '~/apis/admin/get-news'
|
|
||||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
|
||||||
|
|
||||||
const dataResponseSchema = z.object({
|
|
||||||
data: z.object(newsResponseSchema.shape),
|
|
||||||
})
|
|
||||||
|
|
||||||
type TParameters = {
|
|
||||||
slug: string
|
|
||||||
} & THttpServer
|
|
||||||
|
|
||||||
export const getNewsBySlug = async (parameters: TParameters) => {
|
|
||||||
const { slug, accessToken } = parameters
|
|
||||||
try {
|
|
||||||
const { data } = await HttpServer({ accessToken }).get(`/api/news/${slug}`)
|
|
||||||
return dataResponseSchema.parse(data)
|
|
||||||
} catch (error) {
|
|
||||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
107
app/components/ui/text-editor.tsx
Normal file
107
app/components/ui/text-editor.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { useEditor, EditorContent } from '@tiptap/react'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BoldIcon as Bold,
|
||||||
|
ItalicIcon as Italic,
|
||||||
|
UndoIcon as Undo,
|
||||||
|
RedoIcon as Redo,
|
||||||
|
ListIcon as List,
|
||||||
|
ListOrderIcon as ListOrdered,
|
||||||
|
LinkIcon as Link,
|
||||||
|
ImageIcon as Image,
|
||||||
|
CodeIcon as Code,
|
||||||
|
QuoteIcon as Quote,
|
||||||
|
StrikethroughIcon as Strikethrough,
|
||||||
|
UnderlineIcon as Underline,
|
||||||
|
} from '~/components/icons/editor'
|
||||||
|
|
||||||
|
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>',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return <p>Loading editor...</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="0 rounded border bg-white">
|
||||||
|
<div className="mb-4 rounded bg-gray-100 p-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => editor.chain().focus().undo().run()}>
|
||||||
|
<Undo />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => editor.chain().focus().redo().run()}>
|
||||||
|
<Redo />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||||
|
<Bold />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||||
|
<Italic />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
// onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||||
|
>
|
||||||
|
<Underline />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||||
|
<Strikethrough />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
>
|
||||||
|
<List />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
>
|
||||||
|
<ListOrdered />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => editor.chain().focus().toggleCode().run()}>
|
||||||
|
<Code />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
>
|
||||||
|
<Quote />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
// .setLink({ href: prompt('Enter URL') })
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Link />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
// .setImage({ src: prompt('Enter image URL') })
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Image />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className="h-[50vh]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DefaultTextEditor
|
||||||
@ -5,7 +5,6 @@ import { useFetcher, useNavigate, useRouteLoaderData } from 'react-router'
|
|||||||
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
|
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import type { newsResponseSchema } from '~/apis/admin/get-news'
|
|
||||||
import { TextEditor } from '~/components/text-editor'
|
import { TextEditor } from '~/components/text-editor'
|
||||||
import { Button } from '~/components/ui/button'
|
import { Button } from '~/components/ui/button'
|
||||||
import { Combobox } from '~/components/ui/combobox'
|
import { Combobox } from '~/components/ui/combobox'
|
||||||
@ -55,12 +54,8 @@ export const contentSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type TContentSchema = z.infer<typeof contentSchema>
|
export type TContentSchema = z.infer<typeof contentSchema>
|
||||||
type TProperties = {
|
|
||||||
newsData?: z.infer<typeof newsResponseSchema>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ContentsFormPage = (properties: TProperties) => {
|
export const ContentsFormPage = () => {
|
||||||
const { newsData } = properties || {}
|
|
||||||
const fetcher = useFetcher()
|
const fetcher = useFetcher()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_admin.lg-admin')
|
const loaderData = useRouteLoaderData<typeof loader>('routes/_admin.lg-admin')
|
||||||
@ -73,16 +68,14 @@ export const ContentsFormPage = (properties: TProperties) => {
|
|||||||
mode: 'onSubmit',
|
mode: 'onSubmit',
|
||||||
fetcher,
|
fetcher,
|
||||||
resolver: zodResolver(contentSchema),
|
resolver: zodResolver(contentSchema),
|
||||||
values: {
|
defaultValues: {
|
||||||
categories: newsData?.categories || [],
|
categories: [],
|
||||||
tags: newsData?.tags || [],
|
tags: [],
|
||||||
title: newsData?.title || '',
|
title: '',
|
||||||
content: newsData?.content || '',
|
content: '',
|
||||||
featured_image: newsData?.featured_image || '',
|
featured_image: '',
|
||||||
is_premium: newsData?.is_premium || false,
|
is_premium: false,
|
||||||
live_at: newsData?.live_at
|
live_at: '',
|
||||||
? new Date(newsData.live_at).toISOString().split('T')[0]
|
|
||||||
: '',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -105,12 +98,12 @@ export const ContentsFormPage = (properties: TProperties) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<TitleDashboard title={`${newsData ? 'Update' : 'Buat'} Artikel`} />
|
<TitleDashboard title="Buat Artikel" />
|
||||||
<RemixFormProvider {...formMethods}>
|
<RemixFormProvider {...formMethods}>
|
||||||
<fetcher.Form
|
<fetcher.Form
|
||||||
method="post"
|
method="post"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
action="/actions/admin/contents/update"
|
action="/actions/admin/contents/create"
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
45
app/pages/contents-update/index.tsx
Normal file
45
app/pages/contents-update/index.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Field, Input, Label, Select } from '@headlessui/react'
|
||||||
|
|
||||||
|
import { SearchIcon } from '~/components/icons/search'
|
||||||
|
import DefaultTextEditor from '~/components/ui/text-editor'
|
||||||
|
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
||||||
|
|
||||||
|
export const UpdateContentsPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<TitleDashboard title="Update 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -81,7 +81,7 @@ export const ContentsPage = () => {
|
|||||||
className="text-md rounded-md"
|
className="text-md rounded-md"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
Update Artikel
|
Lihat Detail
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@ -103,7 +103,7 @@ export const ContentsPage = () => {
|
|||||||
className="text-md h-[42px] rounded-md"
|
className="text-md h-[42px] rounded-md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
Buat Artikel
|
Create New
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +1,4 @@
|
|||||||
import { getNewsBySlug } from '~/apis/common/get-news-by-slug'
|
import { UpdateContentsPage } from '~/pages/contents-update'
|
||||||
import { handleCookie } from '~/libs/cookies'
|
|
||||||
import { ContentsFormPage } from '~/pages/contents-form'
|
|
||||||
|
|
||||||
import type { Route } from './+types/_admin.lg-admin._dashboard.contents.update.$slug'
|
const DashboardContentUpdateLayout = () => <UpdateContentsPage />
|
||||||
|
|
||||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
|
||||||
const { staffToken } = await handleCookie(request)
|
|
||||||
const { data: newsData } = await getNewsBySlug({
|
|
||||||
accessToken: staffToken,
|
|
||||||
slug: request.url.split('/').pop() ?? '',
|
|
||||||
})
|
|
||||||
return { newsData }
|
|
||||||
}
|
|
||||||
|
|
||||||
const DashboardContentUpdateLayout = ({ loaderData }: Route.ComponentProps) => {
|
|
||||||
const newsData = loaderData.newsData
|
|
||||||
return <ContentsFormPage newsData={newsData} />
|
|
||||||
}
|
|
||||||
export default DashboardContentUpdateLayout
|
export default DashboardContentUpdateLayout
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user