Merge remote-tracking branch 'origin/master' into feature/slicing

This commit is contained in:
fredy.siswanto 2025-03-06 11:46:58 +07:00
commit a635dc1431
25 changed files with 1379 additions and 165 deletions

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

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

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

@ -19,6 +19,10 @@ html,
body {
}
.ProseMirror-focused {
@apply outline-none;
}
table.dataTable thead > tr {
border-bottom: 2px solid #c2c2c2;
}

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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