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 { body {
} }
.ProseMirror-focused {
@apply outline-none;
}
table.dataTable thead > tr { table.dataTable thead > tr {
border-bottom: 2px solid #c2c2c2; 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 id: string
} }
type TInputProperties<T extends FieldValues> = ComponentProps< type TComboboxProperties<T extends FieldValues> = ComponentProps<
typeof HeadlessCombobox typeof HeadlessCombobox
> & { > & {
id: string id: string
@ -35,13 +35,26 @@ type TInputProperties<T extends FieldValues> = ComponentProps<
rules?: RegisterOptions rules?: RegisterOptions
placeholder?: string placeholder?: string
options?: TComboboxOption[] options?: TComboboxOption[]
labelClassName?: string
containerClassName?: string
} }
export const Combobox = <TFormValues extends Record<string, unknown>>( export const Combobox = <TFormValues extends Record<string, unknown>>(
properties: TInputProperties<TFormValues>, properties: TComboboxProperties<TFormValues>,
) => { ) => {
const { id, label, name, rules, disabled, placeholder, options, ...rest } = const {
properties id,
label,
name,
rules,
disabled,
placeholder,
options,
className,
labelClassName,
containerClassName,
...rest
} = properties
const { const {
control, control,
formState: { errors }, formState: { errors },
@ -58,11 +71,11 @@ export const Combobox = <TFormValues extends Record<string, unknown>>(
return ( return (
<Field <Field
className="relative" className={twMerge('relative', containerClassName)}
disabled={disabled} disabled={disabled}
id={id} 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} {error && <span className="text-red-500">{error.message}</span>}
</Label> </Label>
<Controller <Controller
@ -82,7 +95,10 @@ export const Combobox = <TFormValues extends Record<string, unknown>>(
placeholder={placeholder} placeholder={placeholder}
displayValue={(option: TComboboxOption) => option?.name} displayValue={(option: TComboboxOption) => option?.name}
onChange={(event) => setQuery(event.target.value)} 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"> <ComboboxButton className="group absolute inset-y-0 right-0 px-2.5">
<ChevronDownIcon className="size-4 fill-gray-500" /> <ChevronDownIcon className="size-4 fill-gray-500" />

View File

@ -22,6 +22,8 @@ type TInputProperties<T extends FieldValues> = Omit<
label?: ReactNode label?: ReactNode
name: Path<T> name: Path<T>
rules?: RegisterOptions rules?: RegisterOptions
containerClassName?: string
labelClassName?: string
} }
export const Input = <TFormValues extends Record<string, unknown>>( export const Input = <TFormValues extends Record<string, unknown>>(
@ -35,6 +37,9 @@ export const Input = <TFormValues extends Record<string, unknown>>(
type = 'text', type = 'text',
placeholder, placeholder,
disabled, disabled,
className,
containerClassName,
labelClassName,
...rest ...rest
} = properties } = properties
const [inputType, setInputType] = useState(type) const [inputType, setInputType] = useState(type)
@ -48,16 +53,19 @@ export const Input = <TFormValues extends Record<string, unknown>>(
return ( return (
<Field <Field
className="relative" className={twMerge('relative', containerClassName)}
disabled={disabled} disabled={disabled}
id={id} 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} {error && <span className="text-red-500">{error.message}</span>}
</Label> </Label>
<HeadlessInput <HeadlessInput
type={inputType} 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} placeholder={inputType === 'password' ? '******' : placeholder}
{...register(name, rules)} {...register(name, rules)}
{...rest} {...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 DefaultTextEditor = () => {
const editor = useEditor({ const editor = useEditor({
extensions: [StarterKit], extensions: [StarterKit],
immediatelyRender: false,
content: 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>', '<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) => { export const TitleDashboard = (properties: TitleDashboardProperties) => {
const { title } = properties const { title } = properties
return ( return (
<div className="container mx-auto"> <div className="container">
<div className="mb-5 flex items-center justify-between"> <div className="mb-5 flex items-center justify-between">
<h1 className="text-xl font-bold">{title}</h1> <h1 className="text-xl font-bold">{title}</h1>
</div> </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 = () => { export const Sidebar = () => {
const { pathname } = useLocation() const { pathname } = useLocation()
const segments = pathname.split('/')
const path = segments.length > 3 ? segments.slice(0, 3).join('/') : pathname
return ( return (
<div className="flex min-h-[calc(100dvh-80px)] flex-col gap-y-10 overflow-y-auto bg-white p-5"> <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} to={url}
key={`${group}-${title}`} key={`${group}-${title}`}
className={twMerge( 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', 'group/menu flex h-[42px] w-[200px] items-center gap-x-3 rounded-md px-5 transition-all hover:bg-[#707FDD]/10',
)} )}
> >
<Icon <Icon
className={twMerge( 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]', 'h-[18px] w-[18px] transition-all group-hover/menu:text-[#5363AB]',
)} )}
/> />
<span <span
className={twMerge( className={twMerge(
pathname === url ? 'text-[#5363AB]' : 'text-[#273240]', path === url ? 'text-[#5363AB]' : 'text-[#273240]',
'text-base transition-all group-hover/menu:text-[#5363AB]', '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 { TextEditor } from '~/components/text-editor'
import DefaultTextEditor from '~/components/ui/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 { 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 = () => { 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 ( return (
<div className="relative"> <div className="relative">
<TitleDashboard title="Buat Artikel" /> <TitleDashboard title="Buat Artikel" />
<div className="mb-8 flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]"> <RemixFormProvider {...formMethods}>
<div className="w-[400px]"> <fetcher.Form
<Field> method="post"
<Label className="mb-2 block text-sm font-medium">Pilih Tags</Label> onSubmit={handleSubmit}
<div className="relative"> 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 <Input
type="text" id="title"
placeholder="Cari Tags" label="Judul"
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none" 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"
/> />
<div className="absolute inset-y-0 right-0 flex items-center pr-3"> <Input
<SearchIcon className="h-5 w-5" /> 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>
</div> <div className="flex items-end justify-between gap-4">
</Field> <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>
<div className="w-[235px]"> <TextEditor
<Field> id="content"
<Label className="mb-2 block text-sm font-medium">Status</Label> name="content"
<Select className="w-full rounded-lg bg-white p-2 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"> label="Konten"
<option>Pilih Status</option> placeholder="Masukkan Konten"
<option>Aktif</option> className="shadow"
<option>Nonaktif</option> inputClassName="bg-white focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none border-0"
</Select> labelClassName="text-sm font-medium text-[#363636]"
</Field> category="content"
</div> />
</div> </fetcher.Form>
</RemixFormProvider>
<section> <DevTool control={control} />
<DefaultTextEditor />
</section>
</div> </div>
) )
} }

View File

@ -1,4 +1,4 @@
type TContens = { type TContents = {
id: number id: number
createdAt: string createdAt: string
author: string author: string
@ -8,7 +8,7 @@ type TContens = {
status: string status: string
} }
export const CONTENTS: TContens[] = [ export const CONTENTS: TContents[] = [
{ {
id: 1, id: 1,
createdAt: '24/10/2024', 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 DT from 'datatables.net-dt'
import DataTable from 'datatables.net-react' 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 { Button } from '~/components/ui/button'
import { UiTable } from '~/components/ui/table' import { UiTable } from '~/components/ui/table'
import { TitleDashboard } from '~/components/ui/title-dashboard' import { TitleDashboard } from '~/components/ui/title-dashboard'
import type { loader } from '~/routes/_admin.lg-admin._dashboard.contents'
import { CONTENTS } from './data'
export const ContentsPage = () => { export const ContentsPage = () => {
const loaderData = useRouteLoaderData<typeof loader>(
'routes/_admin.lg-admin._dashboard.contents',
)
const newsData = loaderData?.newsData
DataTable.use(DT) DataTable.use(DT)
const dataTable = CONTENTS const dataTable = newsData
const dataColumns = [ const dataColumns = [
{ title: 'No', data: 'id' }, { title: 'No', data: 'id' },
{ title: 'Tanggal Kontent', data: 'createdAt' }, { title: 'Tanggal Konten', data: 'created_at' },
{ title: 'Nama Penulis', data: 'author' }, { title: 'Nama Penulis', data: 'author_id' },
{ title: 'Judul', data: 'title' }, { title: 'Judul', data: 'title' },
{ title: 'Kategori', data: 'category' }, // { title: 'Kategori', data: 'category' },
{ {
title: 'Tags', title: 'Tags',
data: 'tags', data: 'is_premium',
render: (value: string) => { render: (value: string) => {
return value === 'Normal' return value
? `<span class="bg-[#F5F5F5] text-[#4C5CA0] px-2 py-1 rounded-md">${value}</span>` ? `<span class="bg-[#FFFCAF] text-[#DBCA6E] px-4 py-2 rounded-full">Premium</span>`
: `<span class="bg-[#FFFCAF] px-2 py-1 rounded-md">${value}</span>` : `<span class="bg-[#F5F5F5] text-[#4C5CA0] px-4 py-2 rounded-full">Normal</span>`
}, },
}, },
{ // {
title: 'Action', // title: 'Action',
data: 'id', // data: 'id',
}, // },
] ]
const dataSlot = { const dataSlot = {
6: (value: string | number) => { 6: (value: string | number) => {
@ -57,41 +59,12 @@ export const ContentsPage = () => {
return ( return (
<div className="relative"> <div className="relative">
<TitleDashboard title="Konten" /> <TitleDashboard title="Konten" />
<div className="mb-8 flex items-end justify-between"> <div className="mb-8 flex items-end justify-between gap-5">
<div className="flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]"> <div className="flex-1">{/* TODO: Filter */}</div>
<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>
<Button <Button
as={Link} as={Link}
to="/lg-admin/contents/create" to="/lg-admin/contents/create"
className="text-md rounded-md" className="text-md h-[42px] rounded-md"
size="lg" size="lg"
> >
Create New 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 { 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 /> const DashboardContentsLayout = () => <ContentsPage />
export default DashboardContentsLayout export default DashboardContentsLayout

View File

@ -1,6 +1,8 @@
import { Outlet, redirect } from 'react-router' import { Outlet, redirect } from 'react-router'
import { getStaff } from '~/apis/admin/get-staff' 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 { AUTH_PAGES } from '~/configs/pages'
import { AdminProvider } from '~/contexts/admin' import { AdminProvider } from '~/contexts/admin'
import { AdminDefaultLayout } from '~/layouts/admin/default' import { AdminDefaultLayout } from '~/layouts/admin/default'
@ -28,9 +30,13 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
}) })
staffData = data staffData = data
} }
const { data: categoriesData } = await getCategories()
const { data: tagsData } = await getTags()
return { return {
staffData, 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", "@headlessui/react": "^2.2.0",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^4.1.1", "@hookform/resolvers": "^4.1.1",
"@monaco-editor/react": "^4.7.0",
"@react-router/fs-routes": "^7.1.3", "@react-router/fs-routes": "^7.1.3",
"@react-router/node": "^7.1.3", "@react-router/node": "^7.1.3",
"@react-router/serve": "^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/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5", "@tiptap/starter-kit": "^2.11.5",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
@ -32,6 +39,7 @@
"jose": "^6.0.8", "jose": "^6.0.8",
"react": "^19.0.0", "react": "^19.0.0",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-router": "^7.1.3", "react-router": "^7.1.3",

121
pnpm-lock.yaml generated
View File

@ -17,6 +17,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^4.1.1 specifier: ^4.1.1
version: 4.1.1(react-hook-form@7.54.2(react@19.0.0)) 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': '@react-router/fs-routes':
specifier: ^7.1.3 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) 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': '@react-router/serve':
specifier: ^7.1.3 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) 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': '@tiptap/react':
specifier: ^2.11.5 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) 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: react-chartjs-2:
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.3.0(chart.js@4.4.8)(react@19.0.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: react-dom:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.0.0(react@19.0.0) version: 19.0.0(react@19.0.0)
@ -708,6 +732,16 @@ packages:
'@mjackson/node-fetch-server@0.2.0': '@mjackson/node-fetch-server@0.2.0':
resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} 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': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -1474,6 +1508,12 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': ^2.7.0 '@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': '@tiptap/extension-document@2.11.5':
resolution: {integrity: sha512-7I4BRTpIux2a0O2qS3BDmyZ5LGp3pszKbix32CmeVh7lN9dV7W5reDqtJJ9FCZEEF+pZ6e1/DQA362dflwZw2g==} resolution: {integrity: sha512-7I4BRTpIux2a0O2qS3BDmyZ5LGp3pszKbix32CmeVh7lN9dV7W5reDqtJJ9FCZEEF+pZ6e1/DQA362dflwZw2g==}
peerDependencies: peerDependencies:
@ -1507,6 +1547,11 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': ^2.7.0 '@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': '@tiptap/extension-history@2.11.5':
resolution: {integrity: sha512-b+wOS33Dz1azw6F1i9LFTEIJ/gUui0Jwz5ZvmVDpL2ZHBhq1Ui0/spTT+tuZOXq7Y/uCbKL8Liu4WoedIvhboQ==} resolution: {integrity: sha512-b+wOS33Dz1azw6F1i9LFTEIJ/gUui0Jwz5ZvmVDpL2ZHBhq1Ui0/spTT+tuZOXq7Y/uCbKL8Liu4WoedIvhboQ==}
peerDependencies: peerDependencies:
@ -1519,11 +1564,22 @@ packages:
'@tiptap/core': ^2.7.0 '@tiptap/core': ^2.7.0
'@tiptap/pm': ^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': '@tiptap/extension-italic@2.11.5':
resolution: {integrity: sha512-9VGfb2/LfPhQ6TjzDwuYLRvw0A6VGbaIp3F+5Mql8XVdTBHb2+rhELbyhNGiGVR78CaB/EiKb6dO9xu/tBWSYA==} resolution: {integrity: sha512-9VGfb2/LfPhQ6TjzDwuYLRvw0A6VGbaIp3F+5Mql8XVdTBHb2+rhELbyhNGiGVR78CaB/EiKb6dO9xu/tBWSYA==}
peerDependencies: peerDependencies:
'@tiptap/core': ^2.7.0 '@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': '@tiptap/extension-list-item@2.11.5':
resolution: {integrity: sha512-Mp5RD/pbkfW1vdc6xMVxXYcta73FOwLmblQlFNn/l/E5/X1DUSA4iGhgDDH4EWO3swbs03x2f7Zka/Xoj3+WLg==} resolution: {integrity: sha512-Mp5RD/pbkfW1vdc6xMVxXYcta73FOwLmblQlFNn/l/E5/X1DUSA4iGhgDDH4EWO3swbs03x2f7Zka/Xoj3+WLg==}
peerDependencies: peerDependencies:
@ -1544,6 +1600,11 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': ^2.7.0 '@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': '@tiptap/extension-text-style@2.11.5':
resolution: {integrity: sha512-YUmYl0gILSd/u/ZkOmNxjNXVw+mu8fpC2f8G4I4tLODm0zCx09j9DDEJXSrM5XX72nxJQqtSQsCpNKnL0hfeEQ==} resolution: {integrity: sha512-YUmYl0gILSd/u/ZkOmNxjNXVw+mu8fpC2f8G4I4tLODm0zCx09j9DDEJXSrM5XX72nxJQqtSQsCpNKnL0hfeEQ==}
peerDependencies: peerDependencies:
@ -3173,6 +3234,9 @@ packages:
linkify-it@5.0.0: linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} 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: lint-staged@15.4.3:
resolution: {integrity: sha512-FoH1vOeouNh1pw+90S+cnuoFwRfUD9ijY2GKy5h7HS3OR7JVir2N2xrsa0+Twc1B7cW72L+88geG5cW4wIhn7g==} resolution: {integrity: sha512-FoH1vOeouNh1pw+90S+cnuoFwRfUD9ijY2GKy5h7HS3OR7JVir2N2xrsa0+Twc1B7cW72L+88geG5cW4wIhn7g==}
engines: {node: '>=18.12.0'} engines: {node: '>=18.12.0'}
@ -3337,6 +3401,9 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
monaco-editor@0.52.2:
resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==}
morgan@1.10.0: morgan@1.10.0:
resolution: {integrity: sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==} resolution: {integrity: sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -3791,6 +3858,12 @@ packages:
chart.js: ^4.1.1 chart.js: ^4.1.1
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 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: react-d3-tree@3.6.2:
resolution: {integrity: sha512-1ExQlmEnv5iOw9XfZ3EcESDjzGXVKPAmyDJTJbvVfiwkplZtP7CcNEY0tKZf4XSW0FzYJf4aFXprGJen+95yuw==} resolution: {integrity: sha512-1ExQlmEnv5iOw9XfZ3EcESDjzGXVKPAmyDJTJbvVfiwkplZtP7CcNEY0tKZf4XSW0FzYJf4aFXprGJen+95yuw==}
peerDependencies: peerDependencies:
@ -4135,6 +4208,9 @@ packages:
stable-hash@0.0.4: stable-hash@0.0.4:
resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==} resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==}
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
statuses@2.0.1: statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -5214,6 +5290,17 @@ snapshots:
'@mjackson/node-fetch-server@0.2.0': {} '@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': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@ -5948,6 +6035,11 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) '@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))': '@tiptap/extension-document@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))':
dependencies: dependencies:
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
@ -5976,6 +6068,10 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) '@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)': '@tiptap/extension-history@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/pm@2.11.5)':
dependencies: dependencies:
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) '@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/core': 2.11.5(@tiptap/pm@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))': '@tiptap/extension-italic@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))':
dependencies: dependencies:
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) '@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))': '@tiptap/extension-list-item@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))':
dependencies: dependencies:
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
@ -6006,6 +6112,10 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) '@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))': '@tiptap/extension-text-style@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))':
dependencies: dependencies:
'@tiptap/core': 2.11.5(@tiptap/pm@2.11.5) '@tiptap/core': 2.11.5(@tiptap/pm@2.11.5)
@ -7882,6 +7992,8 @@ snapshots:
dependencies: dependencies:
uc.micro: 2.1.0 uc.micro: 2.1.0
linkifyjs@4.2.0: {}
lint-staged@15.4.3: lint-staged@15.4.3:
dependencies: dependencies:
chalk: 5.4.1 chalk: 5.4.1
@ -8028,6 +8140,8 @@ snapshots:
minipass@7.1.2: {} minipass@7.1.2: {}
monaco-editor@0.52.2: {}
morgan@1.10.0: morgan@1.10.0:
dependencies: dependencies:
basic-auth: 2.0.1 basic-auth: 2.0.1
@ -8457,6 +8571,11 @@ snapshots:
chart.js: 4.4.8 chart.js: 4.4.8
react: 19.0.0 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): react-d3-tree@3.6.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies: dependencies:
'@bkrem/react-transition-group': 1.3.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@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: {} stable-hash@0.0.4: {}
state-local@1.0.7: {}
statuses@2.0.1: {} statuses@2.0.1: {}
stream-shift@1.0.3: {} stream-shift@1.0.3: {}