Merge remote-tracking branch 'origin/master' into feature/slicing
This commit is contained in:
commit
a635dc1431
39
app/apis/admin/create-news.ts
Normal file
39
app/apis/admin/create-news.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer } from '~/libs/http-server'
|
||||
import type { TContentSchema } from '~/pages/contents-create'
|
||||
|
||||
const newsResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameter = {
|
||||
accessToken: string
|
||||
payload: TContentSchema
|
||||
}
|
||||
|
||||
export const createNewsRequest = async (parameters: TParameter) => {
|
||||
const { accessToken, payload } = parameters
|
||||
try {
|
||||
const { categories, tags, ...restPayload } = payload
|
||||
const transformedPayload = {
|
||||
...restPayload,
|
||||
categories: categories.map((category) => category?.id),
|
||||
tags: tags?.map((tag) => tag?.id),
|
||||
live_at: new Date(payload?.live_at).toISOString(),
|
||||
}
|
||||
if (transformedPayload.tags?.length === 0) {
|
||||
delete transformedPayload.tags
|
||||
}
|
||||
const { data } = await HttpServer({ accessToken }).post(
|
||||
'/api/news/create',
|
||||
transformedPayload,
|
||||
)
|
||||
return newsResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
48
app/apis/admin/get-news.ts
Normal file
48
app/apis/admin/get-news.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const newsSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
categories: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
code: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}),
|
||||
),
|
||||
tags: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
code: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}),
|
||||
),
|
||||
is_premium: z.boolean(),
|
||||
slug: z.string(),
|
||||
featured_image: z.string(),
|
||||
author_id: z.string(),
|
||||
live_at: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const getNews = async (parameters: THttpServer) => {
|
||||
try {
|
||||
const { data } = await HttpServer(parameters).get(`/api/news`)
|
||||
return newsSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,10 @@ html,
|
||||
body {
|
||||
}
|
||||
|
||||
.ProseMirror-focused {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
table.dataTable thead > tr {
|
||||
border-bottom: 2px solid #c2c2c2;
|
||||
}
|
||||
|
||||
34
app/components/text-editor/editor-button.tsx
Normal file
34
app/components/text-editor/editor-button.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type { CSSProperties, MouseEventHandler, ReactNode } from 'react'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type TProperties = {
|
||||
children: ReactNode
|
||||
onClick: MouseEventHandler
|
||||
disabled?: boolean
|
||||
isActive?: boolean
|
||||
className?: string
|
||||
title: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
export const EditorButton = (properties: TProperties) => {
|
||||
const { children, onClick, disabled, className, isActive, title, style } =
|
||||
properties
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={twMerge(
|
||||
'flex h-6 w-8 cursor-pointer items-center justify-center rounded-md p-2 hover:bg-[#2E2F7C] hover:text-white disabled:cursor-not-allowed disabled:bg-[#2E2F7C]/50 disabled:text-white disabled:hover:bg-[#2E2F7C]/50',
|
||||
isActive ? 'bg-[#2E2F7C]/10' : '',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
413
app/components/text-editor/editor-menubar.tsx
Normal file
413
app/components/text-editor/editor-menubar.tsx
Normal file
@ -0,0 +1,413 @@
|
||||
import {
|
||||
ArrowUturnLeftIcon,
|
||||
ArrowUturnRightIcon,
|
||||
Bars3BottomLeftIcon,
|
||||
Bars3BottomRightIcon,
|
||||
Bars3Icon,
|
||||
Bars4Icon,
|
||||
BoldIcon,
|
||||
CodeBracketIcon,
|
||||
DocumentTextIcon,
|
||||
H1Icon,
|
||||
H2Icon,
|
||||
H3Icon,
|
||||
ItalicIcon,
|
||||
LinkIcon,
|
||||
LinkSlashIcon,
|
||||
ListBulletIcon,
|
||||
NumberedListIcon,
|
||||
PhotoIcon,
|
||||
StrikethroughIcon,
|
||||
SwatchIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import {
|
||||
type SetStateAction,
|
||||
type Dispatch,
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { HexColorInput, HexColorPicker } from 'react-colorful'
|
||||
|
||||
import { useClickOutside } from '~/hooks/use-click-outside'
|
||||
import { isHexCompatible, rgbToHex } from '~/utils/color'
|
||||
|
||||
import { EditorButton } from './editor-button'
|
||||
|
||||
type TProperties = {
|
||||
editor: Editor | null
|
||||
setIsPlainHTML: Dispatch<SetStateAction<boolean>>
|
||||
category: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const EditorMenuBar = (properties: TProperties) => {
|
||||
const {
|
||||
editor,
|
||||
setIsPlainHTML,
|
||||
// category,
|
||||
disabled = false,
|
||||
} = properties
|
||||
// const [isOpenImage, setIsOpenImage] = useState(false)
|
||||
const [isOpenColor, setIsOpenColor] = useState(false)
|
||||
const popover = useRef<HTMLDivElement>(null)
|
||||
const close = useCallback(() => setIsOpenColor(false), [])
|
||||
|
||||
useClickOutside(popover, close)
|
||||
|
||||
const setLink = useCallback(() => {
|
||||
const previousUrl = editor?.getAttributes('link').href
|
||||
const url = globalThis.prompt('URL', previousUrl)
|
||||
|
||||
// cancelled
|
||||
if (url === null) {
|
||||
return
|
||||
}
|
||||
|
||||
// empty
|
||||
if (url === '') {
|
||||
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// update link
|
||||
editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||
}, [editor])
|
||||
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const addImage = (url: string) => {
|
||||
if (url) {
|
||||
editor.chain().focus().setImage({ src: url }).run()
|
||||
}
|
||||
}
|
||||
|
||||
const currentColor: string = editor.getAttributes('textStyle').color
|
||||
const rgbColor = isHexCompatible(currentColor)
|
||||
? currentColor
|
||||
: rgbToHex(currentColor)
|
||||
const handleChangeColor = (selectedColor: string) => {
|
||||
if (selectedColor.length === 7) {
|
||||
editor.chain().focus().setColor(selectedColor).run()
|
||||
}
|
||||
}
|
||||
|
||||
const uploadEnabled = false
|
||||
const toggleUpload = () => {
|
||||
if (uploadEnabled) {
|
||||
// setIsOpenImage(true)
|
||||
} else {
|
||||
const urlImage = globalThis.prompt('URL')
|
||||
if (urlImage) {
|
||||
addImage(urlImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-4 px-4 py-3">
|
||||
<div className="flex divide-x">
|
||||
<div className="flex max-w-[150px] flex-wrap items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
disabled={
|
||||
disabled || !editor.can().chain().focus().toggleBold().run()
|
||||
}
|
||||
isActive={editor.isActive('bold')}
|
||||
title="Bold"
|
||||
>
|
||||
<BoldIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={
|
||||
disabled || !editor.can().chain().focus().toggleItalic().run()
|
||||
}
|
||||
isActive={editor.isActive('italic')}
|
||||
title="Italic"
|
||||
>
|
||||
<ItalicIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
disabled={
|
||||
disabled || !editor.can().chain().focus().toggleStrike().run()
|
||||
}
|
||||
isActive={editor.isActive('strike')}
|
||||
title="Strike"
|
||||
>
|
||||
<StrikethroughIcon />
|
||||
</EditorButton>
|
||||
<div className="relative">
|
||||
<EditorButton
|
||||
onClick={() => setIsOpenColor(true)}
|
||||
title="Text Color"
|
||||
style={{
|
||||
color: rgbColor,
|
||||
}}
|
||||
isActive={true}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SwatchIcon />
|
||||
</EditorButton>
|
||||
{isOpenColor && (
|
||||
<div
|
||||
className="border-md absolute top-8 left-0"
|
||||
ref={popover}
|
||||
>
|
||||
<HexColorPicker
|
||||
className="z-10"
|
||||
color={rgbColor}
|
||||
onChange={handleChangeColor}
|
||||
/>
|
||||
<div className="">
|
||||
<HexColorInput
|
||||
color={rgbColor}
|
||||
onChange={handleChangeColor}
|
||||
prefixed
|
||||
className="relative z-10 mt-1 flex w-full rounded-lg border px-3 py-2 text-sm focus:ring-0 focus-visible:outline-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor.can().chain().focus().setTextAlign('left').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'left' })}
|
||||
title="Align Left"
|
||||
>
|
||||
<Bars3BottomLeftIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().setTextAlign('center').run()
|
||||
}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor.can().chain().focus().setTextAlign('center').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'center' })}
|
||||
title="Align Center"
|
||||
>
|
||||
<Bars3Icon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor.can().chain().focus().setTextAlign('right').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'right' })}
|
||||
title="Align Right"
|
||||
>
|
||||
<Bars3BottomRightIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().setTextAlign('justify').run()
|
||||
}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor.can().chain().focus().setTextAlign('justify').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'justify' })}
|
||||
title="Align Justify"
|
||||
>
|
||||
<Bars4Icon />
|
||||
</EditorButton>
|
||||
</div>
|
||||
<div className="flex max-w-[150px] flex-wrap items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 1 })}
|
||||
title="Heading 1"
|
||||
disabled={disabled}
|
||||
>
|
||||
<H1Icon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
disabled={disabled}
|
||||
>
|
||||
<H2Icon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
disabled={disabled}
|
||||
>
|
||||
<H3Icon />
|
||||
</EditorButton>
|
||||
{/* <EditorButton
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
isActive={editor.isActive('paragraph')}
|
||||
title="Paragraph"
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiParagraph />
|
||||
</EditorButton> */}
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
isActive={editor.isActive('bulletList')}
|
||||
title="Bullet List"
|
||||
disabled={disabled}
|
||||
>
|
||||
<ListBulletIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
isActive={editor.isActive('orderedList')}
|
||||
title="Ordered List"
|
||||
disabled={disabled}
|
||||
>
|
||||
<NumberedListIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
isActive={editor.isActive('codeBlock')}
|
||||
title="Code Block"
|
||||
disabled={disabled}
|
||||
>
|
||||
<CodeBracketIcon />
|
||||
</EditorButton>
|
||||
</div>
|
||||
{/* <div className="flex items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
isActive={editor.isActive('blockquote')}
|
||||
title="Blockquote"
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiDoubleQuotesL />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal Rule"
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiSeparator />
|
||||
</EditorButton>
|
||||
</div> */}
|
||||
{/* <div className="flex items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().setHardBreak().run()}
|
||||
title="Hard Break"
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiTextWrap />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetAllMarks().run()
|
||||
editor.chain().focus().clearNodes().run()
|
||||
}}
|
||||
title="Clear Format"
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiFormatClear />
|
||||
</EditorButton>
|
||||
</div> */}
|
||||
<div className="flex items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => toggleUpload()}
|
||||
title="Insert Image"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PhotoIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => setLink()}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor
|
||||
.can()
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({ href: '' })
|
||||
.run()
|
||||
}
|
||||
isActive={editor.isActive('link')}
|
||||
title="Set Link"
|
||||
>
|
||||
<LinkIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().unsetLink().run()}
|
||||
disabled={disabled || !editor.isActive('link')}
|
||||
title="Unset Link"
|
||||
>
|
||||
<LinkSlashIcon />
|
||||
</EditorButton>
|
||||
</div>
|
||||
<div className="flex items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={disabled || !editor.can().chain().focus().undo().run()}
|
||||
title="Undo"
|
||||
>
|
||||
<ArrowUturnLeftIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={disabled || !editor.can().chain().focus().redo().run()}
|
||||
title="Redo"
|
||||
>
|
||||
<ArrowUturnRightIcon />
|
||||
</EditorButton>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => setIsPlainHTML(true)}
|
||||
title="Switch to Plain Text"
|
||||
>
|
||||
<DocumentTextIcon />
|
||||
</EditorButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <Dialog
|
||||
isOpen={isOpenImage}
|
||||
setIsOpen={setIsOpenImage}
|
||||
title="Insert Image"
|
||||
showCloseButton={true}
|
||||
>
|
||||
<UploadProvider
|
||||
data={{
|
||||
onCancel: () => setIsOpenImage(false),
|
||||
onSave: (file) => {
|
||||
addImage(file)
|
||||
setIsOpenImage(false)
|
||||
},
|
||||
category: category,
|
||||
maxFileSize: 300,
|
||||
selectedFile: '',
|
||||
}}
|
||||
>
|
||||
<Upload />
|
||||
</UploadProvider>
|
||||
</Dialog> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
55
app/components/text-editor/editor-textarea.tsx
Normal file
55
app/components/text-editor/editor-textarea.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { CodeBracketSquareIcon } from '@heroicons/react/20/solid'
|
||||
import MonacoEditor from '@monaco-editor/react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { useRemixFormContext } from 'remix-hook-form'
|
||||
|
||||
import { EditorButton } from './editor-button'
|
||||
|
||||
type TProperties = {
|
||||
name: string
|
||||
disabled?: boolean
|
||||
setIsPlainHTML: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
// const MonacoEditor = dynamic(() => import('@monaco-editor/react'), {
|
||||
// ssr: false,
|
||||
// })
|
||||
|
||||
export const EditorTextArea = (properties: TProperties) => {
|
||||
const { setIsPlainHTML, name, disabled = false } = properties
|
||||
|
||||
const { control } = useRemixFormContext()
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end rounded-[5px_5px_0_0] border-x border-t border-[#D2D2D2] px-4 py-3">
|
||||
<div className="flex gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => setIsPlainHTML(false)}
|
||||
title="Switch to Rich Text"
|
||||
>
|
||||
<CodeBracketSquareIcon />
|
||||
</EditorButton>
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<MonacoEditor
|
||||
language="html"
|
||||
onChange={(newValue) => {
|
||||
field.onChange(newValue)
|
||||
}}
|
||||
value={field.value}
|
||||
options={{
|
||||
readOnly: disabled,
|
||||
wordWrap: 'on',
|
||||
}}
|
||||
className="mb-1 h-96 max-w-none overflow-y-auto rounded-[0_0_5px_5px] border border-[#D2D2D2] p-1"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
146
app/components/text-editor/index.tsx
Normal file
146
app/components/text-editor/index.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { Field, Label } from '@headlessui/react'
|
||||
import { Color } from '@tiptap/extension-color'
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import TextStyle from '@tiptap/extension-text-style'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import {
|
||||
get,
|
||||
type FieldError,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type RegisterOptions,
|
||||
} from 'react-hook-form'
|
||||
import { useRemixFormContext } from 'remix-hook-form'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import { EditorMenuBar } from './editor-menubar'
|
||||
import { EditorTextArea } from './editor-textarea'
|
||||
|
||||
type TProperties<TFormValues extends FieldValues> = {
|
||||
id?: string
|
||||
name: Path<TFormValues>
|
||||
label?: string
|
||||
placeholder?: string
|
||||
labelClassName?: string
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
containerClassName?: string
|
||||
rules?: RegisterOptions
|
||||
disabled?: boolean
|
||||
isRequired?: boolean
|
||||
category: string
|
||||
}
|
||||
|
||||
export const TextEditor = <TFormValues extends Record<string, unknown>>(
|
||||
properties: TProperties<TFormValues>,
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
labelClassName,
|
||||
className,
|
||||
inputClassName,
|
||||
category,
|
||||
disabled = false,
|
||||
containerClassName,
|
||||
} = properties
|
||||
|
||||
const [isPlainHTML, setIsPlainHTML] = useState(false)
|
||||
const [init, setInit] = useState(true)
|
||||
const generatedId = useId()
|
||||
|
||||
const {
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useRemixFormContext()
|
||||
|
||||
const watchContent = watch(name)
|
||||
const error: FieldError = get(errors, name)
|
||||
|
||||
const editor = useEditor({
|
||||
editable: !disabled,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Highlight,
|
||||
Image.configure({
|
||||
inline: true,
|
||||
}),
|
||||
TextStyle,
|
||||
Color.configure({
|
||||
types: ['textStyle'],
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
],
|
||||
immediatelyRender: false,
|
||||
content: watchContent,
|
||||
onUpdate: ({ editor }) => {
|
||||
setValue(name, editor.getHTML() as any) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
})
|
||||
useEffect(() => {
|
||||
if (
|
||||
watchContent &&
|
||||
watchContent.length > 0 &&
|
||||
editor &&
|
||||
(isPlainHTML || init)
|
||||
) {
|
||||
editor.commands.setContent(watchContent)
|
||||
setInit(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [watchContent])
|
||||
|
||||
return (
|
||||
<Field
|
||||
className={twMerge('relative', containerClassName)}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
>
|
||||
{label && (
|
||||
<Label className={twMerge('mb-1 block text-gray-700', labelClassName)}>
|
||||
{label}{' '}
|
||||
{error && <span className="text-red-500">{error.message}</span>}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
{isPlainHTML ? (
|
||||
<EditorTextArea
|
||||
setIsPlainHTML={setIsPlainHTML}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : (
|
||||
<div className={twMerge('rounded-md', className)}>
|
||||
<EditorMenuBar
|
||||
disabled={disabled}
|
||||
category={category}
|
||||
editor={editor}
|
||||
setIsPlainHTML={setIsPlainHTML}
|
||||
/>
|
||||
<EditorContent
|
||||
readOnly={disabled}
|
||||
editor={editor}
|
||||
id={id ?? generatedId}
|
||||
className={twMerge(
|
||||
'prose prose-headings:my-0 prose-p:my-0 max-h-96 max-w-none cursor-text overflow-y-auto rounded-b-md p-2',
|
||||
inputClassName,
|
||||
)}
|
||||
onClick={() => editor?.commands.focus()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@ -26,7 +26,7 @@ type TComboboxOption = {
|
||||
id: string
|
||||
}
|
||||
|
||||
type TInputProperties<T extends FieldValues> = ComponentProps<
|
||||
type TComboboxProperties<T extends FieldValues> = ComponentProps<
|
||||
typeof HeadlessCombobox
|
||||
> & {
|
||||
id: string
|
||||
@ -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>,
|
||||
properties: TComboboxProperties<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" />
|
||||
|
||||
@ -22,6 +22,8 @@ type TInputProperties<T extends FieldValues> = Omit<
|
||||
label?: ReactNode
|
||||
name: Path<T>
|
||||
rules?: RegisterOptions
|
||||
containerClassName?: string
|
||||
labelClassName?: string
|
||||
}
|
||||
|
||||
export const Input = <TFormValues extends Record<string, unknown>>(
|
||||
@ -35,6 +37,9 @@ export const Input = <TFormValues extends Record<string, unknown>>(
|
||||
type = 'text',
|
||||
placeholder,
|
||||
disabled,
|
||||
className,
|
||||
containerClassName,
|
||||
labelClassName,
|
||||
...rest
|
||||
} = properties
|
||||
const [inputType, setInputType] = useState(type)
|
||||
@ -48,16 +53,19 @@ export const Input = <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>
|
||||
<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}
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
import { Field, Label, Select as HeadlessSelect } from '@headlessui/react'
|
||||
import { type ComponentProps, type ReactNode } from 'react'
|
||||
import {
|
||||
get,
|
||||
type FieldError,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type RegisterOptions,
|
||||
} from 'react-hook-form'
|
||||
import { useRemixFormContext } from 'remix-hook-form'
|
||||
|
||||
type TInputProperties<T extends FieldValues> = Omit<
|
||||
ComponentProps<'select'>,
|
||||
'size'
|
||||
> & {
|
||||
id: string
|
||||
label?: ReactNode
|
||||
name: Path<T>
|
||||
rules?: RegisterOptions
|
||||
placeholder?: string
|
||||
options?: {
|
||||
code: string
|
||||
name: string
|
||||
id: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export const Select = <TFormValues extends Record<string, unknown>>(
|
||||
properties: TInputProperties<TFormValues>,
|
||||
) => {
|
||||
const { id, label, name, rules, disabled, placeholder, options, ...rest } =
|
||||
properties
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useRemixFormContext()
|
||||
|
||||
const error: FieldError = get(errors, name)
|
||||
|
||||
return (
|
||||
<Field
|
||||
className="relative"
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
>
|
||||
<Label className="mb-1 block text-gray-700">
|
||||
{label} {error && <span className="text-red-500">{error.message}</span>}
|
||||
</Label>
|
||||
<HeadlessSelect
|
||||
className="focus:inheriten h-[42px] w-full rounded-md border border-[#DFDFDF] p-2"
|
||||
{...register(name, rules)}
|
||||
{...rest}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{options?.map(({ id, name }) => (
|
||||
<option
|
||||
key={id}
|
||||
value={id}
|
||||
>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</HeadlessSelect>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
80
app/components/ui/switch.tsx
Normal file
80
app/components/ui/switch.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { Field, Label, Switch as HeadlessSwitch } from '@headlessui/react'
|
||||
import { type ReactNode } from 'react'
|
||||
import {
|
||||
Controller,
|
||||
get,
|
||||
type FieldError,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type RegisterOptions,
|
||||
} from 'react-hook-form'
|
||||
import { useRemixFormContext } from 'remix-hook-form'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type TSwitchProperties<T extends FieldValues> = {
|
||||
id: string
|
||||
label?: ReactNode
|
||||
name: Path<T>
|
||||
rules?: RegisterOptions
|
||||
containerClassName?: string
|
||||
labelClassName?: string
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
}
|
||||
|
||||
export const Switch = <TFormValues extends Record<string, unknown>>(
|
||||
properties: TSwitchProperties<TFormValues>,
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
rules,
|
||||
containerClassName,
|
||||
labelClassName,
|
||||
className,
|
||||
inputClassName,
|
||||
} = properties
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useRemixFormContext()
|
||||
|
||||
const error: FieldError = get(errors, name)
|
||||
|
||||
return (
|
||||
<Field
|
||||
className={twMerge('relative', containerClassName)}
|
||||
id={id}
|
||||
>
|
||||
<Label className={twMerge('mb-1 block text-gray-700', labelClassName)}>
|
||||
{label} {error && <span className="text-red-500">{error.message}</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<div className={twMerge('flex items-center', className)}>
|
||||
<HeadlessSwitch
|
||||
checked={field.value}
|
||||
onChange={(checked) => {
|
||||
field.onChange(checked)
|
||||
}}
|
||||
className={twMerge(
|
||||
'group relative flex h-7 w-14 cursor-pointer rounded-full bg-[#2E2F7C]/10 p-1 shadow transition-colors duration-200 ease-in-out focus:outline-none data-[checked]:bg-[#2E2F7C]/90 data-[focus]:outline-1 data-[focus]:outline-white',
|
||||
inputClassName,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none inline-block size-5 translate-x-0 rounded-full bg-white ring-0 shadow-lg transition duration-200 ease-in-out group-data-[checked]:translate-x-7"
|
||||
/>
|
||||
</HeadlessSwitch>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
27
app/hooks/use-click-outside.ts
Normal file
27
app/hooks/use-click-outside.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
|
||||
type Event = MouseEvent | TouchEvent
|
||||
|
||||
export const useClickOutside = <T extends HTMLElement = HTMLElement>(
|
||||
reference: RefObject<T | null>,
|
||||
handler: (event: Event) => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const listener = (event: Event) => {
|
||||
const element = reference?.current
|
||||
if (!element || element.contains((event?.target as Node) || undefined)) {
|
||||
return
|
||||
}
|
||||
|
||||
handler(event)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', listener)
|
||||
document.addEventListener('touchstart', listener)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', listener)
|
||||
document.removeEventListener('touchstart', listener)
|
||||
}
|
||||
}, [reference, handler])
|
||||
}
|
||||
@ -5,6 +5,8 @@ import { MENU } from './menu'
|
||||
|
||||
export const Sidebar = () => {
|
||||
const { pathname } = useLocation()
|
||||
const segments = pathname.split('/')
|
||||
const path = segments.length > 3 ? segments.slice(0, 3).join('/') : pathname
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100dvh-80px)] flex-col gap-y-10 overflow-y-auto bg-white p-5">
|
||||
@ -19,19 +21,19 @@ export const Sidebar = () => {
|
||||
to={url}
|
||||
key={`${group}-${title}`}
|
||||
className={twMerge(
|
||||
pathname === url ? 'bg-[#707FDD]/10 font-bold' : '',
|
||||
path === url ? 'bg-[#707FDD]/10 font-bold' : '',
|
||||
'group/menu flex h-[42px] w-[200px] items-center gap-x-3 rounded-md px-5 transition-all hover:bg-[#707FDD]/10',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={twMerge(
|
||||
pathname === url ? 'text-[#5363AB]' : 'text-[#A6ABC8]',
|
||||
path === url ? 'text-[#5363AB]' : 'text-[#A6ABC8]',
|
||||
'h-[18px] w-[18px] transition-all group-hover/menu:text-[#5363AB]',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={twMerge(
|
||||
pathname === url ? 'text-[#5363AB]' : 'text-[#273240]',
|
||||
path === url ? 'text-[#5363AB]' : 'text-[#273240]',
|
||||
'text-base transition-all group-hover/menu:text-[#5363AB]',
|
||||
)}
|
||||
>
|
||||
|
||||
@ -1,45 +1,205 @@
|
||||
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, useNavigate, useRouteLoaderData } from 'react-router'
|
||||
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { SearchIcon } from '~/components/icons/search'
|
||||
import DefaultTextEditor from '~/components/ui/text-editor'
|
||||
import { TextEditor } from '~/components/text-editor'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Combobox } from '~/components/ui/combobox'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import { Switch } from '~/components/ui/switch'
|
||||
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.length, {
|
||||
message: 'Please select a category',
|
||||
}),
|
||||
tags: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
)
|
||||
.optional(),
|
||||
title: z.string().min(1, {
|
||||
message: 'Judul is required',
|
||||
}),
|
||||
content: z.string().min(1, {
|
||||
message: 'Konten 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 navigate = useNavigate()
|
||||
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),
|
||||
defaultValues: {
|
||||
categories: [],
|
||||
tags: [],
|
||||
title: '',
|
||||
content: '',
|
||||
featured_image: '',
|
||||
is_premium: false,
|
||||
live_at: '',
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
navigate('/lg-admin/contents')
|
||||
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">
|
||||
<Input
|
||||
id="title"
|
||||
label="Judul"
|
||||
placeholder="Masukkan Judul"
|
||||
name="title"
|
||||
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="featured_image"
|
||||
label="Gambar Unggulan"
|
||||
placeholder="Masukkan Gambar Unggulan"
|
||||
name="featured_image"
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="text-md h-[42px] rounded-md"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</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"
|
||||
labelClassName="text-sm font-medium text-[#363636]"
|
||||
/>
|
||||
<Switch
|
||||
id="is_premium"
|
||||
name="is_premium"
|
||||
label="Premium"
|
||||
labelClassName="text-sm font-medium text-[#363636]"
|
||||
className="h-[42px]"
|
||||
/>
|
||||
</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>
|
||||
<TextEditor
|
||||
id="content"
|
||||
name="content"
|
||||
label="Konten"
|
||||
placeholder="Masukkan Konten"
|
||||
className="shadow"
|
||||
inputClassName="bg-white focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none border-0"
|
||||
labelClassName="text-sm font-medium text-[#363636]"
|
||||
category="content"
|
||||
/>
|
||||
</fetcher.Form>
|
||||
</RemixFormProvider>
|
||||
|
||||
<section>
|
||||
<DefaultTextEditor />
|
||||
</section>
|
||||
<DevTool control={control} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
type TContens = {
|
||||
type TContents = {
|
||||
id: number
|
||||
createdAt: string
|
||||
author: string
|
||||
@ -8,7 +8,7 @@ type TContens = {
|
||||
status: string
|
||||
}
|
||||
|
||||
export const CONTENTS: TContens[] = [
|
||||
export const CONTENTS: TContents[] = [
|
||||
{
|
||||
id: 1,
|
||||
createdAt: '24/10/2024',
|
||||
|
||||
@ -1,37 +1,39 @@
|
||||
import { Field, Input, Label, Select } from '@headlessui/react'
|
||||
import DT from 'datatables.net-dt'
|
||||
import DataTable from 'datatables.net-react'
|
||||
import { Link } from 'react-router'
|
||||
import { Link, useRouteLoaderData } from 'react-router'
|
||||
|
||||
import { SearchIcon } from '~/components/icons/search'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { UiTable } from '~/components/ui/table'
|
||||
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
||||
|
||||
import { CONTENTS } from './data'
|
||||
import type { loader } from '~/routes/_admin.lg-admin._dashboard.contents'
|
||||
|
||||
export const ContentsPage = () => {
|
||||
const loaderData = useRouteLoaderData<typeof loader>(
|
||||
'routes/_admin.lg-admin._dashboard.contents',
|
||||
)
|
||||
const newsData = loaderData?.newsData
|
||||
|
||||
DataTable.use(DT)
|
||||
const dataTable = CONTENTS
|
||||
const dataTable = newsData
|
||||
const dataColumns = [
|
||||
{ title: 'No', data: 'id' },
|
||||
{ title: 'Tanggal Kontent', data: 'createdAt' },
|
||||
{ title: 'Nama Penulis', data: 'author' },
|
||||
{ title: 'Tanggal Konten', data: 'created_at' },
|
||||
{ title: 'Nama Penulis', data: 'author_id' },
|
||||
{ title: 'Judul', data: 'title' },
|
||||
{ title: 'Kategori', data: 'category' },
|
||||
// { title: 'Kategori', data: 'category' },
|
||||
{
|
||||
title: 'Tags',
|
||||
data: 'tags',
|
||||
data: 'is_premium',
|
||||
render: (value: string) => {
|
||||
return value === 'Normal'
|
||||
? `<span class="bg-[#F5F5F5] text-[#4C5CA0] px-2 py-1 rounded-md">${value}</span>`
|
||||
: `<span class="bg-[#FFFCAF] px-2 py-1 rounded-md">${value}</span>`
|
||||
return value
|
||||
? `<span class="bg-[#FFFCAF] text-[#DBCA6E] px-4 py-2 rounded-full">Premium</span>`
|
||||
: `<span class="bg-[#F5F5F5] text-[#4C5CA0] px-4 py-2 rounded-full">Normal</span>`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
data: 'id',
|
||||
},
|
||||
// {
|
||||
// title: 'Action',
|
||||
// data: 'id',
|
||||
// },
|
||||
]
|
||||
const dataSlot = {
|
||||
6: (value: string | number) => {
|
||||
@ -57,41 +59,12 @@ export const ContentsPage = () => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<TitleDashboard title="Konten" />
|
||||
<div className="mb-8 flex items-end justify-between">
|
||||
<div className="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">
|
||||
Cari User
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Cari Nama"
|
||||
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>
|
||||
<div className="mb-8 flex items-end justify-between gap-5">
|
||||
<div className="flex-1">{/* TODO: Filter */}</div>
|
||||
<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,4 +1,16 @@
|
||||
import { getNews } from '~/apis/admin/get-news'
|
||||
import { handleCookie } from '~/libs/cookies'
|
||||
import { ContentsPage } from '~/pages/dashboard-contents'
|
||||
|
||||
import type { Route } from './+types/_admin.lg-admin._dashboard.contents'
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const { staffToken } = await handleCookie(request)
|
||||
const { data: newsData } = await getNews({
|
||||
accessToken: staffToken,
|
||||
})
|
||||
return { newsData }
|
||||
}
|
||||
|
||||
const DashboardContentsLayout = () => <ContentsPage />
|
||||
export default DashboardContentsLayout
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
64
app/routes/actions.admin.contents.create.ts
Normal file
64
app/routes/actions.admin.contents.create.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { data } from 'react-router'
|
||||
import { getValidatedFormData } from 'remix-hook-form'
|
||||
import { XiorError } from 'xior'
|
||||
|
||||
import { createNewsRequest } from '~/apis/admin/create-news'
|
||||
import { handleCookie } from '~/libs/cookies'
|
||||
import { contentSchema, type TContentSchema } from '~/pages/contents-create'
|
||||
|
||||
import type { Route } from './+types/actions.register'
|
||||
|
||||
export const action = async ({ request }: Route.ActionArgs) => {
|
||||
const { staffToken } = await handleCookie(request)
|
||||
try {
|
||||
const {
|
||||
errors,
|
||||
data: payload,
|
||||
receivedValues: defaultValues,
|
||||
} = await getValidatedFormData<TContentSchema>(
|
||||
request,
|
||||
zodResolver(contentSchema),
|
||||
false,
|
||||
)
|
||||
|
||||
if (errors) {
|
||||
return data({ success: false, errors, defaultValues }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data: newsData } = await createNewsRequest({
|
||||
accessToken: staffToken,
|
||||
payload,
|
||||
})
|
||||
|
||||
return data(
|
||||
{
|
||||
success: true,
|
||||
newsData,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof XiorError) {
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
message: error?.response?.data?.error?.message || error.message,
|
||||
},
|
||||
{
|
||||
status: error?.response?.status || 500,
|
||||
},
|
||||
)
|
||||
}
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
39
app/utils/color.ts
Normal file
39
app/utils/color.ts
Normal file
@ -0,0 +1,39 @@
|
||||
export const isHexCompatible = (hexColor?: string): boolean => {
|
||||
if (hexColor === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
const hexColorRegex = /^#([\dA-Fa-f]{6}|[\dA-Fa-f]{3})$/
|
||||
return hexColorRegex.test(hexColor)
|
||||
}
|
||||
|
||||
export const rgbToHex = (rgb: string): string => {
|
||||
// Extract the integers by matching against a regex
|
||||
const result = rgb.match(/\d+/g)
|
||||
|
||||
if (!result) {
|
||||
return '#000000' // Set default color to #000000 if the RGB string is invalid
|
||||
}
|
||||
|
||||
const [red, green, blue] = result.map(Number)
|
||||
|
||||
// Ensure the values are valid
|
||||
if (
|
||||
red < 0 ||
|
||||
red > 255 ||
|
||||
green < 0 ||
|
||||
green > 255 ||
|
||||
blue < 0 ||
|
||||
blue > 255
|
||||
) {
|
||||
return '#000000' // Set default color to #000000 if the RGB values are invalid
|
||||
}
|
||||
|
||||
// Convert each component to hex
|
||||
const redHex = red.toString(16).padStart(2, '0')
|
||||
const greenHex = green.toString(16).padStart(2, '0')
|
||||
const blueHex = blue.toString(16).padStart(2, '0')
|
||||
|
||||
// Return the combined string
|
||||
return `#${redHex}${greenHex}${blueHex}`
|
||||
}
|
||||
@ -17,9 +17,16 @@
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^4.1.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@react-router/fs-routes": "^7.1.3",
|
||||
"@react-router/node": "^7.1.3",
|
||||
"@react-router/serve": "^7.1.3",
|
||||
"@tiptap/extension-color": "^2.11.5",
|
||||
"@tiptap/extension-highlight": "^2.11.5",
|
||||
"@tiptap/extension-image": "^2.11.5",
|
||||
"@tiptap/extension-link": "^2.11.5",
|
||||
"@tiptap/extension-text-align": "^2.11.5",
|
||||
"@tiptap/extension-text-style": "^2.11.5",
|
||||
"@tiptap/react": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"chart.js": "^4.4.8",
|
||||
@ -32,6 +39,7 @@
|
||||
"jose": "^6.0.8",
|
||||
"react": "^19.0.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-router": "^7.1.3",
|
||||
|
||||
121
pnpm-lock.yaml
generated
121
pnpm-lock.yaml
generated
@ -17,6 +17,9 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1(react-hook-form@7.54.2(react@19.0.0))
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@react-router/fs-routes':
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3(@react-router/dev@7.1.3(@react-router/serve@7.1.3(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.7.3))(@types/node@20.17.16)(babel-plugin-macros@3.1.0)(lightningcss@1.29.1)(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.7.3)(vite@5.4.14(@types/node@20.17.16)(lightningcss@1.29.1)))(typescript@5.7.3)
|
||||
@ -26,6 +29,24 @@ importers:
|
||||
'@react-router/serve':
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.7.3)
|
||||
'@tiptap/extension-color':
|
||||
specifier: ^2.11.5
|
||||
version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/extension-text-style@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5)))
|
||||
'@tiptap/extension-highlight':
|
||||
specifier: ^2.11.5
|
||||
version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))
|
||||
'@tiptap/extension-image':
|
||||
specifier: ^2.11.5
|
||||
version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^2.11.5
|
||||
version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/pm@2.11.5)
|
||||
'@tiptap/extension-text-align':
|
||||
specifier: ^2.11.5
|
||||
version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))
|
||||
'@tiptap/extension-text-style':
|
||||
specifier: ^2.11.5
|
||||
version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))
|
||||
'@tiptap/react':
|
||||
specifier: ^2.11.5
|
||||
version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/pm@2.11.5)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
@ -62,6 +83,9 @@ importers:
|
||||
react-chartjs-2:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0(chart.js@4.4.8)(react@19.0.0)
|
||||
react-colorful:
|
||||
specifier: ^5.6.1
|
||||
version: 5.6.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.0.0(react@19.0.0)
|
||||
@ -708,6 +732,16 @@ packages:
|
||||
'@mjackson/node-fetch-server@0.2.0':
|
||||
resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==}
|
||||
|
||||
'@monaco-editor/loader@1.5.0':
|
||||
resolution: {integrity: sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==}
|
||||
|
||||
'@monaco-editor/react@4.7.0':
|
||||
resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
|
||||
peerDependencies:
|
||||
monaco-editor: '>= 0.25.0 < 1'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@ -1474,6 +1508,12 @@ packages:
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-color@2.11.5':
|
||||
resolution: {integrity: sha512-9gZF6EIpfOJYUt1TtFY37e8iqwKcOmBl8CkFaxq+4mWVvYd2D7KbA0r4tYTxSO0fOBJ5fA/1qJrpvgRlyocp/A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/extension-text-style': ^2.7.0
|
||||
|
||||
'@tiptap/extension-document@2.11.5':
|
||||
resolution: {integrity: sha512-7I4BRTpIux2a0O2qS3BDmyZ5LGp3pszKbix32CmeVh7lN9dV7W5reDqtJJ9FCZEEF+pZ6e1/DQA362dflwZw2g==}
|
||||
peerDependencies:
|
||||
@ -1507,6 +1547,11 @@ packages:
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-highlight@2.11.5':
|
||||
resolution: {integrity: sha512-VBZfT869L9CiTLF8qr+3FBUtJcmlyUTECORNo0ceEiNDg4H6V9uNPwaROMXrWiQCc+DYVCOkx541QrXwNMzxlg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-history@2.11.5':
|
||||
resolution: {integrity: sha512-b+wOS33Dz1azw6F1i9LFTEIJ/gUui0Jwz5ZvmVDpL2ZHBhq1Ui0/spTT+tuZOXq7Y/uCbKL8Liu4WoedIvhboQ==}
|
||||
peerDependencies:
|
||||
@ -1519,11 +1564,22 @@ packages:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/extension-image@2.11.5':
|
||||
resolution: {integrity: sha512-HbUq9AL8gb8eSuQfY/QKkvMc66ZFN/b6jvQAILGArNOgalUfGizoC6baKTJShaExMSPjBZlaAHtJiQKPaGRHaA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-italic@2.11.5':
|
||||
resolution: {integrity: sha512-9VGfb2/LfPhQ6TjzDwuYLRvw0A6VGbaIp3F+5Mql8XVdTBHb2+rhELbyhNGiGVR78CaB/EiKb6dO9xu/tBWSYA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-link@2.11.5':
|
||||
resolution: {integrity: sha512-4Iu/aPzevbYpe50xDI0ZkqRa6nkZ9eF270Ue2qaF3Ab47nehj+9Jl78XXzo8+LTyFMnrETI73TAs1aC/IGySeQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/extension-list-item@2.11.5':
|
||||
resolution: {integrity: sha512-Mp5RD/pbkfW1vdc6xMVxXYcta73FOwLmblQlFNn/l/E5/X1DUSA4iGhgDDH4EWO3swbs03x2f7Zka/Xoj3+WLg==}
|
||||
peerDependencies:
|
||||
@ -1544,6 +1600,11 @@ packages:
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-text-align@2.11.5':
|
||||
resolution: {integrity: sha512-Ei0zDpH5N9EV59ogydK4HTKa4lCPicCsQllM5n/Nf2tUJPir3aiYxzJ73FzhComD4Hpo1ANYnmssBhy8QeoPZA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-text-style@2.11.5':
|
||||
resolution: {integrity: sha512-YUmYl0gILSd/u/ZkOmNxjNXVw+mu8fpC2f8G4I4tLODm0zCx09j9DDEJXSrM5XX72nxJQqtSQsCpNKnL0hfeEQ==}
|
||||
peerDependencies:
|
||||
@ -3173,6 +3234,9 @@ packages:
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
linkifyjs@4.2.0:
|
||||
resolution: {integrity: sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw==}
|
||||
|
||||
lint-staged@15.4.3:
|
||||
resolution: {integrity: sha512-FoH1vOeouNh1pw+90S+cnuoFwRfUD9ijY2GKy5h7HS3OR7JVir2N2xrsa0+Twc1B7cW72L+88geG5cW4wIhn7g==}
|
||||
engines: {node: '>=18.12.0'}
|
||||
@ -3337,6 +3401,9 @@ packages:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
monaco-editor@0.52.2:
|
||||
resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==}
|
||||
|
||||
morgan@1.10.0:
|
||||
resolution: {integrity: sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@ -3791,6 +3858,12 @@ packages:
|
||||
chart.js: ^4.1.1
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react-colorful@5.6.1:
|
||||
resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
react-d3-tree@3.6.2:
|
||||
resolution: {integrity: sha512-1ExQlmEnv5iOw9XfZ3EcESDjzGXVKPAmyDJTJbvVfiwkplZtP7CcNEY0tKZf4XSW0FzYJf4aFXprGJen+95yuw==}
|
||||
peerDependencies:
|
||||
@ -4135,6 +4208,9 @@ packages:
|
||||
stable-hash@0.0.4:
|
||||
resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==}
|
||||
|
||||
state-local@1.0.7:
|
||||
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
||||
|
||||
statuses@2.0.1:
|
||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@ -5214,6 +5290,17 @@ snapshots:
|
||||
|
||||
'@mjackson/node-fetch-server@0.2.0': {}
|
||||
|
||||
'@monaco-editor/loader@1.5.0':
|
||||
dependencies:
|
||||
state-local: 1.0.7
|
||||
|
||||
'@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@monaco-editor/loader': 1.5.0
|
||||
monaco-editor: 0.52.2
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@ -5948,6 +6035,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
|
||||
'@tiptap/extension-color@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/extension-text-style@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5)))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
'@tiptap/extension-text-style': 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))
|
||||
|
||||
'@tiptap/extension-document@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
@ -5976,6 +6068,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
|
||||
'@tiptap/extension-highlight@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
|
||||
'@tiptap/extension-history@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/pm@2.11.5)':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
@ -5986,10 +6082,20 @@ snapshots:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
'@tiptap/pm': 2.11.5
|
||||
|
||||
'@tiptap/extension-image@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
|
||||
'@tiptap/extension-italic@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
|
||||
'@tiptap/extension-link@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/pm@2.11.5)':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
'@tiptap/pm': 2.11.5
|
||||
linkifyjs: 4.2.0
|
||||
|
||||
'@tiptap/extension-list-item@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
@ -6006,6 +6112,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
|
||||
'@tiptap/extension-text-align@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
|
||||
'@tiptap/extension-text-style@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
|
||||
@ -7882,6 +7992,8 @@ snapshots:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
linkifyjs@4.2.0: {}
|
||||
|
||||
lint-staged@15.4.3:
|
||||
dependencies:
|
||||
chalk: 5.4.1
|
||||
@ -8028,6 +8140,8 @@ snapshots:
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
monaco-editor@0.52.2: {}
|
||||
|
||||
morgan@1.10.0:
|
||||
dependencies:
|
||||
basic-auth: 2.0.1
|
||||
@ -8457,6 +8571,11 @@ snapshots:
|
||||
chart.js: 4.4.8
|
||||
react: 19.0.0
|
||||
|
||||
react-colorful@5.6.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
|
||||
react-d3-tree@3.6.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
'@bkrem/react-transition-group': 1.3.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
@ -8876,6 +8995,8 @@ snapshots:
|
||||
|
||||
stable-hash@0.0.4: {}
|
||||
|
||||
state-local@1.0.7: {}
|
||||
|
||||
statuses@2.0.1: {}
|
||||
|
||||
stream-shift@1.0.3: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user