Compare commits
262
Commits
120450fd7c
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b4ea17a16 | ||
|
|
eedb155880 | ||
|
|
84451f1fd4 | ||
|
|
dce745f53d | ||
|
|
57e23adf3c | ||
|
|
35c02d1643 | ||
|
|
1f2f15d204 | ||
|
|
39b7720186 | ||
|
|
de1802c597 | ||
|
|
724a14d741 | ||
|
|
fa5f7fbe92 | ||
|
|
4ff1e23d25 | ||
|
|
9ab67c615a | ||
|
|
65f7bbe0aa | ||
|
|
1c33eba834 | ||
|
|
1885eab4c3 | ||
|
|
f2f49de86b | ||
|
|
e81dad4ec5 | ||
|
|
63bbf70bd6 | ||
|
|
33096ab7c1 | ||
|
|
ab5b545625 | ||
|
|
06672714b7 | ||
|
|
2f8b5dbe0f | ||
|
|
09f8669553 | ||
|
|
731b36ef70 | ||
|
|
305c2cf140 | ||
|
|
6edca07fa6 | ||
|
|
d65aed6828 | ||
|
|
ab3f748195 | ||
|
|
3847fd1896 | ||
|
|
2dc84c582d | ||
|
|
b8378d37ad | ||
|
|
bb3e1bb139 | ||
|
|
66de3dcf04 | ||
|
|
9c9d127dfb | ||
|
|
9745fff853 | ||
|
|
deeefda8bc | ||
|
|
abf7e829ba | ||
|
|
ba4e380023 | ||
|
|
4ca2d8cf84 | ||
|
|
1585830184 | ||
|
|
22918b8bdb | ||
|
|
d32eb2e7ed | ||
|
|
87616ef6bd | ||
|
|
0f332fde79 | ||
|
|
c633397d7b | ||
|
|
680a0a88a2 | ||
|
|
d91dffb013 | ||
|
|
00bb1a46dc | ||
|
|
fe38a63f1c | ||
|
|
978d74d226 | ||
|
|
d767055bdb | ||
|
|
7b840ce5cd | ||
|
|
e7ef7177ca | ||
|
|
4c3a143338 | ||
|
|
cc5331284b | ||
|
|
c7195b7428 | ||
|
|
6d99a37f20 | ||
|
|
405e57b92d | ||
|
|
cbfb8e72cc | ||
|
|
3ddc657cfb | ||
|
|
52085ea25e | ||
|
|
84d10ac983 | ||
|
|
19a5e6ab88 | ||
|
|
d538c56d26 | ||
|
|
deb2004039 | ||
|
|
5c20cc48ab | ||
|
|
bfa46cdca4 | ||
|
|
fc23f45854 | ||
|
|
29baa39a94 | ||
|
|
f04ad1a8f8 | ||
|
|
273add293e | ||
|
|
01f9cf06ca | ||
|
|
ff51941647 | ||
|
|
8a9cacf7b4 | ||
|
|
c89731e124 | ||
|
|
798896e4ee | ||
|
|
b00adf89ec | ||
|
|
2c703de8e5 | ||
|
|
eadfccfc0e | ||
|
|
d77ea01c59 | ||
|
|
6878da0db2 | ||
|
|
f40f2dadde | ||
|
|
cd76ded632 | ||
|
|
0f64b4600b | ||
|
|
0680fb4dc8 | ||
|
|
474c6dc750 | ||
|
|
86ee6abef7 | ||
|
|
86e87998bd | ||
|
|
d63884dde1 | ||
|
|
850f42e99d | ||
|
|
df3b14a212 | ||
|
|
7ab83a4f66 | ||
|
|
36b22d3f4a | ||
|
|
338f4a72c4 | ||
|
|
ac5c095ecc | ||
|
|
1ce5a2130b | ||
|
|
f423f3e1c0 | ||
|
|
c0318fac4c | ||
|
|
0545190497 | ||
|
|
634073342b | ||
|
|
eb7cc04256 | ||
|
|
f026277a88 | ||
|
|
4a21b7d331 | ||
|
|
930d4b8459 | ||
|
|
56c31d7a20 | ||
|
|
70cbb134d8 | ||
|
|
90e2042a91 | ||
|
|
c5b611a300 | ||
|
|
b9fb1112ae | ||
|
|
56d9081b4f | ||
|
|
2c90d32a10 | ||
|
|
8a19529230 | ||
|
|
d1b828bba1 | ||
|
|
2b253633a5 | ||
|
|
ea6462f3ea | ||
|
|
6f0a21dde6 | ||
|
|
07cced218e | ||
|
|
9a2d64eee4 | ||
|
|
dfd1a46694 | ||
|
|
4a188afc8f | ||
|
|
6ec8e5f2a2 | ||
|
|
66bc731b07 | ||
|
|
dbeec8acf3 | ||
|
|
8cddc82031 | ||
|
|
a45a6fb87e | ||
|
|
438d1d40db | ||
|
|
9a5bddef6a | ||
|
|
f97c79aaa9 | ||
|
|
d3f0dac69f | ||
|
|
7f7526783f | ||
|
|
0f5b26c2b8 | ||
|
|
60d11fbfa2 | ||
|
|
54a8a97c09 | ||
|
|
169f0166b8 | ||
|
|
e93c0a8464 | ||
|
|
ba83a6a025 | ||
|
|
c70aa86be6 | ||
|
|
2e13e11673 | ||
|
|
aae10cf3bc | ||
|
|
9a0c6c1f0b | ||
|
|
8c2298ff61 | ||
|
|
061f95d4e5 | ||
|
|
de92703fbe | ||
|
|
a9a350fdb2 | ||
|
|
4bc185434b | ||
|
|
4bb7a23795 | ||
|
|
f291290f55 | ||
|
|
e84c05d0a0 | ||
|
|
c7583413bb | ||
|
|
5b83c3f8dd | ||
|
|
0f414f6963 | ||
|
|
2940057cb1 | ||
|
|
9dca781d6d | ||
|
|
f17d64eb9b | ||
|
|
64bb369c59 | ||
|
|
422c9cbfe2 | ||
|
|
3d13707359 | ||
|
|
9f6c07c3de | ||
|
|
e7eda086e4 | ||
|
|
550b16cfd3 | ||
|
|
0b3217407c | ||
|
|
7afabdaa03 | ||
|
|
9f82779456 | ||
|
|
ee816b8db7 | ||
|
|
1b6321f2bd | ||
|
|
4847ef2be3 | ||
|
|
bad5294030 | ||
|
|
9976626f97 | ||
|
|
d38bf3a705 | ||
|
|
687e3c8d01 | ||
|
|
4554cca392 | ||
|
|
05b3af8718 | ||
|
|
e2e3095c33 | ||
|
|
5b94c6df91 | ||
|
|
634c809489 | ||
|
|
0da2006e78 | ||
|
|
09e5f84b8b | ||
|
|
ecd1900acc | ||
|
|
0b6327e2fb | ||
|
|
06608c7b5d | ||
|
|
333fa32eda | ||
|
|
1881032ed2 | ||
|
|
aad67720c1 | ||
|
|
f3be8138c4 | ||
|
|
46e009f888 | ||
|
|
6e858f1860 | ||
|
|
be63337024 | ||
|
|
4b61fd9501 | ||
|
|
609866beef | ||
|
|
1c6bf68b1e | ||
|
|
fc67298a85 | ||
|
|
b7f51ea46f | ||
|
|
87156e93ee | ||
|
|
40a3553f9d | ||
|
|
c975169f05 | ||
|
|
3a81e7b3db | ||
|
|
50fdd6bc02 | ||
|
|
2c64d430bd | ||
|
|
3a15c0d945 | ||
|
|
ee209b6ceb | ||
|
|
2b5c3b1111 | ||
|
|
a7bae3ea43 | ||
|
|
c91fe48eb9 | ||
|
|
c46e8fc11b | ||
|
|
40a3f33ea3 | ||
|
|
72e2dac328 | ||
|
|
097268647a | ||
|
|
aa660f4f5f | ||
|
|
d885b3cf26 | ||
|
|
d9160b11f7 | ||
|
|
80711e744b | ||
|
|
e0b68611bd | ||
|
|
5c716d7210 | ||
|
|
df41e4050c | ||
|
|
2c05c543ce | ||
|
|
18098d63ba | ||
|
|
afebd8b335 | ||
|
|
75e548dc83 | ||
|
|
d765d92df7 | ||
|
|
7163266100 | ||
|
|
7ce014e70e | ||
|
|
1bb63bec8e | ||
|
|
eac8e0cb43 | ||
|
|
661437a2d3 | ||
|
|
529ac6b590 | ||
|
|
d3dc3b5016 | ||
|
|
b61235a3b5 | ||
|
|
4754e75f59 | ||
|
|
d5c77dc13b | ||
|
|
6e555184f0 | ||
|
|
a635dc1431 | ||
|
|
57a20aa048 | ||
|
|
703e347830 | ||
|
|
d27c068f39 | ||
|
|
32b435d762 | ||
|
|
5cf6eb191d | ||
|
|
a7c5da8d9c | ||
|
|
95565b5313 | ||
|
|
d4d9861727 | ||
|
|
8f8fb6b97c | ||
|
|
c6149f1bd5 | ||
|
|
414806734d | ||
|
|
894698c5d6 | ||
|
|
9117a99cc3 | ||
|
|
a6e6e10c69 | ||
|
|
9dd7e798f7 | ||
|
|
1a1d8cc209 | ||
|
|
8c6d3f0645 | ||
|
|
aca894729e | ||
|
|
d6768f0318 | ||
|
|
1758b7837e | ||
|
|
dc794a29d3 | ||
|
|
6d8e352ffc | ||
|
|
8aeb7a3197 | ||
|
|
78287cc6c5 | ||
|
|
c20022f58c | ||
|
|
7c244e256f | ||
|
|
90d13fe342 | ||
|
|
f4e83f7eac | ||
|
|
a9b9b76ef2 | ||
|
|
256c07a2b9 |
@@ -1,5 +1,7 @@
|
||||
.DS_Store
|
||||
/node_modules/
|
||||
.vscode/cspell.json
|
||||
.vscode/.cspell/
|
||||
.env
|
||||
|
||||
# React Router
|
||||
|
||||
Vendored
+4
-3
@@ -25,7 +25,8 @@
|
||||
"labelClassName",
|
||||
"buttonClassName",
|
||||
"leftNodeClassName",
|
||||
"rightNodeClassName"
|
||||
],
|
||||
"cSpell.words": []
|
||||
"rightNodeClassName",
|
||||
"buttonVariants",
|
||||
"cva"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TAdsSchema } from '~/pages/form-advertisements'
|
||||
import { datePayload } from '~/utils/formatter'
|
||||
|
||||
const advertisementsResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
payload: TAdsSchema
|
||||
} & THttpServer
|
||||
|
||||
export const createAdsRequest = async (parameters: TParameters) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
const transformedPayload = {
|
||||
...payload,
|
||||
start_date: datePayload(payload.start_date),
|
||||
end_date: datePayload(payload.end_date),
|
||||
}
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).post(
|
||||
'/api/ads/create',
|
||||
transformedPayload,
|
||||
)
|
||||
return advertisementsResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TCategorySchema } from '~/pages/form-category'
|
||||
|
||||
const categoryResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
payload: TCategorySchema
|
||||
} & THttpServer
|
||||
|
||||
export const createCategoryRequest = async (parameters: TParameters) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).post(
|
||||
'/api/category/create',
|
||||
payload,
|
||||
)
|
||||
return categoryResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TContentSchema } from '~/pages/form-contents'
|
||||
import { datePayload } from '~/utils/formatter'
|
||||
|
||||
const newsResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameter = {
|
||||
payload: TContentSchema
|
||||
} & THttpServer
|
||||
|
||||
export const createNewsRequest = async (parameters: TParameter) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
const { categories, tags, live_at, ...restPayload } = payload
|
||||
const transformedPayload = {
|
||||
...restPayload,
|
||||
categories: categories.map((category) => category?.id),
|
||||
tags: tags?.map((tag) => tag?.id) || [],
|
||||
live_at: datePayload(live_at),
|
||||
}
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TStaffSchema } from '~/pages/form-staff'
|
||||
|
||||
const createStaffResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameter = {
|
||||
payload: TStaffSchema
|
||||
} & THttpServer
|
||||
|
||||
export const createStaffsRequest = async (parameters: TParameter) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).post(
|
||||
'/api/staff/register',
|
||||
payload,
|
||||
)
|
||||
return createStaffResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TSubscribePlanSchema } from '~/pages/form-subscribe-plan'
|
||||
|
||||
const subscribePlanResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
payload: TSubscribePlanSchema
|
||||
} & THttpServer
|
||||
|
||||
export const createSubscribePlanRequest = async (parameters: TParameters) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
try {
|
||||
const transformedPayload = {
|
||||
...payload,
|
||||
status: Number(payload.status),
|
||||
}
|
||||
const { data } = await HttpServer(restParameters).post(
|
||||
'/api/subscribe-plan/create',
|
||||
transformedPayload,
|
||||
)
|
||||
return subscribePlanResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TTagSchema } from '~/pages/form-tag'
|
||||
|
||||
const tagsResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
payload: TTagSchema
|
||||
} & THttpServer
|
||||
|
||||
export const createTagsRequest = async (parameters: TParameters) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).post(
|
||||
'/api/tag/create',
|
||||
payload,
|
||||
)
|
||||
return tagsResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TAdsSchema } from '~/pages/form-advertisements'
|
||||
|
||||
const deleteAdsResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
id: TAdsSchema['id']
|
||||
} & THttpServer
|
||||
|
||||
export const deleteAdsRequest = async (parameters: TParameters) => {
|
||||
const { id, ...restParameters } = parameters
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).delete(
|
||||
`/api/ads/${id}/delete`,
|
||||
)
|
||||
return deleteAdsResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TCategorySchema } from '~/pages/form-category'
|
||||
|
||||
const deleteCategoriesResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
id: TCategorySchema['id']
|
||||
} & THttpServer
|
||||
|
||||
export const deleteCategoriesRequest = async (parameters: TParameters) => {
|
||||
const { id, ...restParameters } = parameters
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).delete(
|
||||
`/api/category/${id}/delete`,
|
||||
)
|
||||
return deleteCategoriesResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TContentSchema } from '~/pages/form-contents'
|
||||
|
||||
const deleteContentsResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
id: TContentSchema['id']
|
||||
} & THttpServer
|
||||
|
||||
export const deleteContentsRequest = async (parameters: TParameters) => {
|
||||
const { id, ...restParameters } = parameters
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).delete(
|
||||
`/api/news/${id}/delete`,
|
||||
)
|
||||
|
||||
return deleteContentsResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TSubscribePlanSchema } from '~/pages/form-subscribe-plan'
|
||||
|
||||
const subscribePlanResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
id: TSubscribePlanSchema['id']
|
||||
} & THttpServer
|
||||
|
||||
export const deleteSubscribePlanRequest = async (parameters: TParameters) => {
|
||||
const { id, ...restParameters } = parameters
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).delete(
|
||||
`/api/subscribe-plan/${id}/delete`,
|
||||
)
|
||||
return subscribePlanResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TTagSchema } from '~/pages/form-tag'
|
||||
|
||||
const deleteTagsResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
id: TTagSchema['id']
|
||||
} & THttpServer
|
||||
|
||||
export const deleteTagsRequest = async (parameters: TParameters) => {
|
||||
const { id, ...restParameters } = parameters
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).delete(
|
||||
`/api/tag/${id}/delete`,
|
||||
)
|
||||
return deleteTagsResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { newsResponseSchema } from '~/apis/common/get-news'
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const dataResponseSchema = z.object({
|
||||
data: z.object(newsResponseSchema.shape),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
id: string
|
||||
} & THttpServer
|
||||
|
||||
export const getNewsById = async (parameters: TParameters) => {
|
||||
const { id, ...restParameters } = parameters
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).get(
|
||||
`/api/staff/news/${encodeURIComponent(id)}`,
|
||||
)
|
||||
return dataResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const staffSchema = z.object({
|
||||
const staffResponseSchema = z.object({
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
@@ -11,10 +11,10 @@ const staffSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
export const getStaff = async (parameters: THttpServer) => {
|
||||
export const getProfile = async (parameters: THttpServer) => {
|
||||
try {
|
||||
const { data } = await HttpServer(parameters).get(`/api/staff/profile`)
|
||||
return staffSchema.parse(data)
|
||||
return staffResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const staffResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
profile_picture: z.string(),
|
||||
})
|
||||
const staffsResponseSchema = z.object({
|
||||
data: z.array(staffResponseSchema),
|
||||
})
|
||||
|
||||
export type TStaffResponse = z.infer<typeof staffResponseSchema>
|
||||
|
||||
export const getStaffs = async (parameters: THttpServer) => {
|
||||
try {
|
||||
const { data } = await HttpServer(parameters).get(`/api/staff/get-all`)
|
||||
return staffsResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const subscribePlanResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
const subscribeResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
subscribe_plan_id: z.string(),
|
||||
start_date: z.string(),
|
||||
end_date: z.string().nullable(),
|
||||
status: z.number(),
|
||||
auto_renew: z.boolean(),
|
||||
subscribe_plan: subscribePlanResponseSchema,
|
||||
})
|
||||
const userResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string().email(),
|
||||
phone: z.string(),
|
||||
subscribe: subscribeResponseSchema,
|
||||
created_at: z.string(),
|
||||
})
|
||||
const usersResponseSchema = z.object({
|
||||
data: z.array(userResponseSchema),
|
||||
})
|
||||
|
||||
export type TUserResponse = z.infer<typeof userResponseSchema>
|
||||
|
||||
export const getUsers = async (parameters: THttpServer) => {
|
||||
try {
|
||||
const { data } = await HttpServer(parameters).get(`/api/staff/users`)
|
||||
return usersResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer } from '~/libs/http-server'
|
||||
import type { TLoginSchema } from '~/pages/admin-login'
|
||||
import type { TLoginSchema } from '~/pages/staff-login'
|
||||
|
||||
const loginResponseSchema = z.object({
|
||||
data: z.object({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TAdsSchema } from '~/pages/form-advertisements'
|
||||
import { datePayload } from '~/utils/formatter'
|
||||
|
||||
const advertisementsResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
payload: TAdsSchema
|
||||
} & THttpServer
|
||||
|
||||
export const updateAdsRequest = async (parameters: TParameters) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
const { id, ...restPayload } = payload
|
||||
const transformedPayload = {
|
||||
...restPayload,
|
||||
start_date: datePayload(payload.start_date),
|
||||
end_date: datePayload(payload.end_date),
|
||||
}
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).put(
|
||||
`/api/ads/${id}/update`,
|
||||
transformedPayload,
|
||||
)
|
||||
return advertisementsResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TCategorySchema } from '~/pages/form-category'
|
||||
|
||||
const categoryResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
payload: TCategorySchema
|
||||
} & THttpServer
|
||||
|
||||
export const updateCategoryRequest = async (parameters: TParameters) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
const { id, ...restPayload } = payload
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).put(
|
||||
`/api/category/${id}/update`,
|
||||
restPayload,
|
||||
)
|
||||
return categoryResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TContentSchema } from '~/pages/form-contents'
|
||||
import { datePayload } from '~/utils/formatter'
|
||||
|
||||
const newsResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameter = {
|
||||
payload: TContentSchema
|
||||
} & THttpServer
|
||||
|
||||
export const updateNewsRequest = async (parameters: TParameter) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
const { categories, tags, id, live_at, ...restPayload } = payload
|
||||
const transformedPayload = {
|
||||
...restPayload,
|
||||
categories: categories.map((category) => category?.id),
|
||||
tags: tags?.map((tag) => tag?.id) || [],
|
||||
live_at: datePayload(live_at),
|
||||
}
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).put(
|
||||
`/api/news/${id}/update`,
|
||||
transformedPayload,
|
||||
)
|
||||
return newsResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { TProfileSchema } from '~/layouts/admin/dialog-profile'
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const updateProfileResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameter = {
|
||||
payload: TProfileSchema
|
||||
} & THttpServer
|
||||
|
||||
export const updateProfileRequest = async (parameters: TParameter) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).put(
|
||||
'/api/staff/update',
|
||||
payload,
|
||||
)
|
||||
return updateProfileResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TSubscribePlanSchema } from '~/pages/form-subscribe-plan'
|
||||
|
||||
const subscribePlanResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
payload: TSubscribePlanSchema
|
||||
} & THttpServer
|
||||
|
||||
export const updateSubscribePlanRequest = async (parameters: TParameters) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
const { id, ...restPayload } = payload
|
||||
try {
|
||||
const transformedPayload = {
|
||||
...restPayload,
|
||||
status: Number(payload.status),
|
||||
}
|
||||
const { data } = await HttpServer(restParameters).put(
|
||||
`/api/subscribe-plan/${id}/update`,
|
||||
transformedPayload,
|
||||
)
|
||||
return subscribePlanResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TSubscribePlanSchema } from '~/pages/form-subscribe-plan'
|
||||
|
||||
const subscribePlanResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TSubscribeSchema = Pick<TSubscribePlanSchema, 'id'>
|
||||
type TParameters = {
|
||||
payload: { subscribe_plan: TSubscribeSchema }
|
||||
} & THttpServer
|
||||
|
||||
export const updateSubscribeRequest = async (parameters: TParameters) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
const { id } = payload.subscribe_plan
|
||||
try {
|
||||
const transformedPayload = {
|
||||
status: 1,
|
||||
subscribe_plan_id: id,
|
||||
}
|
||||
const { data } = await HttpServer(restParameters).patch(
|
||||
`/api/subscribe/update`,
|
||||
transformedPayload,
|
||||
)
|
||||
console.log(data) // eslint-disable-line no-console
|
||||
|
||||
return subscribePlanResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TTagSchema } from '~/pages/form-tag'
|
||||
|
||||
const tagResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
payload: TTagSchema
|
||||
} & THttpServer
|
||||
|
||||
export const updateTagRequest = async (parameters: TParameters) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
const { id, ...restPayload } = payload
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).put(
|
||||
`/api/tag/${id}/update`,
|
||||
restPayload,
|
||||
)
|
||||
return tagResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { TUploadSchema } from '~/layouts/admin/dialog-upload'
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const uploadResponseSchema = z.object({
|
||||
data: z.object({
|
||||
message: z.string(),
|
||||
data: z.object({
|
||||
file_path: z.string(),
|
||||
file_url: z.string(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
type TParameter = {
|
||||
payload: TUploadSchema & {
|
||||
file: File
|
||||
}
|
||||
} & THttpServer
|
||||
|
||||
export const uploadFileRequest = async (parameters: TParameter) => {
|
||||
const { payload, ...restParameters } = parameters
|
||||
const formdata = new FormData()
|
||||
formdata.append('file', payload.file)
|
||||
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).post(
|
||||
'/api/file',
|
||||
formdata,
|
||||
)
|
||||
return uploadResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const adResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
image_url: z.string(),
|
||||
url: z.string(),
|
||||
start_date: z.string(),
|
||||
end_date: z.string(),
|
||||
clicked: z.number(),
|
||||
})
|
||||
const adsResponseSchema = z.object({
|
||||
data: z.array(adResponseSchema).nullable(),
|
||||
})
|
||||
|
||||
export type TAdResponse = z.infer<typeof adResponseSchema>
|
||||
|
||||
export const getAds = async (parameters?: THttpServer) => {
|
||||
try {
|
||||
const { data } = await HttpServer(parameters).get(`/api/ads`)
|
||||
return adsResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,24 @@ import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const categorySchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
export const categoryResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
code: z.string(),
|
||||
sequence: z.number().nullable(),
|
||||
description: z.string().nullable(),
|
||||
})
|
||||
const categoriesResponseSchema = z.object({
|
||||
data: z.array(categoryResponseSchema),
|
||||
})
|
||||
|
||||
export type TCategorySchema = z.infer<typeof categorySchema>
|
||||
export type TCategoryResponse = z.infer<typeof categoryResponseSchema>
|
||||
export type TCategoriesResponse = z.infer<typeof categoriesResponseSchema>
|
||||
|
||||
export const getCategories = async (parameters?: THttpServer) => {
|
||||
try {
|
||||
const { data } = await HttpServer(parameters).get(`/api/category`)
|
||||
return categorySchema.parse(data)
|
||||
return categoriesResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { categoryResponseSchema } from '~/apis/common/get-categories'
|
||||
import { tagResponseSchema } from '~/apis/common/get-tags'
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const authorSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
profile_picture: z.string(),
|
||||
})
|
||||
export const newsResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
categories: z.array(categoryResponseSchema),
|
||||
tags: z.array(tagResponseSchema),
|
||||
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(),
|
||||
author: authorSchema,
|
||||
})
|
||||
const dataResponseSchema = z.object({
|
||||
data: z.array(
|
||||
newsResponseSchema.extend({
|
||||
views: z.number(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export type TNewsResponse = z.infer<typeof newsResponseSchema>
|
||||
export type TNewsResponseData = z.infer<typeof dataResponseSchema>
|
||||
export type TAuthorResponse = z.infer<typeof authorSchema>
|
||||
type TParameters = {
|
||||
categories?: string[]
|
||||
tags?: string[]
|
||||
active?: boolean
|
||||
limit?: number
|
||||
page?: number
|
||||
query?: string
|
||||
} & THttpServer
|
||||
|
||||
export const getNews = async (parameters?: TParameters) => {
|
||||
const { categories, tags, active, limit, page, query, ...restParameters } =
|
||||
parameters || {}
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).get(`/api/news`, {
|
||||
params: {
|
||||
...(categories && { categories: categories.join('+') }),
|
||||
...(tags && { tags: tags.join('+') }),
|
||||
...(active && { active }),
|
||||
...(limit && { limit }),
|
||||
...(page && { page }),
|
||||
...(query && { q: query }),
|
||||
},
|
||||
})
|
||||
return dataResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const subscribePlanSchema = z.object({
|
||||
id: z.string(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
length: z.number(),
|
||||
price: z.number(),
|
||||
status: z.number(),
|
||||
})
|
||||
|
||||
const subscribePlanResponseSchema = z.object({
|
||||
data: z.array(subscribePlanSchema),
|
||||
})
|
||||
|
||||
export type TSubscribePlanResponse = z.infer<typeof subscribePlanSchema>
|
||||
|
||||
export const getSubscribePlan = async (parameters?: THttpServer) => {
|
||||
try {
|
||||
const { data } = await HttpServer(parameters).get(`/api/subscribe-plan`)
|
||||
return subscribePlanResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const subscriptionSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const getSubscriptions = async (parameters?: THttpServer) => {
|
||||
try {
|
||||
const { data } = await HttpServer(parameters).get(`/api/subscribe-plan`)
|
||||
return subscriptionSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
export const tagResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
const tagsResponseSchema = z.object({
|
||||
data: z.array(tagResponseSchema),
|
||||
})
|
||||
|
||||
export type TTagResponse = z.infer<typeof tagResponseSchema>
|
||||
|
||||
export const getTags = async (parameters?: THttpServer) => {
|
||||
try {
|
||||
const { data } = await HttpServer(parameters).get(`/api/tag`)
|
||||
return tagsResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
import type { TAdsSchema } from '~/pages/form-advertisements'
|
||||
|
||||
const logAdsResponseSchema = z.object({
|
||||
data: z.object({
|
||||
Message: z.string(),
|
||||
}),
|
||||
})
|
||||
type TParameters = {
|
||||
id: TAdsSchema['id']
|
||||
} & THttpServer
|
||||
|
||||
export const createLogAdsRequest = async (parameters: TParameters) => {
|
||||
const { id, ...restParameters } = parameters
|
||||
const payload = {
|
||||
ads_id: id,
|
||||
}
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).post(
|
||||
'/api/logs/ads',
|
||||
payload,
|
||||
)
|
||||
return logAdsResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { newsResponseSchema } from '~/apis/common/get-news'
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const dataResponseSchema = z.object({
|
||||
data: z.object(newsResponseSchema.shape),
|
||||
})
|
||||
|
||||
type TParameters = {
|
||||
slug: string
|
||||
} & THttpServer
|
||||
|
||||
export const getNewsBySlug = async (parameters: TParameters) => {
|
||||
const { slug, ...restParameters } = parameters
|
||||
try {
|
||||
const { data } = await HttpServer(restParameters).get(
|
||||
`/api/news/${encodeURIComponent(slug)}`,
|
||||
)
|
||||
return dataResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,33 @@ import { z } from 'zod'
|
||||
|
||||
import { HttpServer, type THttpServer } from '~/libs/http-server'
|
||||
|
||||
const userSchema = z.object({
|
||||
const userResponseSchema = z.object({
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
subscribe_plan_code: z.string(),
|
||||
subscribe_plan_name: z.string(),
|
||||
subscribe_status: z.string(),
|
||||
phone: z.string(),
|
||||
subscribe: z.object({
|
||||
id: z.string(),
|
||||
subscribe_plan_id: z.string(),
|
||||
start_date: z.string(),
|
||||
end_date: z.string().nullable(),
|
||||
status: z.number(),
|
||||
auto_renew: z.boolean(),
|
||||
subscribe_plan: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
code: z.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export type TUser = z.infer<typeof userSchema>
|
||||
export type TUserResponse = z.infer<typeof userResponseSchema>
|
||||
|
||||
export const getUser = async (parameters: THttpServer) => {
|
||||
try {
|
||||
const { data } = await HttpServer(parameters).get(`/api/user/profile`)
|
||||
return userSchema.parse(data)
|
||||
return userResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||
return Promise.reject(error)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { type TLoginSchema } from '~/layouts/news/form-login'
|
||||
import { type TLoginSchema } from '~/layouts/news/dialog-login'
|
||||
import { HttpServer } from '~/libs/http-server'
|
||||
|
||||
const loginResponseSchema = z.object({
|
||||
export const loginResponseSchema = z.object({
|
||||
data: z.object({
|
||||
token: z.string(),
|
||||
}),
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { TRegisterSchema } from '~/layouts/news/form-register'
|
||||
import type { TRegisterSchema } from '~/layouts/news/dialog-register'
|
||||
import { HttpServer } from '~/libs/http-server'
|
||||
|
||||
const loginResponseSchema = z.object({
|
||||
data: z.object({
|
||||
token: z.string(),
|
||||
}),
|
||||
})
|
||||
import { loginResponseSchema } from './login-user'
|
||||
|
||||
export const userRegisterRequest = async (payload: TRegisterSchema) => {
|
||||
const { subscribe_plan, ...restPayload } = payload
|
||||
const transformedPayload = {
|
||||
...restPayload,
|
||||
subscribe_plan_id: subscribe_plan.id,
|
||||
}
|
||||
try {
|
||||
const { subscribe_plan, ...restPayload } = payload
|
||||
const transformedPayload = {
|
||||
...restPayload,
|
||||
subscribe_plan_id: subscribe_plan,
|
||||
}
|
||||
const { data } = await HttpServer().post(
|
||||
'/api/user/register',
|
||||
transformedPayload,
|
||||
|
||||
+44
-19
@@ -19,6 +19,14 @@ html,
|
||||
body {
|
||||
}
|
||||
|
||||
.ProseMirror-focused {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
.ProseMirror-trailingBreak {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
table.dataTable thead > tr {
|
||||
border-bottom: 2px solid #c2c2c2;
|
||||
}
|
||||
@@ -29,26 +37,43 @@ table.dataTable tbody > tr > td {
|
||||
border-bottom: 1px solid #ebebeb;
|
||||
}
|
||||
|
||||
/* .embla.hero {
|
||||
overflow: hidden;
|
||||
}
|
||||
.embla__container.hero {
|
||||
nav[aria-label='pagination'] {
|
||||
display: flex;
|
||||
background-color: yellow;
|
||||
justify-content: center;
|
||||
}
|
||||
/* Style untuk tombol aktif (current) */
|
||||
.dt-paging-button.current {
|
||||
background-color: #2e2f7c !important;
|
||||
color: white !important;
|
||||
}
|
||||
.embla__slide.hero {
|
||||
flex: 0 0 100%;
|
||||
min-width: 0;
|
||||
} */
|
||||
|
||||
/* .embla__slide {
|
||||
margin-right: 10px;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
/* Style tombol aktif, kecuali jika disabled */
|
||||
div.dt-container .dt-paging .dt-paging-button.current:not(.disabled),
|
||||
div.dt-container .dt-paging .dt-paging-button.current:not(.disabled):hover {
|
||||
color: white !important;
|
||||
background-color: #2e2f7c !important;
|
||||
min-width: 24px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
/* Style tombol disabled */
|
||||
div.dt-container .dt-paging .dt-paging-button.disabled {
|
||||
background-color: transparent !important;
|
||||
color: #ccc !important;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none; /* Agar tidak bisa diklik */
|
||||
}
|
||||
|
||||
/* Menghindari hover effect untuk tombol yang disabled */
|
||||
div.dt-container .dt-paging .dt-paging-button:not(.disabled):hover {
|
||||
background-color: #2e2f7c !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Style default tombol */
|
||||
div.dt-container .dt-paging .dt-paging-button {
|
||||
min-width: 24px;
|
||||
padding: 3px 6px;
|
||||
background-color: transparent !important;
|
||||
color: #2e2f7c !important;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.embla__slide {
|
||||
margin-right: 30px;
|
||||
}
|
||||
} */
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Description,
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
} from '@headlessui/react'
|
||||
import { useEffect, type PropsWithChildren } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useFetcher } from 'react-router'
|
||||
|
||||
import { Button } from '~/components/ui/button'
|
||||
|
||||
type TProperties = PropsWithChildren & {
|
||||
selectedId?: string
|
||||
close: () => void
|
||||
title: string
|
||||
fetcherAction: string
|
||||
}
|
||||
|
||||
export const DialogDelete = (properties: TProperties) => {
|
||||
const { selectedId, close, children, title, fetcherAction } = properties || {}
|
||||
const fetcher = useFetcher()
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data?.success && fetcher.data?.message) {
|
||||
toast.error(fetcher.data.message)
|
||||
}
|
||||
|
||||
if (fetcher.data?.success) {
|
||||
close()
|
||||
toast.success(`${title} berhasil dihapus!`)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetcher.data])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!selectedId}
|
||||
onClose={() => {
|
||||
if (fetcher.state === 'idle') {
|
||||
close()
|
||||
}
|
||||
}}
|
||||
className="relative z-50"
|
||||
transition
|
||||
>
|
||||
<DialogBackdrop
|
||||
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
|
||||
transition
|
||||
/>
|
||||
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
|
||||
>
|
||||
<DialogTitle className="relative text-xl font-bold">
|
||||
<span>Anda akan menghapus</span>{' '}
|
||||
<span className="lowercase">{title}</span> <span>berikut?</span>
|
||||
</DialogTitle>
|
||||
<Description className="space-y-1 text-center text-[#565658]">
|
||||
{children}
|
||||
</Description>
|
||||
<div className="flex justify-end">
|
||||
<fetcher.Form
|
||||
method="POST"
|
||||
action={fetcherAction}
|
||||
className="grid"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger"
|
||||
className="text-md h-[42px] rounded-md"
|
||||
disabled={fetcher.state !== 'idle'}
|
||||
isLoading={fetcher.state !== 'idle'}
|
||||
>
|
||||
Hapus
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type ModalProperties = {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const PopupModal = ({
|
||||
export const DialogNews = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
@@ -34,7 +34,7 @@ export const PopupModal = ({
|
||||
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
|
||||
transition
|
||||
/>
|
||||
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-4 max-sm:bg-white sm:items-center">
|
||||
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
|
||||
@@ -12,7 +12,7 @@ import { LeftArrow } from '~/components/icons/left-arrow'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { APP } from '~/configs/meta'
|
||||
import { useNewsContext } from '~/contexts/news'
|
||||
import type { loader } from '~/routes/_layout'
|
||||
import type { loader } from '~/routes/_news'
|
||||
|
||||
export type ModalProperties = {
|
||||
onClose: () => void
|
||||
@@ -33,10 +33,10 @@ const DESCRIPTIONS: DescriptionMap = {
|
||||
error: 'Terjadi kesalahan. Silakan coba lagi.',
|
||||
}
|
||||
|
||||
export const SuccessModal = ({ isOpen, onClose }: ModalProperties) => {
|
||||
export const DialogSuccess = ({ isOpen, onClose }: ModalProperties) => {
|
||||
const { setIsLoginOpen, setIsSubscribeOpen } = useNewsContext()
|
||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_layout')
|
||||
const userData = loaderData?.userData
|
||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
|
||||
const { userData } = loaderData || {}
|
||||
|
||||
const message = isOpen
|
||||
? DESCRIPTIONS[isOpen]
|
||||
@@ -53,7 +53,7 @@ export const SuccessModal = ({ isOpen, onClose }: ModalProperties) => {
|
||||
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
|
||||
transition
|
||||
/>
|
||||
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-4 max-sm:bg-white sm:items-center">
|
||||
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
|
||||
<DialogPanel
|
||||
className="max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
|
||||
transition
|
||||
@@ -92,7 +92,7 @@ export const SuccessModal = ({ isOpen, onClose }: ModalProperties) => {
|
||||
/>
|
||||
<Button
|
||||
className="mt-5 w-full rounded-md"
|
||||
variant="newsPrimary"
|
||||
variant="primary"
|
||||
as={Link}
|
||||
to="/"
|
||||
onClick={onClose}
|
||||
@@ -111,18 +111,18 @@ export const SuccessModal = ({ isOpen, onClose }: ModalProperties) => {
|
||||
{userData ? (
|
||||
<Button
|
||||
className="mt-5 w-full rounded-md"
|
||||
variant="newsSecondary"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onClose()
|
||||
setIsSubscribeOpen(true)
|
||||
}}
|
||||
>
|
||||
Select Subscription
|
||||
Pilih Paken Berlangganan
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="mt-5 w-full rounded-md"
|
||||
variant="newsPrimary"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
onClose()
|
||||
setIsLoginOpen(true)
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useAsyncError } from 'react-router'
|
||||
|
||||
export const ErrorAwait = () => {
|
||||
const error = useAsyncError()
|
||||
return (
|
||||
<p>
|
||||
{error instanceof Error ? error.message : 'An unexpected error occurred.'}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const CashierMachineIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={16}
|
||||
viewBox="0 0 14 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M11.778 3.87h-1.553v-.988h1.268c.324 0 .586-.325.586-.723V.833c0-.4-.262-.723-.586-.723h-3.46c-.325 0-.586.324-.586.723V2.16c0 .4.26.723.585.723h1.292v.989H3.07a.628.628 0 00-.629.626L.876 12.59v2.521H14v-2.52l-1.593-8.093a.628.628 0 00-.63-.626zm-7.15 7.505h-.966v-.982h.967v.982zM3.662 9.5v-.982h.967V9.5H3.66zm2.843 1.89h-.967v-.997h.967v.996zm0-1.905h-.982v-.953h.982v.953zm1.875 1.902h-.982v-.994h.982v.994zm-.982-1.902v-.967h.982v.967h-.982zM8.38 7.61H3.662V5.735H8.38V7.61zm2.798 0H9.285v-.956h1.892v.956z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const ChartIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={18}
|
||||
height={19}
|
||||
viewBox="0 0 18 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.498 1.61h7.004c2.55 0 3.99 1.447 3.998 3.998v7.005c0 2.55-1.447 3.997-3.998 3.997H5.498c-2.551 0-3.998-1.447-3.998-3.997V5.608c0-2.55 1.447-3.998 3.998-3.998zm3.539 11.895a.62.62 0 00.623-.562V5.3a.612.612 0 00-.285-.592.63.63 0 00-.96.592v7.643a.632.632 0 00.622.562zm3.45 0c.316 0 .585-.24.623-.562v-2.46a.629.629 0 00-.96-.593.604.604 0 00-.285.593v2.46c.03.322.3.562.623.562zm-6.322-.562a.62.62 0 01-.623.562.62.62 0 01-.622-.562V7.76a.63.63 0 01.293-.592.617.617 0 01.66 0c.202.127.315.36.292.592v5.183z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const ChatIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={18}
|
||||
height={19}
|
||||
viewBox="0 0 18 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M1.5 9.122C1.5 5.17 4.657 1.61 9.015 1.61c4.26 0 7.485 3.493 7.485 7.49 0 4.633-3.78 7.51-7.5 7.51-1.23 0-2.595-.33-3.69-.976-.382-.233-.705-.406-1.117-.27l-1.515.45c-.383.12-.728-.18-.615-.586l.502-1.682a.786.786 0 00-.052-.676C1.868 11.683 1.5 10.384 1.5 9.122zm6.525 0c0 .533.427.961.96.969a.963.963 0 000-1.923.956.956 0 00-.96.954zm3.457.007c0 .526.428.962.96.962a.963.963 0 000-1.923.958.958 0 00-.96.961zm-5.954.962a.967.967 0 01-.96-.962c0-.533.427-.961.96-.961.532 0 .96.428.96.961a.967.967 0 01-.96.962z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* Note: `ChevronDoubleIcon` default mengarah ke kiri.
|
||||
* Gunakan class `rotate-xx` untuk mengubah arah ikon.
|
||||
*/
|
||||
export const ChevronDoubleIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={20}
|
||||
height={20}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={properties.className}
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.512 4.427l-2.984 3.58 2.877 3.575a.667.667 0 01-1.04.836l-3.218-4a.667.667 0 01.007-.844l3.334-4a.667.667 0 011.024.853zm-5.69-.853a.667.667 0 011.023.853l-2.984 3.58 2.877 3.575a.667.667 0 01-1.039.836l-3.218-4a.666.666 0 01.007-.844l3.333-4z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
/**
|
||||
* Note: `ChevronIcon` default mengarah ke bawah.
|
||||
* Gunakan class `rotate-xx` untuk mengubah arah ikon.
|
||||
*/
|
||||
export const ChevronIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={21}
|
||||
height={21}
|
||||
viewBox="0 0 21 21"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={properties.className}
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M10.197 13.623l5.008-5.008-1.177-1.18-3.83 3.834-3.831-3.833-1.178 1.178 5.008 5.009z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const DoctorIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={25}
|
||||
height={28}
|
||||
viewBox="0 0 25 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M12.714 14.364a6.821 6.821 0 006.822-6.822A6.821 6.821 0 0012.714.72a6.822 6.822 0 00-6.822 6.822 6.822 6.822 0 006.822 6.822zM6.32 23.318c0 .708.57 1.279 1.279 1.279s1.279-.57 1.279-1.28c0-.708-.57-1.278-1.28-1.278-.708 0-1.278.57-1.278 1.279zM17.83 16.1v2.612a4.27 4.27 0 013.41 4.178v2.223a.855.855 0 01-.687.837l-1.716.34a.424.424 0 01-.501-.335l-.165-.837a.422.422 0 01.336-.5l1.028-.209v-1.519c0-3.347-5.116-3.47-5.116.102v1.423l1.028.207c.23.048.379.272.336.502l-.165.836a.432.432 0 01-.501.336l-1.663-.224a.852.852 0 01-.736-.847V22.89a4.274 4.274 0 013.412-4.178v-2.41c-.118.038-.235.06-.352.102a9.244 9.244 0 01-3.06.522c-1.07 0-2.1-.186-3.059-.522a5.889 5.889 0 00-1.204-.277v4.349a2.974 2.974 0 012.132 2.846 2.987 2.987 0 01-2.985 2.985 2.987 2.987 0 01-2.985-2.985c0-1.348.901-2.478 2.132-2.846v-4.285c-3.39.57-5.974 3.49-5.974 7.04v2.388a2.39 2.39 0 002.387 2.388h19.102a2.39 2.39 0 002.388-2.388v-2.388c0-3.837-3.028-6.944-6.822-7.13z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const DocumentIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={18}
|
||||
height={19}
|
||||
viewBox="0 0 18 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.857 1.61h6.286c2.317 0 3.607 1.335 3.607 3.623v7.747c0 2.325-1.29 3.63-3.607 3.63H5.857c-2.28 0-3.607-1.305-3.607-3.63V5.233c0-2.288 1.328-3.623 3.607-3.623zm.203 3.495v-.007h2.242a.588.588 0 010 1.178H6.06a.585.585 0 010-1.17zm0 4.56h5.88a.586.586 0 000-1.17H6.06a.586.586 0 000 1.17zm0 3.428h5.88c.3-.03.525-.286.525-.585a.588.588 0 00-.525-.593H6.06a.596.596 0 00-.563.908c.12.187.338.3.563.27z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const BoldIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={20}
|
||||
height={20}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M11.4062 15.625H5.625V4.375H10.9375C11.5639 4.37504 12.1771 4.55435 12.7048 4.89174C13.2325 5.22914 13.6526 5.71052 13.9155 6.27903C14.1784 6.84754 14.2731 7.47942 14.1884 8.10001C14.1037 8.72061 13.8431 9.30399 13.4375 9.78125C13.9673 10.205 14.3528 10.7825 14.5408 11.4344C14.7289 12.0862 14.7102 12.7803 14.4875 13.4211C14.2647 14.0619 13.8488 14.6179 13.297 15.0126C12.7452 15.4073 12.0847 15.6213 11.4062 15.625ZM7.5 13.75H11.3937C11.5784 13.75 11.7613 13.7136 11.9319 13.643C12.1025 13.5723 12.2575 13.4687 12.3881 13.3381C12.5187 13.2075 12.6223 13.0525 12.693 12.8819C12.7636 12.7113 12.8 12.5284 12.8 12.3438C12.8 12.1591 12.7636 11.9762 12.693 11.8056C12.6223 11.635 12.5187 11.48 12.3881 11.3494C12.2575 11.2188 12.1025 11.1152 11.9319 11.0445C11.7613 10.9739 11.5784 10.9375 11.3937 10.9375H7.5V13.75ZM7.5 9.0625H10.9375C11.1222 9.0625 11.305 9.02613 11.4756 8.95546C11.6463 8.88478 11.8013 8.7812 11.9319 8.65062C12.0625 8.52004 12.166 8.36501 12.2367 8.1944C12.3074 8.02378 12.3438 7.84092 12.3438 7.65625C12.3438 7.47158 12.3074 7.28872 12.2367 7.1181C12.166 6.94749 12.0625 6.79246 11.9319 6.66188C11.8013 6.5313 11.6463 6.42772 11.4756 6.35704C11.305 6.28637 11.1222 6.25 10.9375 6.25H7.5V9.0625Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const ItalicIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M19.625 9.625v-1.25H11.5v1.25h3.213l-2.732 8.75H8.375v1.25H16.5v-1.25h-3.213l2.732-8.75h3.606z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const UnderlineIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M6.5 20.25h15v1.25h-15v-1.25zm7.5-1.875A4.375 4.375 0 019.625 14V7.125h1.25V14a3.125 3.125 0 106.25 0V7.125h1.25V14A4.375 4.375 0 0114 18.375z"
|
||||
fill="#212529"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const UndoIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M16.5 10.25H8.884l2.242-2.241-.876-.884-3.75 3.75 3.75 3.75.876-.884-2.24-2.241H16.5a3.75 3.75 0 010 7.5h-5v1.25h5a5 5 0 100-10z"
|
||||
fill="#212529"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const RedoIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M11.5 10.25h7.616l-2.242-2.241.876-.884 3.75 3.75-3.75 3.75-.876-.884 2.24-2.241H11.5a3.75 3.75 0 000 7.5h5v1.25h-5a5 5 0 010-10z"
|
||||
fill="#212529"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const ListIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M8.375 11.5a1.875 1.875 0 100-3.75 1.875 1.875 0 000 3.75zM8.375 20.25a1.875 1.875 0 100-3.75 1.875 1.875 0 000 3.75zM14 17.75h8.75V19H14v-1.25zM14 9h8.75v1.25H14V9z"
|
||||
fill="#212529"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const ListOrderIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M14 17.75h8.75V19H14v-1.25zM14 9h8.75v1.25H14V9zm-5 2.5v-5H7.75v.625H6.5v1.25h1.25V11.5H6.5v1.25h3.75V11.5H9zm1.25 10H6.5V19a1.25 1.25 0 011.25-1.25H9V16.5H6.5v-1.25H9a1.25 1.25 0 011.25 1.25v1.25A1.25 1.25 0 019 19H7.75v1.25h2.5v1.25z"
|
||||
fill="#212529"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const LinkIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M22.281 8.225a3.75 3.75 0 00-5.312 0l.887.888a2.506 2.506 0 113.544 3.543l-5 5a2.504 2.504 0 01-3.544-3.537l.882-.888-.882-.887-.887.887a3.75 3.75 0 000 5.313 3.75 3.75 0 002.656 1.081 3.75 3.75 0 002.669-1.1l5-5a3.75 3.75 0 00-.013-5.3z"
|
||||
fill="#212529"
|
||||
/>
|
||||
<path
|
||||
d="M6.619 19.512a2.5 2.5 0 010-3.543l5-5a2.5 2.5 0 013.543 0 2.462 2.462 0 01.713 1.781 2.5 2.5 0 01-.731 1.781l-1.325 1.344.887.887 1.325-1.325a3.76 3.76 0 10-5.318-5.318l-5 5a3.75 3.75 0 000 5.319A3.75 3.75 0 008.375 21.5a3.794 3.794 0 002.675-1.1l-.888-.887a2.5 2.5 0 01-3.543 0z"
|
||||
fill="#212529"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const ImageIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M15.875 12.75a1.875 1.875 0 100-3.75 1.875 1.875 0 000 3.75zm0-2.5a.625.625 0 110 1.25.625.625 0 010-1.25z"
|
||||
fill="#212529"
|
||||
/>
|
||||
<path
|
||||
d="M20.25 6.5H7.75A1.25 1.25 0 006.5 7.75v12.5a1.25 1.25 0 001.25 1.25h12.5a1.25 1.25 0 001.25-1.25V7.75a1.25 1.25 0 00-1.25-1.25zm0 13.75H7.75V16.5l3.125-3.125 3.494 3.494a1.25 1.25 0 001.762 0l.994-.994L20.25 19v1.25zm0-3.019l-2.244-2.244a1.25 1.25 0 00-1.762 0l-.994.994-3.494-3.493a1.25 1.25 0 00-1.762 0L7.75 14.73V7.75h12.5v9.481z"
|
||||
fill="#212529"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const CodeIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M23.375 14L19 18.375l-.881-.881L21.606 14l-3.487-3.494.881-.881L23.375 14zm-18.75 0L9 9.625l.881.881L6.394 14l3.487 3.494-.881.881L4.625 14zm7.137 5.927L15.025 7.75l1.208.323L12.97 20.25l-1.208-.323z"
|
||||
fill="#212529"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const QuoteIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M11.5 13.375H7.819a5.626 5.626 0 012.431-3.838l1.119-.75-.688-1.037-1.118.75A6.875 6.875 0 006.5 14.219v4.156a1.25 1.25 0 001.25 1.25h3.75a1.25 1.25 0 001.25-1.25v-3.75a1.25 1.25 0 00-1.25-1.25zm8.75 0h-3.681A5.625 5.625 0 0119 9.537l1.119-.75-.681-1.037-1.125.75a6.875 6.875 0 00-3.063 5.719v4.156a1.25 1.25 0 001.25 1.25h3.75a1.25 1.25 0 001.25-1.25v-3.75a1.25 1.25 0 00-1.25-1.25z"
|
||||
fill="#212529"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const StrikethroughIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M21.5 13.375h-6.277a25.033 25.033 0 00-.837-.209c-1.755-.415-2.747-.719-2.747-2.14a1.792 1.792 0 01.492-1.34 2.993 2.993 0 011.883-.681c1.769-.044 2.584.556 3.251 1.468l1.01-.737a4.67 4.67 0 00-4.27-1.981 4.233 4.233 0 00-2.75 1.038 3.015 3.015 0 00-.866 2.234 2.733 2.733 0 001.084 2.348H6.5v1.25h8.533c1.229.357 1.964.82 1.983 2.1a1.949 1.949 0 01-.54 1.495 3.64 3.64 0 01-2.324.78 4.146 4.146 0 01-3.216-1.681l-.958.802a5.328 5.328 0 004.155 2.13h.062a4.794 4.794 0 003.155-1.138 3.175 3.175 0 00.916-2.408 3.094 3.094 0 00-.72-2.08H21.5v-1.25z"
|
||||
fill="#212529"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const GraphIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={29}
|
||||
height={28}
|
||||
viewBox="0 0 29 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M19.383 2.499H9.471c-4.306 0-6.872 2.496-6.872 6.682v9.625c0 4.197 2.566 6.693 6.872 6.693h9.9c4.306 0 6.873-2.495 6.873-6.681V9.18c.012-4.186-2.555-6.682-6.86-6.682zM9.258 21.071c0 .472-.402.863-.887.863s-.887-.391-.887-.863v-2.38c0-.471.402-.863.887-.863s.887.392.887.863v2.38zm6.056 0c0 .472-.402.863-.887.863s-.887-.391-.887-.863V16.3c0-.471.402-.862.887-.862s.887.39.887.862v4.773zm6.057 0c0 .472-.403.863-.888.863s-.887-.391-.887-.863v-7.153c0-.471.402-.862.887-.862s.888.391.888.863v7.152zm0-10.787c0 .472-.403.863-.888.863s-.887-.391-.887-.863V9.17a23.266 23.266 0 01-11.012 6.164c-.071.023-.142.023-.213.023a.891.891 0 01-.864-.655.859.859 0 01.651-1.047 21.515 21.515 0 0010.35-5.876H17.03c-.485 0-.887-.391-.887-.863 0-.471.402-.862.887-.862h3.466c.048 0 .083.023.13.023.06.011.119.011.178.034.059.023.106.058.165.092.036.023.071.035.107.058.012.011.012.023.024.023.047.046.082.092.118.138.035.046.07.08.083.126.023.046.023.092.035.15.012.057.036.115.036.184 0 .011.011.023.011.034v3.37h-.011z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const ImageSkeletonIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={40}
|
||||
height={40}
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 18"
|
||||
{...properties}
|
||||
>
|
||||
<path d="M18 0H2a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2Zm-5.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm4.376 10.481A1 1 0 0 1 16 15H4a1 1 0 0 1-.895-1.447l3.5-7A1 1 0 0 1 7.468 6a.965.965 0 0 1 .9.5l2.775 4.757 1.546-1.887a1 1 0 0 1 1.618.1l2.541 4a1 1 0 0 1 .028 1.011Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const LinkIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
x="0px"
|
||||
y="0px"
|
||||
width={95}
|
||||
height={95}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
viewBox="0 0 1024 1024"
|
||||
{...properties}
|
||||
>
|
||||
<path d="M574 665.4a8.03 8.03 0 0 0-11.3 0L446.5 781.6c-53.8 53.8-144.6 59.5-204 0-59.5-59.5-53.8-150.2 0-204l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3l-39.8-39.8a8.03 8.03 0 0 0-11.3 0L191.4 526.5c-84.6 84.6-84.6 221.5 0 306s221.5 84.6 306 0l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3L574 665.4zm258.6-474c-84.6-84.6-221.5-84.6-306 0L410.3 307.6a8.03 8.03 0 0 0 0 11.3l39.7 39.7c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c53.8-53.8 144.6-59.5 204 0 59.5 59.5 53.8 150.2 0 204L665.3 562.6a8.03 8.03 0 0 0 0 11.3l39.8 39.8c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c84.5-84.6 84.5-221.5 0-306.1zM610.1 372.3a8.03 8.03 0 0 0-11.3 0L372.3 598.7a8.03 8.03 0 0 0 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l226.4-226.4c3.1-3.1 3.1-8.2 0-11.3l-39.5-39.6z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const MedicalNotesIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={12}
|
||||
height={16}
|
||||
viewBox="0 0 12 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M9.844 1.985H7.5C7.5.951 6.66.11 5.625.11 4.591.11 3.75.951 3.75 1.985H1.406C.63 1.985 0 2.615 0 3.392v10.312c0 .776.63 1.406 1.406 1.406h8.438c.776 0 1.406-.63 1.406-1.406V3.392c0-.777-.63-1.407-1.406-1.407zm-4.219-.703c.39 0 .703.314.703.703 0 .39-.313.703-.703.703a.701.701 0 01-.703-.703c0-.39.313-.703.703-.703zm2.813 8.906a.235.235 0 01-.235.235h-1.64v1.64a.235.235 0 01-.235.235H4.922a.235.235 0 01-.234-.235v-1.64H3.046a.235.235 0 01-.235-.235V8.782c0-.129.106-.234.235-.234h1.64v-1.64c0-.13.106-.235.235-.235h1.406c.129 0 .234.105.234.234v1.64h1.641c.129 0 .235.106.235.235v1.406zm0-5.625a.235.235 0 01-.235.235H3.047a.235.235 0 01-.235-.235v-.468c0-.13.106-.235.235-.235h5.156c.129 0 .235.106.235.235v.468z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
type TNotificationIcon = {
|
||||
showBadge?: boolean
|
||||
} & JSX.IntrinsicAttributes &
|
||||
SVGProps<SVGSVGElement>
|
||||
|
||||
export const NotificationIcon = ({
|
||||
showBadge = false,
|
||||
...properties
|
||||
}: TNotificationIcon) => {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={19}
|
||||
viewBox="0 0 16 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M6.597 18.53a1.616 1.616 0 01-1.608-1.6h3.2c.001.213-.04.425-.12.623-.21.482-.639.833-1.152.944h-.038c-.093.02-.187.03-.282.032zm6.4-2.4H.197v-1.6l1.6-.8v-4.4a6.452 6.452 0 01.739-3.249 3.723 3.723 0 012.46-1.808V2.53h3.2v1.744c2.064.492 3.2 2.287 3.2 5.056v4.4l1.6.8v1.6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
{showBadge && (
|
||||
<circle
|
||||
cx={11.1968}
|
||||
cy={4.306_64}
|
||||
r={3.6}
|
||||
fill="#EC5252"
|
||||
stroke="#fff"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
export const PlusIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M6 8H1a.965.965 0 01-.712-.288A.972.972 0 010 7c0-.283.095-.52.288-.712A.97.97 0 011 6h5V1c0-.283.096-.52.288-.712A.972.972 0 017 0c.283 0 .52.095.713.288A.96.96 0 018 1v5h5c.283 0 .521.096.713.288.192.192.288.43.287.712 0 .283-.097.52-.288.713A.957.957 0 0113 8H8v5a.968.968 0 01-.288.713A.964.964 0 017 14a.973.973 0 01-.712-.288A.965.965 0 016 13V8z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const ProfileIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={18}
|
||||
height={19}
|
||||
viewBox="0 0 18 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.97 5.82A3.956 3.956 0 019 9.789a3.956 3.956 0 01-3.97-3.97A3.955 3.955 0 019 1.853a3.955 3.955 0 013.97 3.968zM9 16.852c-3.253 0-6-.53-6-2.57s2.764-2.55 6-2.55c3.254 0 6 .529 6 2.569s-2.764 2.55-6 2.55z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const SettingIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={18}
|
||||
height={19}
|
||||
viewBox="0 0 18 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.302 10.537c.268.142.475.367.62.592.284.465.261 1.035-.015 1.538l-.537.9c-.283.48-.812.78-1.356.78a1.6 1.6 0 01-.813-.225c-.199-.128-.429-.173-.674-.173-.759 0-1.395.623-1.418 1.365 0 .863-.705 1.538-1.587 1.538H8.48c-.89 0-1.594-.675-1.594-1.538-.016-.742-.652-1.365-1.41-1.365-.254 0-.483.045-.675.173a1.62 1.62 0 01-.813.225 1.6 1.6 0 01-1.364-.78l-.529-.9c-.283-.488-.299-1.073-.015-1.538.123-.225.353-.45.613-.592.215-.105.353-.278.483-.48.383-.645.153-1.493-.498-1.875a1.533 1.533 0 01-.567-2.123l.513-.885a1.593 1.593 0 012.162-.57c.666.36 1.533.12 1.923-.517a1.16 1.16 0 00.177-.66c-.016-.293.069-.57.214-.795a1.646 1.646 0 011.357-.78h1.08c.568 0 1.081.315 1.365.78.138.225.23.502.207.795-.015.225.054.45.176.66a1.48 1.48 0 001.932.517 1.584 1.584 0 012.153.57l.514.885a1.52 1.52 0 01-.567 2.123c-.652.382-.882 1.23-.49 1.875.122.202.26.375.475.48zm-8.47-1.178c0 1.178.973 2.115 2.177 2.115 1.203 0 2.153-.937 2.153-2.115 0-1.177-.95-2.122-2.153-2.122-1.204 0-2.177.945-2.177 2.122z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const UserDoctorIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={25}
|
||||
height={28}
|
||||
viewBox="0 0 25 28"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
d="M12.714 14.364a6.821 6.821 0 006.822-6.822A6.821 6.821 0 0012.714.72a6.822 6.822 0 00-6.822 6.822 6.822 6.822 0 006.822 6.822zM6.32 23.318c0 .708.57 1.279 1.279 1.279s1.279-.57 1.279-1.28c0-.708-.57-1.278-1.28-1.278-.708 0-1.278.57-1.278 1.279zM17.83 16.1v2.612a4.27 4.27 0 013.41 4.178v2.223a.855.855 0 01-.687.837l-1.716.34a.424.424 0 01-.501-.335l-.165-.837a.422.422 0 01.336-.5l1.028-.209v-1.519c0-3.347-5.116-3.47-5.116.102v1.423l1.028.207c.23.048.379.272.336.502l-.165.836a.432.432 0 01-.501.336l-1.663-.224a.852.852 0 01-.736-.847V22.89a4.274 4.274 0 013.412-4.178v-2.41c-.118.038-.235.06-.352.102a9.244 9.244 0 01-3.06.522c-1.07 0-2.1-.186-3.059-.522a5.889 5.889 0 00-1.204-.277v4.349a2.974 2.974 0 012.132 2.846 2.987 2.987 0 01-2.985 2.985 2.987 2.987 0 01-2.985-2.985c0-1.348.901-2.478 2.132-2.846v-4.285c-3.39.57-5.974 3.49-5.974 7.04v2.388a2.39 2.39 0 002.387 2.388h19.102a2.39 2.39 0 002.388-2.388v-2.388c0-3.837-3.028-6.944-6.822-7.13z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const WalletIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
width={18}
|
||||
height={19}
|
||||
viewBox="0 0 18 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...properties}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M13.327 6.638H16.5c0-2.548-1.527-4.036-4.113-4.036H5.613C3.027 2.602 1.5 4.09 1.5 6.605v5.493c0 2.515 1.527 4.004 4.113 4.004h6.774c2.586 0 4.113-1.489 4.113-4.004v-.234h-3.173c-1.473 0-2.667-1.164-2.667-2.6 0-1.436 1.194-2.6 2.667-2.6v-.026zm0 1.118h2.613c.31 0 .56.244.56.546V10.2a.56.56 0 01-.56.546h-2.553A1.58 1.58 0 0111.82 9.55c-.085-.44.034-.893.325-1.24.29-.345.723-.548 1.181-.554zm.113 1.995h.247c.316 0 .573-.25.573-.559a.566.566 0 00-.573-.559h-.247a.574.574 0 00-.405.16.545.545 0 00-.168.393c0 .31.255.562.573.565zM5.053 6.638h4.234c.316 0 .573-.25.573-.56a.566.566 0 00-.573-.558H5.053a.567.567 0 00-.573.552c0 .31.256.562.573.566z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Button } from '@headlessui/react'
|
||||
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 text-black hover:bg-[#2E2F7C] hover:text-white active:bg-[#2E2F7C]/50 disabled:cursor-not-allowed disabled:bg-[#2E2F7C]/50 disabled:text-white disabled:opacity-50',
|
||||
isActive ? 'bg-[#2E2F7C]/10' : '',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { Input } from '@headlessui/react'
|
||||
import {
|
||||
ArrowUturnLeftIcon,
|
||||
ArrowUturnRightIcon,
|
||||
Bars3BottomLeftIcon,
|
||||
Bars3BottomRightIcon,
|
||||
Bars3Icon,
|
||||
BoldIcon,
|
||||
CloudArrowUpIcon,
|
||||
CodeBracketIcon,
|
||||
DocumentTextIcon,
|
||||
H1Icon,
|
||||
H2Icon,
|
||||
H3Icon,
|
||||
ItalicIcon,
|
||||
LinkIcon,
|
||||
LinkSlashIcon,
|
||||
ListBulletIcon,
|
||||
NumberedListIcon,
|
||||
PhotoIcon,
|
||||
StrikethroughIcon,
|
||||
SwatchIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import {
|
||||
Bars3BottomCenterIcon,
|
||||
QuotationMarkIcon,
|
||||
} from '@sidekickicons/react/24/solid'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import {
|
||||
type SetStateAction,
|
||||
type Dispatch,
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import { HexColorInput, HexColorPicker } from 'react-colorful'
|
||||
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { useAdminContext } from '~/contexts/admin'
|
||||
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 { setIsUploadOpen, uploadedFile, setUploadedFile, isUploadOpen } =
|
||||
useAdminContext()
|
||||
const [isOpenImage, setIsOpenImage] = useState(false)
|
||||
const [imageUrl, setImageUrl] = useState('')
|
||||
const [isOpenColor, setIsOpenColor] = useState(false)
|
||||
const popover = useRef<HTMLDivElement>(null)
|
||||
const close = useCallback(() => {
|
||||
setIsOpenColor(false)
|
||||
setIsOpenImage(false)
|
||||
if (imageUrl) {
|
||||
addImage(imageUrl)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [imageUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedFile && isUploadOpen === category) {
|
||||
addImage(uploadedFile)
|
||||
setUploadedFile(undefined)
|
||||
setIsUploadOpen(undefined)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uploadedFile])
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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 className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={
|
||||
disabled || !editor.can().chain().focus().toggleItalic().run()
|
||||
}
|
||||
isActive={editor.isActive('italic')}
|
||||
title="Italic"
|
||||
>
|
||||
<ItalicIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
disabled={
|
||||
disabled || !editor.can().chain().focus().toggleStrike().run()
|
||||
}
|
||||
isActive={editor.isActive('strike')}
|
||||
title="Strike"
|
||||
>
|
||||
<StrikethroughIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<div className="relative">
|
||||
<EditorButton
|
||||
onClick={() => setIsOpenColor(true)}
|
||||
title="Text Color"
|
||||
style={{
|
||||
color: rgbColor,
|
||||
}}
|
||||
isActive={true}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SwatchIcon className="size-4" />
|
||||
</EditorButton>
|
||||
{isOpenColor && (
|
||||
<div
|
||||
className="border-md absolute top-8 left-0"
|
||||
ref={popover}
|
||||
>
|
||||
<HexColorPicker
|
||||
className="z-10 rounded-lg shadow"
|
||||
color={rgbColor}
|
||||
onChange={handleChangeColor}
|
||||
/>
|
||||
<div className="">
|
||||
<HexColorInput
|
||||
color={rgbColor}
|
||||
onChange={handleChangeColor}
|
||||
prefixed
|
||||
className="relative z-10 mt-1 flex w-full rounded-lg border-0 bg-white px-3 py-2 text-sm shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none focus-visible:outline-0 disabled:bg-gray-100"
|
||||
/>
|
||||
</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 className="size-4" />
|
||||
</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"
|
||||
>
|
||||
<Bars3BottomCenterIcon className="size-4" />
|
||||
</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 className="size-4" />
|
||||
</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"
|
||||
>
|
||||
<Bars3Icon className="size-4" />
|
||||
</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 className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
disabled={disabled}
|
||||
>
|
||||
<H2Icon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
disabled={disabled}
|
||||
>
|
||||
<H3Icon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
isActive={editor.isActive('codeBlock')}
|
||||
title="Code Block"
|
||||
disabled={disabled}
|
||||
>
|
||||
<CodeBracketIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
isActive={editor.isActive('bulletList')}
|
||||
title="Bullet List"
|
||||
disabled={disabled}
|
||||
>
|
||||
<ListBulletIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
isActive={editor.isActive('orderedList')}
|
||||
title="Ordered List"
|
||||
disabled={disabled}
|
||||
>
|
||||
<NumberedListIcon className="size-4" />
|
||||
</EditorButton>
|
||||
{/* <EditorButton
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
isActive={editor.isActive('paragraph')}
|
||||
title="Paragraph"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PilcrowIcon className="size-4" />
|
||||
</EditorButton> */}
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
isActive={editor.isActive('blockquote')}
|
||||
title="Blockquote"
|
||||
disabled={disabled}
|
||||
>
|
||||
<QuotationMarkIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetAllMarks().run()
|
||||
editor.chain().focus().clearNodes().run()
|
||||
}}
|
||||
title="Clear Format"
|
||||
disabled={disabled}
|
||||
>
|
||||
<XCircleIcon className="size-4" />
|
||||
</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>
|
||||
</div> */}
|
||||
<div className="flex items-start gap-1 px-1">
|
||||
<div className="relative">
|
||||
<EditorButton
|
||||
onClick={() => setIsOpenImage(true)}
|
||||
title="Insert Image"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PhotoIcon className="size-4" />
|
||||
</EditorButton>
|
||||
{isOpenImage && (
|
||||
<div
|
||||
className="border-md absolute top-8 left-0 w-50"
|
||||
ref={popover}
|
||||
>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Masukkan URL Gambar"
|
||||
color={rgbColor}
|
||||
onChange={(event) => {
|
||||
setImageUrl(event.target.value)
|
||||
}}
|
||||
className="z-10 flex h-[42px] w-full rounded-lg border-0 bg-white p-2 pr-8 text-sm shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none focus-visible:outline-0 disabled:bg-gray-100"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="fit"
|
||||
className="absolute top-0 right-3 h-[42px]"
|
||||
onClick={() => {
|
||||
setIsUploadOpen('content')
|
||||
}}
|
||||
>
|
||||
<CloudArrowUpIcon className="size-4 text-gray-500/50" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EditorButton
|
||||
onClick={() => setLink()}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor
|
||||
.can()
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({ href: '' })
|
||||
.run()
|
||||
}
|
||||
isActive={editor.isActive('link')}
|
||||
title="Set Link"
|
||||
>
|
||||
<LinkIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().unsetLink().run()}
|
||||
disabled={disabled || !editor.isActive('link')}
|
||||
title="Unset Link"
|
||||
>
|
||||
<LinkSlashIcon className="size-4" />
|
||||
</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 className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={disabled || !editor.can().chain().focus().redo().run()}
|
||||
title="Redo"
|
||||
>
|
||||
<ArrowUturnRightIcon className="size-4" />
|
||||
</EditorButton>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => setIsPlainHTML(true)}
|
||||
title="Switch to Plain Text"
|
||||
>
|
||||
<DocumentTextIcon className="size-4" />
|
||||
</EditorButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { CodeBracketSquareIcon } from '@heroicons/react/24/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"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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 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 = {
|
||||
id?: string
|
||||
name: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
labelClassName?: string
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
containerClassName?: string
|
||||
rules?: RegisterOptions
|
||||
disabled?: boolean
|
||||
isRequired?: boolean
|
||||
category: string
|
||||
}
|
||||
|
||||
export const TextEditor = (properties: TProperties) => {
|
||||
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.configure({
|
||||
paragraph: {
|
||||
HTMLAttributes: {
|
||||
class: 'min-h-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
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 string)
|
||||
},
|
||||
})
|
||||
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.5 prose-p:my-0.5 max-w-none cursor-text overflow-y-auto rounded-b-md p-2',
|
||||
inputClassName,
|
||||
)}
|
||||
onClick={() => editor?.commands.focus()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { BANNER } from '~/data/contents'
|
||||
|
||||
export const Banner = () => {
|
||||
const [emblaReference] = useEmblaCarousel({ loop: false })
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="embla overflow-hidden"
|
||||
ref={emblaReference}
|
||||
>
|
||||
<div className="embla__container flex">
|
||||
{BANNER.map(({ urlImage, alt, link }, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="embla__slide max-h-[100px] min-h-[65px] w-full min-w-0 flex-none"
|
||||
>
|
||||
<Link
|
||||
to={link}
|
||||
className="mt-2 h-full py-2"
|
||||
>
|
||||
<img
|
||||
src={urlImage}
|
||||
alt={alt}
|
||||
className="h-[70px] w-[100%] content-center sm:h-full"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Link } from 'react-router'
|
||||
|
||||
type BreadcrumbProperty = {
|
||||
slug: string
|
||||
}
|
||||
|
||||
export const Breadcrumb = (property: BreadcrumbProperty) => {
|
||||
const { slug } = property
|
||||
return (
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<Link to={'#'}>Blog</Link>
|
||||
<div>{'>'}</div>
|
||||
<Link
|
||||
to={'#'}
|
||||
className="text-ellipsis md:text-clip ..."
|
||||
>
|
||||
{slug.slice(0, 20) + '...'}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button as HeadlessButton } from '@headlessui/react'
|
||||
import { ArrowPathIcon } from '@heroicons/react/24/solid'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import type { ReactNode, ElementType, ComponentPropsWithoutRef } from 'react'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
@@ -8,23 +9,30 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
newsPrimary: 'bg-[#2E2F7C] text-white text-lg',
|
||||
newsPrimaryOutline: 'border-[3px] border-white text-white text-lg',
|
||||
newsSecondary: 'border-[3px] border-[#2E2F7C] text-[#2E2F7C] text-lg',
|
||||
primary:
|
||||
'bg-[#2E2F7C] text-white text-lg hover:bg-[#4C5CA0] hover:shadow transition active:bg-[#6970B4]',
|
||||
danger:
|
||||
'bg-[#EF4444] text-white text-lg hover:shadow transition active:bg-[#FEE2E2] hover:bg-[#FCA5A5]',
|
||||
primaryOutline:
|
||||
'border-[3px] bg-[#2E2F7C] border-white text-white text-lg hover:bg-[#4C5CA0] hover:shadow-lg active:shadow-2xl transition active:bg-[#6970B4]',
|
||||
outline:
|
||||
'border-[3px] bg-white hover:shadow-lg active:shadow-2xl border-[#2E2F7C] text-[#2E2F7C] hover:text-[#4C5CA0] active:text-[#6970B4] text-lg hover:border-[#4C5CA0] transition active:border-[#6970B4]',
|
||||
icon: '',
|
||||
link: '',
|
||||
link: 'font-semibold text-[#2E2F7C] hover:text-[#4C5CA0] active:text-[#6970B4] transition',
|
||||
secondary:
|
||||
'hover:bg-[#707FDD]/10 active:bg-[#707FDD]/20 hover:text-[#707FDD] text-[#273240]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-[50px] w-[150px]',
|
||||
block: 'h-[50px] w-full',
|
||||
icon: 'h-9 w-9',
|
||||
icon: 'size-9 rounded-full',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
fit: 'w-fit',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'newsPrimary',
|
||||
variant: 'primary',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
@@ -35,6 +43,8 @@ type ButtonBaseProperties = {
|
||||
variant?: VariantProps<typeof buttonVariants>['variant']
|
||||
size?: VariantProps<typeof buttonVariants>['size']
|
||||
className?: string
|
||||
isLoading?: boolean
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
type PolymorphicReference<C extends ElementType> =
|
||||
@@ -45,22 +55,28 @@ type ButtonProperties<C extends ElementType> = ButtonBaseProperties & {
|
||||
ref?: PolymorphicReference<C>
|
||||
} & Omit<ComponentPropsWithoutRef<C>, keyof ButtonBaseProperties>
|
||||
|
||||
export const Button = <C extends ElementType = 'button'>({
|
||||
as,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
...properties
|
||||
}: ButtonProperties<C>) => {
|
||||
export const Button = <C extends ElementType = 'button'>(
|
||||
properties: ButtonProperties<C>,
|
||||
) => {
|
||||
const {
|
||||
as,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
isLoading = false,
|
||||
icon,
|
||||
...restProperties
|
||||
} = properties
|
||||
const Component = as || HeadlessButton
|
||||
const classes = twMerge(buttonVariants({ variant, size, className }))
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={classes}
|
||||
{...properties}
|
||||
{...restProperties}
|
||||
>
|
||||
{isLoading ? <ArrowPathIcon className="size-5 animate-spin" /> : icon}
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { JSX } from 'react'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
import { formatNumberWithPeriods } from '~/utils/formatter'
|
||||
|
||||
@@ -6,17 +6,14 @@ type CardReportProperty = {
|
||||
title: string
|
||||
amount: number
|
||||
currency?: string
|
||||
icon: (
|
||||
properties: React.JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>,
|
||||
) => JSX.Element
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
url?: string
|
||||
counter?: number[]
|
||||
}
|
||||
|
||||
export const CardReport = (properties: CardReportProperty) => {
|
||||
const { title, amount, icon: Icon, counter, currency } = properties
|
||||
const { title, amount, icon: Icon, currency } = properties
|
||||
return (
|
||||
<div className="rounded bg-white px-4 py-6 shadow-sm">
|
||||
<div className="rounded-xl bg-white px-4 py-6 shadow-sm">
|
||||
<div className="flex items-center">
|
||||
<Icon
|
||||
className="ml-2 rounded-xl bg-[#2E2F7C] p-2 text-white"
|
||||
@@ -32,11 +29,6 @@ export const CardReport = (properties: CardReportProperty) => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{counter?.length && (
|
||||
<div className="flex items-center pt-2">
|
||||
Pribadi: {counter[0]} | Perusahaan: {counter[1]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { ComponentProps, FC, PropsWithChildren } from 'react'
|
||||
type TProperties = PropsWithChildren<ComponentProps<'div'>>
|
||||
|
||||
export const Card: FC<TProperties> = (properties) => {
|
||||
const { children, ...rest } = properties
|
||||
const { children, ...restProperties } = properties
|
||||
return (
|
||||
<div
|
||||
className="border-[.2px] border-black/20 bg-white p-[30px]"
|
||||
{...rest}
|
||||
{...restProperties}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouteLoaderData } from 'react-router'
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react'
|
||||
import { Await, useRouteLoaderData } from 'react-router'
|
||||
import { stripHtml } from 'string-strip-html'
|
||||
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { ErrorAwait } from '~/components/error/await'
|
||||
import { ImageSkeletonIcon } from '~/components/icons/image-skeleton'
|
||||
import { CarouselButton } from '~/components/ui/button-slide'
|
||||
import { useNewsContext } from '~/contexts/news'
|
||||
import type { loader } from '~/routes/_layout'
|
||||
import type { loader } from '~/routes/_news'
|
||||
import type { TNews } from '~/types/news'
|
||||
import { getPremiumAttribute } from '~/utils/render'
|
||||
|
||||
import { Button } from './button'
|
||||
|
||||
export const CarouselHero = (properties: TNews) => {
|
||||
const { setIsSuccessOpen } = useNewsContext()
|
||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_layout')
|
||||
const userData = loaderData?.userData
|
||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
|
||||
const { userData } = loaderData || {}
|
||||
const { title, description, items } = properties
|
||||
const [emblaReference, emblaApi] = useEmblaCarousel({ loop: false })
|
||||
|
||||
@@ -71,41 +75,81 @@ export const CarouselHero = (properties: TNews) => {
|
||||
ref={emblaReference}
|
||||
>
|
||||
<div className="embla__container hero flex sm:gap-x-8">
|
||||
{items.map(({ featured, title, content, slug, isPremium }, index) => (
|
||||
<div
|
||||
className="embla__slide hero w-full min-w-0 flex-none"
|
||||
key={index}
|
||||
>
|
||||
<div className="max-sm:mt-2 sm:flex">
|
||||
<img
|
||||
className="col-span-2 aspect-[174/100] object-cover"
|
||||
src={featured}
|
||||
alt={title}
|
||||
/>
|
||||
<div className="flex h-full flex-col justify-between gap-7 sm:px-5">
|
||||
<div>
|
||||
<h3 className="mt-2 w-full text-2xl font-bold sm:mt-0 sm:text-4xl">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-md mt-5 text-[#777777] sm:text-xl">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="block"
|
||||
{...getPremiumAttribute({
|
||||
isPremium,
|
||||
slug,
|
||||
onClick: () => setIsSuccessOpen('warning'),
|
||||
userData,
|
||||
})}
|
||||
>
|
||||
View More
|
||||
</Button>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="embla__slide hero flex w-full min-w-0 flex-none animate-pulse justify-between gap-3 max-sm:mt-2 sm:flex">
|
||||
<div className="flex aspect-[174/100] h-full items-center justify-center rounded-md bg-gray-300 dark:bg-gray-700">
|
||||
<ImageSkeletonIcon />
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-7">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="h-6 w-full rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-6 w-[20%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[90%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[90%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[90%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[90%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[50%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div className="h-[50px] w-full bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
}
|
||||
>
|
||||
<Await
|
||||
resolve={items}
|
||||
errorElement={<ErrorAwait />}
|
||||
>
|
||||
{(value) =>
|
||||
value.data.map(
|
||||
(
|
||||
{ featured_image, title, content, slug, is_premium },
|
||||
index,
|
||||
) => (
|
||||
<div
|
||||
className="embla__slide hero w-full min-w-0 flex-none max-sm:mt-2 sm:flex"
|
||||
key={index}
|
||||
>
|
||||
<img
|
||||
className="aspect-[174/100] h-full rounded-md object-cover"
|
||||
src={featured_image}
|
||||
alt={title}
|
||||
/>
|
||||
<div className="flex h-full flex-col justify-between gap-7 sm:px-5">
|
||||
<div>
|
||||
<h3 className="mt-2 w-full text-2xl font-bold sm:mt-0 sm:text-4xl">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-md mt-5 line-clamp-10 text-[#777777] sm:text-xl">
|
||||
{stripHtml(content).result}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="block"
|
||||
{...getPremiumAttribute({
|
||||
isPremium: is_premium,
|
||||
slug,
|
||||
onClick: () => setIsSuccessOpen('warning'),
|
||||
userData,
|
||||
})}
|
||||
>
|
||||
View More
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouteLoaderData } from 'react-router'
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react'
|
||||
import { Await, useRouteLoaderData } from 'react-router'
|
||||
import { stripHtml } from 'string-strip-html'
|
||||
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { ErrorAwait } from '~/components/error/await'
|
||||
import { ImageSkeletonIcon } from '~/components/icons/image-skeleton'
|
||||
import { CarouselButton } from '~/components/ui/button-slide'
|
||||
import { useNewsContext } from '~/contexts/news'
|
||||
import type { loader } from '~/routes/_layout'
|
||||
import type { loader } from '~/routes/_news'
|
||||
import type { TNews } from '~/types/news'
|
||||
import { getPremiumAttribute } from '~/utils/render'
|
||||
|
||||
import { Button } from './button'
|
||||
import { Tags } from './tags'
|
||||
|
||||
export const CarouselSection = (properties: TNews) => {
|
||||
const { setIsSuccessOpen } = useNewsContext()
|
||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_layout')
|
||||
const userData = loaderData?.userData
|
||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
|
||||
const { userData } = loaderData || {}
|
||||
const { title, description, items } = properties
|
||||
const [emblaReference, emblaApi] = useEmblaCarousel({
|
||||
loop: false,
|
||||
@@ -76,60 +81,81 @@ export const CarouselSection = (properties: TNews) => {
|
||||
ref={emblaReference}
|
||||
>
|
||||
<div className="embla__container col-span-3 flex max-h-[586px] sm:gap-x-8">
|
||||
{items.map(
|
||||
({ featured, title, content, tags, slug, isPremium }, index) => (
|
||||
<Suspense
|
||||
fallback={Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
className="embla__slide w-full min-w-0 flex-none sm:w-1/3"
|
||||
key={index}
|
||||
className="embla__slide flex w-full min-w-0 flex-none animate-pulse flex-col justify-between gap-3 sm:w-1/3"
|
||||
>
|
||||
<div className="flex flex-col justify-between">
|
||||
<img
|
||||
className="aspect-[5/4] max-h-[280px] w-full rounded-md object-cover"
|
||||
src={featured}
|
||||
alt={title}
|
||||
/>
|
||||
<div className={'flex flex-col justify-between gap-4'}>
|
||||
<div className={'flex text-sm uppercase'}>
|
||||
{tags?.map((item) => (
|
||||
<span
|
||||
key={index}
|
||||
className="my-3 mr-2 inline-block rounded bg-[#F4F4F4] px-3 py-1 font-bold text-[#777777]"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
{isPremium && (
|
||||
<span className="my-3 mr-2 inline-block rounded bg-[#D1C675] px-3 py-1 font-bold text-[#9D761D]">
|
||||
Premium Content
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mt-2 w-full text-xl font-bold sm:text-2xl lg:mt-0">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-md mt-5 text-[#777777] sm:text-xl">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="block"
|
||||
{...getPremiumAttribute({
|
||||
isPremium,
|
||||
slug,
|
||||
onClick: () => setIsSuccessOpen('warning'),
|
||||
userData,
|
||||
})}
|
||||
className="mb-5"
|
||||
>
|
||||
View More
|
||||
</Button>
|
||||
<div className="flex h-[280px] w-full items-center justify-center rounded-md bg-gray-300 dark:bg-gray-700">
|
||||
<ImageSkeletonIcon />
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="h-6 w-full rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-6 w-[20%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="h-5 max-w-[80%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[50%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[50px] w-full bg-gray-200 dark:bg-gray-700" />
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
>
|
||||
<Await
|
||||
resolve={items}
|
||||
errorElement={<ErrorAwait />}
|
||||
>
|
||||
{(value) =>
|
||||
value.data.map(
|
||||
(
|
||||
{ featured_image, title, content, tags, slug, is_premium },
|
||||
index,
|
||||
) => (
|
||||
<div
|
||||
className="embla__slide flex w-full min-w-0 flex-none flex-col justify-between gap-3 sm:w-1/3"
|
||||
key={index}
|
||||
>
|
||||
<img
|
||||
className="aspect-[174/100] max-h-[280px] w-full rounded-md object-cover sm:aspect-[5/4]"
|
||||
src={featured_image}
|
||||
alt={title}
|
||||
/>
|
||||
<div className={'flex flex-col justify-between gap-4'}>
|
||||
<div className="flex h-28 flex-col items-start justify-center gap-4">
|
||||
<Tags
|
||||
tags={tags || []}
|
||||
is_premium={is_premium}
|
||||
/>
|
||||
<h3 className="mt-2 line-clamp-2 w-full text-xl font-bold sm:text-2xl lg:mt-0">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="line-clamp-3 text-base text-[#777777] sm:text-xl">
|
||||
{stripHtml(content).result}
|
||||
</p>
|
||||
<Button
|
||||
size="block"
|
||||
{...getPremiumAttribute({
|
||||
isPremium: is_premium,
|
||||
slug,
|
||||
onClick: () => setIsSuccessOpen('warning'),
|
||||
userData,
|
||||
})}
|
||||
>
|
||||
View More
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { useRouteLoaderData } from 'react-router'
|
||||
import { Suspense } from 'react'
|
||||
import { Await, useRouteLoaderData } from 'react-router'
|
||||
import { stripHtml } from 'string-strip-html'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import { ErrorAwait } from '~/components/error/await'
|
||||
import { CarouselNextIcon } from '~/components/icons/carousel-next'
|
||||
import { CarouselPreviousIcon } from '~/components/icons/carousel-previous'
|
||||
import { ImageSkeletonIcon } from '~/components/icons/image-skeleton'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { useNewsContext } from '~/contexts/news'
|
||||
import type { loader } from '~/routes/_layout'
|
||||
import type { loader } from '~/routes/_news'
|
||||
import type { TNews } from '~/types/news'
|
||||
import { getPremiumAttribute } from '~/utils/render'
|
||||
|
||||
import { Tags } from './tags'
|
||||
|
||||
export const CategorySection = (properties: TNews) => {
|
||||
const { setIsSuccessOpen } = useNewsContext()
|
||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_layout')
|
||||
const userData = loaderData?.userData
|
||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
|
||||
const { userData } = loaderData || {}
|
||||
|
||||
const { title, description, items } = properties
|
||||
|
||||
@@ -38,68 +44,87 @@ export const CategorySection = (properties: TNews) => {
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-3 sm:gap-x-8">
|
||||
{items.map(
|
||||
({ featured, title, content, tags, slug, isPremium }, index) => (
|
||||
<Suspense
|
||||
fallback={Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={twMerge('grid sm:gap-x-8')}
|
||||
className="grid gap-3 sm:gap-x-8"
|
||||
>
|
||||
<img
|
||||
className={twMerge(
|
||||
'aspect-[5/4] w-full rounded-md object-cover',
|
||||
)}
|
||||
src={featured}
|
||||
alt={title}
|
||||
/>
|
||||
<div className={twMerge('flex flex-col justify-between gap-4')}>
|
||||
<div
|
||||
className={twMerge(
|
||||
'my-3 flex gap-2 uppercase max-sm:text-sm',
|
||||
)}
|
||||
>
|
||||
{tags?.map((item) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block rounded bg-[#F4F4F4] px-3 py-1 font-bold text-[#777777]"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
{isPremium && (
|
||||
<span className="inline-block rounded bg-[#D1C675] px-3 py-1 font-bold text-[#9D761D]">
|
||||
Premium Content
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
className={twMerge(
|
||||
'mt-2 w-full text-xl font-bold sm:mt-0 sm:text-2xl',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-md mt-5 text-[#777777] sm:text-xl">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="block"
|
||||
{...getPremiumAttribute({
|
||||
isPremium,
|
||||
slug,
|
||||
onClick: () => setIsSuccessOpen('warning'),
|
||||
userData,
|
||||
})}
|
||||
className="mb-5"
|
||||
>
|
||||
View More
|
||||
</Button>
|
||||
<div className="flex h-[280px] w-full items-center justify-center rounded-md bg-gray-300 dark:bg-gray-700">
|
||||
<ImageSkeletonIcon />
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="h-6 w-full rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-6 w-[20%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="h-5 max-w-[80%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[50%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[50px] w-full bg-gray-200 dark:bg-gray-700" />
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
>
|
||||
<Await
|
||||
resolve={items}
|
||||
errorElement={<ErrorAwait />}
|
||||
>
|
||||
{(value) =>
|
||||
value.data.map(
|
||||
(
|
||||
{ featured_image, title, content, tags, slug, is_premium },
|
||||
index,
|
||||
) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid gap-3 sm:gap-x-8"
|
||||
>
|
||||
<img
|
||||
className="aspect-[174/100] w-full rounded-md object-cover sm:aspect-[5/4]"
|
||||
src={featured_image}
|
||||
alt={title}
|
||||
/>
|
||||
<div className="flex flex-col justify-between gap-4">
|
||||
<Tags
|
||||
tags={tags}
|
||||
is_premium={is_premium}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
className={twMerge(
|
||||
'mt-2 w-full text-xl font-bold sm:mt-0 sm:text-2xl',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-md mt-5 line-clamp-3 text-[#777777] sm:text-xl">
|
||||
{stripHtml(content).result}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="block"
|
||||
{...getPremiumAttribute({
|
||||
isPremium: is_premium,
|
||||
slug,
|
||||
onClick: () => setIsSuccessOpen('warning'),
|
||||
userData,
|
||||
})}
|
||||
className="mb-5"
|
||||
>
|
||||
View More
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="my-5 mt-5 flex flex-row-reverse">
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export type TColorBadge =
|
||||
| 'baru'
|
||||
| 'premium'
|
||||
| 'pembayaran'
|
||||
| 'active'
|
||||
| 'inactive'
|
||||
| 'expired'
|
||||
| 1
|
||||
| 0
|
||||
|
||||
export const getStatusBadge = (status: TColorBadge) => {
|
||||
const statusColors = {
|
||||
baru: 'bg-[#DFE5FF] text-[#4C5CA0]',
|
||||
premium: 'bg-[#FFFCAF] text-[#DBCA6E]',
|
||||
pembayaran: 'bg-[#FEC4FF] text-[#CC6EDB]',
|
||||
active: 'bg-[#D1FAE5] text-[#10B981]',
|
||||
inactive: 'bg-[#FEE2E2] text-[#EF4444]',
|
||||
expired: 'bg-[#FEC4FF] text-[#CC6EDB]',
|
||||
1: 'bg-[#DFE5FF] text-[#4C5CA0]',
|
||||
0: 'bg-[#FEE2E2] text-[#EF4444]',
|
||||
}
|
||||
return statusColors[status] || 'bg-gray-200 text-gray-700'
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
Field,
|
||||
Label,
|
||||
Combobox as HeadlessCombobox,
|
||||
ComboboxInput,
|
||||
ComboboxButton,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/react'
|
||||
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/solid'
|
||||
import { useState, type ComponentProps, type ReactNode } from 'react'
|
||||
import {
|
||||
get,
|
||||
type FieldError,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type RegisterOptions,
|
||||
Controller,
|
||||
} from 'react-hook-form'
|
||||
import { useRemixFormContext } from 'remix-hook-form'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type TComboboxOption = {
|
||||
code: string
|
||||
name: string
|
||||
id: string
|
||||
}
|
||||
|
||||
type TComboboxProperties<T extends FieldValues> = ComponentProps<
|
||||
typeof HeadlessCombobox
|
||||
> & {
|
||||
id: string
|
||||
label?: ReactNode
|
||||
name: Path<T>
|
||||
rules?: RegisterOptions
|
||||
placeholder?: string
|
||||
options?: TComboboxOption[]
|
||||
labelClassName?: string
|
||||
containerClassName?: string
|
||||
}
|
||||
|
||||
export const Combobox = <TFormValues extends Record<string, unknown>>(
|
||||
properties: TComboboxProperties<TFormValues>,
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
rules,
|
||||
disabled,
|
||||
placeholder,
|
||||
options,
|
||||
className,
|
||||
labelClassName,
|
||||
containerClassName,
|
||||
...restProperties
|
||||
} = properties
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useRemixFormContext()
|
||||
const [query, setQuery] = useState('')
|
||||
const filteredOptions =
|
||||
query === ''
|
||||
? options
|
||||
: options?.filter((option) =>
|
||||
option.name.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
|
||||
const error: FieldError = get(errors, name)
|
||||
|
||||
return (
|
||||
<Field
|
||||
className={twMerge('relative', containerClassName)}
|
||||
disabled={disabled}
|
||||
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 }) => (
|
||||
<HeadlessCombobox
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
immediate
|
||||
{...restProperties}
|
||||
>
|
||||
<div className="relative">
|
||||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
displayValue={(option: TComboboxOption) => option?.name}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className={twMerge(
|
||||
'focus:inheriten h-[42px] w-full rounded-md border border-[#DFDFDF] p-2 placeholder:text-inherit',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
<ComboboxButton className="group absolute inset-y-0 right-0 px-2.5">
|
||||
<ChevronDownIcon className="size-4 fill-gray-500" />
|
||||
</ComboboxButton>
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
anchor={{ to: 'bottom', gap: '8px' }}
|
||||
transition
|
||||
className={twMerge(
|
||||
'w-[var(--input-width)] rounded-md border border-[#DFDFDF] p-1 empty:invisible',
|
||||
'bg-white transition duration-100 ease-in data-[leave]:data-[closed]:opacity-0',
|
||||
)}
|
||||
>
|
||||
{filteredOptions?.map((option) => (
|
||||
<ComboboxOption
|
||||
key={option.id}
|
||||
value={option}
|
||||
className="group flex cursor-default items-center gap-2 rounded-lg px-3 py-1.5 select-none data-[focus]:bg-white/10"
|
||||
>
|
||||
<CheckIcon className="invisible size-4 group-data-[selected]:visible" />
|
||||
<div className="text-sm/6">{option.name}</div>
|
||||
</ComboboxOption>
|
||||
))}
|
||||
</ComboboxOptions>
|
||||
</HeadlessCombobox>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Field, Label, Input as HeadlessInput } from '@headlessui/react'
|
||||
import { CloudArrowUpIcon } from '@heroicons/react/24/solid'
|
||||
import { useEffect, type ComponentProps, type ReactNode } from 'react'
|
||||
import { get, type FieldError, type RegisterOptions } from 'react-hook-form'
|
||||
import { useRemixFormContext } from 'remix-hook-form'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import { useAdminContext, type TUpload } from '~/contexts/admin'
|
||||
|
||||
import { Button } from './button'
|
||||
|
||||
type TInputProperties = Omit<ComponentProps<'input'>, 'size'> & {
|
||||
id: string
|
||||
label?: ReactNode
|
||||
name: string
|
||||
rules?: RegisterOptions
|
||||
containerClassName?: string
|
||||
labelClassName?: string
|
||||
category: TUpload
|
||||
}
|
||||
|
||||
export const InputFile = (properties: TInputProperties) => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
rules,
|
||||
placeholder,
|
||||
disabled,
|
||||
className,
|
||||
containerClassName,
|
||||
labelClassName,
|
||||
category,
|
||||
...restProperties
|
||||
} = properties
|
||||
const { setIsUploadOpen, uploadedFile, setUploadedFile, isUploadOpen } =
|
||||
useAdminContext()
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
} = useRemixFormContext()
|
||||
|
||||
const error: FieldError = get(errors, name)
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedFile && isUploadOpen === (category || name)) {
|
||||
setValue(name, uploadedFile)
|
||||
setUploadedFile(undefined)
|
||||
setIsUploadOpen(undefined)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uploadedFile])
|
||||
|
||||
return (
|
||||
<Field
|
||||
className={twMerge('relative', containerClassName)}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
>
|
||||
<Label className={twMerge('mb-1 block text-gray-700', labelClassName)}>
|
||||
{label} {error && <span className="text-red-500">{error.message}</span>}
|
||||
</Label>
|
||||
<HeadlessInput
|
||||
className={twMerge(
|
||||
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2 pr-8',
|
||||
className,
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
{...register(name, rules)}
|
||||
{...restProperties}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="fit"
|
||||
className="absolute right-3 h-[42px]"
|
||||
onClick={() => {
|
||||
setIsUploadOpen(category)
|
||||
}}
|
||||
>
|
||||
<CloudArrowUpIcon className="size-4 text-gray-500/50" />
|
||||
</Button>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -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,7 +37,10 @@ export const Input = <TFormValues extends Record<string, unknown>>(
|
||||
type = 'text',
|
||||
placeholder,
|
||||
disabled,
|
||||
...rest
|
||||
className,
|
||||
containerClassName,
|
||||
labelClassName,
|
||||
...restProperties
|
||||
} = properties
|
||||
const [inputType, setInputType] = useState(type)
|
||||
|
||||
@@ -48,33 +53,37 @@ 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,
|
||||
type === 'password' ? 'pr-8' : '',
|
||||
)}
|
||||
placeholder={inputType === 'password' ? '******' : placeholder}
|
||||
{...register(name, rules)}
|
||||
{...rest}
|
||||
{...restProperties}
|
||||
/>
|
||||
{type === 'password' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="fit"
|
||||
className="absolute right-3 h-[42px] text-gray-500"
|
||||
className="absolute right-3 h-[42px]"
|
||||
onClick={() =>
|
||||
setInputType(inputType === 'password' ? 'text' : 'password')
|
||||
}
|
||||
>
|
||||
<EyeIcon
|
||||
className={twMerge(
|
||||
'h-4 w-4',
|
||||
'size-4',
|
||||
inputType === 'password' ? 'text-gray-500/50' : 'text-gray-500',
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { TAuthorResponse } from '~/apis/common/get-news'
|
||||
import { formatDate } from '~/utils/formatter'
|
||||
|
||||
type TDetailNewsAuthor = {
|
||||
author?: TAuthorResponse
|
||||
live_at?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
export const NewsAuthor = ({ author, live_at, text }: TDetailNewsAuthor) => {
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-2 align-middle">
|
||||
<img
|
||||
src={author?.profile_picture || '/images/profile-placeholder.svg'}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = '/images/profile-placeholder.svg'
|
||||
}}
|
||||
alt={author?.name}
|
||||
className="size-12 rounded-full bg-[#C4C4C4] object-cover"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h4 className="text-md">{author?.name}</h4>
|
||||
<p className="flex gap-1 text-sm">
|
||||
<span>{live_at && `${formatDate(live_at)}`}</span>
|
||||
<span>·</span>
|
||||
<span>{text}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export const Newsletter = (property: NewsletterProperties) => {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="newsPrimary"
|
||||
variant="primary"
|
||||
size="block"
|
||||
>
|
||||
Subscribe
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { ChevronIcon } from '~/components/icons/chevron'
|
||||
import { ChevronDoubleIcon } from '~/components/icons/chevron-double'
|
||||
|
||||
type PaginationProperties = {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
export const Pagination: React.FC<PaginationProperties> = ({
|
||||
currentPage = 1,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}) => {
|
||||
const renderPageNumbers = () => {
|
||||
const pages = []
|
||||
for (let index = 1; index <= totalPages; index++) {
|
||||
pages.push(
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onPageChange(index)}
|
||||
className={`rounded-lg px-3 py-1 ${
|
||||
currentPage === index ? 'bg-[#2E2F7C] text-white' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{index}
|
||||
</button>,
|
||||
)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2 text-[#2E2F7C]">
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronDoubleIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronIcon className="rotate-90" />
|
||||
</button>
|
||||
|
||||
{renderPageNumbers()}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronIcon className="rotate-270" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronDoubleIcon className="rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type RegisterOptions,
|
||||
Controller,
|
||||
} from 'react-hook-form'
|
||||
import { useRemixFormContext } from 'remix-hook-form'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type TInputProperties<T extends FieldValues> = Omit<
|
||||
type TSelectProperties<T extends FieldValues> = Omit<
|
||||
ComponentProps<'select'>,
|
||||
'size'
|
||||
> & {
|
||||
@@ -17,22 +19,33 @@ type TInputProperties<T extends FieldValues> = Omit<
|
||||
label?: ReactNode
|
||||
name: Path<T>
|
||||
rules?: RegisterOptions
|
||||
containerClassName?: string
|
||||
labelClassName?: string
|
||||
placeholder?: string
|
||||
options?: {
|
||||
code: string
|
||||
name: string
|
||||
id: string
|
||||
value: string | number
|
||||
}[]
|
||||
}
|
||||
|
||||
export const Select = <TFormValues extends Record<string, unknown>>(
|
||||
properties: TInputProperties<TFormValues>,
|
||||
properties: TSelectProperties<TFormValues>,
|
||||
) => {
|
||||
const { id, label, name, rules, disabled, placeholder, options, ...rest } =
|
||||
properties
|
||||
|
||||
const {
|
||||
register,
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
rules,
|
||||
disabled,
|
||||
placeholder,
|
||||
options,
|
||||
className,
|
||||
labelClassName,
|
||||
containerClassName,
|
||||
...restProperties
|
||||
} = properties
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useRemixFormContext()
|
||||
|
||||
@@ -40,28 +53,46 @@ export const Select = <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>
|
||||
<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}
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<HeadlessSelect
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
className={twMerge(
|
||||
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2',
|
||||
className,
|
||||
)}
|
||||
{...restProperties}
|
||||
>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</HeadlessSelect>
|
||||
<option
|
||||
value=""
|
||||
disabled
|
||||
selected={!field.value}
|
||||
>
|
||||
{placeholder}
|
||||
</option>
|
||||
{options?.map(({ value, name }) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</HeadlessSelect>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,58 +1,79 @@
|
||||
import type { FC } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { LinkIcon } from '@heroicons/react/24/solid'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
FacebookShareButton,
|
||||
LinkedinShareButton,
|
||||
TwitterShareButton,
|
||||
} from 'react-share'
|
||||
|
||||
import { FacebookIcon } from '~/components/icons/facebook'
|
||||
import { InstagramIcon } from '~/components/icons/instagram'
|
||||
import { LinkIcon } from '~/components/icons/link-icon'
|
||||
import { LinkedinIcon } from '~/components/icons/linkedin'
|
||||
import { XIcon } from '~/components/icons/x'
|
||||
|
||||
type SocialMediaProperties = {
|
||||
className?: string
|
||||
type SocialShareButtonsProperties = {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const dataSocialMedia = [
|
||||
{
|
||||
type: 'link',
|
||||
url: 'post-id/',
|
||||
icon: LinkIcon,
|
||||
},
|
||||
{
|
||||
type: 'facebook',
|
||||
url: 'https://facebook.com/',
|
||||
icon: FacebookIcon,
|
||||
},
|
||||
{
|
||||
type: 'linkedin',
|
||||
url: 'https://linkedin.com/',
|
||||
icon: LinkedinIcon,
|
||||
},
|
||||
{
|
||||
type: 'x',
|
||||
url: 'https://x.com/',
|
||||
icon: XIcon,
|
||||
},
|
||||
{
|
||||
type: 'instagram',
|
||||
url: 'https://instagram.com/',
|
||||
icon: InstagramIcon,
|
||||
},
|
||||
]
|
||||
export const SocialShareButtons = ({
|
||||
url,
|
||||
title,
|
||||
}: SocialShareButtonsProperties) => {
|
||||
const [showPopup, setShowPopup] = useState(false)
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(url)
|
||||
setShowPopup(true)
|
||||
setTimeout(() => setShowPopup(false), 2000)
|
||||
}
|
||||
|
||||
const handleInstagramShare = () => {
|
||||
const instagramUrl = `https://www.instagram.com/direct/new/?text=${encodeURIComponent(title + ' ' + url)}`
|
||||
window.open(instagramUrl, '_blank')
|
||||
}
|
||||
|
||||
export const IconsSocial: FC<SocialMediaProperties> = ({ className }) => {
|
||||
return (
|
||||
<div className={twMerge('flex gap-2', className)}>
|
||||
{dataSocialMedia.map(({ url, icon: Icon }, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
to={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon className="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="relative cursor-pointer"
|
||||
>
|
||||
<LinkIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
|
||||
{showPopup && (
|
||||
<div className="absolute top-12 w-48 rounded-lg border-2 border-gray-400 bg-white p-2 shadow-lg">
|
||||
Link berhasil disalin!
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<FacebookShareButton
|
||||
url={url}
|
||||
title={title}
|
||||
>
|
||||
<FacebookIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
|
||||
</FacebookShareButton>
|
||||
|
||||
<LinkedinShareButton
|
||||
url={url}
|
||||
title={title}
|
||||
>
|
||||
<LinkedinIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
|
||||
</LinkedinShareButton>
|
||||
|
||||
<TwitterShareButton
|
||||
url={url}
|
||||
title={title}
|
||||
>
|
||||
<XIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
|
||||
</TwitterShareButton>
|
||||
|
||||
<button
|
||||
onClick={handleInstagramShare}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<InstagramIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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
|
||||
options?: {
|
||||
true: string
|
||||
false: string
|
||||
}
|
||||
}
|
||||
|
||||
export const Switch = <TFormValues extends Record<string, unknown>>(
|
||||
properties: TSwitchProperties<TFormValues>,
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
rules,
|
||||
containerClassName,
|
||||
labelClassName,
|
||||
className,
|
||||
inputClassName,
|
||||
options,
|
||||
} = 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 flex h-7 cursor-pointer items-center 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,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
'order-2 text-xs transition duration-200 group-data-[checked]:order-1',
|
||||
options?.true ? 'w-18' : 'w-8',
|
||||
field.value ? 'text-white' : 'text-black/80',
|
||||
)}
|
||||
>
|
||||
{field.value ? options?.true : options?.false}
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none order-1 size-5 rounded-full bg-white ring-0 shadow-lg transition duration-200 group-data-[checked]:order-2"
|
||||
/>
|
||||
</HeadlessSwitch>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Field, Input, Label, Select } from '@headlessui/react'
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/solid'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface SearchFilterProperties {
|
||||
title: string
|
||||
columns?: string[]
|
||||
onSearch: (value: string) => void
|
||||
onStatusFilter?: (value: string) => Promise<void>
|
||||
}
|
||||
export const TableSearchFilter: React.FC<SearchFilterProperties> = ({
|
||||
onSearch,
|
||||
onStatusFilter,
|
||||
title,
|
||||
}: SearchFilterProperties) => {
|
||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
|
||||
const handleSearch = (searchValue: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(searchValue.target.value)
|
||||
onSearch(searchValue.target.value)
|
||||
}
|
||||
|
||||
const handleStatusFilter = (
|
||||
searchValue: React.ChangeEvent<HTMLSelectElement>,
|
||||
) => {
|
||||
setStatusFilter(searchValue.target.value)
|
||||
onStatusFilter?.(searchValue.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<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 {title}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
placeholder={`Cari Nama ${title}`}
|
||||
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">
|
||||
<MagnifyingGlassIcon className="size-5 text-[#363636]" />
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
{onStatusFilter && (
|
||||
// will handel if filter have dropdown status
|
||||
<div className="w-[235px]">
|
||||
<Field>
|
||||
<Label className="mb-2 block text-sm font-medium">Status</Label>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={handleStatusFilter}
|
||||
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>
|
||||
)
|
||||
}
|
||||
+29
-11
@@ -1,14 +1,13 @@
|
||||
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
||||
import DataTable from 'datatables.net-react'
|
||||
import DT from 'datatables.net-dt'
|
||||
import DataTable, { type DataTableProps } from 'datatables.net-react'
|
||||
import React from 'react'
|
||||
|
||||
export type UiTableProperties = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any[]
|
||||
columns: ConfigColumns[]
|
||||
slots?: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
options?: Config
|
||||
type UiTableProperties = {
|
||||
title: string
|
||||
} & DataTableProps
|
||||
|
||||
const renderPaginationIcon = (icon: string) => {
|
||||
return `<div class="pagination-icon">${icon}</div>`
|
||||
}
|
||||
|
||||
export const UiTable: React.FC<UiTableProperties> = ({
|
||||
@@ -24,15 +23,34 @@ export const UiTable: React.FC<UiTableProperties> = ({
|
||||
<h3 className="py-1 font-semibold text-[#4C5CA0]">{title}</h3>
|
||||
<div className="rounded-lg">
|
||||
<DataTable
|
||||
className="cell-border"
|
||||
className="cell-border text-sm"
|
||||
data={data}
|
||||
columns={columns}
|
||||
slots={slots}
|
||||
options={{
|
||||
headerCallback: (thead) => {
|
||||
thead.classList.add('text-left')
|
||||
},
|
||||
paging: true,
|
||||
searching: true,
|
||||
searching: false,
|
||||
ordering: true,
|
||||
info: true,
|
||||
info: false,
|
||||
language: {
|
||||
paginate: {
|
||||
first: renderPaginationIcon(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 "><path stroke-linecap="round" stroke-linejoin="round" d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5" /></svg>`,
|
||||
),
|
||||
previous: renderPaginationIcon(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" /></svg>`,
|
||||
),
|
||||
next: renderPaginationIcon(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>`,
|
||||
),
|
||||
last: renderPaginationIcon(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4"> <path stroke-linecap="round" stroke-linejoin="round" d="m5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5" /> </svg>`,
|
||||
),
|
||||
},
|
||||
},
|
||||
...options,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import type { TTagResponse } from '~/apis/common/get-tags'
|
||||
|
||||
type TProperties = {
|
||||
tags: TTagResponse[]
|
||||
is_premium?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Tags = (properties: TProperties) => {
|
||||
const { tags, is_premium, className } = properties
|
||||
return (
|
||||
<div className={twMerge('flex gap-2 uppercase max-sm:text-sm', className)}>
|
||||
{tags?.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-block rounded bg-[#F4F4F4] px-3 py-1 font-bold text-[#777777]"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{is_premium && (
|
||||
<span className="inline-block rounded bg-[#D1C675] px-3 py-1 font-bold text-[#9D761D]">
|
||||
Premium Content
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useEditor, EditorContent } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
|
||||
import {
|
||||
BoldIcon as Bold,
|
||||
ItalicIcon as Italic,
|
||||
UndoIcon as Undo,
|
||||
RedoIcon as Redo,
|
||||
ListIcon as List,
|
||||
ListOrderIcon as ListOrdered,
|
||||
LinkIcon as Link,
|
||||
ImageIcon as Image,
|
||||
CodeIcon as Code,
|
||||
QuoteIcon as Quote,
|
||||
StrikethroughIcon as Strikethrough,
|
||||
UnderlineIcon as Underline,
|
||||
} from '~/components/icons/editor'
|
||||
|
||||
const DefaultTextEditor = () => {
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit],
|
||||
content:
|
||||
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quis lobortis nisl cursus bibendum sit nulla accumsan sodales ornare. At urna viverra non suspendisse neque, lorem. Pretium condimentum pellentesque gravida id etiam sit sed arcu euismod. Rhoncus proin orci duis scelerisque molestie cursus tincidunt aliquam.</p>',
|
||||
})
|
||||
|
||||
if (!editor) {
|
||||
return <p>Loading editor...</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="0 rounded border bg-white">
|
||||
<div className="mb-4 rounded bg-gray-100 p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => editor.chain().focus().undo().run()}>
|
||||
<Undo />
|
||||
</button>
|
||||
<button onClick={() => editor.chain().focus().redo().run()}>
|
||||
<Redo />
|
||||
</button>
|
||||
<button onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||
<Bold />
|
||||
</button>
|
||||
<button onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||
<Italic />
|
||||
</button>
|
||||
<button
|
||||
// onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<Underline />
|
||||
</button>
|
||||
<button onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||
<Strikethrough />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<List />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ListOrdered />
|
||||
</button>
|
||||
<button onClick={() => editor.chain().focus().toggleCode().run()}>
|
||||
<Code />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
>
|
||||
<Quote />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
// .setLink({ href: prompt('Enter URL') })
|
||||
.run()
|
||||
}
|
||||
>
|
||||
<Link />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
// .setImage({ src: prompt('Enter image URL') })
|
||||
.run()
|
||||
}
|
||||
>
|
||||
<Image />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="h-[50vh]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DefaultTextEditor
|
||||
@@ -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>
|
||||
|
||||
+3
-2
@@ -1,8 +1,8 @@
|
||||
import { MENU as ADMIN_MENU } from '~/layouts/admin/menu'
|
||||
import { MENU as ADMIN_MENU, SUB_MENU } from '~/layouts/admin/menu'
|
||||
|
||||
export const APP = {
|
||||
title: 'LegalGo',
|
||||
description: 'Legalgo Platform Berita Dunia dan Indonesia.',
|
||||
description: 'LegalGo Platform Berita Dunia dan Indonesia.',
|
||||
logo: '/images/logo.png',
|
||||
}
|
||||
|
||||
@@ -23,4 +23,5 @@ export const META_TITLE_CONFIG: TMetaTitleConfig = [
|
||||
...ADMIN_MENU.flatMap((menu) =>
|
||||
menu.items.map((item) => ({ path: item.url, title: item.title })),
|
||||
),
|
||||
...SUB_MENU,
|
||||
]
|
||||
|
||||
+26
-10
@@ -3,17 +3,24 @@ import {
|
||||
useState,
|
||||
useContext,
|
||||
type PropsWithChildren,
|
||||
type SetStateAction,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
import { z } from 'zod'
|
||||
|
||||
type AdminProfile = {
|
||||
name: string
|
||||
}
|
||||
export const uploadCategorySchema = z
|
||||
.enum(['featured_image', 'ads', 'content', 'profile_picture'])
|
||||
.optional()
|
||||
|
||||
export type TUpload = z.infer<typeof uploadCategorySchema>
|
||||
|
||||
type AdminContextProperties = {
|
||||
adminProfile: AdminProfile
|
||||
setAdminProfile: Dispatch<SetStateAction<AdminProfile>>
|
||||
isUploadOpen: TUpload
|
||||
setIsUploadOpen: Dispatch<SetStateAction<TUpload>>
|
||||
uploadedFile?: string
|
||||
setUploadedFile: Dispatch<SetStateAction<string | undefined>>
|
||||
editProfile: boolean
|
||||
setEditProfile: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextProperties | undefined>(
|
||||
@@ -21,12 +28,21 @@ const AdminContext = createContext<AdminContextProperties | undefined>(
|
||||
)
|
||||
|
||||
export const AdminProvider = ({ children }: PropsWithChildren) => {
|
||||
const [adminProfile, setAdminProfile] = useState<AdminProfile>({
|
||||
name: 'Admin',
|
||||
})
|
||||
const [isUploadOpen, setIsUploadOpen] = useState<TUpload>()
|
||||
const [uploadedFile, setUploadedFile] = useState<string | undefined>()
|
||||
const [editProfile, setEditProfile] = useState(false)
|
||||
|
||||
return (
|
||||
<AdminContext.Provider value={{ adminProfile, setAdminProfile }}>
|
||||
<AdminContext.Provider
|
||||
value={{
|
||||
isUploadOpen,
|
||||
setIsUploadOpen,
|
||||
uploadedFile,
|
||||
setUploadedFile,
|
||||
editProfile,
|
||||
setEditProfile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AdminContext.Provider>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
|
||||
import type { ModalProperties } from '~/components/popup/success-modal'
|
||||
import type { ModalProperties } from '~/components/dialog/success'
|
||||
|
||||
type NewsContextProperties = {
|
||||
isLoginOpen: boolean
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import type { TNews } from '~/types/news'
|
||||
type TBanner = {
|
||||
id: number
|
||||
urlImage: string
|
||||
alt: string
|
||||
link: string
|
||||
status: 'active' | 'draft' | 'inactive'
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export const DUMMY_DESCRIPTION = 'Berita Terhangat hari ini'
|
||||
|
||||
export const SPOTLIGHT: TNews = {
|
||||
title: 'SPOTLIGHT',
|
||||
description: DUMMY_DESCRIPTION,
|
||||
items: [
|
||||
{
|
||||
title: '01 Hotman Paris Membuka Perpustakaan di tengah Diskotik',
|
||||
content:
|
||||
'Pengacara Kondang, Hotman Paris Hutapea, membuka sebuah perpustakaan baru di dalam diskotik nya yang berlokasi di daerah Jakarta Pusat, Hotman berkata Perpustakaan ini dibuka dengan harapan untuk meningkatkan gairah membaca masyarakat Indonesia, namun sayangnya..',
|
||||
featured: '/images/news-1.jpg',
|
||||
slug: 'hotman-paris-membuka-perpustakaan-di-tengah-diskotik',
|
||||
},
|
||||
{
|
||||
title: '02 Travelling as a way of self-discovery and progress',
|
||||
content:
|
||||
'Pengacara Kondang, Hotman Paris Hutapea, membuka sebuah perpustakaan baru di dalam diskotik nya yang berlokasi di daerah Jakarta Pusat, Hotman berkata Perpustakaan ini dibuka dengan harapan untuk meningkatkan gairah membaca masyarakat Indonesia, namun sayangnya..',
|
||||
featured: 'https://placehold.co/600x400.png',
|
||||
slug: 'hotman-paris-membuka-perpustakaan-di-tengah-diskotik',
|
||||
},
|
||||
{
|
||||
title: '03 Travelling as a way of self-discovery and progress',
|
||||
content:
|
||||
'Pengacara Kondang, Hotman Paris Hutapea, membuka sebuah perpustakaan baru di dalam diskotik nya yang berlokasi di daerah Jakarta Pusat, Hotman berkata Perpustakaan ini dibuka dengan harapan untuk meningkatkan gairah membaca masyarakat Indonesia, namun sayangnya..',
|
||||
featured: '/images/news-1.jpg',
|
||||
slug: 'hotman-paris-membuka-perpustakaan-di-tengah-diskotik',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const BERITA: TNews = {
|
||||
title: 'BERITA',
|
||||
description: DUMMY_DESCRIPTION,
|
||||
items: [
|
||||
{
|
||||
title: '01 Travelling as a way of self-discovery and progress ',
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
|
||||
featured: '/images/news-2.jpg',
|
||||
tags: ['Hukum Property'],
|
||||
slug: 'travelling-as-a-way-of-self-discovery-and-progress',
|
||||
},
|
||||
{
|
||||
title: '02 How does writing influence your personal brand?',
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
|
||||
featured: '/images/news-3.jpg',
|
||||
tags: ['Hukum'],
|
||||
slug: 'how-does-writing-influence-your-personal-brand',
|
||||
},
|
||||
{
|
||||
title: '03 Helping a local business reinvent itself',
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
|
||||
featured: '/images/news-4.jpg',
|
||||
tags: ['Hukum Property'],
|
||||
isPremium: true,
|
||||
slug: 'helping-a-local-business-reinvent-itself',
|
||||
},
|
||||
{
|
||||
title: 'Travelling as a way of self-discovery and progress',
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
|
||||
featured: 'https://placehold.co/600x400.png',
|
||||
tags: ['Hukum Property'],
|
||||
slug: 'travelling-as-a-way-of-self-discovery-and-progress',
|
||||
},
|
||||
{
|
||||
title: 'How does writing influence your personal brand?',
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
|
||||
featured: '/images/news-3.jpg',
|
||||
tags: ['Hukum'],
|
||||
slug: 'how-does-writing-influence-your-personal-brand',
|
||||
},
|
||||
{
|
||||
title: 'Helping a local business reinvent itself',
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
|
||||
featured: '/images/news-4.jpg',
|
||||
tags: ['Hukum Property'],
|
||||
isPremium: true,
|
||||
slug: 'helping-a-local-business-reinvent-itself',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const KAJIAN: TNews = {
|
||||
title: 'KAJIAN',
|
||||
description: DUMMY_DESCRIPTION,
|
||||
items: [
|
||||
{
|
||||
title: 'Travelling as a way of self-discovery and progress ',
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
|
||||
featured: '/images/news-2.jpg',
|
||||
tags: ['Hukum Property'],
|
||||
isPremium: true,
|
||||
slug: 'travelling-as-a-way-of-self-discovery-and-progress ',
|
||||
},
|
||||
{
|
||||
title: 'Travelling as a way of self-discovery and progress ',
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
|
||||
featured: '/images/news-2.jpg',
|
||||
tags: ['Hukum Property'],
|
||||
isPremium: true,
|
||||
slug: 'travelling-as-a-way-of-self-discovery-and-progress ',
|
||||
},
|
||||
{
|
||||
title: 'Travelling as a way of self-discovery and progress ',
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
|
||||
featured: '/images/news-2.jpg',
|
||||
tags: ['Hukum Property'],
|
||||
isPremium: true,
|
||||
slug: 'travelling-as-a-way-of-self-discovery-and-progress ',
|
||||
},
|
||||
{
|
||||
title: 'Travelling as a way of self-discovery and progress ',
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
|
||||
featured: 'https://placehold.co/600x400.png',
|
||||
tags: ['Hukum Property'],
|
||||
isPremium: true,
|
||||
slug: 'travelling-as-a-way-of-self-discovery-and-progress ',
|
||||
},
|
||||
{
|
||||
title: 'How does writing influence your personal brand? ',
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
|
||||
featured: '/images/news-3.jpg',
|
||||
tags: ['Hukum Property'],
|
||||
isPremium: true,
|
||||
slug: 'how-does-writing-influence-your-personal-brand',
|
||||
},
|
||||
{
|
||||
title: 'Helping a local business reinvent itself ',
|
||||
content:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
|
||||
featured: '/images/news-4.jpg',
|
||||
tags: ['Hukum Property'],
|
||||
isPremium: true,
|
||||
slug: 'helping-a-local-business-reinvent-itself',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const BANNER: TBanner[] = [
|
||||
{
|
||||
id: 1,
|
||||
urlImage: '/images/banner.png',
|
||||
alt: 'banner',
|
||||
link: '/category/spotlight',
|
||||
status: 'active',
|
||||
createdAt: '2021-08-01',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
urlImage: 'https://placehold.co/1000x65.png',
|
||||
alt: 'banner',
|
||||
status: 'draft',
|
||||
link: '/#',
|
||||
createdAt: '2021-08-01',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
urlImage: 'https://placehold.co/1000x65.png',
|
||||
alt: 'banner',
|
||||
status: 'draft',
|
||||
link: '/#',
|
||||
createdAt: '2021-08-01',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
urlImage: '/images/banner.png',
|
||||
alt: 'banner',
|
||||
link: '/#',
|
||||
status: 'active',
|
||||
createdAt: '2021-08-01',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
urlImage: '/images/banner.png',
|
||||
alt: 'banner',
|
||||
link: '/#',
|
||||
status: 'inactive',
|
||||
createdAt: '2021-08-01',
|
||||
},
|
||||
]
|
||||
@@ -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])
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
|
||||
import { DialogProfile } from './dialog-profile'
|
||||
import { DialogUpload } from './dialog-upload'
|
||||
import { Navbar } from './navbar'
|
||||
import { Sidebar } from './sidebar'
|
||||
|
||||
@@ -12,6 +14,9 @@ export const AdminDashboardLayout = (properties: PropsWithChildren) => {
|
||||
<Sidebar />
|
||||
<div className="min-h-[calc(100dvh-80px)] flex-1 p-8">{children}</div>
|
||||
</div>
|
||||
|
||||
<DialogUpload />
|
||||
<DialogProfile />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
|
||||
export const AdminDefaultLayout = (properties: PropsWithChildren) => {
|
||||
const { children } = properties
|
||||
return (
|
||||
<main className="font-admin relative min-h-dvh bg-[#F7F8FC]">
|
||||
{children}
|
||||
<Toaster />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
} from '@headlessui/react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useEffect } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useFetcher, useRouteLoaderData } from 'react-router'
|
||||
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import { InputFile } from '~/components/ui/input-file'
|
||||
import { useAdminContext } from '~/contexts/admin'
|
||||
import type { loader } from '~/routes/_admin.lg-admin'
|
||||
|
||||
export const profileSchema = z.object({
|
||||
name: z.string().min(1, 'Wajib diisi'),
|
||||
email: z.string().email('Email tidak valid'),
|
||||
profile_picture: z.string().url({
|
||||
message: 'URL tidak valid',
|
||||
}),
|
||||
})
|
||||
|
||||
export type TProfileSchema = z.infer<typeof profileSchema>
|
||||
|
||||
export const DialogProfile = () => {
|
||||
const { editProfile, setEditProfile } = useAdminContext()
|
||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_admin.lg-admin')
|
||||
const { staffData } = loaderData || {}
|
||||
const fetcher = useFetcher()
|
||||
|
||||
const formMethods = useRemixForm<TProfileSchema>({
|
||||
mode: 'onSubmit',
|
||||
fetcher,
|
||||
resolver: zodResolver(profileSchema),
|
||||
values: {
|
||||
name: staffData?.name || '',
|
||||
email: staffData?.email || '',
|
||||
profile_picture: staffData?.profile_picture || '',
|
||||
},
|
||||
})
|
||||
|
||||
const { handleSubmit } = formMethods
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data?.success && fetcher.data?.message) {
|
||||
toast.error(fetcher.data.message)
|
||||
}
|
||||
|
||||
if (fetcher.data?.success) {
|
||||
setEditProfile(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetcher.data])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={editProfile}
|
||||
onClose={() => {
|
||||
if (fetcher.state === 'idle') {
|
||||
setEditProfile(false)
|
||||
}
|
||||
}}
|
||||
className="relative z-50"
|
||||
transition
|
||||
>
|
||||
<DialogBackdrop
|
||||
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
|
||||
transition
|
||||
/>
|
||||
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="w-full max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
|
||||
>
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
className="text-xl font-bold"
|
||||
>
|
||||
Update Profile
|
||||
</DialogTitle>
|
||||
<RemixFormProvider {...formMethods}>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
action="/actions/admin/profile"
|
||||
>
|
||||
<Input
|
||||
name="name"
|
||||
id="name"
|
||||
label="Nama"
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
<Input
|
||||
id="email"
|
||||
label="Email"
|
||||
placeholder="Contoh: legal@legalgo.id"
|
||||
name="email"
|
||||
/>
|
||||
<InputFile
|
||||
name="profile_picture"
|
||||
id="profile_picture"
|
||||
label="Gambar Profil"
|
||||
placeholder="Unggah gambar profil Anda"
|
||||
category="profile_picture"
|
||||
/>
|
||||
<Button
|
||||
disabled={fetcher.state !== 'idle'}
|
||||
isLoading={fetcher.state !== 'idle'}
|
||||
type="submit"
|
||||
className="w-full rounded-md py-2"
|
||||
>
|
||||
Simpan
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
</RemixFormProvider>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Dialog, DialogBackdrop, DialogPanel, Input } from '@headlessui/react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useEffect, type ChangeEvent } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useFetcher } from 'react-router'
|
||||
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { uploadCategorySchema, useAdminContext } from '~/contexts/admin'
|
||||
|
||||
export const uploadSchema = z.object({
|
||||
file: z.instanceof(File),
|
||||
category: uploadCategorySchema,
|
||||
})
|
||||
|
||||
export type TUploadSchema = z.infer<typeof uploadSchema>
|
||||
|
||||
export const DialogUpload = () => {
|
||||
const { isUploadOpen, setUploadedFile, setIsUploadOpen } = useAdminContext()
|
||||
const fetcher = useFetcher()
|
||||
const maxFileSize = 10 * 1024 // 10MB
|
||||
|
||||
const formMethods = useRemixForm<TUploadSchema>({
|
||||
mode: 'onSubmit',
|
||||
fetcher,
|
||||
resolver: zodResolver(uploadSchema),
|
||||
})
|
||||
|
||||
const { handleSubmit, register, setValue } = formMethods
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data?.success && fetcher.data?.message) {
|
||||
toast.error(fetcher.data.message)
|
||||
}
|
||||
|
||||
if (fetcher.data?.success) {
|
||||
setUploadedFile(fetcher.data.uploadData.data.file_url)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetcher.data])
|
||||
|
||||
const handleChange = async function (event: ChangeEvent<HTMLInputElement>) {
|
||||
event.preventDefault()
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
const files: File[] = [...event.target.files]
|
||||
|
||||
onChange(files, event)
|
||||
}
|
||||
}
|
||||
|
||||
const onChange = async function (
|
||||
files: File[],
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) {
|
||||
const file = files[0]
|
||||
const img = new Image()
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please upload an image file.')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > maxFileSize * 1024) {
|
||||
toast.error(`File size is too big!`)
|
||||
return
|
||||
}
|
||||
|
||||
img.addEventListener('load', () => {
|
||||
handleFiles(event)
|
||||
})
|
||||
|
||||
img.src = URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
const handleFiles = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0]
|
||||
setValue('file', file)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!isUploadOpen}
|
||||
onClose={() => {
|
||||
if (fetcher.state === 'idle') {
|
||||
setIsUploadOpen(undefined)
|
||||
}
|
||||
}}
|
||||
className="relative z-50"
|
||||
transition
|
||||
>
|
||||
<DialogBackdrop
|
||||
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
|
||||
transition
|
||||
/>
|
||||
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="w-full max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
|
||||
>
|
||||
<RemixFormProvider {...formMethods}>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
action="/actions/admin/upload"
|
||||
encType="multipart/form-data"
|
||||
>
|
||||
<Input
|
||||
type="file"
|
||||
id="input-file-upload"
|
||||
accept="image/*"
|
||||
className="h-[42px] w-full cursor-pointer rounded-md border border-[#DFDFDF] p-2"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
id="input-file-upload-type"
|
||||
value={isUploadOpen}
|
||||
{...register('category')}
|
||||
/>
|
||||
<Button
|
||||
disabled={fetcher.state !== 'idle'}
|
||||
isLoading={fetcher.state !== 'idle'}
|
||||
type="submit"
|
||||
className="w-full rounded-md py-2"
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
</RemixFormProvider>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+64
-31
@@ -1,21 +1,22 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
import { ChartIcon } from '~/components/icons/chart'
|
||||
import { ChatIcon } from '~/components/icons/chat'
|
||||
import { DocumentIcon } from '~/components/icons/document'
|
||||
import { MedicalNotesIcon } from '~/components/icons/medical-notes'
|
||||
import { ProfileIcon } from '~/components/icons/profile'
|
||||
import { SettingIcon } from '~/components/icons/setting'
|
||||
import { WalletIcon } from '~/components/icons/wallet'
|
||||
import {
|
||||
BriefcaseIcon,
|
||||
ChartBarSquareIcon,
|
||||
ClipboardDocumentCheckIcon,
|
||||
DocumentCurrencyDollarIcon,
|
||||
MegaphoneIcon,
|
||||
NewspaperIcon,
|
||||
PresentationChartLineIcon,
|
||||
TagIcon,
|
||||
UsersIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
type TMenu = {
|
||||
group: string
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => JSX.Element
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -24,29 +25,29 @@ export const MENU: TMenu[] = [
|
||||
group: 'Menu',
|
||||
items: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
title: 'Dasbor',
|
||||
url: '/lg-admin',
|
||||
icon: ChartIcon,
|
||||
icon: ChartBarSquareIcon,
|
||||
},
|
||||
{
|
||||
title: 'User',
|
||||
title: 'Pengguna',
|
||||
url: '/lg-admin/users',
|
||||
icon: DocumentIcon,
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
title: 'Konten',
|
||||
title: 'Artikel',
|
||||
url: '/lg-admin/contents',
|
||||
icon: ChatIcon,
|
||||
icon: NewspaperIcon,
|
||||
},
|
||||
{
|
||||
title: 'Advertisement',
|
||||
title: 'Spanduk Iklan',
|
||||
url: '/lg-admin/advertisements',
|
||||
icon: MedicalNotesIcon,
|
||||
icon: MegaphoneIcon,
|
||||
},
|
||||
{
|
||||
title: 'Subscription',
|
||||
title: 'Pelanggan',
|
||||
url: '/lg-admin/subscriptions',
|
||||
icon: ChartIcon,
|
||||
icon: PresentationChartLineIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -54,20 +55,52 @@ export const MENU: TMenu[] = [
|
||||
group: 'Others',
|
||||
items: [
|
||||
{
|
||||
title: 'Data Situs',
|
||||
url: '/lg-admin/site-data',
|
||||
icon: WalletIcon,
|
||||
title: 'Kategori',
|
||||
url: '/lg-admin/categories',
|
||||
icon: ClipboardDocumentCheckIcon,
|
||||
},
|
||||
{
|
||||
title: 'Pengaturan',
|
||||
url: '/lg-admin/settings',
|
||||
icon: SettingIcon,
|
||||
title: 'Tag',
|
||||
url: '/lg-admin/tags',
|
||||
icon: TagIcon,
|
||||
},
|
||||
{
|
||||
title: 'Admin',
|
||||
url: '/lg-admin/admins',
|
||||
icon: ProfileIcon,
|
||||
title: 'Paket Berlangganan',
|
||||
url: '/lg-admin/subscribe-plan',
|
||||
icon: DocumentCurrencyDollarIcon,
|
||||
},
|
||||
{
|
||||
title: 'Staf',
|
||||
url: '/lg-admin/staffs',
|
||||
icon: BriefcaseIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const SUB_MENU = [
|
||||
{
|
||||
title: 'Buat Artikel',
|
||||
path: '/lg-admin/contents/create',
|
||||
},
|
||||
{
|
||||
title: 'Update Artikel',
|
||||
path: '/lg-admin/contents/update',
|
||||
},
|
||||
{
|
||||
title: 'Buat Kategori',
|
||||
path: '/lg-admin/categories/create',
|
||||
},
|
||||
{
|
||||
title: 'Update Kategori',
|
||||
path: '/lg-admin/categories/update',
|
||||
},
|
||||
{
|
||||
title: 'Buat Tag',
|
||||
path: '/lg-admin/tags/create',
|
||||
},
|
||||
{
|
||||
title: 'Update Tag',
|
||||
path: '/lg-admin/tags/update',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { Button, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
|
||||
import {
|
||||
ArrowRightStartOnRectangleIcon,
|
||||
UserIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/solid'
|
||||
import { Link, useFetcher, useRouteLoaderData } from 'react-router'
|
||||
|
||||
import { ChevronIcon } from '~/components/icons/chevron'
|
||||
import { NotificationIcon } from '~/components/icons/notification'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { APP } from '~/configs/meta'
|
||||
import { useAdminContext } from '~/contexts/admin'
|
||||
import type { loader } from '~/routes/_admin.lg-admin'
|
||||
|
||||
export const Navbar = () => {
|
||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_admin.lg-admin')
|
||||
const staffData = loaderData?.staffData
|
||||
const { staffData } = loaderData || {}
|
||||
const fetcher = useFetcher()
|
||||
const { setEditProfile } = useAdminContext()
|
||||
|
||||
return (
|
||||
<div className="flex h-20 items-center justify-between border-b border-[#ECECEC] bg-white px-10 py-5">
|
||||
@@ -25,40 +31,61 @@ export const Navbar = () => {
|
||||
</Link>
|
||||
<div className="flex items-center gap-x-8">
|
||||
<Popover className="relative">
|
||||
<PopoverButton className="flex w-3xs cursor-pointer items-center justify-between focus:outline-none">
|
||||
<div className="flex items-center">
|
||||
<PopoverButton className="flex w-3xs cursor-pointer items-center justify-between rounded-xl p-2 ring-1 ring-[#707FDD]/10 hover:shadow focus:outline-none">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img
|
||||
src={staffData?.profile_picture}
|
||||
src={
|
||||
staffData?.profile_picture ||
|
||||
'/images/profile-placeholder.svg'
|
||||
}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = '/images/profile-placeholder.svg'
|
||||
}}
|
||||
alt={staffData?.name}
|
||||
className="mr-3 h-8 w-8 rounded-full bg-[#C4C4C4] object-cover"
|
||||
className="size-8 rounded-full bg-[#C4C4C4] object-cover"
|
||||
/>
|
||||
<span className="text-xs">{staffData?.name}</span>
|
||||
|
||||
<span className="text-sm">{staffData?.name}</span>
|
||||
</div>
|
||||
<ChevronIcon className="opacity-50" />
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</PopoverButton>
|
||||
<PopoverPanel
|
||||
anchor={{ to: 'bottom', gap: '8px' }}
|
||||
transition
|
||||
className="flex w-3xs flex-col rounded-xl border border-[#ECECEC] bg-white p-3 transition duration-200 ease-in-out data-[closed]:-translate-y-1 data-[closed]:opacity-0"
|
||||
className="flex w-3xs flex-col divide-y divide-black/5 rounded-xl border border-[#ECECEC] bg-white transition duration-200 ease-in-out data-[closed]:-translate-y-1 data-[closed]:opacity-0"
|
||||
>
|
||||
<fetcher.Form
|
||||
method="POST"
|
||||
action="/actions/admin/logout"
|
||||
className="grid"
|
||||
>
|
||||
<div className="p-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="cursor-pointer rounded p-1 hover:bg-[#707FDD]/10 hover:text-[#5363AB]"
|
||||
variant="secondary"
|
||||
className="w-full justify-start rounded p-1 px-3 text-base font-bold"
|
||||
onClick={() => {
|
||||
setEditProfile(true)
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
<UserIcon className="size-5" />
|
||||
<span>Profile</span>
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<fetcher.Form
|
||||
method="POST"
|
||||
action="/actions/admin/logout"
|
||||
className="grid"
|
||||
>
|
||||
<Button
|
||||
disabled={fetcher.state !== 'idle'}
|
||||
isLoading={fetcher.state !== 'idle'}
|
||||
type="submit"
|
||||
className="w-full justify-start rounded p-1 px-3 text-base font-bold"
|
||||
variant="secondary"
|
||||
icon={<ArrowRightStartOnRectangleIcon className="size-5" />}
|
||||
>
|
||||
<span>Logout</span>
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
<NotificationIcon
|
||||
className="text-[#B0C3CC]"
|
||||
showBadge={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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' : '',
|
||||
'group/menu flex h-[42px] w-[200px] items-center gap-x-3 rounded-md px-5 transition-all hover:bg-[#707FDD]/10',
|
||||
path === url ? 'bg-[#707FDD]/10 font-bold' : '',
|
||||
'group/menu flex h-[42px] w-[240px] items-center gap-x-3 rounded-md px-5 transition hover:bg-[#707FDD]/10 active:bg-[#707FDD]/20',
|
||||
)}
|
||||
>
|
||||
<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]',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Button } from '@headlessui/react'
|
||||
import Autoplay from 'embla-carousel-autoplay'
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
import { useFetcher, useRouteLoaderData } from 'react-router'
|
||||
|
||||
import type { loader } from '~/routes/_news'
|
||||
|
||||
export const Banner = () => {
|
||||
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
|
||||
const { adsData } = loaderData || {}
|
||||
const [emblaReference] = useEmblaCarousel({ loop: true }, [
|
||||
Autoplay({
|
||||
stopOnInteraction: false,
|
||||
stopOnMouseEnter: true,
|
||||
}),
|
||||
])
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="embla overflow-hidden"
|
||||
ref={emblaReference}
|
||||
>
|
||||
<div className="embla__container flex">
|
||||
{adsData?.map(({ image_url: urlImage, url, id }, index) => (
|
||||
<fetcher.Form
|
||||
method="POST"
|
||||
action={`/actions/log/ads/${id}`}
|
||||
key={index}
|
||||
className="embla__slide max-h-[100px] min-h-[65px] w-full min-w-0 flex-none"
|
||||
>
|
||||
<Button
|
||||
className="h-full w-full cursor-pointer py-2"
|
||||
type="submit"
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
>
|
||||
<img
|
||||
src={urlImage}
|
||||
alt={id}
|
||||
className="h-[70px] w-[100%] object-contain object-center sm:h-full"
|
||||
/>
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user