Initial commit
This commit is contained in:
commit
74b27f9341
|
|
@ -0,0 +1,41 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
"use client";
|
||||
import { AddIcon, CloudUploadIcon, TimesIcon } from "@/components/icons";
|
||||
import AdvertiseTable from "@/components/table/advertise/advertise-table";
|
||||
import * as z from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useState } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { close, error, loading } from "@/config/swal";
|
||||
import useDisclosure from "@/components/useDisclosure";
|
||||
import { createAdvertise } from "@/service/advertisement";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const createArticleSchema = z.object({
|
||||
title: z.string().min(2, {
|
||||
message: "Judul harus diisi",
|
||||
}),
|
||||
url: z.string().min(1, {
|
||||
message: "Link harus diisi",
|
||||
}),
|
||||
description: z.string().min(2, {
|
||||
message: "Deskripsi harus diisi",
|
||||
}),
|
||||
});
|
||||
|
||||
export default function AdvertisePage() {
|
||||
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
|
||||
const MySwal = withReactContent(Swal);
|
||||
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [placement, setPlacement] = useState("banner");
|
||||
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const formOptions = {
|
||||
resolver: zodResolver(createArticleSchema),
|
||||
defaultValues: { title: "", description: "", url: "" },
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: (acceptedFiles) => {
|
||||
setFiles(acceptedFiles.map((file) => Object.assign(file)));
|
||||
},
|
||||
maxFiles: 1,
|
||||
accept:
|
||||
placement === "banner"
|
||||
? {
|
||||
"image/*": [],
|
||||
"video/*": [],
|
||||
}
|
||||
: { "image/*": [] },
|
||||
});
|
||||
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<UserSettingSchema>(formOptions);
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
|
||||
loading();
|
||||
const formData = {
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
placement: placement,
|
||||
redirectLink: values.url,
|
||||
};
|
||||
const res = await createAdvertise(formData);
|
||||
if (res?.error) {
|
||||
error(res?.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
// const idNow = res?.data?.data?.id;
|
||||
|
||||
if (files.length > 0) {
|
||||
const formFiles = new FormData();
|
||||
formFiles.append("file", files[0]);
|
||||
// const resFile = await createMediaFileAdvertise(idNow, formFiles);
|
||||
}
|
||||
|
||||
close();
|
||||
MySwal.fire({
|
||||
title: "Sukses",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "OK",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
setRefresh(!refresh);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFile = (file: File) => {
|
||||
const uploadedFiles = files;
|
||||
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
|
||||
setFiles([...filtered]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-hidden overflow-y-scroll">
|
||||
<div className="px-2 md:px-4 md:py-4 w-full">
|
||||
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3">
|
||||
<Button
|
||||
size="default"
|
||||
className="bg-[#F07C00] text-white w-full lg:w-fit"
|
||||
onClick={onOpen}
|
||||
>
|
||||
Buat Baru
|
||||
<AddIcon />
|
||||
</Button>
|
||||
<AdvertiseTable triggerRefresh={refresh} />
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Advertise</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Judul</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full border rounded-lg"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.title && (
|
||||
<p className="text-red-400 text-sm">{errors.title?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Deskripsi</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Textarea
|
||||
id="description"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full border rounded-lg"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.description && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.description?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Link</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
id="url"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full border rounded-lg"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.url && (
|
||||
<p className="text-red-400 text-sm">{errors.url?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm mt-3">Penempatan</p>
|
||||
<RadioGroup
|
||||
value={placement}
|
||||
onValueChange={setPlacement}
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="banner" id="banner" />
|
||||
<Label htmlFor="banner">Banner</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="jumbotron" id="jumbotron" />
|
||||
<Label htmlFor="jumbotron">Jumbotron</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm mt-3">Thumbnail</p>
|
||||
{files.length < 1 && (
|
||||
<div {...getRootProps({ className: "dropzone" })}>
|
||||
<input {...getInputProps()} />
|
||||
<div className="w-full text-center border-dashed border rounded-md py-[52px] flex items-center flex-col">
|
||||
<CloudUploadIcon />
|
||||
<h4 className="text-2xl font-medium mb-1 mt-3 text-card-foreground/80">
|
||||
Tarik file disini atau klik untuk upload.
|
||||
</h4>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
(Upload file dengan format .jpg, .jpeg, atau .png. Ukuran
|
||||
maksimal 100mb.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-row gap-2">
|
||||
<img
|
||||
src={URL.createObjectURL(files[0])}
|
||||
className="w-[30%]"
|
||||
alt="thumbnail"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveFile(files[0])}
|
||||
>
|
||||
<TimesIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
<Button type="submit">Simpan</Button>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Tutup
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import CreateArticleForm from "@/components/form/article/create-article-form";
|
||||
|
||||
export default function CreateArticle() {
|
||||
return (
|
||||
<div className="bg-slate-100 p-3 lg:p-8 dark:!bg-black overflow-y-auto">
|
||||
<CreateArticleForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import EditArticleForm from "@/components/form/article/edit-article-form";
|
||||
|
||||
export default function DetailArticlePage() {
|
||||
return (
|
||||
<div className="">
|
||||
{/* <div className="flex flex-row justify-between border-b-2 px-4 bg-white shadow-md">
|
||||
<div className="flex flex-col gap-1 py-2">
|
||||
<h1 className="font-bold text-[25px]">Article</h1>
|
||||
<p className="text-[14px]">Article</p>
|
||||
</div>
|
||||
<span className="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M5 1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm0 3h5v1H5zm0 2h5v1H5zm0 2h5v1H5zm10 7H5v-1h10zm0-2H5v-1h10zm0-2H5v-1h10zm0-2h-4V4h4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div> */}
|
||||
<div className="h-[96vh] p-3 lg:p-8 bg-slate-100 dark:!bg-black overflow-y-auto">
|
||||
<EditArticleForm isDetail={true} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import EditArticleForm from "@/components/form/article/edit-article-form";
|
||||
|
||||
export default function UpdateArticlePage() {
|
||||
return (
|
||||
<div>
|
||||
{/* <div className="flex flex-row justify-between border-b-2 px-4 bg-white shadow-md">
|
||||
<div className="flex flex-col gap-1 py-2">
|
||||
<h1 className="font-bold text-[25px]">Article</h1>
|
||||
<p className="text-[14px]">Article</p>
|
||||
</div>
|
||||
<span className="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M5 1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm0 3h5v1H5zm0 2h5v1H5zm0 2h5v1H5zm10 7H5v-1h10zm0-2H5v-1h10zm0-2H5v-1h10zm0-2h-4V4h4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div> */}
|
||||
<div className="h-[96vh] p-3 lg:p-8 bg-slate-100 dark:!bg-black overflow-y-auto">
|
||||
<EditArticleForm isDetail={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
import ArticleTable from "@/components/table/article-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function BasicPage() {
|
||||
return (
|
||||
<div>
|
||||
{/* <div className="flex flex-row justify-between border-b-2 mb-4 px-4 ">
|
||||
<div className="flex flex-col gap-1 py-2">
|
||||
<h1 className="font-bold text-[25px]">Article</h1>
|
||||
<p className="text-[14px]">Article</p>
|
||||
</div>
|
||||
<span className="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M5 1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm0 3h5v1H5zm0 2h5v1H5zm0 2h5v1H5zm10 7H5v-1h10zm0-2H5v-1h10zm0-2H5v-1h10zm0-2h-4V4h4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div> */}
|
||||
<div className="overflow-x-hidden overflow-y-scroll w-full">
|
||||
<div className="px-2 md:px-4 md:py-4 w-full">
|
||||
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3">
|
||||
<Link href="/admin/article/create">
|
||||
<Button className="bg-[#F07C00] text-white w-full lg:w-fit hover:bg-[#d96e00]">
|
||||
Tambah Artikel
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<ArticleTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import DashboardContainer from "@/components/main/dashboard/dashboard-container";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function AdminPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-gradient-to-br from-slate-50/50 via-white to-slate-50/50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="h-full overflow-auto bg-gradient-to-br from-slate-50/50 via-white to-slate-50/50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="p-6">
|
||||
<DashboardContainer />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { AdminLayout } from "@/components/layout/admin-layout";
|
||||
|
||||
export default function AdminPageLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AdminLayout>{children}</AdminLayout>;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import MagazineTable from "@/components/table/magazine/magazine-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function MagazineTablePage() {
|
||||
return (
|
||||
<div className="overflow-x-hidden overflow-y-scroll">
|
||||
<div className="px-2 md:px-4 md:py-4 w-full">
|
||||
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3 w-full">
|
||||
<Link href="/admin/magazine/create">
|
||||
<Button className="bg-[#F07C00] text-white hover:bg-[#d96e00] w-full lg:w-auto">
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
Tambah Majalah
|
||||
</Button>
|
||||
</Link>
|
||||
<MagazineTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
"use client";
|
||||
import { AddIcon, CloudUploadIcon, TimesIcon } from "@/components/icons";
|
||||
import CategoriesTable from "@/components/table/master-categories/categories-table";
|
||||
import * as z from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactSelect from "react-select";
|
||||
import makeAnimated from "react-select/animated";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { close, error, loading } from "@/config/swal";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import Image from "next/image";
|
||||
|
||||
import { getArticleByCategory } from "@/service/article";
|
||||
import {
|
||||
createCategory,
|
||||
uploadCategoryThumbnail,
|
||||
} from "@/service/master-categories";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import useDisclosure from "@/components/useDisclosure";
|
||||
|
||||
const createArticleSchema = z.object({
|
||||
title: z.string().min(2, {
|
||||
message: "Judul harus diisi",
|
||||
}),
|
||||
description: z.string().min(2, {
|
||||
message: "Deskripsi harus diisi",
|
||||
}),
|
||||
tags: z.array(z.string()),
|
||||
// parent: z.array(categorySchema).nonempty({
|
||||
// message: "Kategori harus memiliki setidaknya satu item",
|
||||
// }),
|
||||
// tags: z.array(z.string()).nonempty({
|
||||
// message: "Minimal 1 tag",
|
||||
// }),
|
||||
});
|
||||
|
||||
interface CategoryType {
|
||||
id: number;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export default function MasterCategoryTable() {
|
||||
const MySwal = withReactContent(Swal);
|
||||
const animatedComponents = makeAnimated();
|
||||
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
|
||||
const [listCategory, setListCategory] = useState<CategoryType[]>([]);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [tag, setTag] = useState("");
|
||||
const [selectedParent, setSelectedParent] = useState<any>();
|
||||
const [isDetail] = useState<any>();
|
||||
|
||||
const formOptions = {
|
||||
resolver: zodResolver(createArticleSchema),
|
||||
defaultValues: { title: "", description: "", tags: [] },
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: (acceptedFiles) => {
|
||||
setFiles(acceptedFiles.map((file) => Object.assign(file)));
|
||||
},
|
||||
maxFiles: 1,
|
||||
});
|
||||
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
getValues,
|
||||
setError,
|
||||
clearErrors,
|
||||
} = useForm<UserSettingSchema>(formOptions);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategory();
|
||||
}, []);
|
||||
|
||||
const fetchCategory = async () => {
|
||||
const res = await getArticleByCategory();
|
||||
if (res?.data?.data) {
|
||||
setupCategory(res?.data?.data);
|
||||
}
|
||||
};
|
||||
|
||||
const setupCategory = (data: any) => {
|
||||
const temp = [];
|
||||
for (const element of data) {
|
||||
temp.push({
|
||||
id: element.id,
|
||||
label: element.title,
|
||||
value: element.id,
|
||||
});
|
||||
}
|
||||
setListCategory(temp);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
|
||||
console.log("values,", values);
|
||||
loading();
|
||||
const formData = {
|
||||
title: values.title,
|
||||
statusId: 1,
|
||||
parentId: selectedParent ? selectedParent.id : 0,
|
||||
tags: values.tags.join(","),
|
||||
description: values.description,
|
||||
};
|
||||
|
||||
const response = await createCategory(formData);
|
||||
|
||||
if (response?.error) {
|
||||
error(response.message);
|
||||
return false;
|
||||
}
|
||||
const categoryId = response?.data?.data?.id;
|
||||
const formFiles = new FormData();
|
||||
|
||||
formFiles.append("files", files[0]);
|
||||
const resFile = await uploadCategoryThumbnail(categoryId, formFiles);
|
||||
if (resFile?.error) {
|
||||
error(resFile.message);
|
||||
return false;
|
||||
}
|
||||
close();
|
||||
setRefresh(!refresh);
|
||||
MySwal.fire({
|
||||
title: "Sukses",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "OK",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFile = (file: File) => {
|
||||
const uploadedFiles = files;
|
||||
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
|
||||
setFiles([...filtered]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-hidden overflow-y-scroll">
|
||||
<div className="px-2 md:px-4 md:py-4 w-full">
|
||||
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3">
|
||||
<Button
|
||||
size="default"
|
||||
className="bg-[#F07C00] text-white w-full lg:w-fit flex items-center gap-2"
|
||||
onClick={onOpen}
|
||||
>
|
||||
Tambah Kategori
|
||||
<AddIcon />
|
||||
</Button>
|
||||
<CategoriesTable triggerRefresh={refresh} />
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-6xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kategori Baru</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Nama Kategori</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readOnly={isDetail}
|
||||
className="w-full border rounded-lg"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.title && (
|
||||
<p className="text-red-400 text-sm">{errors.title?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Deskripsi</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Textarea
|
||||
id="description"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readOnly={isDetail}
|
||||
className="w-full border rounded-lg"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.description && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.description?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm mt-3">Parent</p>
|
||||
<ReactSelect
|
||||
className="basic-single text-black z-50"
|
||||
classNames={{
|
||||
control: () =>
|
||||
"!rounded-lg bg-white !border !border-gray-200 dark:!border-stone-500",
|
||||
}}
|
||||
classNamePrefix="select"
|
||||
value={selectedParent}
|
||||
isDisabled={isDetail}
|
||||
onChange={setSelectedParent}
|
||||
closeMenuOnSelect={false}
|
||||
components={animatedComponents}
|
||||
isClearable
|
||||
isSearchable
|
||||
isMulti={false}
|
||||
placeholder="Kategori..."
|
||||
name="sub-module"
|
||||
options={listCategory}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm mt-3">Tag Terkait</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="tags"
|
||||
render={({ field: { value } }) => (
|
||||
<Input
|
||||
id="tags"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (tag.trim() !== "") {
|
||||
setValue("tags", [...value, tag.trim()]);
|
||||
setTag("");
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full border rounded-lg"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{getValues("tags")?.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-blue-500 text-white px-2 py-1 rounded-full text-sm flex items-center gap-1"
|
||||
>
|
||||
{item}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const filteredTags = getValues("tags").filter(
|
||||
(tag) => tag !== item
|
||||
);
|
||||
if (filteredTags.length === 0) {
|
||||
setError("tags", {
|
||||
type: "manual",
|
||||
message: "Tags tidak boleh kosong",
|
||||
});
|
||||
} else {
|
||||
clearErrors("tags");
|
||||
setValue("tags", filteredTags);
|
||||
}
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm mt-3">Thumbnail</p>
|
||||
{files.length < 1 && (
|
||||
<div {...getRootProps({ className: "dropzone" })}>
|
||||
<input {...getInputProps()} />
|
||||
<div className="w-full text-center border-dashed border rounded-md py-[52px] flex flex-col items-center">
|
||||
<CloudUploadIcon />
|
||||
<h4 className="text-2xl font-medium mb-1 mt-3 text-muted-foreground">
|
||||
Tarik file disini atau klik untuk upload.
|
||||
</h4>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
( Upload file dengan format .jpg, .jpeg, atau .png. Ukuran
|
||||
maksimal 100mb.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-row gap-2">
|
||||
<Image
|
||||
src={URL.createObjectURL(files[0])}
|
||||
className="w-[30%]"
|
||||
alt="thumbnail"
|
||||
width={480}
|
||||
height={480}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleRemoveFile(files[0])}
|
||||
>
|
||||
<TimesIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="self-end">
|
||||
{!isDetail && <Button type="submit">Simpan</Button>}
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Tutup
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import FormMasterUser from "@/components/form/form-master-user";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default function CreateMasterUserPage() {
|
||||
return (
|
||||
<Card className="h-[96vh] rounded-md bg-transparent">
|
||||
<FormMasterUser />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import FormMasterUserEdit from "@/components/form/form-master-user-edit";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default function CreateMasterUserPage() {
|
||||
return (
|
||||
<Card className="h-[96vh] rounded-md bg-transparent">
|
||||
<FormMasterUserEdit />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
import { AddIcon } from "@/components/icons";
|
||||
import MasterUserTable from "@/components/table/master-user-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function MasterUserPage() {
|
||||
return (
|
||||
<div className="overflow-x-hidden overflow-y-scroll">
|
||||
<div className="px-2 md:px-4 md:py-4 w-full">
|
||||
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl py-3">
|
||||
<Link href="/admin/master-user/create" className="mx-3">
|
||||
<Button
|
||||
size="default"
|
||||
color="primary"
|
||||
className="bg-[#F07C00] text-white"
|
||||
>
|
||||
Pengguna Baru
|
||||
<AddIcon />
|
||||
</Button>
|
||||
</Link>
|
||||
<MasterUserTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import StaticPageBuilder from "@/components/main/static-page/static-page-main";
|
||||
|
||||
export default function StaticPageGenerator() {
|
||||
return (
|
||||
<div className="bg-transparent p-4 overflow-auto">
|
||||
<StaticPageBuilder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import StaticPageBuilderEdit from "@/components/form/static-page/static-page-edit-form";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default function StaticPageEdit() {
|
||||
return (
|
||||
<Card className="rounded-md bg-transparent p-4 overflow-auto">
|
||||
<StaticPageBuilderEdit />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { AddIcon } from "@/components/icons";
|
||||
import StaticPageTable from "@/components/table/static-page-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function StaticPageGeneratorList() {
|
||||
return (
|
||||
<div className="overflow-x-hidden overflow-y-scroll rounded-lg">
|
||||
<div className="px-2 md:px-4 md:py-4 w-full">
|
||||
<div className="bg-white shadow-lg dark:bg-[#18181b] rounded-xl p-3">
|
||||
<Link href="/admin/static-page/create">
|
||||
<Button size="default" color="primary" className="bg-[#F07C00] text-white w-full lg:w-fit">
|
||||
Tambah Halaman
|
||||
<AddIcon />
|
||||
</Button>
|
||||
</Link>
|
||||
<StaticPageTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import Agent from "@/components/landing-page/agent";
|
||||
import Footer from "@/components/landing-page/footer";
|
||||
import Galeri from "@/components/landing-page/galeri";
|
||||
import HeaderAbout from "@/components/landing-page/header-about";
|
||||
import HeaderItems from "@/components/landing-page/header-item";
|
||||
import Help from "@/components/landing-page/help";
|
||||
import FormJaecoo from "@/components/landing-page/jaecoo-form";
|
||||
import Navbar from "@/components/landing-page/navbar";
|
||||
import NearestLocation from "@/components/landing-page/nearest-location";
|
||||
|
||||
import Service from "@/components/landing-page/service";
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
|
||||
<div className="relative z-10 bg-white w-full mx-auto">
|
||||
<Navbar />
|
||||
<HeaderAbout />
|
||||
<Agent />
|
||||
<Galeri />
|
||||
<FormJaecoo />
|
||||
<Help />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <> {children}</>;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import Login from "@/components/form/login";
|
||||
import React from "react";
|
||||
|
||||
export default function AuthPage() {
|
||||
return (
|
||||
<>
|
||||
<Login />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
|
|
@ -0,0 +1,122 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { Metadata } from "next";
|
||||
// import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
// const geistSans = Geist({
|
||||
// variable: "--font-geist-sans",
|
||||
// subsets: ["latin"],
|
||||
// });
|
||||
|
||||
// const geistMono = Geist_Mono({
|
||||
// variable: "--font-geist-mono",
|
||||
// subsets: ["latin"],
|
||||
// });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Jaecoo",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import Footer from "@/components/landing-page/footer";
|
||||
import Header from "@/components/landing-page/header";
|
||||
import Items from "@/components/landing-page/items";
|
||||
import Location from "@/components/landing-page/location";
|
||||
import Navbar from "@/components/landing-page/navbar";
|
||||
import Video from "@/components/landing-page/video";
|
||||
import Agent from "@/components/landing-page/agent";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
|
||||
<div className="relative z-10 bg-white w-full mx-auto">
|
||||
<Navbar />
|
||||
<div className="flex-1">
|
||||
<Header />
|
||||
</div>
|
||||
<Items />
|
||||
<Video />
|
||||
<Agent />
|
||||
<Location />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import Exterior from "@/components/landing-page/exterior";
|
||||
import FeaturesAndSpecifications from "@/components/landing-page/features-and-specifications";
|
||||
import Footer from "@/components/landing-page/footer";
|
||||
import HeaderProduct from "@/components/landing-page/header-product";
|
||||
import Interior from "@/components/landing-page/interior";
|
||||
import Navbar from "@/components/landing-page/navbar";
|
||||
|
||||
export default function ProductPage() {
|
||||
return (
|
||||
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
|
||||
<div className="relative z-10 bg-white w-full mx-auto">
|
||||
<Navbar />
|
||||
<HeaderProduct />
|
||||
<Exterior />
|
||||
<Interior />
|
||||
<FeaturesAndSpecifications />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import Footer from "@/components/landing-page/footer";
|
||||
import HeaderItems from "@/components/landing-page/header-item";
|
||||
import Navbar from "@/components/landing-page/navbar";
|
||||
import NearestLocation from "@/components/landing-page/nearest-location";
|
||||
|
||||
import Service from "@/components/landing-page/service";
|
||||
|
||||
export default function ServicePage() {
|
||||
return (
|
||||
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
|
||||
<div className="relative z-10 bg-white w-full mx-auto">
|
||||
<Navbar />
|
||||
<HeaderItems />
|
||||
<Service />
|
||||
<NearestLocation />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
// components/custom-editor.js
|
||||
|
||||
import React from "react";
|
||||
import { CKEditor } from "@ckeditor/ckeditor5-react";
|
||||
import Editor from "@/vendor/ckeditor5/build/ckeditor";
|
||||
|
||||
function CustomEditor(props) {
|
||||
return (
|
||||
<CKEditor
|
||||
editor={Editor}
|
||||
data={props.initialData}
|
||||
onChange={(event, editor) => {
|
||||
const data = editor.getData();
|
||||
console.log({ event, editor, data });
|
||||
props.onChange(data);
|
||||
}}
|
||||
config={{
|
||||
toolbar: [
|
||||
"heading",
|
||||
"fontsize",
|
||||
"bold",
|
||||
"italic",
|
||||
"link",
|
||||
"numberedList",
|
||||
"bulletedList",
|
||||
"undo",
|
||||
"redo",
|
||||
"alignment",
|
||||
"outdent",
|
||||
"indent",
|
||||
"blockQuote",
|
||||
"insertTable",
|
||||
"codeBlock",
|
||||
"sourceEditing",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomEditor;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import React from "react";
|
||||
import { CKEditor } from "@ckeditor/ckeditor5-react";
|
||||
import Editor from "@/vendor/ckeditor5/build/ckeditor";
|
||||
|
||||
function ViewEditor(props) {
|
||||
return (
|
||||
<CKEditor
|
||||
editor={Editor}
|
||||
data={props.initialData}
|
||||
disabled={true}
|
||||
config={{
|
||||
// toolbar: [],
|
||||
isReadOnly: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewEditor;
|
||||
|
|
@ -0,0 +1,875 @@
|
|||
"use client";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { CloudUploadIcon, TimesIcon } from "@/components/icons";
|
||||
import Image from "next/image";
|
||||
import ReactSelect from "react-select";
|
||||
import makeAnimated from "react-select/animated";
|
||||
import { htmlToString } from "@/utils/global";
|
||||
import { close, error, loading, successToast } from "@/config/swal";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Cookies from "js-cookie";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
createArticle,
|
||||
createArticleSchedule,
|
||||
getArticleByCategory,
|
||||
uploadArticleFile,
|
||||
uploadArticleThumbnail,
|
||||
} from "@/service/article";
|
||||
import {
|
||||
saveManualContext,
|
||||
updateManualArticle,
|
||||
} from "@/service/generate-article";
|
||||
import { getUserLevels } from "@/service/user-levels-service";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getCategoryById } from "@/service/master-categories";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import GenerateSingleArticleForm from "./generate-ai-single-form";
|
||||
import GenerateContentRewriteForm from "./generate-ai-content-rewrite-form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const CustomEditor = dynamic(
|
||||
() => {
|
||||
return import("@/components/editor/custom-editor");
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
interface FileWithPreview extends File {
|
||||
preview: string;
|
||||
}
|
||||
|
||||
interface CategoryType {
|
||||
id: number;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
const categorySchema = z.object({
|
||||
id: z.number(),
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
interface DiseData {
|
||||
id: number;
|
||||
articleBody: string;
|
||||
title: string;
|
||||
metaTitle: string;
|
||||
description: string;
|
||||
metaDescription: string;
|
||||
mainKeyword: string;
|
||||
additionalKeywords: string;
|
||||
}
|
||||
|
||||
const createArticleSchema = z.object({
|
||||
title: z.string().min(2, {
|
||||
message: "Judul harus diisi",
|
||||
}),
|
||||
slug: z.string().min(2, {
|
||||
message: "Slug harus diisi",
|
||||
}),
|
||||
description: z.string().min(2, {
|
||||
message: "Deskripsi harus diisi",
|
||||
}),
|
||||
category: z.array(categorySchema).nonempty({
|
||||
message: "Kategori harus memiliki setidaknya satu item",
|
||||
}),
|
||||
tags: z.array(z.string()).nonempty({
|
||||
message: "Minimal 1 tag",
|
||||
}),
|
||||
});
|
||||
|
||||
export default function CreateArticleForm() {
|
||||
const userLevel = Cookies.get("ulne");
|
||||
const animatedComponents = makeAnimated();
|
||||
const MySwal = withReactContent(Swal);
|
||||
const router = useRouter();
|
||||
const [files, setFiles] = useState<FileWithPreview[]>([]);
|
||||
const [useAi, setUseAI] = useState(false);
|
||||
const [listCategory, setListCategory] = useState<CategoryType[]>([]);
|
||||
const [tag, setTag] = useState("");
|
||||
const [thumbnailImg, setThumbnailImg] = useState<File[]>([]);
|
||||
const [selectedMainImage, setSelectedMainImage] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [thumbnailValidation, setThumbnailValidation] = useState("");
|
||||
const [filesValidation, setFileValidation] = useState("");
|
||||
const [diseData, setDiseData] = useState<DiseData>();
|
||||
const [selectedWritingType, setSelectedWritingType] = useState("single");
|
||||
const [status, setStatus] = useState<"publish" | "draft" | "scheduled">(
|
||||
"publish"
|
||||
);
|
||||
const [isScheduled, setIsScheduled] = useState(false);
|
||||
|
||||
const [startDateValue, setStartDateValue] = useState<any>(null);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: (acceptedFiles) => {
|
||||
setFiles((prevFiles) => [
|
||||
...prevFiles,
|
||||
...acceptedFiles.map((file) => Object.assign(file)),
|
||||
]);
|
||||
},
|
||||
multiple: true,
|
||||
accept: {
|
||||
"image/*": [],
|
||||
},
|
||||
});
|
||||
|
||||
const formOptions = {
|
||||
resolver: zodResolver(createArticleSchema),
|
||||
defaultValues: { title: "", description: "", category: [], tags: [] },
|
||||
};
|
||||
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
getValues,
|
||||
watch,
|
||||
setError,
|
||||
clearErrors,
|
||||
} = useForm<UserSettingSchema>(formOptions);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategory();
|
||||
}, []);
|
||||
|
||||
const fetchCategory = async () => {
|
||||
const res = await getArticleByCategory();
|
||||
if (res?.data?.data) {
|
||||
setupCategory(res?.data?.data);
|
||||
}
|
||||
};
|
||||
|
||||
const setupCategory = (data: any) => {
|
||||
const temp = [];
|
||||
for (const element of data) {
|
||||
temp.push({
|
||||
id: element.id,
|
||||
label: element.title,
|
||||
value: element.id,
|
||||
});
|
||||
}
|
||||
setListCategory(temp);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
|
||||
if ((thumbnailImg.length < 1 && !selectedMainImage) || files.length < 1) {
|
||||
if (files.length < 1) {
|
||||
setFileValidation("Required");
|
||||
} else {
|
||||
setFileValidation("");
|
||||
}
|
||||
if (thumbnailImg.length < 1 && !selectedMainImage) {
|
||||
setThumbnailValidation("Required");
|
||||
} else {
|
||||
setThumbnailValidation("");
|
||||
}
|
||||
} else {
|
||||
setThumbnailValidation("");
|
||||
setFileValidation("");
|
||||
MySwal.fire({
|
||||
title: "Simpan Data",
|
||||
text: "",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
cancelButtonColor: "#d33",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "Simpan",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
save(values);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (useAi === false) {
|
||||
setValue("description", "");
|
||||
}
|
||||
}, [useAi]);
|
||||
|
||||
function removeImgTags(htmlString: string) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(String(htmlString), "text/html");
|
||||
|
||||
const images = doc.querySelectorAll("img");
|
||||
images.forEach((img) => img.remove());
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
const saveArticleToDise = async (
|
||||
values: z.infer<typeof createArticleSchema>
|
||||
) => {
|
||||
if (useAi) {
|
||||
const request = {
|
||||
id: diseData?.id,
|
||||
title: values.title,
|
||||
articleBody: removeImgTags(values.description),
|
||||
metaDescription: diseData?.metaDescription,
|
||||
metaTitle: diseData?.metaTitle,
|
||||
mainKeyword: diseData?.mainKeyword,
|
||||
additionalKeywords: diseData?.additionalKeywords,
|
||||
createdBy: "345",
|
||||
style: "Informational",
|
||||
projectId: 2,
|
||||
clientId: "humasClientIdtest",
|
||||
lang: "id",
|
||||
};
|
||||
const res = await updateManualArticle(request);
|
||||
if (res.error) {
|
||||
error(res.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
return diseData?.id;
|
||||
} else {
|
||||
const request = {
|
||||
title: values.title,
|
||||
articleBody: removeImgTags(values.description),
|
||||
metaDescription: values.title,
|
||||
metaTitle: values.title,
|
||||
mainKeyword: values.title,
|
||||
additionalKeywords: values.title,
|
||||
createdBy: "345",
|
||||
style: "Informational",
|
||||
projectId: 2,
|
||||
clientId: "humasClientIdtest",
|
||||
lang: "id",
|
||||
};
|
||||
|
||||
const res = await saveManualContext(request);
|
||||
if (res.error) {
|
||||
res.message;
|
||||
return 0;
|
||||
}
|
||||
return res?.data?.data?.id;
|
||||
}
|
||||
};
|
||||
|
||||
const getUserLevelApprovalStatus = async () => {
|
||||
const res = await getUserLevels(String(userLevel));
|
||||
return res?.data?.data?.isApprovalActive;
|
||||
};
|
||||
|
||||
const save = async (values: z.infer<typeof createArticleSchema>) => {
|
||||
loading();
|
||||
|
||||
const userLevelStatus = await getUserLevelApprovalStatus();
|
||||
const formData = {
|
||||
title: values.title,
|
||||
typeId: 1,
|
||||
slug: values.slug,
|
||||
categoryIds: values.category.map((a) => a.id).join(","),
|
||||
tags: values.tags.join(","),
|
||||
description: htmlToString(removeImgTags(values.description)),
|
||||
htmlDescription: removeImgTags(values.description),
|
||||
aiArticleId: await saveArticleToDise(values),
|
||||
// isDraft: userLevelStatus ? true : status === "draft",
|
||||
// isPublish: userLevelStatus ? false : status === "publish",
|
||||
isDraft: status === "draft",
|
||||
isPublish: status === "publish",
|
||||
};
|
||||
|
||||
const response = await createArticle(formData);
|
||||
|
||||
if (response?.error) {
|
||||
error(response.message);
|
||||
return false;
|
||||
}
|
||||
const articleId = response?.data?.data?.id;
|
||||
|
||||
if (files?.length > 0) {
|
||||
const formFiles = new FormData();
|
||||
|
||||
for (const element of files) {
|
||||
formFiles.append("file", element);
|
||||
const resFile = await uploadArticleFile(articleId, formFiles);
|
||||
}
|
||||
}
|
||||
if (thumbnailImg?.length > 0 || files?.length > 0) {
|
||||
if (thumbnailImg?.length > 0) {
|
||||
const formFiles = new FormData();
|
||||
|
||||
formFiles.append("files", thumbnailImg[0]);
|
||||
const resFile = await uploadArticleThumbnail(articleId, formFiles);
|
||||
} else {
|
||||
const formFiles = new FormData();
|
||||
|
||||
if (selectedMainImage) {
|
||||
formFiles.append("files", files[selectedMainImage - 1]);
|
||||
|
||||
const resFile = await uploadArticleThumbnail(articleId, formFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "scheduled") {
|
||||
const request = {
|
||||
id: articleId,
|
||||
date: `${startDateValue?.year}-${startDateValue?.month}-${startDateValue?.day}`,
|
||||
};
|
||||
const res = await createArticleSchedule(request);
|
||||
}
|
||||
|
||||
close();
|
||||
successSubmit("/admin/article", articleId, values.slug);
|
||||
};
|
||||
|
||||
function successSubmit(redirect: string, id: number, slug: string) {
|
||||
const url =
|
||||
`${window.location.protocol}//${window.location.host}` +
|
||||
"/news/detail/" +
|
||||
`${id}-${slug}`;
|
||||
MySwal.fire({
|
||||
title: "Sukses",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "OK",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
router.push(redirect);
|
||||
successToast("Article Url", url);
|
||||
} else {
|
||||
router.push(redirect);
|
||||
successToast("Article Url", url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const watchTitle = watch("title");
|
||||
const generateSlug = (title: string) => {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue("slug", generateSlug(watchTitle));
|
||||
}, [watchTitle]);
|
||||
|
||||
const renderFilePreview = (file: FileWithPreview) => {
|
||||
if (file.type.startsWith("image")) {
|
||||
return (
|
||||
<Image
|
||||
width={48}
|
||||
height={48}
|
||||
alt={file.name}
|
||||
src={URL.createObjectURL(file)}
|
||||
className=" rounded border p-0.5"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return "Not Found";
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = (file: FileWithPreview) => {
|
||||
const uploadedFiles = files;
|
||||
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
|
||||
setFiles([...filtered]);
|
||||
};
|
||||
|
||||
const fileList = files.map((file, index) => (
|
||||
<div
|
||||
key={file.name}
|
||||
className=" flex justify-between border px-3.5 py-3 my-6 rounded-md"
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="file-preview">{renderFilePreview(file)}</div>
|
||||
<div>
|
||||
<div className=" text-sm text-card-foreground">{file.name}</div>
|
||||
<div className=" text-xs font-light text-muted-foreground">
|
||||
{Math.round(file.size / 100) / 10 > 1000 ? (
|
||||
<>{(Math.round(file.size / 100) / 10000).toFixed(1)}</>
|
||||
) : (
|
||||
<>{(Math.round(file.size / 100) / 10).toFixed(1)}</>
|
||||
)}
|
||||
{" kb"}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={String(index)}
|
||||
value={String(index)}
|
||||
checked={selectedMainImage === index + 1}
|
||||
onCheckedChange={() => setSelectedMainImage(index + 1)}
|
||||
/>
|
||||
<label htmlFor={String(index)} className="text-black text-xs">
|
||||
Jadikan Thumbnail
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="rounded-full"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveFile(file)}
|
||||
>
|
||||
<TimesIcon />
|
||||
</Button>
|
||||
</div>
|
||||
));
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = event.target.files;
|
||||
if (selectedFiles) {
|
||||
setThumbnailImg(Array.from(selectedFiles));
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCategory = watch("category");
|
||||
|
||||
useEffect(() => {
|
||||
getDetailCategory();
|
||||
}, [selectedCategory]);
|
||||
|
||||
const getDetailCategory = async () => {
|
||||
let temp = getValues("tags");
|
||||
for (const element of selectedCategory) {
|
||||
const res = await getCategoryById(element?.id);
|
||||
const tagList = res?.data?.data?.tags;
|
||||
if (tagList) {
|
||||
temp = [...temp, ...res?.data?.data?.tags];
|
||||
}
|
||||
}
|
||||
const uniqueArray = temp.filter(
|
||||
(item, index) => temp.indexOf(item) === index
|
||||
);
|
||||
|
||||
setValue("tags", uniqueArray as [string, ...string[]]);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col lg:flex-row gap-8 text-black"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="w-full lg:w-[65%] bg-white rounded-lg p-8 flex flex-col gap-1">
|
||||
<p className="text-sm">Judul</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="title"
|
||||
type="text"
|
||||
placeholder="Masukkan judul artikel"
|
||||
className="w-full border rounded-lg dark:border-gray-400"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{errors?.title && (
|
||||
<p className="text-red-400 text-sm mb-3">{errors.title?.message}</p>
|
||||
)}
|
||||
<p className="text-sm mt-3">Slug</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="text"
|
||||
id="title"
|
||||
placeholder=""
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
className="w-full border rounded-lg dark:border-gray-400"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{errors?.slug && (
|
||||
<p className="text-red-400 text-sm mb-3">{errors.slug?.message}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Switch checked={useAi} onCheckedChange={setUseAI} />
|
||||
<p className="text-sm text-black">Bantuan AI</p>
|
||||
</div>
|
||||
|
||||
{useAi && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
value={selectedWritingType ?? ""}
|
||||
onValueChange={(value) => {
|
||||
if (value !== "") setSelectedWritingType(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||
<SelectValue placeholder="Writing Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="single">Single Article</SelectItem>
|
||||
<SelectItem value="rewrite">Content Rewrite</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedWritingType === "single" ? (
|
||||
<GenerateSingleArticleForm
|
||||
content={(data) => {
|
||||
setDiseData(data);
|
||||
setValue(
|
||||
"description",
|
||||
data?.articleBody ? data?.articleBody : ""
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<GenerateContentRewriteForm
|
||||
content={(data) => {
|
||||
setDiseData(data);
|
||||
setValue(
|
||||
"description",
|
||||
data?.articleBody ? data?.articleBody : ""
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm mt-3">Deskripsi</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomEditor onChange={onChange} initialData={value} />
|
||||
)}
|
||||
/>
|
||||
{errors?.description && (
|
||||
<p className="text-red-400 text-sm mb-3">
|
||||
{errors.description?.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm mt-3">File Media</p>
|
||||
<Fragment>
|
||||
<div {...getRootProps({ className: "dropzone" })}>
|
||||
<input {...getInputProps()} />
|
||||
<div className=" w-full text-center border-dashed border border-default-200 dark:border-default-300 rounded-md py-[52px] flex items-center flex-col">
|
||||
<CloudUploadIcon size={50} className="text-gray-300" />
|
||||
<h4 className=" text-2xl font-medium mb-1 mt-3 text-card-foreground/80">
|
||||
Tarik file disini atau klik untuk upload.
|
||||
</h4>
|
||||
<div className=" text-xs text-muted-foreground">
|
||||
( Upload file dengan format .jpg, .jpeg, atau .png. Ukuran
|
||||
maksimal 100mb.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{files.length ? (
|
||||
<Fragment>
|
||||
<div>{fileList}</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<Button onClick={() => setFiles([])} size="sm">
|
||||
Hapus Semua
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
) : null}
|
||||
</Fragment>
|
||||
{filesValidation !== "" && files.length < 1 && (
|
||||
<p className="text-red-400 text-sm mb-3">Upload File Media</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full lg:w-[35%] flex flex-col gap-8">
|
||||
<div className="h-fit bg-white rounded-lg p-8 flex flex-col gap-1">
|
||||
<p className="text-sm">Thubmnail</p>
|
||||
|
||||
{selectedMainImage && files.length >= selectedMainImage ? (
|
||||
<div className="flex flex-row">
|
||||
<img
|
||||
src={URL.createObjectURL(files[selectedMainImage - 1])}
|
||||
className="w-[30%]"
|
||||
alt="thumbnail"
|
||||
/>
|
||||
<Button
|
||||
className="border-none rounded-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedMainImage(null)}
|
||||
>
|
||||
<TimesIcon />
|
||||
</Button>
|
||||
</div>
|
||||
) : thumbnailImg.length > 0 ? (
|
||||
<div className="flex flex-row">
|
||||
<img
|
||||
src={URL.createObjectURL(thumbnailImg[0])}
|
||||
className="w-[30%]"
|
||||
alt="thumbnail"
|
||||
/>
|
||||
<Button
|
||||
className="border-none rounded-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setThumbnailImg([])}
|
||||
>
|
||||
<TimesIcon />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* <label htmlFor="file-upload">
|
||||
<button>Upload Thumbnail</button>
|
||||
</label>{" "} */}
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
multiple
|
||||
className="w-fit h-fit"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{thumbnailValidation !== "" && (
|
||||
<p className="text-red-400 text-sm mb-3">
|
||||
Upload thumbnail atau pilih dari File Media
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-sm mt-3">Kategori</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="category"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ReactSelect
|
||||
className="basic-single text-black z-50"
|
||||
classNames={{
|
||||
control: (state: any) =>
|
||||
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
|
||||
}}
|
||||
classNamePrefix="select"
|
||||
onChange={onChange}
|
||||
closeMenuOnSelect={false}
|
||||
components={animatedComponents}
|
||||
isClearable={true}
|
||||
isSearchable={true}
|
||||
isMulti={true}
|
||||
placeholder="Kategori..."
|
||||
name="sub-module"
|
||||
options={listCategory}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.category && (
|
||||
<p className="text-red-400 text-sm mb-3">
|
||||
{errors.category?.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm">Tags</p>
|
||||
{/* <Controller
|
||||
control={control}
|
||||
name="tags"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Textarea
|
||||
type="text"
|
||||
id="tags"
|
||||
placeholder=""
|
||||
label=""
|
||||
value={tag}
|
||||
onValueChange={setTag}
|
||||
startContent={
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{value.map((item, index) => (
|
||||
<Chip
|
||||
color="primary"
|
||||
key={index}
|
||||
className=""
|
||||
onClose={() => {
|
||||
const filteredTags = value.filter((tag) => tag !== item);
|
||||
if (filteredTags.length === 0) {
|
||||
setError("tags", {
|
||||
type: "manual",
|
||||
message: "Tags tidak boleh kosong",
|
||||
});
|
||||
} else {
|
||||
clearErrors("tags");
|
||||
setValue("tags", filteredTags as [string, ...string[]]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (tag.trim() !== "") {
|
||||
setValue("tags", [...value, tag.trim()]);
|
||||
setTag("");
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}}
|
||||
labelPlacement="outside"
|
||||
className="w-full h-fit"
|
||||
classNames={{
|
||||
inputWrapper: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||
}}
|
||||
variant="bordered"
|
||||
/>
|
||||
)}
|
||||
/> */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="tags"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="w-full">
|
||||
{/* Menampilkan tags */}
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{value.map((item: string, index: number) => (
|
||||
<Badge
|
||||
key={index}
|
||||
className="flex items-center gap-1 px-2 py-1 text-sm"
|
||||
variant="secondary"
|
||||
>
|
||||
{item}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const filteredTags = value.filter(
|
||||
(tag: string) => tag !== item
|
||||
);
|
||||
if (filteredTags.length === 0) {
|
||||
setError("tags", {
|
||||
type: "manual",
|
||||
message: "Tags tidak boleh kosong",
|
||||
});
|
||||
} else {
|
||||
clearErrors("tags");
|
||||
setValue(
|
||||
"tags",
|
||||
filteredTags as [string, ...string[]]
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="text-red-500 text-xs ml-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Textarea input */}
|
||||
<Textarea
|
||||
id="tags"
|
||||
placeholder="Tekan Enter untuk menambahkan tag"
|
||||
value={tag ?? ""}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (tag.trim() !== "") {
|
||||
setValue("tags", [...value, tag.trim()]);
|
||||
setTag("");
|
||||
clearErrors("tags");
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="border rounded-lg"
|
||||
aria-label="Tags Input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors?.tags && (
|
||||
<p className="text-red-400 text-sm mb-3">{errors.tags?.message}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 mt-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="schedule-switch"
|
||||
checked={isScheduled}
|
||||
onCheckedChange={setIsScheduled}
|
||||
/>
|
||||
<label htmlFor="schedule-switch" className="text-black text-sm">
|
||||
Publish dengan Jadwal
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* {isScheduled && (
|
||||
<div className="flex flex-col lg:flex-row gap-3">
|
||||
<div className="w-full lg:w-[140px] flex flex-col gal-2 ">
|
||||
<p className="text-sm">Tanggal</p>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full !h-[30px] lg:h-[40px] border-1 rounded-lg text-black"
|
||||
variant="outline"
|
||||
>
|
||||
{startDateValue
|
||||
? convertDateFormatNoTime(startDateValue)
|
||||
: "-"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="bg-transparent p-0">
|
||||
<Calendar
|
||||
selected={startDateValue}
|
||||
onSelect={setStartDateValue}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end gap-3">
|
||||
<Button
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={isScheduled && startDateValue == null}
|
||||
onClick={() =>
|
||||
isScheduled ? setStatus("scheduled") : setStatus("publish")
|
||||
}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
<Button
|
||||
color="success"
|
||||
type="submit"
|
||||
onClick={() => setStatus("draft")}
|
||||
>
|
||||
<p className="text-white">Draft</p>
|
||||
</Button>
|
||||
|
||||
<Link href="/admin/article">
|
||||
<Button variant="outline" type="button">
|
||||
Kembali
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,342 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { useEffect, useState } from "react";
|
||||
import { close, error, loading } from "@/config/swal";
|
||||
import { delay } from "@/utils/global";
|
||||
import dynamic from "next/dynamic";
|
||||
import GetSeoScore from "./get-seo-score-form";
|
||||
import {
|
||||
getDetailArticle,
|
||||
getGenerateRewriter,
|
||||
} from "@/service/generate-article";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const CustomEditor = dynamic(
|
||||
() => {
|
||||
return import("@/components/editor/custom-editor");
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const writingStyle = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Friendly",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: "Professional",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Informational",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Neutral",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Witty",
|
||||
},
|
||||
];
|
||||
|
||||
const articleSize = [
|
||||
{
|
||||
id: 1,
|
||||
name: "News (300 - 900 words)",
|
||||
value: "News",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Info (900 - 2000 words)",
|
||||
value: "Info",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Detail (2000 - 5000 words)",
|
||||
value: "Detail",
|
||||
},
|
||||
];
|
||||
|
||||
interface DiseData {
|
||||
id: number;
|
||||
articleBody: string;
|
||||
title: string;
|
||||
metaTitle: string;
|
||||
description: string;
|
||||
metaDescription: string;
|
||||
mainKeyword: string;
|
||||
additionalKeywords: string;
|
||||
}
|
||||
|
||||
export default function GenerateContentRewriteForm(props: {
|
||||
content: (data: DiseData) => void;
|
||||
}) {
|
||||
const [selectedWritingSyle, setSelectedWritingStyle] =
|
||||
useState("Informational");
|
||||
const [selectedArticleSize, setSelectedArticleSize] = useState("News");
|
||||
const [selectedLanguage, setSelectedLanguage] = useState("id");
|
||||
const [mainKeyword, setMainKeyword] = useState("");
|
||||
const [articleIds, setArticleIds] = useState<number[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const onSubmit = async () => {
|
||||
loading();
|
||||
const request = {
|
||||
advConfig: "",
|
||||
context: mainKeyword,
|
||||
style: selectedWritingSyle,
|
||||
sentiment: "Informational",
|
||||
urlContext: null,
|
||||
contextType: "article",
|
||||
lang: selectedLanguage,
|
||||
createdBy: "123123",
|
||||
clientId: "humasClientIdtest",
|
||||
};
|
||||
const res = await getGenerateRewriter(request);
|
||||
close();
|
||||
if (res?.error) {
|
||||
error("Error");
|
||||
}
|
||||
setArticleIds([...articleIds, res?.data?.data?.id]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getArticleDetail();
|
||||
}, [selectedId]);
|
||||
|
||||
const checkArticleStatus = async (data: string | null) => {
|
||||
if (data === null) {
|
||||
delay(7000).then(() => {
|
||||
getArticleDetail();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getArticleDetail = async () => {
|
||||
if (selectedId) {
|
||||
const res = await getDetailArticle(selectedId);
|
||||
const data = res?.data?.data;
|
||||
checkArticleStatus(data?.articleBody);
|
||||
if (data?.articleBody !== null) {
|
||||
setIsLoading(false);
|
||||
props.content(data);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
props.content({
|
||||
id: data?.id,
|
||||
articleBody: "",
|
||||
title: "",
|
||||
metaTitle: "",
|
||||
description: "",
|
||||
metaDescription: "",
|
||||
additionalKeywords: "",
|
||||
mainKeyword: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<form className="flex flex-col w-full mt-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 w-full">
|
||||
{/* <Select
|
||||
label="Writing Style"
|
||||
variant="bordered"
|
||||
labelPlacement="outside"
|
||||
placeholder=""
|
||||
selectedKeys={[selectedWritingSyle]}
|
||||
onChange={(e) =>
|
||||
e.target.value !== ""
|
||||
? setSelectedWritingStyle(e.target.value)
|
||||
: ""
|
||||
}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
label: "!text-black",
|
||||
value: "!text-black",
|
||||
trigger: [
|
||||
"border-1 rounded-lg",
|
||||
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
|
||||
],
|
||||
}}
|
||||
>
|
||||
<SelectSection>
|
||||
{writingStyle.map((style) => (
|
||||
<SelectItem key={style.name}>{style.name}</SelectItem>
|
||||
))}
|
||||
</SelectSection>
|
||||
</Select> */}
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm font-medium text-black dark:text-white">
|
||||
Writing Style
|
||||
</label>
|
||||
<Select
|
||||
value={selectedWritingSyle}
|
||||
onValueChange={(value) => setSelectedWritingStyle(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||
<SelectValue placeholder="Writing Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{writingStyle.map((style) => (
|
||||
<SelectItem key={style.name} value={style.name}>
|
||||
{style.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* <Select
|
||||
label="Article Size"
|
||||
variant="bordered"
|
||||
labelPlacement="outside"
|
||||
placeholder=""
|
||||
selectedKeys={[selectedArticleSize]}
|
||||
onChange={(e) => (e.target.value !== "" ? setSelectedArticleSize(e.target.value) : "")}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
label: "!text-black",
|
||||
value: "!text-black",
|
||||
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||
}}
|
||||
>
|
||||
<SelectSection>
|
||||
{articleSize.map((size) => (
|
||||
<SelectItem key={size.value}>{size.name}</SelectItem>
|
||||
))}
|
||||
</SelectSection>
|
||||
</Select> */}
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm font-medium text-black dark:text-white">
|
||||
Article Size
|
||||
</label>
|
||||
<Select
|
||||
value={selectedArticleSize}
|
||||
onValueChange={(value) => setSelectedArticleSize(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||
<SelectValue placeholder="Writing Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{articleSize.map((size) => (
|
||||
<SelectItem key={size.name} value={size.name}>
|
||||
{size.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* <Select
|
||||
label="Bahasa"
|
||||
variant="bordered"
|
||||
labelPlacement="outside"
|
||||
placeholder=""
|
||||
selectedKeys={[selectedLanguage]}
|
||||
onChange={(e) => (e.target.value !== "" ? setSelectedLanguage(e.target.value) : "")}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
label: "!text-black",
|
||||
value: "!text-black",
|
||||
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||
}}
|
||||
>
|
||||
<SelectSection>
|
||||
<SelectItem key="id">Indonesia</SelectItem>
|
||||
<SelectItem key="en">English</SelectItem>
|
||||
</SelectSection>
|
||||
</Select> */}
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-sm font-medium text-black dark:text-white">
|
||||
Language
|
||||
</label>
|
||||
<Select
|
||||
value={selectedLanguage}
|
||||
onValueChange={(value) => setSelectedLanguage(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||
<SelectValue placeholder="Writing Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="id">Indonesia</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col mt-3">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<p className="text-sm">Text</p>
|
||||
</div>
|
||||
<div className="w-[78vw] lg:w-full">
|
||||
<CustomEditor onChange={setMainKeyword} initialData={mainKeyword} />
|
||||
</div>
|
||||
{mainKeyword == "" && (
|
||||
<p className="text-red-400 text-sm">Required</p>
|
||||
)}
|
||||
{/* {articleIds.length < 3 && (
|
||||
<Button color="primary" className="my-5 w-full py-5 text-xs md:text-base" type="button" onPress={onSubmit} isDisabled={mainKeyword == ""}>
|
||||
Generate
|
||||
</Button>
|
||||
)} */}
|
||||
{articleIds.length < 3 && (
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
type="button"
|
||||
disabled={mainKeyword === ""}
|
||||
className="my-5 w-full py-5 text-xs md:text-base"
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{articleIds.length > 0 && (
|
||||
<div className="flex flex-row gap-1 mt-2">
|
||||
{/* {articleIds?.map((id, index) => (
|
||||
<Button onPress={() => setSelectedId(id)} key={id} isLoading={isLoading && selectedId == id} color={selectedId == id && isLoading ? "warning" : selectedId == id ? "success" : "default"}>
|
||||
<p className={selectedId == id ? "text-white" : "text-black"}>Article {index + 1}</p>
|
||||
</Button>
|
||||
))} */}
|
||||
{articleIds?.map((id, index) => (
|
||||
<Button
|
||||
key={id}
|
||||
onClick={() => setSelectedId(id)}
|
||||
disabled={isLoading && selectedId === id}
|
||||
variant={
|
||||
selectedId === id
|
||||
? isLoading
|
||||
? "secondary"
|
||||
: "default"
|
||||
: "outline"
|
||||
}
|
||||
className={
|
||||
selectedId === id ? "bg-green-600 text-white" : "text-black"
|
||||
}
|
||||
>
|
||||
{isLoading && selectedId === id
|
||||
? "Loading..."
|
||||
: `Article ${index + 1}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div>
|
||||
<GetSeoScore id={String(selectedId)} />
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
"use client";
|
||||
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
||||
import { useEffect, useState } from "react";
|
||||
import { close, error, loading } from "@/config/swal";
|
||||
import { delay } from "@/utils/global";
|
||||
import dynamic from "next/dynamic";
|
||||
import { getDetailArticle, getGenerateRewriter } from "@/service/generate-article";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import GetSeoScore from "./get-seo-score-form";
|
||||
|
||||
const CustomEditor = dynamic(
|
||||
() => {
|
||||
return import("@/components/editor/custom-editor");
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const writingStyle = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Friendly",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: "Professional",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Informational",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Neutral",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Witty",
|
||||
},
|
||||
];
|
||||
|
||||
const articleSize = [
|
||||
{
|
||||
id: 1,
|
||||
name: "News (300 - 900 words)",
|
||||
value: "News",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Info (900 - 2000 words)",
|
||||
value: "Info",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Detail (2000 - 5000 words)",
|
||||
value: "Detail",
|
||||
},
|
||||
];
|
||||
|
||||
interface DiseData {
|
||||
id: number;
|
||||
articleBody: string;
|
||||
title: string;
|
||||
metaTitle: string;
|
||||
description: string;
|
||||
metaDescription: string;
|
||||
mainKeyword: string;
|
||||
additionalKeywords: string;
|
||||
}
|
||||
|
||||
export default function GenerateContentRewriteForm(props: { content: (data: DiseData) => void }) {
|
||||
const [selectedWritingSyle, setSelectedWritingStyle] = useState("Informational");
|
||||
const [selectedArticleSize, setSelectedArticleSize] = useState("News");
|
||||
const [selectedLanguage, setSelectedLanguage] = useState("id");
|
||||
const [mainKeyword, setMainKeyword] = useState("");
|
||||
const [articleIds, setArticleIds] = useState<number[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const onSubmit = async () => {
|
||||
loading();
|
||||
const request = {
|
||||
advConfig: "",
|
||||
context: mainKeyword,
|
||||
style: selectedWritingSyle,
|
||||
sentiment: "Informational",
|
||||
urlContext: null,
|
||||
contextType: "article",
|
||||
lang: selectedLanguage,
|
||||
createdBy: "123123",
|
||||
clientId: "humasClientIdtest",
|
||||
};
|
||||
const res = await getGenerateRewriter(request);
|
||||
close();
|
||||
if (res?.error) {
|
||||
error("Error");
|
||||
}
|
||||
setArticleIds([...articleIds, res?.data?.data?.id]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getArticleDetail();
|
||||
}, [selectedId]);
|
||||
|
||||
const checkArticleStatus = async (data: string | null) => {
|
||||
if (data === null) {
|
||||
delay(7000).then(() => {
|
||||
getArticleDetail();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getArticleDetail = async () => {
|
||||
if (selectedId) {
|
||||
const res = await getDetailArticle(selectedId);
|
||||
const data = res?.data?.data;
|
||||
checkArticleStatus(data?.articleBody);
|
||||
if (data?.articleBody !== null) {
|
||||
setIsLoading(false);
|
||||
props.content(data);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
props.content({
|
||||
id: data?.id,
|
||||
articleBody: "",
|
||||
title: "",
|
||||
metaTitle: "",
|
||||
description: "",
|
||||
metaDescription: "",
|
||||
additionalKeywords: "",
|
||||
mainKeyword: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<form className="flex flex-col w-full mt-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 w-full">
|
||||
{/* <Select
|
||||
label="Writing Style"
|
||||
variant="bordered"
|
||||
labelPlacement="outside"
|
||||
placeholder=""
|
||||
selectedKeys={[selectedWritingSyle]}
|
||||
onChange={(e) =>
|
||||
e.target.value !== ""
|
||||
? setSelectedWritingStyle(e.target.value)
|
||||
: ""
|
||||
}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
label: "!text-black",
|
||||
value: "!text-black",
|
||||
trigger: [
|
||||
"border-1 rounded-lg",
|
||||
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
|
||||
],
|
||||
}}
|
||||
>
|
||||
<SelectSection>
|
||||
{writingStyle.map((style) => (
|
||||
<SelectItem key={style.name}>{style.name}</SelectItem>
|
||||
))}
|
||||
</SelectSection>
|
||||
</Select> */}
|
||||
<Select value={selectedWritingSyle} onValueChange={(value) => setSelectedWritingStyle(value)}>
|
||||
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||
<SelectValue placeholder="Writing Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{writingStyle.map((style) => (
|
||||
<SelectItem key={style.name} value={style.name}>
|
||||
{style.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* <Select
|
||||
label="Article Size"
|
||||
variant="bordered"
|
||||
labelPlacement="outside"
|
||||
placeholder=""
|
||||
selectedKeys={[selectedArticleSize]}
|
||||
onChange={(e) => (e.target.value !== "" ? setSelectedArticleSize(e.target.value) : "")}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
label: "!text-black",
|
||||
value: "!text-black",
|
||||
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||
}}
|
||||
>
|
||||
<SelectSection>
|
||||
{articleSize.map((size) => (
|
||||
<SelectItem key={size.value}>{size.name}</SelectItem>
|
||||
))}
|
||||
</SelectSection>
|
||||
</Select> */}
|
||||
<Select value={selectedArticleSize} onValueChange={(value) => setSelectedArticleSize(value)}>
|
||||
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||
<SelectValue placeholder="Writing Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{articleSize.map((style) => (
|
||||
<SelectItem key={style.name} value={style.name}>
|
||||
{style.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* <Select
|
||||
label="Bahasa"
|
||||
variant="bordered"
|
||||
labelPlacement="outside"
|
||||
placeholder=""
|
||||
selectedKeys={[selectedLanguage]}
|
||||
onChange={(e) => (e.target.value !== "" ? setSelectedLanguage(e.target.value) : "")}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
label: "!text-black",
|
||||
value: "!text-black",
|
||||
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||
}}
|
||||
>
|
||||
<SelectSection>
|
||||
<SelectItem key="id">Indonesia</SelectItem>
|
||||
<SelectItem key="en">English</SelectItem>
|
||||
</SelectSection>
|
||||
</Select> */}
|
||||
<Select value={selectedLanguage} onValueChange={(value) => setSelectedLanguage(value)}>
|
||||
<SelectTrigger className="w-full border rounded-lg text-black dark:border-gray-400">
|
||||
<SelectValue placeholder="Writing Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="id">Indonesia</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col mt-3">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<p className="text-sm">Text</p>
|
||||
</div>
|
||||
<div className="w-[78vw] lg:w-full">
|
||||
<CustomEditor onChange={setMainKeyword} initialData={mainKeyword} />
|
||||
</div>
|
||||
{mainKeyword == "" && <p className="text-red-400 text-sm">Required</p>}
|
||||
{articleIds.length < 3 && (
|
||||
<Button onClick={onSubmit} type="button" disabled={mainKeyword === "" || isLoading} className="my-5 w-full py-5 text-xs md:text-base">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
"Generate"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{articleIds.length > 0 && (
|
||||
<div className="flex flex-row gap-1 mt-2">
|
||||
{articleIds?.map((id, index) => (
|
||||
<Button key={id} onClick={() => setSelectedId(id)} disabled={isLoading && selectedId === id} variant={selectedId === id ? "default" : "outline"} className="flex items-center gap-2">
|
||||
{isLoading && selectedId === id ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
`Article ${index + 1}`
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div>
|
||||
<GetSeoScore id={String(selectedId)} />
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,448 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { close, error, loading } from "@/config/swal";
|
||||
import { delay } from "@/utils/global";
|
||||
import GetSeoScore from "./get-seo-score-form";
|
||||
import {
|
||||
generateDataArticle,
|
||||
getDetailArticle,
|
||||
getGenerateKeywords,
|
||||
getGenerateTitle,
|
||||
} from "@/service/generate-article";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const writingStyle = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Friendly",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: "Professional",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Informational",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Neutral",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Witty",
|
||||
},
|
||||
];
|
||||
|
||||
const articleSize = [
|
||||
{
|
||||
id: 1,
|
||||
name: "News (300 - 900 words)",
|
||||
value: "News",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Info (900 - 2000 words)",
|
||||
value: "Info",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Detail (2000 - 5000 words)",
|
||||
value: "Detail",
|
||||
},
|
||||
];
|
||||
|
||||
interface DiseData {
|
||||
id: number;
|
||||
articleBody: string;
|
||||
title: string;
|
||||
metaTitle: string;
|
||||
description: string;
|
||||
metaDescription: string;
|
||||
mainKeyword: string;
|
||||
additionalKeywords: string;
|
||||
}
|
||||
|
||||
export default function GenerateSingleArticleForm(props: {
|
||||
content: (data: DiseData) => void;
|
||||
}) {
|
||||
const [selectedWritingSyle, setSelectedWritingStyle] =
|
||||
useState("Informational");
|
||||
const [selectedArticleSize, setSelectedArticleSize] = useState("News");
|
||||
const [selectedLanguage, setSelectedLanguage] = useState("id");
|
||||
const [mainKeyword, setMainKeyword] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [additionalKeyword, setAdditionalKeyword] = useState("");
|
||||
const [articleIds, setArticleIds] = useState<number[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const generateAll = async (keyword: string | undefined) => {
|
||||
if (keyword) {
|
||||
generateTitle(keyword);
|
||||
generateKeywords(keyword);
|
||||
}
|
||||
};
|
||||
|
||||
const generateTitle = async (keyword: string | undefined) => {
|
||||
if (keyword) {
|
||||
loading();
|
||||
const req = {
|
||||
keyword: keyword,
|
||||
style: selectedWritingSyle,
|
||||
website: "None",
|
||||
connectToWeb: true,
|
||||
lang: selectedLanguage,
|
||||
pointOfView: "None",
|
||||
clientId: "",
|
||||
};
|
||||
const res = await getGenerateTitle(req);
|
||||
const data = res?.data?.data;
|
||||
setTitle(data);
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const generateKeywords = async (keyword: string | undefined) => {
|
||||
if (keyword) {
|
||||
const req = {
|
||||
keyword: keyword,
|
||||
style: selectedWritingSyle,
|
||||
website: "None",
|
||||
connectToWeb: true,
|
||||
lang: selectedLanguage,
|
||||
pointOfView: "0",
|
||||
clientId: "",
|
||||
};
|
||||
loading();
|
||||
const res = await getGenerateKeywords(req);
|
||||
const data = res?.data?.data;
|
||||
setAdditionalKeyword(data);
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
loading();
|
||||
const request = {
|
||||
advConfig: "",
|
||||
style: selectedWritingSyle,
|
||||
website: "None",
|
||||
connectToWeb: true,
|
||||
lang: selectedLanguage,
|
||||
pointOfView: "None",
|
||||
title: title,
|
||||
imageSource: "Web",
|
||||
mainKeyword: mainKeyword,
|
||||
additionalKeywords: additionalKeyword,
|
||||
targetCountry: null,
|
||||
articleSize: selectedArticleSize,
|
||||
projectId: 2,
|
||||
createdBy: "123123",
|
||||
clientId: "humasClientIdtest",
|
||||
};
|
||||
const res = await generateDataArticle(request);
|
||||
close();
|
||||
if (res?.error) {
|
||||
error("Error");
|
||||
}
|
||||
setArticleIds([...articleIds, res?.data?.data?.id]);
|
||||
// props.articleId(res?.data?.data?.id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getArticleDetail();
|
||||
}, [selectedId]);
|
||||
|
||||
const checkArticleStatus = async (data: string | null) => {
|
||||
if (data === null) {
|
||||
delay(7000).then(() => {
|
||||
getArticleDetail();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getArticleDetail = async () => {
|
||||
if (selectedId) {
|
||||
const res = await getDetailArticle(selectedId);
|
||||
const data = res?.data?.data;
|
||||
checkArticleStatus(data?.articleBody);
|
||||
if (data?.articleBody !== null) {
|
||||
setIsLoading(false);
|
||||
props.content(data);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
props.content({
|
||||
id: data?.id,
|
||||
articleBody: "",
|
||||
title: "",
|
||||
metaTitle: "",
|
||||
description: "",
|
||||
metaDescription: "",
|
||||
additionalKeywords: "",
|
||||
mainKeyword: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<form className="flex flex-col w-full mt-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 w-full">
|
||||
{/* <Select
|
||||
label="Writing Style"
|
||||
variant="bordered"
|
||||
labelPlacement="outside"
|
||||
placeholder=""
|
||||
selectedKeys={[selectedWritingSyle]}
|
||||
onChange={(e) =>
|
||||
e.target.value !== ""
|
||||
? setSelectedWritingStyle(e.target.value)
|
||||
: ""
|
||||
}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
label: "!text-black",
|
||||
value: "!text-black",
|
||||
trigger: [
|
||||
"border-1 rounded-lg",
|
||||
"dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400",
|
||||
],
|
||||
}}
|
||||
>
|
||||
<SelectSection>
|
||||
{writingStyle.map((style) => (
|
||||
<SelectItem key={style.name}>{style.name}</SelectItem>
|
||||
))}
|
||||
</SelectSection>
|
||||
</Select> */}
|
||||
<Select
|
||||
value={selectedWritingSyle}
|
||||
onValueChange={(value) => {
|
||||
if (value !== "") setSelectedWritingStyle(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
|
||||
<SelectValue placeholder="Writing Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{writingStyle.map((style) => (
|
||||
<SelectItem key={style.name} value={style.name}>
|
||||
{style.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* <Select
|
||||
label="Article Size"
|
||||
variant="bordered"
|
||||
labelPlacement="outside"
|
||||
placeholder=""
|
||||
selectedKeys={[selectedArticleSize]}
|
||||
onChange={(e) => (e.target.value !== "" ? setSelectedArticleSize(e.target.value) : "")}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
label: "!text-black",
|
||||
value: "!text-black",
|
||||
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||
}}
|
||||
>
|
||||
<SelectSection>
|
||||
{articleSize.map((size) => (
|
||||
<SelectItem key={size.value}>{size.name}</SelectItem>
|
||||
))}
|
||||
</SelectSection>
|
||||
</Select> */}
|
||||
<Select
|
||||
value={selectedArticleSize}
|
||||
onValueChange={(value) => {
|
||||
if (value !== "") setSelectedArticleSize(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
|
||||
<SelectValue placeholder="Writing Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{articleSize.map((style) => (
|
||||
<SelectItem key={style.name} value={style.name}>
|
||||
{style.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* <Select
|
||||
label="Bahasa"
|
||||
variant="bordered"
|
||||
labelPlacement="outside"
|
||||
placeholder=""
|
||||
selectedKeys={[selectedLanguage]}
|
||||
onChange={(e) => (e.target.value !== "" ? setSelectedLanguage(e.target.value) : "")}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
label: "!text-black",
|
||||
value: "!text-black",
|
||||
trigger: ["border-1 rounded-lg", "dark:group-data-[focused=false]:bg-transparent !border-1 dark:!border-gray-400"],
|
||||
}}
|
||||
>
|
||||
<SelectSection>
|
||||
<SelectItem key="id">Indonesia</SelectItem>
|
||||
<SelectItem key="en">English</SelectItem>
|
||||
</SelectSection>
|
||||
</Select> */}
|
||||
<Select
|
||||
value={selectedLanguage}
|
||||
onValueChange={(value) => {
|
||||
if (value !== "") setSelectedLanguage(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full border border-gray-300 dark:border-gray-400 rounded-lg text-black">
|
||||
<SelectValue placeholder="Bahasa" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="id">Indonesia</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col mt-3">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<p className="text-sm">Main Keyword</p>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => generateAll(mainKeyword)}
|
||||
disabled={isLoading} // tambahkan state kontrol loading
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Process"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="mainKeyword"
|
||||
placeholder="Masukkan keyword utama"
|
||||
value={mainKeyword}
|
||||
onChange={(e) => setMainKeyword(e.target.value)}
|
||||
className="w-full mt-1 border border-gray-300 rounded-lg dark:border-gray-400"
|
||||
/>
|
||||
|
||||
{mainKeyword == "" && (
|
||||
<p className="text-red-400 text-sm">Required</p>
|
||||
)}
|
||||
<div className="flex flex-row gap-2 items-center mt-3">
|
||||
<p className="text-sm">Title</p>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => generateTitle(mainKeyword)}
|
||||
disabled={mainKeyword === ""}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="title"
|
||||
placeholder=""
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full mt-1 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" // Custom styling using className
|
||||
aria-label="Title"
|
||||
/>
|
||||
|
||||
{/* {title == "" && <p className="text-red-400 text-sm">Required</p>} */}
|
||||
<div className="flex flex-row gap-2 items-center mt-2">
|
||||
<p className="text-sm">Additional Keyword</p>
|
||||
<Button
|
||||
className="text-sm"
|
||||
size="sm"
|
||||
onClick={() => generateKeywords(mainKeyword)}
|
||||
disabled={mainKeyword === ""}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="additionalKeyword"
|
||||
placeholder=""
|
||||
value={additionalKeyword}
|
||||
onChange={(e) => setAdditionalKeyword(e.target.value)}
|
||||
className="mt-1 border rounded-lg dark:bg-transparent dark:border-gray-400"
|
||||
aria-label="Additional Keyword"
|
||||
/>
|
||||
|
||||
{/* {additionalKeyword == "" && (
|
||||
<p className="text-red-400 text-sm">Required</p>
|
||||
)} */}
|
||||
{/* {articleIds.length < 3 && (
|
||||
<Button color="primary" className="my-5 w-full py-5 text-xs md:text-base" type="button" onPress={onSubmit} isDisabled={mainKeyword == "" || title == "" || additionalKeyword == ""}>
|
||||
Generate
|
||||
</Button>
|
||||
)} */}
|
||||
{articleIds.length < 3 && (
|
||||
<Button
|
||||
className="my-5 w-full py-5 text-xs md:text-base"
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={
|
||||
mainKeyword === "" || title === "" || additionalKeyword === ""
|
||||
}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{articleIds.length > 0 && (
|
||||
<div className="flex flex-row gap-1 mt-2">
|
||||
{articleIds.map((id, index) => (
|
||||
<Button
|
||||
key={id}
|
||||
onClick={() => setSelectedId(id)}
|
||||
disabled={isLoading && selectedId === id}
|
||||
className={`
|
||||
${
|
||||
selectedId === id
|
||||
? isLoading
|
||||
? "bg-yellow-500"
|
||||
: "bg-green-600"
|
||||
: "bg-gray-200"
|
||||
}
|
||||
text-sm px-4 py-2 rounded text-white transition-colors
|
||||
`}
|
||||
>
|
||||
Article {index + 1}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<div>
|
||||
<GetSeoScore id={String(selectedId)} />
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { CustomCircularProgress } from "@/components/layout/costum-circular-progress";
|
||||
import { getSeoScore } from "@/service/generate-article";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function GetSeoScore(props: { id: string }) {
|
||||
useEffect(() => {
|
||||
fetchSeoScore();
|
||||
}, [props.id]);
|
||||
|
||||
const [totalScoreSEO, setTotalScoreSEO] = useState();
|
||||
const [errorSEO, setErrorSEO] = useState<any>([]);
|
||||
const [warningSEO, setWarningSEO] = useState<any>([]);
|
||||
const [optimizedSEO, setOptimizedSEO] = useState<any>([]);
|
||||
|
||||
const fetchSeoScore = async () => {
|
||||
const res = await getSeoScore(props?.id);
|
||||
if (res.error) {
|
||||
// error(res.message);
|
||||
return false;
|
||||
}
|
||||
setTotalScoreSEO(res.data.data?.seo_analysis?.score || 0);
|
||||
const errorList: any[] = [
|
||||
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.error,
|
||||
...res.data.data?.seo_analysis?.analysis?.content_quality?.error,
|
||||
];
|
||||
setErrorSEO(errorList);
|
||||
const warningList: any[] = [
|
||||
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.warning,
|
||||
...res.data.data?.seo_analysis?.analysis?.content_quality?.warning,
|
||||
];
|
||||
setWarningSEO(warningList);
|
||||
const optimizedList: any[] = [
|
||||
...res.data.data?.seo_analysis?.analysis?.keyword_optimization?.optimized,
|
||||
...res.data.data?.seo_analysis?.analysis?.content_quality?.optimized,
|
||||
];
|
||||
setOptimizedSEO(optimizedList);
|
||||
};
|
||||
return (
|
||||
<div className="overflow-y-auto my-2">
|
||||
<div className="text-black flex flex-col rounded-md gap-3">
|
||||
<p className="font-semibold text-lg"> SEO Score</p>
|
||||
{totalScoreSEO ? (
|
||||
<div className="flex flex-row gap-5 w-full">
|
||||
{/* <CircularProgress
|
||||
aria-label=""
|
||||
color="warning"
|
||||
showValueLabel={true}
|
||||
size="lg"
|
||||
value={Number(totalScoreSEO) * 100}
|
||||
/> */}
|
||||
<CustomCircularProgress value={Number(totalScoreSEO) * 100} />
|
||||
<div>
|
||||
{/* <ApexChartDonut value={Number(totalScoreSEO) * 100} /> */}
|
||||
</div>
|
||||
<div className="flex flex-row gap-5">
|
||||
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-red-500 rounded-lg">
|
||||
{/* <TimesIcon size={15} className="text-danger" /> */}
|
||||
Error : {errorSEO.length || 0}
|
||||
</div>
|
||||
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-yellow-500 rounded-lg">
|
||||
{/* <p className="text-warning w-[15px] h-[15px] text-center mt-[-10px]">
|
||||
!
|
||||
</p> */}
|
||||
Warning : {warningSEO.length || 0}
|
||||
</div>
|
||||
<div className="px-2 py-1 border radius-md flex flex-row gap-2 items-center border-green-500 rounded-lg">
|
||||
{/* <CheckIcon size={15} className="text-success" /> */}
|
||||
Optimize : {optimizedSEO.length || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
"Belum ada Data"
|
||||
)}
|
||||
{totalScoreSEO && (
|
||||
// <Accordion
|
||||
// variant="splitted"
|
||||
// itemClasses={{
|
||||
// base: "!bg-transparent",
|
||||
// title: "text-black",
|
||||
// }}
|
||||
// >
|
||||
// <AccordionItem
|
||||
// key="1"
|
||||
// aria-label="Error"
|
||||
// // startContent={<TimesIcon size={20} className="text-danger" />}
|
||||
// title={`${errorSEO?.length || 0} Errors`}
|
||||
// >
|
||||
// <div className="flex flex-col gap-2">
|
||||
// {errorSEO?.map((item: any) => (
|
||||
// <p key={item} className="w-full border border-red-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3">
|
||||
// {item}
|
||||
// </p>
|
||||
// ))}
|
||||
// </div>
|
||||
// </AccordionItem>
|
||||
// <AccordionItem
|
||||
// key="2"
|
||||
// aria-label="Warning"
|
||||
// // startContent={
|
||||
// // <p className="text-warning w-[20px] h-[20px] text-center mt-[-10px]">
|
||||
// // !
|
||||
// // </p>
|
||||
// // }
|
||||
// title={`${warningSEO?.length || 0} Warnings`}
|
||||
// >
|
||||
// <div className="flex flex-col gap-2">
|
||||
// {warningSEO?.map((item: any) => (
|
||||
// <p key={item} className="w-full border border-yellow-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3">
|
||||
// {item}
|
||||
// </p>
|
||||
// ))}
|
||||
// </div>
|
||||
// </AccordionItem>
|
||||
// <AccordionItem
|
||||
// key="3"
|
||||
// aria-label="Optimized"
|
||||
// // startContent={<CheckIcon size={20} className="text-success" />}
|
||||
// title={`${optimizedSEO?.length || 0} Optimized`}
|
||||
// >
|
||||
// <div className="flex flex-col gap-2">
|
||||
// {optimizedSEO?.map((item: any) => (
|
||||
// <p key={item} className="w-full border border-green-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3">
|
||||
// {item}
|
||||
// </p>
|
||||
// ))}
|
||||
// </div>
|
||||
// </AccordionItem>
|
||||
// </Accordion>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
<AccordionItem value="error">
|
||||
<AccordionTrigger className="text-black">{`${
|
||||
errorSEO?.length || 0
|
||||
} Errors`}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
{errorSEO?.map((item: any) => (
|
||||
<p
|
||||
key={item}
|
||||
className="w-full border border-red-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
|
||||
>
|
||||
{item}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="warning">
|
||||
<AccordionTrigger className="text-black">{`${
|
||||
warningSEO?.length || 0
|
||||
} Warnings`}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
{warningSEO?.map((item: any) => (
|
||||
<p
|
||||
key={item}
|
||||
className="w-full border border-yellow-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
|
||||
>
|
||||
{item}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="optimized">
|
||||
<AccordionTrigger className="text-black">{`${
|
||||
optimizedSEO?.length || 0
|
||||
} Optimized`}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
{optimizedSEO?.map((item: any) => (
|
||||
<p
|
||||
key={item}
|
||||
className="w-full border border-green-500 rounded-md h-[40px] text-left flex flex-col justify-center px-3"
|
||||
>
|
||||
{item}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,442 @@
|
|||
"use client";
|
||||
|
||||
import { close, error, loading } from "@/config/swal";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import { z } from "zod";
|
||||
import ReactSelect from "react-select";
|
||||
import makeAnimated from "react-select/animated";
|
||||
import { editMasterUsers, getDetailMasterUsers } from "@/service/master-user";
|
||||
import { getAllUserLevels } from "@/service/user-levels-service";
|
||||
import { listUserRole } from "@/service/master-user-role";
|
||||
import { Card } from "../ui/card";
|
||||
import { Label } from "../ui/label";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const userSchema = z.object({
|
||||
id: z.number(),
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const masterUserSchema = z.object({
|
||||
fullname: z.string().min(1, { message: "Required" }),
|
||||
username: z.string().min(1, { message: "Required" }),
|
||||
email: z.string().min(1, { message: "Required" }),
|
||||
identityNumber: z.string().min(1, { message: "Required" }),
|
||||
genderType: z.string().min(1, { message: "Required" }),
|
||||
phoneNumber: z.string().min(1, { message: "Required" }),
|
||||
address: z.string().min(1, { message: "Required" }),
|
||||
userLevelType: userSchema,
|
||||
userRoleType: userSchema,
|
||||
});
|
||||
|
||||
export default function FormMasterUserEdit() {
|
||||
const router = useRouter();
|
||||
const MySwal = withReactContent(Swal);
|
||||
const animatedComponents = makeAnimated();
|
||||
const params = useParams();
|
||||
const id = params?.id;
|
||||
const [parentList, setParentList] = useState<any>([]);
|
||||
const [listRole, setListRole] = useState<any>([]);
|
||||
|
||||
const formOptions = {
|
||||
resolver: zodResolver(masterUserSchema),
|
||||
};
|
||||
type MicroIssueSchema = z.infer<typeof masterUserSchema>;
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
|
||||
setValue,
|
||||
} = useForm<MicroIssueSchema>(formOptions);
|
||||
|
||||
async function save(data: z.infer<typeof masterUserSchema>) {
|
||||
const formData = {
|
||||
address: data.address,
|
||||
email: data.email,
|
||||
fullname: data.fullname,
|
||||
genderType: data.genderType,
|
||||
identityNumber: data.identityNumber,
|
||||
phoneNumber: data.phoneNumber,
|
||||
userLevelId: data.userLevelType.id,
|
||||
userRoleId: data.userRoleType.id,
|
||||
username: data.username,
|
||||
};
|
||||
|
||||
const response = await editMasterUsers(formData, String(id));
|
||||
|
||||
if (response?.error) {
|
||||
error(response.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
successSubmit("/admin/master-user");
|
||||
}
|
||||
|
||||
function successSubmit(redirect: any) {
|
||||
MySwal.fire({
|
||||
title: "Sukses",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "OK",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
router.push(redirect);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onSubmit(data: z.infer<typeof masterUserSchema>) {
|
||||
MySwal.fire({
|
||||
title: "Simpan Data",
|
||||
text: "",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
cancelButtonColor: "#d33",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "Simpan",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
save(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
initFetch();
|
||||
}, [id]);
|
||||
|
||||
const initFetch = async () => {
|
||||
loading();
|
||||
const res = await getDetailMasterUsers(String(id));
|
||||
const profile = res?.data?.data;
|
||||
const listLevel = await fetchUserLevel();
|
||||
const listRole = await fetchUserRole();
|
||||
const findLevel = listLevel?.find((a) => a.id === profile.userLevelId);
|
||||
const findRole = listRole?.find((a) => a.id === profile.userRoleId);
|
||||
setValue("fullname", profile?.fullname);
|
||||
setValue("username", profile?.username);
|
||||
setValue("email", profile?.email);
|
||||
setValue("address", profile?.address);
|
||||
setValue("identityNumber", profile?.identityNumber);
|
||||
setValue("genderType", profile?.genderType);
|
||||
setValue("phoneNumber", profile?.phoneNumber);
|
||||
if (findLevel) {
|
||||
setValue("userLevelType", findLevel);
|
||||
}
|
||||
if (findRole) {
|
||||
setValue("userRoleType", findRole);
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
const fetchUserLevel = async () => {
|
||||
const res = await getAllUserLevels();
|
||||
if (res?.data?.data) {
|
||||
return setupParent(res?.data?.data, "level");
|
||||
}
|
||||
};
|
||||
const fetchUserRole = async () => {
|
||||
const request = {
|
||||
limit: 100,
|
||||
page: 1,
|
||||
};
|
||||
const res = await listUserRole(request);
|
||||
if (res?.data?.data) {
|
||||
return setupParent(res?.data?.data, "role");
|
||||
}
|
||||
};
|
||||
|
||||
const setupParent = (data: any, type: "level" | "role") => {
|
||||
const temp = [];
|
||||
for (const element of data) {
|
||||
temp.push({
|
||||
id: element.id,
|
||||
label: element.name,
|
||||
value: element.aliasName || element.code,
|
||||
});
|
||||
}
|
||||
if (type === "level") {
|
||||
setParentList(temp);
|
||||
} else {
|
||||
setListRole(temp);
|
||||
}
|
||||
return temp;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-5 my-5 overflow-y-auto">
|
||||
<form
|
||||
method="POST"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full lg:w-1/2 lg:ml-4"
|
||||
>
|
||||
<Card className="rounded-md p-5 flex flex-col gap-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="fullname"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-2">
|
||||
<Label htmlFor="title">Nama Lengkap</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="title"
|
||||
placeholder="Nama Lengkap..."
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.fullname?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.fullname?.message}</p>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="username"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="username"
|
||||
placeholder="Username..."
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.username?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.username?.message}</p>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder="Email..."
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.email?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.email?.message}</p>
|
||||
)}
|
||||
|
||||
{/* <Controller
|
||||
control={control}
|
||||
name="identityType"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
variant="bordered"
|
||||
labelPlacement="outside"
|
||||
label="Identity Type"
|
||||
placeholder="Select"
|
||||
className="max-w-xs"
|
||||
selectedKeys={[value]}
|
||||
onChange={onChange}
|
||||
>
|
||||
{typeIdentity.map((type) => (
|
||||
<SelectItem key={type.value}>{type.value}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.identityType?.message && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.identityType?.message}
|
||||
</p>
|
||||
)} */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="identityNumber"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-2">
|
||||
<Label htmlFor="identityNumber">NRP</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="identityNumber"
|
||||
placeholder="NRP..."
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.identityNumber?.message && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.identityNumber?.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="address"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-2">
|
||||
<Label htmlFor="alamat">Alamat</Label>
|
||||
<Textarea
|
||||
id="alamat"
|
||||
placeholder="Alamat..."
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e)}
|
||||
className="border border-gray-300 dark:border-gray-400 rounded-lg dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.address?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.address?.message}</p>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="genderType"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gender">Gender</Label>
|
||||
<RadioGroup
|
||||
id="gender"
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className="flex flex-row gap-6"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="Male" id="male" />
|
||||
<Label htmlFor="male">Laki-laki</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="Female" id="female" />
|
||||
<Label htmlFor="female">Perempuan</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.genderType?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.genderType?.message}</p>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="userLevelType"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<p className="text-sm mt-3">Level Pengguna</p>
|
||||
<ReactSelect
|
||||
className="basic-single text-black z-50"
|
||||
classNames={{
|
||||
control: (state: any) =>
|
||||
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
|
||||
}}
|
||||
classNamePrefix="select"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
closeMenuOnSelect={false}
|
||||
components={animatedComponents}
|
||||
isClearable={true}
|
||||
isSearchable={true}
|
||||
isMulti={false}
|
||||
placeholder=""
|
||||
name="sub-module"
|
||||
options={parentList}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{errors.userLevelType?.message && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.userLevelType?.message}
|
||||
</p>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="userRoleType"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<p className="text-sm mt-3">Peran Pengguna</p>
|
||||
<ReactSelect
|
||||
className="basic-single text-black z-49"
|
||||
classNames={{
|
||||
control: (state: any) =>
|
||||
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
|
||||
}}
|
||||
classNamePrefix="select"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
closeMenuOnSelect={false}
|
||||
components={animatedComponents}
|
||||
isClearable={true}
|
||||
isSearchable={true}
|
||||
isMulti={false}
|
||||
placeholder=""
|
||||
name="sub-module"
|
||||
options={listRole}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{errors.userRoleType?.message && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.userRoleType?.message}
|
||||
</p>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="phoneNumber"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full z-0 space-y-2">
|
||||
<Label htmlFor="identityNumber">No. Handphone</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="identityNumber"
|
||||
placeholder="08*********"
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.phoneNumber?.message && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.phoneNumber?.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href={`/admin/master-user`}>
|
||||
<Button color="danger" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" color="primary" variant="default">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,577 @@
|
|||
"use client";
|
||||
import { close, error, loading } from "@/config/swal";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import { z } from "zod";
|
||||
import { EyeFilledIcon, EyeSlashFilledIcon } from "../icons";
|
||||
import ReactPasswordChecklist from "react-password-checklist";
|
||||
import ReactSelect from "react-select";
|
||||
import makeAnimated from "react-select/animated";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createMasterUser } from "@/service/master-user";
|
||||
import { getAllUserLevels } from "@/service/user-levels-service";
|
||||
import { listUserRole } from "@/service/master-user-role";
|
||||
import { Card } from "../ui/card";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const userSchema = z.object({
|
||||
id: z.number(),
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const masterUserSchema = z.object({
|
||||
fullname: z.string().min(1, { message: "Required" }),
|
||||
username: z.string().min(1, { message: "Required" }),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password harus memiliki minimal 8 karakter.")
|
||||
.refine((password) => /[A-Z]/.test(password), {
|
||||
message: "Password harus memiliki minimal satu huruf kapital.",
|
||||
})
|
||||
.refine((password) => /[0-9]/.test(password), {
|
||||
message: "Password harus memiliki minimal satu angka.",
|
||||
})
|
||||
.refine((password) => /[!@#$%^&*(),.?":{}|<>]/.test(password), {
|
||||
message: "Password harus memiliki minimal satu simbol.",
|
||||
}),
|
||||
passwordValidate: z.string().min(1, { message: "Required" }),
|
||||
email: z.string().min(1, { message: "Required" }),
|
||||
identityNumber: z.string().min(1, { message: "Required" }),
|
||||
genderType: z.string().min(1, { message: "Required" }),
|
||||
phoneNumber: z.string().min(1, { message: "Required" }),
|
||||
address: z.string().min(1, { message: "Required" }),
|
||||
userLevelType: userSchema,
|
||||
userRoleType: userSchema,
|
||||
});
|
||||
|
||||
export default function FormMasterUser() {
|
||||
const router = useRouter();
|
||||
const animatedComponents = makeAnimated();
|
||||
|
||||
const MySwal = withReactContent(Swal);
|
||||
const [isVisible, setIsVisible] = useState([false, false]);
|
||||
const [isValidPassword, setIsValidPassword] = useState(false);
|
||||
const [parentList, setParentList] = useState<any>([]);
|
||||
const [listRole, setListRole] = useState<any>([]);
|
||||
|
||||
const toggleVisibility = (type: number) => {
|
||||
setIsVisible(
|
||||
type === 0 ? [!isVisible[0], isVisible[1]] : [isVisible[0], !isVisible[1]]
|
||||
);
|
||||
};
|
||||
|
||||
const formOptions = {
|
||||
resolver: zodResolver(masterUserSchema),
|
||||
defaultValues: { password: "", passwordValidate: "" },
|
||||
};
|
||||
type MicroIssueSchema = z.infer<typeof masterUserSchema>;
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setError,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<MicroIssueSchema>(formOptions);
|
||||
|
||||
const passwordVal = watch("password");
|
||||
const passwordConfVal = watch("passwordValidate");
|
||||
|
||||
async function save(data: z.infer<typeof masterUserSchema>) {
|
||||
const formData = {
|
||||
address: data.address,
|
||||
password: data.password,
|
||||
email: data.email,
|
||||
fullname: data.fullname,
|
||||
genderType: data.genderType,
|
||||
identityNumber: data.identityNumber,
|
||||
identityType: "nrp",
|
||||
phoneNumber: data.phoneNumber,
|
||||
userLevelId: data.userLevelType.id,
|
||||
userRoleId: data.userRoleType.id,
|
||||
username: data.username,
|
||||
};
|
||||
|
||||
const response = await createMasterUser(formData);
|
||||
|
||||
if (response?.error) {
|
||||
error(response.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
successSubmit("/admin/master-user");
|
||||
}
|
||||
|
||||
function successSubmit(redirect: any) {
|
||||
MySwal.fire({
|
||||
title: "Sukses",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "OK",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
router.push(redirect);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onSubmit(data: z.infer<typeof masterUserSchema>) {
|
||||
if (data.password === data.passwordValidate) {
|
||||
MySwal.fire({
|
||||
title: "Simpan Data",
|
||||
text: "",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
cancelButtonColor: "#d33",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "Simpan",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
save(data);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setError("passwordValidate", {
|
||||
type: "manual",
|
||||
message: "Password harus sama.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const generatePassword = () => {
|
||||
const length = Math.floor(Math.random() * 9) + 8;
|
||||
|
||||
const upperCaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const lowerCaseChars = "abcdefghijklmnopqrstuvwxyz";
|
||||
const numberChars = "0123456789";
|
||||
const specialChars = "!@#$%^&*";
|
||||
const allChars =
|
||||
upperCaseChars + lowerCaseChars + numberChars + specialChars;
|
||||
|
||||
let generatedPassword = "";
|
||||
|
||||
generatedPassword +=
|
||||
upperCaseChars[Math.floor(Math.random() * upperCaseChars.length)];
|
||||
generatedPassword +=
|
||||
specialChars[Math.floor(Math.random() * specialChars.length)];
|
||||
generatedPassword +=
|
||||
numberChars[Math.floor(Math.random() * numberChars.length)];
|
||||
generatedPassword +=
|
||||
lowerCaseChars[Math.floor(Math.random() * lowerCaseChars.length)];
|
||||
|
||||
for (let i = generatedPassword.length; i < length; i++) {
|
||||
generatedPassword +=
|
||||
allChars[Math.floor(Math.random() * allChars.length)];
|
||||
}
|
||||
|
||||
generatedPassword = generatedPassword
|
||||
.split("")
|
||||
.sort(() => 0.5 - Math.random())
|
||||
.join("");
|
||||
|
||||
setValue("password", generatedPassword);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserLevel();
|
||||
fetchUserRole();
|
||||
}, []);
|
||||
|
||||
const fetchUserLevel = async () => {
|
||||
loading();
|
||||
const res = await getAllUserLevels();
|
||||
close();
|
||||
if (res?.data?.data) {
|
||||
setupParent(res?.data?.data, "level");
|
||||
}
|
||||
};
|
||||
const fetchUserRole = async () => {
|
||||
loading();
|
||||
const request = {
|
||||
limit: 100,
|
||||
page: 1,
|
||||
};
|
||||
const res = await listUserRole(request);
|
||||
close();
|
||||
if (res?.data?.data) {
|
||||
setupParent(res?.data?.data, "role");
|
||||
}
|
||||
};
|
||||
|
||||
const setupParent = (data: any, type: "level" | "role") => {
|
||||
const temp = [];
|
||||
for (const element of data) {
|
||||
temp.push({
|
||||
id: element.id,
|
||||
label: element.name,
|
||||
value: element.aliasName || element.code,
|
||||
});
|
||||
}
|
||||
if (type === "level") {
|
||||
setParentList(temp);
|
||||
} else {
|
||||
setListRole(temp);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-5 my-5 overflow-y-auto">
|
||||
<form method="POST" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Card className="rounded-md p-5 flex flex-col gap-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="fullname"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor="title"
|
||||
className="mb-1 block text-sm font-medium"
|
||||
>
|
||||
Nama Lengkap
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="title"
|
||||
placeholder="Nama Lengkap..."
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.fullname?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.fullname?.message}</p>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="username"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor="username"
|
||||
className="mb-1 block text-sm font-medium"
|
||||
>
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="username"
|
||||
placeholder="Username..."
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.username?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.username?.message}</p>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor="email"
|
||||
className="mb-1 block text-sm font-medium"
|
||||
>
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder="Email..."
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.email?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.email?.message}</p>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="identityNumber"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor="identityNumber"
|
||||
className="mb-1 block text-sm font-medium"
|
||||
>
|
||||
NRP
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="identityNumber"
|
||||
placeholder="NRP..."
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.identityNumber?.message && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.identityNumber?.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="address"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor="alamat"
|
||||
className="mb-1 block text-sm font-medium"
|
||||
>
|
||||
Alamat
|
||||
</Label>
|
||||
<Textarea
|
||||
id="alamat"
|
||||
placeholder="Alamat..."
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-400 rounded-lg bg-white dark:bg-transparent text-black dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.address?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.address?.message}</p>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="genderType"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full">
|
||||
<Label className="mb-2 block text-sm font-medium">Gender</Label>
|
||||
<RadioGroup
|
||||
className="flex flex-row gap-4"
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="Male" id="male" />
|
||||
<Label htmlFor="male">Laki-laki</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="Female" id="female" />
|
||||
<Label htmlFor="female">Perempuan</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.genderType?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.genderType?.message}</p>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="phoneNumber"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-2">
|
||||
<Label htmlFor="identityNumber">No. Handphone</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="identityNumber"
|
||||
placeholder="08*********"
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.phoneNumber?.message && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.phoneNumber?.message}
|
||||
</p>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="userLevelType"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<p className="text-sm mt-3">Level Pengguna</p>
|
||||
<ReactSelect
|
||||
className="basic-single text-black z-50"
|
||||
classNames={{
|
||||
control: (state: any) =>
|
||||
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
|
||||
}}
|
||||
classNamePrefix="select"
|
||||
onChange={onChange}
|
||||
closeMenuOnSelect={false}
|
||||
components={animatedComponents}
|
||||
isClearable={true}
|
||||
isSearchable={true}
|
||||
isMulti={false}
|
||||
placeholder=""
|
||||
name="sub-module"
|
||||
options={parentList}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{errors.userLevelType?.message && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.userLevelType?.message}
|
||||
</p>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="userRoleType"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<p className="text-sm mt-3">Peran Pengguna</p>
|
||||
<ReactSelect
|
||||
className="basic-single text-black z-49"
|
||||
classNames={{
|
||||
control: (state: any) =>
|
||||
"!rounded-lg bg-white !border-1 !border-gray-200 dark:!border-stone-500",
|
||||
}}
|
||||
classNamePrefix="select"
|
||||
onChange={onChange}
|
||||
closeMenuOnSelect={false}
|
||||
components={animatedComponents}
|
||||
isClearable={true}
|
||||
isSearchable={true}
|
||||
isMulti={false}
|
||||
placeholder=""
|
||||
name="sub-module"
|
||||
options={listRole}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{errors.userRoleType?.message && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.userRoleType?.message}
|
||||
</p>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-2 relative z-0">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={isVisible[0] ? "text" : "password"}
|
||||
id="password"
|
||||
placeholder="Password..."
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full pr-10"
|
||||
/>
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-2xl text-muted-foreground focus:outline-none"
|
||||
type="button"
|
||||
onClick={() => toggleVisibility(0)}
|
||||
>
|
||||
{isVisible[0] ? (
|
||||
<EyeSlashFilledIcon className="pointer-events-none" />
|
||||
) : (
|
||||
<EyeFilledIcon className="pointer-events-none" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.password?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.password?.message}</p>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="passwordValidate"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-2 relative z-0">
|
||||
<Label htmlFor="passwordValidate">Konfirmasi Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={isVisible[1] ? "text" : "password"}
|
||||
id="passwordValidate"
|
||||
placeholder="Konfirmasi Password..."
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full pr-10"
|
||||
/>
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-2xl text-muted-foreground focus:outline-none"
|
||||
type="button"
|
||||
onClick={() => toggleVisibility(1)}
|
||||
>
|
||||
{isVisible[1] ? (
|
||||
<EyeSlashFilledIcon className="pointer-events-none" />
|
||||
) : (
|
||||
<EyeFilledIcon className="pointer-events-none" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.passwordValidate?.message && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.passwordValidate?.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<a
|
||||
className="cursor-pointer text-[#DD8306]"
|
||||
onClick={generatePassword}
|
||||
>
|
||||
Generate Password
|
||||
</a>
|
||||
<ReactPasswordChecklist
|
||||
rules={["minLength", "specialChar", "number", "capital", "match"]}
|
||||
minLength={8}
|
||||
value={passwordVal}
|
||||
valueAgain={passwordConfVal}
|
||||
onChange={(isValid) => {
|
||||
setIsValidPassword(isValid);
|
||||
}}
|
||||
className="text-black dark:text-white text-sm my-3"
|
||||
messages={{
|
||||
minLength: "Password must be more than 8 characters",
|
||||
specialChar: "Password must include a special character",
|
||||
number: "Password must include a number",
|
||||
capital: "Password must include an uppercase letter",
|
||||
match: "Passwords match",
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href={`/admin/master-user`}>
|
||||
<Button color="danger" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" color="primary" variant="default">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,526 @@
|
|||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Cookies from "js-cookie";
|
||||
import { close, error, loading } from "@/config/swal";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import {
|
||||
checkUsernames,
|
||||
emailValidation,
|
||||
getProfile,
|
||||
postSignIn,
|
||||
setupEmail,
|
||||
} from "@/service/master-user";
|
||||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
import { Label } from "../ui/label";
|
||||
|
||||
import { saveActivity } from "@/service/activity-log";
|
||||
import { EyeFilledIcon, EyeSlashFilledIcon } from "../icons";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isVisibleSetup, setIsVisibleSetup] = useState([false, false]);
|
||||
const [oldEmail, setOldEmail] = useState("");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [passwordSetup, setPasswordSetup] = useState("");
|
||||
const [confPasswordSetup, setConfPasswordSetup] = useState("");
|
||||
|
||||
const toggleVisibility = () => setIsVisible(!isVisible);
|
||||
const [needOtp, setNeedOtp] = useState(false);
|
||||
const [isFirstLogin, setFirstLogin] = useState(false);
|
||||
const [otpValue, setOtpValue] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [accessData, setAccessData] = useState<any>();
|
||||
const [profile, setProfile] = useState<any>();
|
||||
const [isValidEmail, setIsValidEmail] = useState(false);
|
||||
const [isResetPassword, setIsResetPassword] = useState(false);
|
||||
const [checkUsernameValue, setCheckUsernameValue] = useState("");
|
||||
const MySwal = withReactContent(Swal);
|
||||
|
||||
const setValUsername = (e: any) => {
|
||||
const uname = e.replaceAll(/[^\w.-]/g, "");
|
||||
setUsername(uname.toLowerCase());
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const data = {
|
||||
username: username,
|
||||
password: password,
|
||||
};
|
||||
|
||||
if (!username || !password) {
|
||||
error("Username & Password Wajib Diisi !");
|
||||
} else {
|
||||
loading();
|
||||
const response = await postSignIn(data);
|
||||
if (response?.error) {
|
||||
error("Username / Password Tidak Sesuai");
|
||||
} else {
|
||||
const profile = await getProfile(response?.data?.data?.access_token);
|
||||
const dateTime: any = new Date();
|
||||
|
||||
const newTime: any = dateTime.getTime() + 10 * 60 * 1000;
|
||||
|
||||
Cookies.set("access_token", response?.data?.data?.access_token, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("refresh_token", response?.data?.data?.refresh_token, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("time_refresh", newTime, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("is_first_login", "true", {
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
});
|
||||
await saveActivity(
|
||||
{
|
||||
activityTypeId: 1,
|
||||
url: "https://dev.mikulnews.com/auth",
|
||||
userId: profile?.data?.data?.id,
|
||||
},
|
||||
response?.data?.data?.access_token
|
||||
);
|
||||
Cookies.set("profile_picture", profile?.data?.data?.profilePictureUrl, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("uie", profile?.data?.data?.id, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("ufne", profile?.data?.data?.fullname, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("ulie", profile?.data?.data?.userLevelGroup, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("username", profile?.data?.data?.username, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("urie", profile?.data?.data?.roleId, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("roleName", profile?.data?.data?.roleName, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("masterPoldaId", profile?.data?.data?.masterPoldaId, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("ulne", profile?.data?.data?.userLevelId, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("urce", profile?.data?.data?.roleCode, {
|
||||
expires: 1,
|
||||
});
|
||||
Cookies.set("email", profile?.data?.data?.email, {
|
||||
expires: 1,
|
||||
});
|
||||
router.push("/admin/dashboard");
|
||||
Cookies.set("status", "login", {
|
||||
expires: 1,
|
||||
});
|
||||
|
||||
close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkUsername = async () => {
|
||||
const res = await checkUsernames(checkUsernameValue);
|
||||
if (res?.error) {
|
||||
error("Username tidak ditemukan");
|
||||
return false;
|
||||
}
|
||||
MySwal.fire({
|
||||
title: "",
|
||||
text: "",
|
||||
html: (
|
||||
<>
|
||||
<p>
|
||||
Kami telah mengirimkan tautan untuk mengatur ulang kata sandi ke
|
||||
email Anda
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
Apakah Anda sudah menerima emailnya? Jika belum, periksa folder spam
|
||||
Anda
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
icon: "info",
|
||||
cancelButtonColor: "#d33",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "Oke",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const submitCheckEmail = async () => {
|
||||
const req = {
|
||||
oldEmail: oldEmail,
|
||||
newEmail: newEmail,
|
||||
username: username,
|
||||
password: password,
|
||||
};
|
||||
|
||||
const res = await setupEmail(req);
|
||||
if (res?.error) {
|
||||
if (res.message?.messages[0]) {
|
||||
error(res.message?.messages[0]);
|
||||
} else {
|
||||
error(res?.message);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
close();
|
||||
setNeedOtp(true);
|
||||
setFirstLogin(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left Side - Logo Section */}
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-gray-600 via-gray-700 to-gray-800 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-black/20"></div>
|
||||
<div className="relative z-10 flex items-center justify-center w-full p-12">
|
||||
<div className="text-center">
|
||||
<Link href={"/"}>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-8 shadow-2xl border border-white/20">
|
||||
<img
|
||||
src="/masjaecoo.png"
|
||||
alt="Mikul News Logo"
|
||||
className="max-w-xs h-auto drop-shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="mt-8 text-white/90">
|
||||
<h2 className="text-2xl font-bold mb-2">Portal Jaecoo</h2>
|
||||
<p className="text-sm opacity-80">Platform beyond classic</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute top-10 left-10 w-20 h-20 bg-white/10 rounded-full blur-xl"></div>
|
||||
<div className="absolute bottom-20 right-20 w-32 h-32 bg-white/5 rounded-full blur-2xl"></div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Login Form */}
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-gray-50">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile Logo */}
|
||||
<div className="lg:hidden text-center mb-8">
|
||||
<Link href={"/"}>
|
||||
<img
|
||||
src="/masjaecoonav.png"
|
||||
alt="Mikul News Logo"
|
||||
className="w-64 h-10 mx-auto"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isFirstLogin ? (
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-orange-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Setup Akun
|
||||
</h2>
|
||||
<p className="text-gray-600">Lengkapi informasi email Anda</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="old-email"
|
||||
className="text-sm font-medium text-gray-700 mb-2 block"
|
||||
>
|
||||
Email Lama
|
||||
</Label>
|
||||
<Input
|
||||
id="old-email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="Masukkan email lama"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-colors"
|
||||
value={oldEmail}
|
||||
onChange={(e) => setOldEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="new-email"
|
||||
className="text-sm font-medium text-gray-700 mb-2 block"
|
||||
>
|
||||
Email Baru
|
||||
</Label>
|
||||
<Input
|
||||
id="new-email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="Masukkan email baru"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-colors"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
onClick={submitCheckEmail}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : needOtp ? (
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Verifikasi OTP
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Masukkan kode OTP yang telah dikirim
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isResetPassword ? (
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Reset Password
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Masukkan username untuk reset password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="reset-username"
|
||||
className="text-sm font-medium text-gray-700 mb-2 block"
|
||||
>
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Masukkan username"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors"
|
||||
value={checkUsernameValue}
|
||||
onChange={(e) =>
|
||||
setCheckUsernameValue(e.target.value.trim())
|
||||
}
|
||||
onPaste={(e) =>
|
||||
setCheckUsernameValue(e.currentTarget.value.trim())
|
||||
}
|
||||
onCopy={(e) =>
|
||||
setCheckUsernameValue(e.currentTarget.value.trim())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white font-semibold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={checkUsername}
|
||||
disabled={checkUsernameValue === ""}
|
||||
>
|
||||
Check Username
|
||||
</Button>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-gray-100">
|
||||
<Link
|
||||
href={`/`}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition-colors lg:hidden"
|
||||
>
|
||||
Beranda
|
||||
</Link>
|
||||
|
||||
<button
|
||||
className="text-sm text-red-600 hover:text-red-700 font-medium transition-colors"
|
||||
onClick={() => setIsResetPassword(false)}
|
||||
>
|
||||
Kembali ke Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Selamat Datang
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Portal Jaecoo - Platform beyond classic
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="username"
|
||||
className="text-sm font-medium text-gray-700 mb-2 block"
|
||||
>
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
required
|
||||
type="text"
|
||||
placeholder="Masukkan username"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-colors"
|
||||
value={username}
|
||||
onChange={(e) => setValUsername(e.target.value.trim())}
|
||||
onPaste={(e) =>
|
||||
setValUsername(e.currentTarget.value.trim())
|
||||
}
|
||||
onCopy={(e) => setValUsername(e.currentTarget.value.trim())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium text-gray-700 mb-2 block"
|
||||
>
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
required
|
||||
type={isVisible ? "text" : "password"}
|
||||
placeholder="Masukkan password"
|
||||
className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-colors"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVisibility}
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600 focus:outline-none transition-colors"
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeSlashFilledIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<EyeFilledIcon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-700 text-white font-semibold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Masuk ke Portal
|
||||
</Button>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-gray-100">
|
||||
<Link
|
||||
href={`/`}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition-colors lg:hidden"
|
||||
>
|
||||
Beranda
|
||||
</Link>
|
||||
|
||||
<button
|
||||
className="text-sm text-red-600 hover:text-red-700 font-medium transition-colors"
|
||||
onClick={() => setIsResetPassword(true)}
|
||||
>
|
||||
Lupa Password?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="mt-8 p-4 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<div className="flex items-start">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium mb-1">Informasi Portal</p>
|
||||
<p className="text-blue-700">Akses informasi terkini dan status permintaan informasi yang telah diajukan.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import PasswordChecklist from "react-password-checklist";
|
||||
import { close, error, loading } from "@/config/swal";
|
||||
import { savePassword } from "@/service/master-user";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function PasswordForm(props: { doFetch: () => void }) {
|
||||
const MySwal = withReactContent(Swal);
|
||||
const [isVisible, setIsVisible] = useState([false, false]);
|
||||
|
||||
const [passwordConf, setPasswordConf] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isValidPassword, setIsValidPassword] = useState(false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
loading();
|
||||
const data = {
|
||||
password: password,
|
||||
confirmPassword: passwordConf,
|
||||
};
|
||||
const res = await savePassword(data);
|
||||
if (res?.error) {
|
||||
error(res.message);
|
||||
return false;
|
||||
}
|
||||
close();
|
||||
props.doFetch();
|
||||
MySwal.fire({
|
||||
title: "Sukses",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "OK",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const generatePassword = () => {
|
||||
const length = Math.floor(Math.random() * 9) + 8;
|
||||
|
||||
const upperCaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const lowerCaseChars = "abcdefghijklmnopqrstuvwxyz";
|
||||
const numberChars = "0123456789";
|
||||
const specialChars = "!@#$%^&*";
|
||||
const allChars =
|
||||
upperCaseChars + lowerCaseChars + numberChars + specialChars;
|
||||
|
||||
let generatedPassword = "";
|
||||
|
||||
generatedPassword +=
|
||||
upperCaseChars[Math.floor(Math.random() * upperCaseChars.length)];
|
||||
generatedPassword +=
|
||||
specialChars[Math.floor(Math.random() * specialChars.length)];
|
||||
generatedPassword +=
|
||||
numberChars[Math.floor(Math.random() * numberChars.length)];
|
||||
generatedPassword +=
|
||||
lowerCaseChars[Math.floor(Math.random() * lowerCaseChars.length)];
|
||||
|
||||
for (let i = generatedPassword.length; i < length; i++) {
|
||||
generatedPassword +=
|
||||
allChars[Math.floor(Math.random() * allChars.length)];
|
||||
}
|
||||
|
||||
generatedPassword = generatedPassword
|
||||
.split("")
|
||||
.sort(() => 0.5 - Math.random())
|
||||
.join("");
|
||||
|
||||
setPassword(generatedPassword);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Password</p>
|
||||
<div className="relative">
|
||||
<Input
|
||||
required
|
||||
type={isVisible[0] ? "text" : "password"}
|
||||
placeholder=""
|
||||
className="w-full pr-10 border border-gray-300 rounded-lg dark:border-gray-400"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setPassword(target.value.trim());
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setPassword(target.value.trim());
|
||||
}}
|
||||
onCopy={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setPassword(target.value.trim());
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsVisible([!isVisible[0], isVisible[1]])}
|
||||
className="absolute inset-y-0 right-2 flex items-center text-gray-400 focus:outline-none"
|
||||
>
|
||||
{isVisible[0] ? (
|
||||
<EyeOff className="w-5 h-5 pointer-events-none" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 pointer-events-none" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Konfirmasi Password</p>
|
||||
<div className="relative">
|
||||
<Input
|
||||
required
|
||||
type={isVisible[1] ? "text" : "password"}
|
||||
placeholder=""
|
||||
className="w-full pr-10 border border-gray-300 rounded-lg dark:border-gray-400"
|
||||
value={passwordConf}
|
||||
onChange={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setPasswordConf(target.value.trim());
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setPasswordConf(target.value.trim());
|
||||
}}
|
||||
onCopy={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setPasswordConf(target.value.trim());
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsVisible([isVisible[0], !isVisible[1]])}
|
||||
className="absolute inset-y-0 right-2 flex items-center text-gray-400 focus:outline-none"
|
||||
>
|
||||
{isVisible[1] ? (
|
||||
<EyeOff className="w-5 h-5 pointer-events-none" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 pointer-events-none" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<a className="cursor-pointer text-[#DD8306]" onClick={generatePassword}>
|
||||
Generate Password
|
||||
</a>
|
||||
<PasswordChecklist
|
||||
rules={["minLength", "specialChar", "number", "capital", "match"]}
|
||||
minLength={8}
|
||||
value={password}
|
||||
valueAgain={passwordConf}
|
||||
onChange={(isValid) => {
|
||||
setIsValidPassword(isValid);
|
||||
}}
|
||||
className="text-black dark:text-white text-sm my-3"
|
||||
messages={{
|
||||
minLength: "Password must be more than 8 characters",
|
||||
specialChar: "Password must include a special character",
|
||||
number: "Password must include a number",
|
||||
capital: "Password must include an uppercase letter",
|
||||
match: "Passwords match",
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="w-fit mt-4 bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={onSubmit}
|
||||
disabled={!isValidPassword}
|
||||
>
|
||||
Simpan
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import { close, error, loading } from "@/config/swal";
|
||||
import { updateProfile } from "@/service/master-user";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const formSchema = z.object({
|
||||
fullname: z.string().min(1, {
|
||||
message: "Harus diisi",
|
||||
}),
|
||||
username: z.string().min(1, {
|
||||
message: "Harus diisi",
|
||||
}),
|
||||
email: z
|
||||
.string()
|
||||
.email({
|
||||
message: "Email tidak valid",
|
||||
})
|
||||
.min(2, {
|
||||
message: "Harus diisi",
|
||||
}),
|
||||
nrp: z.string().min(1, {
|
||||
message: "Harus diisi",
|
||||
}),
|
||||
address: z.string().min(1, {
|
||||
message: "Harus diisi",
|
||||
}),
|
||||
gender: z.string().min(1, {
|
||||
message: "Harus diisi",
|
||||
}),
|
||||
phoneNumber: z.string().min(1, {
|
||||
message: "Harus diisi",
|
||||
}),
|
||||
});
|
||||
|
||||
export default function ProfileForm(props: {
|
||||
profile: any;
|
||||
doFetch: () => void;
|
||||
}) {
|
||||
const MySwal = withReactContent(Swal);
|
||||
const { profile } = props;
|
||||
const formOptions = {
|
||||
resolver: zodResolver(formSchema),
|
||||
};
|
||||
type UserSettingSchema = z.infer<typeof formSchema>;
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
} = useForm<UserSettingSchema>(formOptions);
|
||||
|
||||
useEffect(() => {
|
||||
setValue("fullname", profile?.fullname);
|
||||
setValue("username", profile?.username);
|
||||
setValue("email", profile?.email);
|
||||
setValue("address", profile?.address);
|
||||
setValue("nrp", profile?.identityNumber);
|
||||
setValue("gender", profile?.genderType);
|
||||
setValue("phoneNumber", profile?.phoneNumber);
|
||||
}, [profile]);
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
loading();
|
||||
const req = {
|
||||
address: values.address,
|
||||
fullname: values.fullname,
|
||||
username: values.username,
|
||||
email: values.email,
|
||||
identityNumber: values.nrp,
|
||||
phoneNumber: values.phoneNumber,
|
||||
genderType: values.gender,
|
||||
userLevelId: profile.userLevelId,
|
||||
userRoleId: profile.userRoleId,
|
||||
};
|
||||
const res = await updateProfile(req);
|
||||
close();
|
||||
if (res?.error) {
|
||||
error(res.message);
|
||||
return false;
|
||||
}
|
||||
props.doFetch();
|
||||
MySwal.fire({
|
||||
title: "Sukses",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "OK",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-3 " onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Username</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="username"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
type="text"
|
||||
id="username"
|
||||
placeholder=""
|
||||
readOnly
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 rounded-lg dark:border-gray-400"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.username && (
|
||||
<p className="text-red-400 text-sm mb-3">
|
||||
{errors.username?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="fullname"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="fullname" className="text-sm">
|
||||
Nama Lengkap
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="fullname"
|
||||
placeholder=""
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors?.fullname && (
|
||||
<p className="text-red-400 text-sm mb-3">
|
||||
{errors.fullname?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="email" className="text-sm">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder=""
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors?.email && (
|
||||
<p className="text-red-400 text-sm mb-3">{errors.email?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="nrp"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="nrp" className="text-sm">
|
||||
NRP
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="nrp"
|
||||
placeholder=""
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors?.nrp && (
|
||||
<p className="text-red-400 text-sm mb-3">{errors.nrp?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="address"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="address" className="text-sm">
|
||||
Alamat
|
||||
</label>
|
||||
<Textarea
|
||||
id="address"
|
||||
placeholder=""
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors?.address && (
|
||||
<p className="text-red-400 text-sm mb-3">{errors.address?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Gender</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="gender"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<RadioGroup
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="male" id="male" />
|
||||
<label htmlFor="male" className="text-sm">
|
||||
Laki-laki
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="female" id="female" />
|
||||
<label htmlFor="female" className="text-sm">
|
||||
Perempuan
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors?.gender && (
|
||||
<p className="text-red-400 text-sm mb-3">{errors.gender?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="phoneNumber"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="phoneNumber" className="text-sm">
|
||||
Nomor Handphone
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="phoneNumber"
|
||||
placeholder=""
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 rounded-lg dark:border-gray-400 dark:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors?.phoneNumber && (
|
||||
<p className="text-red-400 text-sm mb-3">
|
||||
{errors.phoneNumber?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button color="primary" type="submit" className="w-fit mt-4">
|
||||
Simpan
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { close, error, loading } from "@/config/swal";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import { editCustomStaticPage, getCustomStaticDetail } from "@/service/static-page-service";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const CustomEditor = dynamic(
|
||||
() => {
|
||||
return import("@/components/editor/custom-editor");
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const formSchema = z.object({
|
||||
slug: z.string().min(2, {
|
||||
message: "Slug must be at least 2 characters.",
|
||||
}),
|
||||
title: z.string().min(2, {
|
||||
message: "Title must be at least 2 characters.",
|
||||
}),
|
||||
description: z.string().min(2, {
|
||||
message: "Main Keyword must be at least 2 characters.",
|
||||
}),
|
||||
htmlBody: z.string().min(2, {
|
||||
message: "Main Keyword must be at least 2 characters.",
|
||||
}),
|
||||
});
|
||||
|
||||
export default function StaticPageBuilderEdit() {
|
||||
const MySwal = withReactContent(Swal);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id;
|
||||
const formOptions = {
|
||||
resolver: zodResolver(formSchema),
|
||||
};
|
||||
type UserSettingSchema = z.infer<typeof formSchema>;
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
setValue,
|
||||
getValues,
|
||||
} = useForm<UserSettingSchema>(formOptions);
|
||||
|
||||
useEffect(() => {
|
||||
initFetch();
|
||||
}, [id]);
|
||||
|
||||
const initFetch = async () => {
|
||||
const res = await getCustomStaticDetail(id ? String(id) : "");
|
||||
const data = res?.data?.data;
|
||||
console.log("res", data);
|
||||
setValue("title", data?.title);
|
||||
setValue("slug", data?.slug);
|
||||
setValue("description", data?.description);
|
||||
setValue("htmlBody", addPreCode(data?.htmlBody));
|
||||
};
|
||||
|
||||
const addPreCode = (htmlString: string): string => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlString, "text/html");
|
||||
|
||||
const bodyContent = doc.body.innerHTML.trim();
|
||||
|
||||
return `<pre><code class="language-html">${bodyContent.replace(/</g, "<").replace(/>/g, ">")}</code></pre>`;
|
||||
};
|
||||
|
||||
const content = watch("htmlBody");
|
||||
const generatedPage = useCallback(() => {
|
||||
const sanitizedContent = DOMPurify.sanitize(content);
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.innerHTML = sanitizedContent;
|
||||
|
||||
return (
|
||||
<Card className="rounded-md border p-4">
|
||||
<div dangerouslySetInnerHTML={{ __html: textArea.value }} />
|
||||
</Card>
|
||||
);
|
||||
}, [content]);
|
||||
|
||||
function createSlug(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
}
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
const request = {
|
||||
id: Number(id),
|
||||
title: values.title,
|
||||
slug: values.slug,
|
||||
description: values.description,
|
||||
htmlBody: values.htmlBody,
|
||||
};
|
||||
loading();
|
||||
const res = await editCustomStaticPage(request);
|
||||
|
||||
if (res?.error) {
|
||||
error(res.message);
|
||||
return false;
|
||||
}
|
||||
close();
|
||||
|
||||
successSubmit("/admin/static-page");
|
||||
};
|
||||
|
||||
function successSubmit(redirect: any) {
|
||||
MySwal.fire({
|
||||
title: "Sukses",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "OK",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
router.push(redirect);
|
||||
}
|
||||
});
|
||||
}
|
||||
// const title = watch("title");
|
||||
// useEffect(() => {
|
||||
// if (getValues("title")) {
|
||||
// setValue("slug", createSlug(getValues("title")));
|
||||
// }
|
||||
// }, [title]);
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-3 px-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-2">
|
||||
<Label htmlFor="title" className="text-sm font-medium">
|
||||
Title
|
||||
</Label>
|
||||
<Input type="text" id="title" placeholder="Title" value={value ?? ""} onChange={onChange} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.title?.message && <p className="text-red-400 text-sm">{errors.title?.message}</p>}
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-2">
|
||||
<Label htmlFor="slug" className="text-sm font-medium">
|
||||
Slug
|
||||
</Label>
|
||||
<Input type="text" id="slug" placeholder="Slug" value={value ?? ""} onChange={onChange} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field: { onChange, value } }) => <div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-sm font-medium">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Description"
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>}
|
||||
/>
|
||||
{errors.description?.message && <p className="text-red-400 text-sm">{errors.description?.message}</p>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
Editor
|
||||
<Controller control={control} name="htmlBody" render={({ field: { onChange, value } }) => <CustomEditor onChange={onChange} initialData={value} />} />
|
||||
</div>
|
||||
<div className="px-4 flex flex-col gap-2">
|
||||
Preview
|
||||
{generatedPage()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end w-full">
|
||||
<Button type="submit" color="primary" className="w-fit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,214 @@
|
|||
import * as React from "react";
|
||||
import { IconSvgProps } from "@/types/globals";
|
||||
|
||||
export const DashboardUserIcon = ({
|
||||
size,
|
||||
height = 48,
|
||||
width = 48,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
{...props}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 3c2.21 0 4 1.79 4 4s-1.79 4-4 4s-4-1.79-4-4s1.79-4 4-4m4 10.54c0 1.06-.28 3.53-2.19 6.29L13 15l.94-1.88c-.62-.07-1.27-.12-1.94-.12s-1.32.05-1.94.12L11 15l-.81 4.83C8.28 17.07 8 14.6 8 13.54c-2.39.7-4 1.96-4 3.46v4h16v-4c0-1.5-1.6-2.76-4-3.46"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DashboardBriefcaseIcon = ({
|
||||
size,
|
||||
height = 48,
|
||||
width = 48,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={size || height}
|
||||
viewBox="0 0 48 48"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path fill="#f5bc00" d="M44,41H4V10h40V41z" />
|
||||
<polygon fill="#eb7900" points="44,26 24,26 4,26 4,10 44,10" />
|
||||
<path fill="#eb7900" d="M17,26h-6v3h6V26z" />
|
||||
<path fill="#eb7900" d="M37,26h-6v3h6V26z" />
|
||||
<rect width="14" height="3" x="17" y="7" fill="#f5bc00" />
|
||||
<path fill="#eb0000" d="M17,23h-6v3h6V23z" />
|
||||
<path fill="#eb0000" d="M37,23h-6v3h6V23z" />
|
||||
</svg>
|
||||
);
|
||||
export const DashboardMailboxIcon = ({
|
||||
size,
|
||||
height = 48,
|
||||
width = 48,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={size || height}
|
||||
width={size || width}
|
||||
{...props}
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path fill="#3dd9eb" d="M43,36H13V11h22c4.418,0,8,3.582,8,8V36z" />
|
||||
<path
|
||||
fill="#7debf5"
|
||||
d="M21,36H5V19c0-4.418,3.582-8,8-8l0,0c4.418,0,8,3.582,8,8V36z"
|
||||
/>
|
||||
<path fill="#6c19ff" d="M21,36h5v8h-5V36z" />
|
||||
<polygon fill="#eb0000" points="27,16 27,20 35,20 35,24 39,24 39,16" />
|
||||
<rect width="8" height="3" x="9" y="20" fill="#3dd9eb" />
|
||||
</svg>
|
||||
);
|
||||
export const DashboardShareIcon = ({
|
||||
size,
|
||||
height = 48,
|
||||
width = 48,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={size || height}
|
||||
width={size || width}
|
||||
{...props}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M503.691 189.836L327.687 37.851C312.281 24.546 288 35.347 288 56.015v80.053C127.371 137.907 0 170.1 0 322.326c0 61.441 39.581 122.309 83.333 154.132c13.653 9.931 33.111-2.533 28.077-18.631C66.066 312.814 132.917 274.316 288 272.085V360c0 20.7 24.3 31.453 39.687 18.164l176.004-152c11.071-9.562 11.086-26.753 0-36.328"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const DashboardSpeecIcon = ({
|
||||
size,
|
||||
height = 48,
|
||||
width = 48,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={size || height}
|
||||
width={size || width}
|
||||
{...props}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 0a2 2 0 0 0-2 2h9a2 2 0 0 1 2 2v12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M13 20a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2zM9 5h4v5H9zM4 5h4v1H4zm0 2h4v1H4zm0 2h4v1H4zm0 2h9v1H4zm0 2h9v1H4zm0 2h9v1H4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DashboardConnectIcon = ({
|
||||
size,
|
||||
height = 48,
|
||||
width = 48,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={size || height}
|
||||
width={size || width}
|
||||
{...props}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M2 22V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18H6zm7.075-7.75L12 12.475l2.925 1.775l-.775-3.325l2.6-2.25l-3.425-.275L12 5.25L10.675 8.4l-3.425.275l2.6 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DashboardTopLeftPointIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M18 18L6 6m0 0h9M6 6v9"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DashboardRightDownPointIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M5.47 5.47a.75.75 0 0 1 1.06 0l10.72 10.72V9a.75.75 0 0 1 1.5 0v9a.75.75 0 0 1-.75.75H9a.75.75 0 0 1 0-1.5h7.19L5.47 6.53a.75.75 0 0 1 0-1.06"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const DashboardCommentIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={size || height}
|
||||
width={size || width}
|
||||
{...props}
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<defs>
|
||||
<mask id="ipSComment0">
|
||||
<g
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="4"
|
||||
>
|
||||
<path fill="#fff" stroke="#fff" d="M44 6H4v30h9v5l10-5h21z" />
|
||||
<path stroke="#000" d="M14 19.5v3m10-3v3m10-3v3" />
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
<path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipSComment0)" />
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import * as React from "react";
|
||||
import { IconSvgProps } from "@/types/globals";
|
||||
|
||||
export const PdfIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 15 15"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3.5 8H3V7h.5a.5.5 0 0 1 0 1M7 10V7h.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M1 1.5A1.5 1.5 0 0 1 2.5 0h8.207L14 3.293V13.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 1 13.5zM3.5 6H2v5h1V9h.5a1.5 1.5 0 1 0 0-3m4 0H6v5h1.5A1.5 1.5 0 0 0 9 9.5v-2A1.5 1.5 0 0 0 7.5 6m2.5 5V6h3v1h-2v1h1v1h-1v2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const CsvIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
{...props}
|
||||
viewBox="0 0 15 15"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M1 1.5A1.5 1.5 0 0 1 2.5 0h8.207L14 3.293V13.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 1 13.5zM2 6h3v1H3v3h2v1H2zm7 0H6v3h2v1H6v1h3V8H7V7h2zm2 0h-1v3.707l1.5 1.5l1.5-1.5V6h-1v3.293l-.5.5l-.5-.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const ExcelIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 15 15"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3.793 7.5L2.146 5.854l.708-.708L4.5 6.793l1.646-1.647l.708.708L5.207 7.5l1.647 1.646l-.708.708L4.5 8.207L2.854 9.854l-.708-.708z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M3.5 0A1.5 1.5 0 0 0 2 1.5V3h-.5A1.5 1.5 0 0 0 0 4.5v6A1.5 1.5 0 0 0 1.5 12H2v1.5A1.5 1.5 0 0 0 3.5 15h10a1.5 1.5 0 0 0 1.5-1.5v-12A1.5 1.5 0 0 0 13.5 0zm-2 4a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-.5-.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const WordIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
{...props}
|
||||
viewBox="0 0 15 15"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m2.015 5.621l1 4a.5.5 0 0 0 .901.156l.584-.876l.584.876a.5.5 0 0 0 .901-.156l1-4l-.97-.242l-.726 2.903l-.373-.56a.5.5 0 0 0-.832 0l-.373.56l-.726-2.903z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M3.5 0A1.5 1.5 0 0 0 2 1.5V3h-.5A1.5 1.5 0 0 0 0 4.5v6A1.5 1.5 0 0 0 1.5 12H2v1.5A1.5 1.5 0 0 0 3.5 15h10a1.5 1.5 0 0 0 1.5-1.5v-12A1.5 1.5 0 0 0 13.5 0zm-2 4a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-.5-.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PptIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
{...props}
|
||||
viewBox="0 0 15 15"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 8h.5a.5.5 0 0 0 0-1H3zm4 0h.5a.5.5 0 0 0 0-1H7z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M1 1.5A1.5 1.5 0 0 1 2.5 0h8.207L14 3.293V13.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 1 13.5zM2 6h1.5a1.5 1.5 0 1 1 0 3H3v2H2zm4 0h1.5a1.5 1.5 0 1 1 0 3H7v2H6zm5 5h1V7h1V6h-3v1h1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const FileIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
{...props}
|
||||
viewBox="0 0 15 15"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m10.5.5l.354-.354L10.707 0H10.5zm3 3h.5v-.207l-.146-.147zm-1 10.5h-10v1h10zM2 13.5v-12H1v12zM2.5 1h8V0h-8zM13 3.5v10h1v-10zM10.146.854l3 3l.708-.708l-3-3zM2.5 14a.5.5 0 0 1-.5-.5H1A1.5 1.5 0 0 0 2.5 15zm10 1a1.5 1.5 0 0 0 1.5-1.5h-1a.5.5 0 0 1-.5.5zM2 1.5a.5.5 0 0 1 .5-.5V0A1.5 1.5 0 0 0 1 1.5z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const UserProfileIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
{...props}
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 7c0 1.66-1.34 3-3 3S5 8.66 5 7s1.34-3 3-3s3 1.34 3 3"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M16 8c0 4.42-3.58 8-8 8s-8-3.58-8-8s3.58-8 8-8s8 3.58 8 8M4 13.75C4.16 13.484 5.71 11 7.99 11c2.27 0 3.83 2.49 3.99 2.75A6.98 6.98 0 0 0 14.99 8c0-3.87-3.13-7-7-7s-7 3.13-7 7c0 2.38 1.19 4.49 3.01 5.75"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const SettingsIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
{...props}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zm2.8-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -0,0 +1,487 @@
|
|||
import * as React from "react";
|
||||
import { IconSvgProps } from "@/types/globals";
|
||||
|
||||
export const MenuBurgerIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2.5"
|
||||
d="M3 6h18M3 12h18M3 18h18"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DashboardIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M13 9V3h8v6zM3 13V3h8v10zm10 8V11h8v10zM3 21v-6h8v6z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const HomeIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M2 12.204c0-2.289 0-3.433.52-4.381c.518-.949 1.467-1.537 3.364-2.715l2-1.241C9.889 2.622 10.892 2 12 2c1.108 0 2.11.622 4.116 1.867l2 1.241c1.897 1.178 2.846 1.766 3.365 2.715c.519.948.519 2.092.519 4.38v1.522c0 3.9 0 5.851-1.172 7.063C19.657 22 17.771 22 14 22h-4c-3.771 0-5.657 0-6.828-1.212C2 19.576 2 17.626 2 13.725z" />
|
||||
<path strokeLinecap="round" d="M12 15v3" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Submenu1Icon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={size || height}
|
||||
viewBox="0 0 48 48"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<mask id="ipTData0">
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="4"
|
||||
>
|
||||
<path d="M44 11v27c0 3.314-8.954 6-20 6S4 41.314 4 38V11" />
|
||||
<path d="M44 29c0 3.314-8.954 6-20 6S4 32.314 4 29m40-9c0 3.314-8.954 6-20 6S4 23.314 4 20" />
|
||||
<ellipse cx="24" cy="10" fill="#555" rx="20" ry="6" />
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
<path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipTData0)" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Submenu2Icon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={size || height}
|
||||
viewBox="0 0 256 256"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M230.93 220a8 8 0 0 1-6.93 4H32a8 8 0 0 1-6.92-12c15.23-26.33 38.7-45.21 66.09-54.16a72 72 0 1 1 73.66 0c27.39 8.95 50.86 27.83 66.09 54.16a8 8 0 0 1 .01 8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const InfoCircleIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={size || height}
|
||||
viewBox="0 0 1024 1024"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448s448-200.6 448-448S759.4 64 512 64m0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372s372 166.6 372 372s-166.6 372-372 372"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M464 336a48 48 0 1 0 96 0a48 48 0 1 0-96 0m72 112h-48c-4.4 0-8 3.6-8 8v272c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V456c0-4.4-3.6-8-8-8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const MinusCircleIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={size || height}
|
||||
viewBox="0 0 16 16"
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<g fill="currentColor">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16" />
|
||||
<path d="M4 8a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7A.5.5 0 0 1 4 8" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TableIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 22,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={size || height}
|
||||
viewBox="0 0 24 22"
|
||||
width={size || width}
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const ArticleIcon = ({
|
||||
size,
|
||||
height = 20,
|
||||
width = 20,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={size || height}
|
||||
width={size || width}
|
||||
viewBox="0 0 20 20"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5 1a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm0 3h5v1H5zm0 2h5v1H5zm0 2h5v1H5zm10 7H5v-1h10zm0-2H5v-1h10zm0-2H5v-1h10zm0-2h-4V4h4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const MagazineIcon = ({
|
||||
size,
|
||||
height = 20,
|
||||
width = 20,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={size || height}
|
||||
width={size || width}
|
||||
viewBox="0 0 128 128"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="#bdbdbd"
|
||||
d="M-125.7 124.54V11.79c29.36 1.85 58.81 1.91 88.18.19c1.77-.1 3.21 1.08 3.21 2.66v107.04c0 1.58-1.44 2.94-3.21 3.05a727 727 0 0 1-88.18-.19"
|
||||
/>
|
||||
<path
|
||||
fill="#e0e0e0"
|
||||
d="M-125.7 124.54V11.79c27.11-5.31 54.34-8.57 81.45-9.76c1.64-.07 2.96 1.15 2.96 2.73V111.8c0 1.58-1.33 2.9-2.96 2.98c-27.11 1.19-54.34 4.45-81.45 9.76"
|
||||
/>
|
||||
<g fill="#757575">
|
||||
<path d="M-92.84 42.86c-7.46 1-14.91 2.16-22.36 3.47v-3.22c7.45-1.31 14.9-2.47 22.36-3.47zm-12.76-15.72c-3.2.51-6.4 1.04-9.6 1.6v-8.47c3.2-.56 6.4-1.1 9.6-1.6zm12.17-1.78c-3.2.43-6.4.9-9.6 1.39v-8.47c3.2-.49 6.4-.95 9.6-1.39zm12.17-1.52q-4.8.54-9.6 1.17v-8.47q4.8-.63 9.6-1.17zm12.17-1.23c-3.2.29-6.4.61-9.6.95v-8.47c3.2-.35 6.4-.66 9.6-.95zm17.21 5.12a548 548 0 0 0-63.31 7.42v-.81c21.09-3.72 42.23-6.19 63.31-7.42zm-32.67 14.08c-2.21.26-4.41.54-6.62.83v-3.22c2.21-.29 4.41-.57 6.62-.83z" />
|
||||
<path d="M-75.06 40.77c-3.6.37-7.21.77-10.81 1.2v-3.22c3.6-.44 7.21-.84 10.81-1.2zm12.29-1.11l-3.66.3v-3.22l3.66-.3z" />
|
||||
<path d="M-65.38 39.87c-2.47.21-4.95.43-7.42.67v-3.22c2.47-.24 4.95-.46 7.42-.67zm10.32-.76c-2.02.13-4.05.27-6.07.42v-3.22c2.02-.15 4.05-.29 6.07-.42zm-49.89 14.57c-3.42.54-6.83 1.11-10.25 1.71v-3.22c3.41-.6 6.83-1.17 10.25-1.71zm9.51-1.4c-2.63.37-5.26.75-7.89 1.16v-3.22c2.63-.41 5.26-.79 7.89-1.16z" />
|
||||
<path d="M-84.55 50.87c-3.95.47-7.89.98-11.84 1.54v-3.22c3.95-.56 7.89-1.07 11.84-1.54z" />
|
||||
<path d="M-75.06 49.82c-3.6.37-7.21.77-10.81 1.2V47.8c3.6-.44 7.21-.84 10.81-1.2zm12.29-1.1l-3.66.3V45.8l3.66-.3z" />
|
||||
<path d="M-61.13 48.59c-3.89.29-7.78.63-11.67 1.01v-3.22c3.89-.38 7.78-.71 11.67-1.01z" />
|
||||
<path d="M-51.89 47.97c-3.08.18-6.16.39-9.25.62v-3.22c3.08-.23 6.17-.44 9.25-.62zm-49.5 14.22q-6.9 1.035-13.8 2.25v-3.22q6.9-1.215 13.8-2.25z" />
|
||||
<path d="M-95.44 61.33c-2.63.37-5.26.75-7.89 1.16v-3.22c2.63-.41 5.26-.79 7.89-1.16zm10.89-1.4c-2.76.33-5.53.68-8.29 1.05v-3.22c2.76-.37 5.53-.72 8.29-1.05z" />
|
||||
<path d="M-78.26 59.21c-2.54.27-5.07.56-7.61.87v-3.22c2.54-.31 5.07-.6 7.61-.87zm26.37 24.99q-12.075.705-24.18 1.95V55.76q12.09-1.245 24.18-1.95zm-38.55-14.48c-8.25 1.07-16.51 2.34-24.75 3.79v-3.22c8.24-1.45 16.5-2.72 24.75-3.79z" />
|
||||
<path d="M-95.44 70.39c-1.31.18-2.63.37-3.94.56v-3.22c1.31-.19 2.63-.38 3.94-.56zm10.89-1.41c-2.21.26-4.41.54-6.62.83v-3.22c2.21-.29 4.41-.57 6.62-.83z" />
|
||||
<path d="M-78.32 68.28c-2.51.27-5.03.56-7.54.86v-3.22c2.51-.31 5.03-.59 7.54-.86zm-23.07 12.03q-6.9 1.035-13.8 2.25v-3.22q6.9-1.215 13.8-2.25z" />
|
||||
<path d="M-98.16 79.83c-1.72.25-3.44.51-5.17.77v-3.22c1.72-.27 3.44-.52 5.17-.77zm13.61-1.79q-5.445.645-10.89 1.41v-3.22c3.63-.51 7.26-.97 10.89-1.41z" />
|
||||
<path d="M-80.46 77.57c-1.8.2-3.6.41-5.41.63v-3.22c1.8-.22 3.6-.43 5.41-.63zm-16.95 11.21c-5.93.85-11.86 1.79-17.79 2.84V88.4c5.92-1.04 11.85-1.99 17.79-2.84z" />
|
||||
<path d="M-92.54 88.1c-2.28.31-4.56.62-6.84.96v-3.22q3.42-.495 6.84-.96zm7.99-1.01c-1.75.21-3.5.43-5.25.65v-3.22c1.75-.23 3.5-.44 5.25-.65z" />
|
||||
<path d="M-78.32 86.39c-2.51.27-5.03.56-7.54.86v-3.22c2.51-.31 5.03-.59 7.54-.86zm-23.07 12.03q-6.9 1.035-13.8 2.25v-3.22q6.9-1.215 13.8-2.25zm14.22-1.95q-6.105.75-12.21 1.65V94.9q6.105-.9 12.21-1.65z" />
|
||||
<path d="M-84.55 96.15c-2.21.26-4.41.54-6.62.83v-3.22c2.21-.29 4.41-.57 6.62-.83z" />
|
||||
<path d="M-75.06 95.1c-3.6.37-7.21.77-10.81 1.2v-3.22c3.6-.44 7.21-.84 10.81-1.2zm12.29-1.1l-3.66.3v-3.22l3.66-.3zm-5.64.47c-1.46.13-2.93.27-4.39.41v-3.22c1.46-.14 2.93-.28 4.39-.41z" />
|
||||
<path d="M-51.89 93.25c-6 .35-12.01.8-18.01 1.35v-3.22c6.01-.55 12.01-1 18.01-1.35zm-43.56 13.36c-6.59.92-13.17 1.96-19.75 3.11v-3.22c6.58-1.16 13.16-2.2 19.75-3.11zm22.09-2.62c-3.48.34-6.96.72-10.44 1.13v-3.22c3.48-.41 6.96-.78 10.44-1.13zm-13.53 1.5c-2.18.27-4.36.55-6.55.85v-3.22c2.18-.3 4.36-.58 6.55-.85z" />
|
||||
</g>
|
||||
<path
|
||||
fill="#eee"
|
||||
d="M15.71 280.41V170.86h76.08a2.77 2.77 0 0 1 2.77 2.77v104.01a2.77 2.77 0 0 1-2.77 2.77z"
|
||||
/>
|
||||
<g fill="#757575">
|
||||
<path d="M25.53 203.19h20.88v3.13H25.53zm0-22.19h8.96v8.23h-8.96zm11.36 0h8.96v8.23h-8.96zm11.36 0h8.96v8.23h-8.96zm11.36 0h8.96v8.23h-8.96zm-34.08 13.66h59.12v.79H25.53zm22.44 8.53h6.18v3.13h-6.18z" />
|
||||
<path d="M52.92 203.19h10.09v3.13H52.92zm18.14 0h3.42v3.13h-3.42z" />
|
||||
<path d="M65.11 203.19h6.93v3.13h-6.93zm10.9 0h5.67v3.13h-5.67zm-50.48 8.8h9.57v3.13h-9.57zm11.08 0h7.37v3.13h-7.37z" />
|
||||
<path d="M43.1 211.99h11.05v3.13H43.1z" />
|
||||
<path d="M52.92 211.99h10.09v3.13H52.92zm18.14 0h3.42v3.13h-3.42z" />
|
||||
<path d="M65.11 211.99H76v3.13H65.11zm10.9 0h8.64v3.13h-8.64zm-50.48 8.8h12.89v3.13H25.53z" />
|
||||
<path d="M36.61 220.79h7.37v3.13h-7.37zm9.8 0h7.74v3.13h-7.74z" />
|
||||
<path d="M52.92 220.79h7.1v3.13h-7.1zm9.15 0h22.58v29.53H62.07zm-36.54 8.8h23.11v3.13H25.53z" />
|
||||
<path d="M40.3 229.59h3.68v3.13H40.3zm7.67 0h6.18v3.13h-6.18z" />
|
||||
<path d="M52.92 229.59h7.04v3.13h-7.04zm-27.39 8.8h12.89v3.13H25.53z" />
|
||||
<path d="M36.61 238.39h4.82v3.13h-4.82zm7.37 0h10.17v3.13H43.98z" />
|
||||
<path d="M52.92 238.39h5.04v3.13h-5.04zm-27.39 8.79h16.61v3.13H25.53z" />
|
||||
<path d="M40.3 247.18h6.38v3.13H40.3zm8.95 0h4.9v3.13h-4.9z" />
|
||||
<path d="M52.92 247.18h7.04v3.13h-7.04zm-27.39 8.8h12.89v3.13H25.53zm14.77 0h11.39v3.13H40.3z" />
|
||||
<path d="M47.97 255.98h6.18v3.13h-6.18z" />
|
||||
<path d="M52.92 255.98h10.09v3.13H52.92zm18.14 0h3.42v3.13h-3.42zm-5.95 0h4.1v3.13h-4.1z" />
|
||||
<path d="M67.82 255.98h16.82v3.13H67.82zm-42.29 8.8h18.44v3.13H25.53zm29.32 0h9.74v3.13h-9.74zm-9 0h6.11v3.13h-6.11z" />
|
||||
</g>
|
||||
<path
|
||||
fill="#bdbdbd"
|
||||
d="M16.62 124.27V14.04c30.52 2.2 61.18 2.27 91.71.21c1.68-.11 3.05 1.04 3.05 2.58v104.65c0 1.54-1.36 2.89-3.05 3a659 659 0 0 1-91.71-.21"
|
||||
/>
|
||||
<path
|
||||
fill="#e0e0e0"
|
||||
d="M16.62 124.25V14.02C44.36 7.91 72.21 3.9 99.95 2.03c1.53-.1 2.77 1.07 2.77 2.61v104.65c0 1.54-1.24 2.87-2.77 2.97c-27.74 1.87-55.59 5.88-83.33 11.99"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#616161"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="5"
|
||||
d="M28.75 49.34c20.6-4.08 41.25-7 61.84-8.74M28.75 63.23a565 565 0 0 1 26.45-4.6M28.75 77.11a565 565 0 0 1 26.45-4.6m-26.45 33.06c20.6-4.08 41.25-7 61.84-8.74M28.75 91a565 565 0 0 1 26.45-4.6"
|
||||
/>
|
||||
<path
|
||||
fill="#616161"
|
||||
d="M64.86 87.55a560 560 0 0 1 24.67-2.69c1.49-.13 2.69-1.44 2.69-2.94V54.54c0-1.5-1.21-2.61-2.69-2.48c-8.22.71-16.44 1.61-24.67 2.69c-1.49.2-2.7 1.58-2.7 3.07V85.2c.01 1.5 1.21 2.55 2.7 2.35m-34.4-52.14c2.03-.4 4.05-.78 6.08-1.15c1.49-.27 2.69-1.7 2.69-3.2v-7.02c0-1.5-1.21-2.49-2.69-2.22c-2.03.37-4.05.76-6.08 1.15c-1.49.29-2.69 1.75-2.69 3.24v7.02c-.01 1.5 1.2 2.47 2.69 2.18m15.96-2.88c2.03-.34 4.05-.66 6.08-.97c1.49-.23 2.7-1.62 2.7-3.12v-7.02c0-1.5-1.21-2.53-2.7-2.3c-2.03.31-4.06.64-6.08.97c-1.49.25-2.69 1.67-2.69 3.16v7.02c0 1.5 1.2 2.51 2.69 2.26m15.97-2.41c2.03-.28 4.06-.54 6.08-.8c1.49-.19 2.7-1.54 2.7-3.04v-7.02c0-1.5-1.21-2.57-2.7-2.38c-2.03.25-4.06.52-6.08.8c-1.49.2-2.7 1.59-2.7 3.08v7.02c.01 1.5 1.22 2.54 2.7 2.34"
|
||||
/>
|
||||
<path
|
||||
fill="#e0e0e0"
|
||||
d="M374.07 165.73V44.63h92.1a3.06 3.06 0 0 1 3.06 3.06v114.98a3.06 3.06 0 0 1-3.06 3.06z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#616161"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="5"
|
||||
d="M387.48 86.21h68.34m-68.34 15.26h29.23m-29.23 15.26h29.23M387.48 148h68.34m-68.34-16.01h29.23"
|
||||
/>
|
||||
<path
|
||||
fill="#616161"
|
||||
d="M427.38 134.75h27.26c1.64 0 2.98-1.33 2.98-2.98v-30.08c0-1.64-1.33-2.98-2.98-2.98h-27.26c-1.64 0-2.98 1.33-2.98 2.98v30.08a2.987 2.987 0 0 0 2.98 2.98m-38.01-63.47h6.72c1.64 0 2.98-1.33 2.98-2.98v-7.71c0-1.64-1.33-2.98-2.98-2.98h-6.72c-1.64 0-2.98 1.33-2.98 2.98v7.71c0 1.65 1.33 2.98 2.98 2.98m17.64 0h6.72c1.64 0 2.98-1.33 2.98-2.98v-7.71c0-1.64-1.33-2.98-2.98-2.98h-6.72c-1.64 0-2.98 1.33-2.98 2.98v7.71a2.987 2.987 0 0 0 2.98 2.98m17.65 0h6.72c1.64 0 2.98-1.33 2.98-2.98v-7.71c0-1.64-1.33-2.98-2.98-2.98h-6.72c-1.64 0-2.98 1.33-2.98 2.98v7.71c0 1.65 1.33 2.98 2.98 2.98"
|
||||
/>
|
||||
<path
|
||||
fill="#bdbdbd"
|
||||
d="M479.86 165.73V44.63h92.1a3.06 3.06 0 0 1 3.06 3.06v114.98a3.06 3.06 0 0 1-3.06 3.06z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const StaticPageIcon = ({
|
||||
size,
|
||||
height = 20,
|
||||
width = 20,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 2048 2048"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M1755 512h-475V37zm37 128v1408H128V0h1024v640z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const MasterUsersIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 640 512"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M144 0a80 80 0 1 1 0 160a80 80 0 1 1 0-160m368 0a80 80 0 1 1 0 160a80 80 0 1 1 0-160M0 298.7C0 239.8 47.8 192 106.7 192h42.7c15.9 0 31 3.5 44.6 9.7c-1.3 7.2-1.9 14.7-1.9 22.3c0 38.2 16.8 72.5 43.3 96H21.3C9.6 320 0 310.4 0 298.7M405.3 320h-.7c26.6-23.5 43.3-57.8 43.3-96c0-7.6-.7-15-1.9-22.3c13.6-6.3 28.7-9.7 44.6-9.7h42.7c58.9 0 106.7 47.8 106.7 106.7c0 11.8-9.6 21.3-21.3 21.3H405.4zM224 224a96 96 0 1 1 192 0a96 96 0 1 1-192 0m-96 261.3c0-73.6 59.7-133.3 133.3-133.3h117.3c73.7 0 133.4 59.7 133.4 133.3c0 14.7-11.9 26.7-26.7 26.7H154.6c-14.7 0-26.7-11.9-26.7-26.7z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const MasterRoleIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M15 21h-2a2 2 0 0 1 0-4h2v-2h-2a4 4 0 0 0 0 8h2Zm8-2a4 4 0 0 1-4 4h-2v-2h2a2 2 0 0 0 0-4h-2v-2h2a4 4 0 0 1 4 4"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14 18h4v2h-4zm-7 1a6 6 0 0 1 .09-1H3v-1.4c0-2 4-3.1 6-3.1a8.6 8.6 0 0 1 1.35.125A5.95 5.95 0 0 1 13 13h5V4a2.006 2.006 0 0 0-2-2h-4.18a2.988 2.988 0 0 0-5.64 0H2a2.006 2.006 0 0 0-2 2v14a2.006 2.006 0 0 0 2 2h5.09A6 6 0 0 1 7 19M9 2a1 1 0 1 1-1 1a1.003 1.003 0 0 1 1-1m0 4a3 3 0 1 1-3 3a2.996 2.996 0 0 1 3-3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const MasterUserLevelIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 640 512"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M192 256c61.9 0 112-50.1 112-112S253.9 32 192 32S80 82.1 80 144s50.1 112 112 112m76.8 32h-8.3c-20.8 10-43.9 16-68.5 16s-47.6-6-68.5-16h-8.3C51.6 288 0 339.6 0 403.2V432c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48v-28.8c0-63.6-51.6-115.2-115.2-115.2M480 256c53 0 96-43 96-96s-43-96-96-96s-96 43-96 96s43 96 96 96m48 32h-3.8c-13.9 4.8-28.6 8-44.2 8s-30.3-3.2-44.2-8H432c-20.4 0-39.2 5.9-55.7 15.4c24.4 26.3 39.7 61.2 39.7 99.8v38.4c0 2.2-.5 4.3-.6 6.4H592c26.5 0 48-21.5 48-48c0-61.9-50.1-112-112-112"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const MasterCategoryIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 32 32"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14 25h14v2H14zm-6.83 1l-2.58 2.58L6 30l4-4l-4-4l-1.42 1.41zM14 15h14v2H14zm-6.83 1l-2.58 2.58L6 20l4-4l-4-4l-1.42 1.41zM14 5h14v2H14zM7.17 6L4.59 8.58L6 10l4-4l-4-4l-1.42 1.41z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const AddvertiseIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
{...props}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 8q-1.65 0-2.825 1.175T8 12q0 1.125.563 2.075t1.562 1.475q.4.2.563.587t-.013.788q-.175.35-.525.525t-.7 0q-1.575-.75-2.512-2.225T6 12q0-2.5 1.75-4.25T12 6q1.775 0 3.263.938T17.475 9.5q.15.35-.012.7t-.513.5q-.4.175-.8 0t-.6-.575q-.525-1-1.475-1.562T12 8m0-4Q8.65 4 6.325 6.325T4 12q0 3.15 2.075 5.4t5.2 2.55q.425.05.737.375t.288.75t-.313.7t-.712.25q-1.95-.125-3.638-.975t-2.95-2.213t-1.975-3.125T2 12q0-2.075.788-3.9t2.137-3.175T8.1 2.788T12 2q3.925 0 6.838 2.675t3.187 6.6q.05.4-.237.688t-.713.312t-.762-.275t-.388-.725q-.375-3-2.612-5.137T12 4m7.55 17.5l-3.3-3.275l-.75 2.275q-.125.35-.475.338t-.475-.363L12.275 12.9q-.1-.275.125-.5t.5-.125l7.575 2.275q.35.125.363.475t-.338.475l-2.275.75l3.3 3.3q.425.425.425.975t-.425.975t-.987.425t-.988-.425"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const SuggestionsIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
{...props}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17.175 14H7.5q-1.875 0-3.187-1.312T3 9.5t1.313-3.187T7.5 5q.425 0 .713.288T8.5 6t-.288.713T7.5 7q-1.05 0-1.775.725T5 9.5t.725 1.775T7.5 12h9.675L14.3 9.1q-.275-.275-.288-.687T14.3 7.7q.275-.275.7-.275t.7.275l4.6 4.6q.3.3.3.7t-.3.7l-4.6 4.6q-.3.3-.7.288t-.7-.313q-.275-.3-.288-.7t.288-.7z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export const CommentIcon = ({
|
||||
size,
|
||||
height = 24,
|
||||
width = 24,
|
||||
fill = "currentColor",
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
{...props}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6.5 13.5h11v-1h-11zm0-3h11v-1h-11zm0-3h11v-1h-11zM4.616 17q-.691 0-1.153-.462T3 15.385V4.615q0-.69.463-1.153T4.615 3h14.77q.69 0 1.152.462T21 4.615v15.462L17.923 17z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const agents = [
|
||||
{
|
||||
name: "Zamroni",
|
||||
title: "Spv Jaecoo Kelapa Gading",
|
||||
image: "/zamroni.png",
|
||||
},
|
||||
{
|
||||
name: "Murtiyono",
|
||||
title: "Spv Jaecoo Kelapa Gading",
|
||||
image: "/murtiyono.jpg",
|
||||
},
|
||||
{
|
||||
name: "Sutino",
|
||||
title: "Spv Jaecoo Kelapa Gading",
|
||||
image: "/sutino.png",
|
||||
},
|
||||
{
|
||||
name: "Amendra Ismail",
|
||||
title: "Spv Jaecoo Kelapa Gading",
|
||||
image: "/amendra.png",
|
||||
},
|
||||
{
|
||||
name: "Henny",
|
||||
title: "Branch Manager Jaecoo Kelapa Gading",
|
||||
image: "/henny.png",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Agent() {
|
||||
return (
|
||||
<section className="py-16 px-6 md:px-12 bg-[#f9f9f9] text-center mt-0">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-3xl md:text-6xl font-semibold text-gray-900 mb-2"
|
||||
>
|
||||
Agen Unggulan Kami
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-gray-600 mb-10 text-lg"
|
||||
>
|
||||
Temui anggota tim kami yang luar biasa
|
||||
</motion.p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6 justify-center">
|
||||
{agents.map((agent, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
delay: index * 0.2 + 0.3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
className="bg-white shadow-md px-4 py-10 flex flex-col items-center h-[317px]"
|
||||
>
|
||||
<div className="relative w-36 h-40 mb-14">
|
||||
<Image
|
||||
src={agent.image}
|
||||
alt={agent.name}
|
||||
fill
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-2xl text-gray-900">{agent.name}</h3>
|
||||
<p className="text-xs text-gray-600 text-center mt-1">
|
||||
{agent.title}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: "REAR BUMPER DESIGN",
|
||||
description:
|
||||
"Featuring refined lines and bold contours, the rear bumper enhances the vehicle’s sporty and stylish character.",
|
||||
image: "/pj7-1.png",
|
||||
},
|
||||
{
|
||||
title: "PANORAMIC SUNROOF",
|
||||
description:
|
||||
"The Panoramic Sunroof transforms your journey, creating a brighter and more spacious atmosphere.",
|
||||
image: "/pj7-2.png",
|
||||
},
|
||||
{
|
||||
title: "REAR BUMPER DESIGN",
|
||||
description:
|
||||
"Featuring refined lines and bold contours, the rear bumper enhances the vehicle’s sporty and stylish character.",
|
||||
image: "/pj7-3.png",
|
||||
},
|
||||
{
|
||||
title: "NATURAL DAYLIGHT LED LIGHTING SYSTEM",
|
||||
description:
|
||||
"Illuminate your journey with the Natural Daylight LED Lighting System, designed to mimic the clarity and warmth of sunlight.",
|
||||
image: "/pj7-4.png",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Exterior() {
|
||||
const [ref, inView] = useInView({ triggerOnce: true, threshold: 0.2 });
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
setShow(true);
|
||||
}
|
||||
}, [inView]);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
className="py-10 px-4 sm:px-6 md:px-20 bg-white overflow-hidden"
|
||||
>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={show ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-2xl mt-5 mb-8"
|
||||
>
|
||||
<span className="text-[#1F6779] font-semibold">Jaecoo 7 AWD</span>{" "}
|
||||
Teknologi dan Exterior
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={show ? { opacity: 1, scale: 1 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative w-full h-[300px] sm:h-[400px] md:h-[600px]"
|
||||
>
|
||||
<Image
|
||||
src="/jj7-awd.png"
|
||||
alt="Aluminium Chassis"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
/>
|
||||
<div className="absolute bottom-6 left-3 sm:left-3 md:left-6 max-w-5xl">
|
||||
<h2 className="text-xl sm:text-xl font-semibold text-white">
|
||||
ALUMINIUM CHASSIS
|
||||
</h2>
|
||||
<p className="text-xs sm:text-xs mt-2 text-gray-200">
|
||||
Built with a lightweight aluminum chassis, offering enhanced
|
||||
strength, durability, and improved performance for a superior
|
||||
driving experience.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={show ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ delay: 0.2, duration: 0.8 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-2 sm:gap-4 mt-5"
|
||||
>
|
||||
{features.map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="relative aspect-[4/3] overflow-hidden group"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
sizes="(max-width: 768px) 100vw, 25vw"
|
||||
/>
|
||||
<div className="absolute bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||
<h3 className="text-sm sm:text-base font-bold text-white">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-gray-300 mt-1">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
export default function FeaturesAndSpecifications() {
|
||||
return (
|
||||
<section className="pt-10 px-4 sm:px-6 md:px-20 bg-white">
|
||||
<h2 className="text-2xl mt-5 mb-8">
|
||||
<span className="text-[#1F6779] font-semibold">Jaecoo 7 AWD</span> Fitur
|
||||
</h2>
|
||||
|
||||
<div className="relative w-full h-[300px] sm:h-[400px] md:h-[600px]">
|
||||
<Image
|
||||
src="/fitur1.png"
|
||||
alt="Aluminium Chassis"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
/>
|
||||
<div className="absolute bottom-26 left-3 sm:left-10 md:left-26 max-w-xs bg-white/60 rounded-lg p-4">
|
||||
<h2 className="text-xl sm:text-sm font-semibold text-black">
|
||||
Lane Changing Assistance
|
||||
</h2>
|
||||
<p className="text-xs sm:text-xs mt-2 text-black">
|
||||
Advanced safety feature that monitors surrounding traffic and
|
||||
provides alerts or steering support to help ensure sasfe and
|
||||
confident lane changes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full h-[300px] sm:h-[400px] md:h-[600px] my-20">
|
||||
<Image
|
||||
src="/fitur2.png"
|
||||
alt="Aluminium Chassis"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl mt-5 mb-8">
|
||||
<span className="text-[#1F6779] font-semibold">Jaecoo 7 AWD</span>{" "}
|
||||
Spesifikasi
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-x-32 gap-y-6 text-sm sm:text-base text-start my-10">
|
||||
<div>
|
||||
<p className="text-gray-500">Max Power</p>
|
||||
<p className="font-bold">136,5kw/183hp</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Power Train</p>
|
||||
<p className="font-bold">1.6T+7DHT</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Torque</p>
|
||||
<p className="font-bold">275N.m</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Sensor</p>
|
||||
<p className="font-bold">8 Sensor</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Max Speed</p>
|
||||
<p className="font-bold">180km/h</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">0-100km-h</p>
|
||||
<p className="font-bold">9,2s</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Airbag</p>
|
||||
<p className="font-bold">8 airbags</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">ADAS</p>
|
||||
<p className="font-bold">19 adas</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 pt-10 ">
|
||||
<div className="text-start px-4 border-l border-gray-300">
|
||||
<div className="mb-14 ml-3">
|
||||
<div className="text-4xl mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g fill="none">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
// stroke-linejoin="round"
|
||||
// stroke-width="2"
|
||||
d="M12 13v7.098c0 .399 0 .598-.129.67c-.129.071-.298-.035-.636-.246L4.94 16.588c-.46-.288-.69-.431-.815-.658C4 15.705 4 15.434 4 14.893V8m8 5L4 8m8 5l5.286-3.304c1.218-.761 1.827-1.142 1.827-1.696s-.609-.935-1.827-1.696L13.06 3.663c-.516-.323-.773-.484-1.06-.484s-.544.161-1.06.484L4 8"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19 12a1 1 0 1 0 2 0zm.875-3.93L19 8.553zM19 9.107V12h2V9.108zm.59-2.544l-3.06-1.912l-1.06 1.696l3.06 1.913zM21 9.109c0-.252.001-.51-.02-.733a2 2 0 0 0-.23-.79l-1.75.97c-.027-.05-.02-.073-.011.01c.01.106.011.254.011.543zm-2.47-.848c.246.154.37.233.454.298c.067.05.043.045.016-.004l1.75-.97a2 2 0 0 0-.549-.614c-.177-.136-.397-.272-.611-.405z"
|
||||
/>
|
||||
<circle
|
||||
cx="17.5"
|
||||
cy="16.5"
|
||||
r="2.5"
|
||||
stroke="currentColor"
|
||||
// stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
// stroke-linecap="round"
|
||||
// stroke-width="2"
|
||||
d="m21 20l-1.5-1.5"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14.53 20.598a1 1 0 0 0-1.06-1.696zM11 20.375l-.53.848zm.937.444l-.063.998zm.126 0L12 19.82zm-.533-1.292l-3-1.875l-1.06 1.696l3 1.875zm1.94-.625l-.5.313l1.06 1.695l.5-.312zm-.5.313l-.5.312l1.06 1.696l.5-.312zm-2.5 2.008c.213.133.429.27.625.368c.214.108.47.206.779.226L12 19.82c.056.003.072.022-.005-.016a7 7 0 0 1-.465-.278zm2-1.696a7 7 0 0 1-.465.278c-.077.038-.061.02-.005.016l.126 1.996c.31-.02.565-.118.779-.226c.196-.099.412-.235.625-.368zm-.596 2.29q.126.008.252 0L12 19.82z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold mb-1"> {">"}TERSEDIA DALAM STOK</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Jelajahi pilihan hebat kami dari mobil Jaecoo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-start px-4 border-l border-r border-gray-300">
|
||||
<div className="mb-14 ml-3">
|
||||
<div className="text-2xl mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7L4 8v10h16V8zm0-2l8-5H4zM4 8V6v12z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold mb-1">{">"}BERITAHU SAYA</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Daftar untuk semua berita terbaru dari Jaecoo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-start px-4 border-r border-gray-300">
|
||||
<div className="mb-14">
|
||||
<div className="text-2xl mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
// fill-rule="evenodd"
|
||||
d="M256 42.667c117.821 0 213.334 95.513 213.334 213.333c0 117.821-95.513 213.334-213.334 213.334c-117.82 0-213.333-95.513-213.333-213.334C42.667 138.18 138.18 42.667 256 42.667M85.334 256c0 87.032 65.145 158.848 149.332 169.346V316.358c-21.87-7.73-38.283-27.01-41.913-50.51L85.636 245.762q-.301 5.081-.302 10.238m341.031-10.238l-107.118 20.086c-3.629 23.5-20.043 42.78-41.913 50.51v108.988C361.523 414.848 426.668 343.032 426.668 256q-.001-5.156-.302-10.238M256 85.334c-76.056 0-140.493 49.75-162.541 118.484l107.16 20.085C211.699 204.827 232.352 192 256 192c23.65 0 44.302 12.827 55.382 31.903l107.16-20.085C396.493 135.084 332.057 85.334 256 85.334"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold mb-1">{">"}PESAN TEST DRIVE</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Atur test drive di jalan melalui Dealer terdekat kami
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import Image from "next/image";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-black text-[#c7dbe3] px-6 md:px-20 py-16">
|
||||
<div className="flex flex-col md:flex-row gap-10">
|
||||
<div className="w-full md:w-4/12">
|
||||
<Image
|
||||
src="/masjaecoo.png"
|
||||
alt="Jaecoo"
|
||||
width={300}
|
||||
height={200}
|
||||
className="ml-4"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 mt-6 ml-24 md:ml-20 text-xl text-[#c7dbe3]">
|
||||
<div className="hover:text-white cursor-pointer">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
// stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
// stroke-linecap="round"
|
||||
// stroke-linejoin="round"
|
||||
d="M12 16a4 4 0 1 0 0-8a4 4 0 0 0 0 8"
|
||||
/>
|
||||
<path d="M3 16V8a5 5 0 0 1 5-5h8a5 5 0 0 1 5 5v8a5 5 0 0 1-5 5H8a5 5 0 0 1-5-5Z" />
|
||||
<path
|
||||
// stroke-linecap="round"
|
||||
// stroke-linejoin="round"
|
||||
d="m17.5 6.51l.01-.011"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="hover:text-white cursor-pointer">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14 13.5h2.5l1-4H14v-2c0-1.03 0-2 2-2h1.5V2.14c-.326-.043-1.557-.14-2.857-.14C11.928 2 10 3.657 10 6.7v2.8H7v4h3V22h4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="hover:text-white cursor-pointer">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16 8.245V15.5a6.5 6.5 0 1 1-5-6.326v3.163a3.5 3.5 0 1 0 2 3.163V2h3a5 5 0 0 0 5 5v3a7.97 7.97 0 0 1-5-1.755"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="hover:text-white cursor-pointer">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.244 4c.534.003 1.87.016 3.29.073l.504.022c1.429.067 2.857.183 3.566.38c.945.266 1.687 1.04 1.938 2.022c.4 1.56.45 4.602.456 5.339l.001.152v.174c-.007.737-.057 3.78-.457 5.339c-.254.985-.997 1.76-1.938 2.022c-.709.197-2.137.313-3.566.38l-.504.023c-1.42.056-2.756.07-3.29.072l-.235.001h-.255c-1.13-.007-5.856-.058-7.36-.476c-.944-.266-1.687-1.04-1.938-2.022c-.4-1.56-.45-4.602-.456-5.339v-.326c.006-.737.056-3.78.456-5.339c.254-.985.997-1.76 1.939-2.021c1.503-.419 6.23-.47 7.36-.476zM9.999 8.5v7l6-3.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:w-8/12 ">
|
||||
<div className="flex flex-wrap md:flex-row gap-10 md:gap-28">
|
||||
<div>
|
||||
<h4 className="font-semibold text-white mb-4">ABOUT</h4>
|
||||
<ul className="space-y-4 text-sm">
|
||||
<li>
|
||||
<a href="#">Partnership</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">Terms of Use</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">Privacy</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-white mb-4">PRODUCT</h4>
|
||||
<ul className="space-y-4 text-sm">
|
||||
<li>
|
||||
<a href="#">About</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">Features</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">Support</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-white mb-4">RESOURCES</h4>
|
||||
<ul className="space-y-4 text-sm">
|
||||
<li>
|
||||
<a href="#">Career</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">Blog</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">Legal</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-white mb-4">CONTACT</h4>
|
||||
<ul className="space-y-4 text-sm">
|
||||
<li>
|
||||
<a href="https://jaecoo.com" target="_blank" rel="noreferrer">
|
||||
jaecoo.com
|
||||
</a>
|
||||
</li>
|
||||
<li>0851-1234-567</li>
|
||||
<li>
|
||||
<p className="font-semibold text-white">
|
||||
Jaecoo Kelapa Gading
|
||||
</p>
|
||||
<p>
|
||||
Jl. Boulevard Raya Blok LA 6 No. 34-35,
|
||||
<br />
|
||||
Kelapa Gading, Jakarta Utara 14240
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const images = ["/g1.png", "/g2.png", "/g3.png", "/g4.png"];
|
||||
|
||||
export default function Galeri() {
|
||||
const [consent, setConsent] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="px-4 py-12 md:px-20 bg-white">
|
||||
<motion.h2
|
||||
className="text-3xl font-bold mb-8 text-black"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
Galeri Kami
|
||||
</motion.h2>
|
||||
|
||||
<div className="flex flex-col gap-8 mb-16">
|
||||
<div className="flex justify-start gap-8 flex-wrap">
|
||||
{[images[0], images[1]].map((src, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="relative w-[400px] h-[250px] overflow-hidden"
|
||||
initial={{ opacity: 0, x: -40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={`Galeri ${index + 1}`}
|
||||
fill
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-8 flex-wrap">
|
||||
{[images[2], images[3]].map((src, index) => (
|
||||
<motion.div
|
||||
key={index + 2}
|
||||
className="relative w-[400px] h-[250px] overflow-hidden"
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={`Galeri ${index + 3}`}
|
||||
fill
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function HeaderAbout() {
|
||||
return (
|
||||
<section className="py-10 px-4 sm:px-6 md:px-10 bg-white">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<motion.div
|
||||
className="relative w-full h-[300px] sm:h-[400px] md:h-[640px] overflow-hidden "
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<Image
|
||||
src="/about-i.png"
|
||||
alt="about-header"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 640px"
|
||||
priority
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col lg:flex-row gap-10 mt-10">
|
||||
<motion.div
|
||||
className="w-full lg:w-6/12 space-y-6 sm:ml-15"
|
||||
initial={{ opacity: 0, x: -40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-black">
|
||||
Mengenal Lebih Dekat Dealer Resmi Jaecoo
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg sm:text-xl font-semibold text-gray-800 mb-2">
|
||||
Deskripsi Singkat
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed text-base">
|
||||
Dealer resmi Jaecoo sejak 2023, berlokasi di pusat Bandung. Kami
|
||||
melayani penjualan, servis, serta test drive dengan fasilitas
|
||||
showroom modern dan teknisi bersertifikat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-gray-700 text-sm space-y-2">
|
||||
<p>
|
||||
<strong>Alamat:</strong> Jaecoo Cihampelas Bandung, Jl. Cihampelas
|
||||
No. 264-268, Bandung, Jawa Barat 40131
|
||||
</p>
|
||||
<p>
|
||||
<strong>Telepon:</strong> 021-12345678
|
||||
</p>
|
||||
<p>
|
||||
<strong>Email:</strong> info@dealerjaecoo.id
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="relative w-full lg:w-[536px] h-[300px] sm:h-[400px] lg:h-[576px] overflow-hidden"
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<Image
|
||||
src="/dealer.png"
|
||||
alt="Dealer Jaecoo"
|
||||
fill
|
||||
className="object-cover object-center"
|
||||
sizes="(max-width: 768px) 100vw, 536px"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function HeaderItems() {
|
||||
return (
|
||||
<section className="py-10 px-6 md:px-10 bg-white">
|
||||
<motion.div
|
||||
className="flex flex-col items-center gap-10"
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<div className="relative w-full h-[640px] overflow-hidden p-5">
|
||||
<Image
|
||||
src={"/service.png"}
|
||||
alt="Service Header"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Button } from "../ui/button";
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HeaderProduct() {
|
||||
return (
|
||||
<>
|
||||
<section className="py-10 px-4 sm:px-6 md:px-10 bg-white">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="flex flex-col items-center gap-6"
|
||||
>
|
||||
<div className="relative w-full h-[300px] sm:h-[400px] md:h-[640px] overflow-hidden">
|
||||
<Image
|
||||
src="/product1.jpg"
|
||||
alt="about-header"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 640px"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.6 }}
|
||||
className="flex flex-col md:flex-row justify-between items-center my-8"
|
||||
>
|
||||
<p className="text-lg md:text-5xl font-bold">JAECOO 7 AWD</p>
|
||||
<div className="flex items-center mt-2 md:mt-0 gap-3">
|
||||
<a
|
||||
href="https://drive.google.com/file/d/1Nici3bdjUG524sUYQgHfbeO63xW6f1_o/view"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button className="bg-white text-black border w-[100px] h-[30px] md:w-[200px] md:h-[40px] rounded-xl hover:bg-amber-50 hover:cursor-pointer">
|
||||
BROSUR
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<Link href={"/about"}>
|
||||
<Button className="bg-[#1F6779] h-[30px] md:w-[200px] md:h-[40px] rounded-xl hover:cursor-pointer">
|
||||
TEST DRIVE
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 0.8 }}
|
||||
className="relative w-full h-[300px] sm:h-[400px] md:h-[640px] overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
src="/color.png"
|
||||
alt="about-header"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 640px"
|
||||
priority
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -40 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.6 }}
|
||||
className="absolute top-1/2 left-5 md:left-56 transform -translate-y-1/2 flex flex-col gap-4 z-10"
|
||||
>
|
||||
{[
|
||||
"linear-gradient(to bottom, #C9CCD3, #FFFFFF)",
|
||||
"linear-gradient(to bottom, #788662, #C9CCD3)",
|
||||
"linear-gradient(to bottom, #1A1A1A, #3A3A3A)",
|
||||
"linear-gradient(to bottom, #E1ECF4, #FFFFFF)",
|
||||
].map((bg, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="w-6 h-6 rounded-full border border-white shadow-md hover:cursor-pointer"
|
||||
style={{ background: bg }}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "@/components/ui/carousel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
|
||||
const heroImages = ["/Hero.png", "/hero2.png", "/hero3.png"];
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<section className="relative w-full overflow-hidden bg-white">
|
||||
<Carousel className="w-full relative">
|
||||
<CarouselContent>
|
||||
{heroImages.map((img, index) => (
|
||||
<CarouselItem key={index}>
|
||||
<div className="relative w-full h-[810px]">
|
||||
<Image
|
||||
src={img}
|
||||
alt={`JAECOO Image ${index + 1}`}
|
||||
width={1400}
|
||||
height={810}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
|
||||
{index === 0 && (
|
||||
<div className="absolute inset-0 flex flex-col justify-center items-start px-6 md:px-28 z-10">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
className="text-3xl sm:text-4xl md:text-5xl font-bold text-black mb-4"
|
||||
>
|
||||
JAECOO J7 AWD
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.9,
|
||||
ease: "easeOut",
|
||||
delay: 0.2,
|
||||
}}
|
||||
className="text-lg text-black mb-6"
|
||||
>
|
||||
DELICATE OFF-ROAD SUV
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="flex items-center gap-4"
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut", delay: 0.4 }}
|
||||
>
|
||||
<Link href={"/about"}>
|
||||
<Button className="rounded-full bg-[#155B6E] px-6 py-2 text-white hover:cursor-pointer">
|
||||
TEST DRIVE
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={"/product"}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full border-black text-black px-6 py-2 hover:cursor-pointer hover:bg-amber-50"
|
||||
>
|
||||
JELAJAHI
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CarouselPrevious className="absolute left-6 top-1/2 -translate-y-1/2 z-20 border border-[#155B6E] text-[#155B6E] bg-white" />
|
||||
<CarouselNext className="absolute right-6 top-1/2 -translate-y-1/2 z-20 border border-[#155B6E] text-[#155B6E] bg-white" />
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import Image from "next/image";
|
||||
|
||||
export default function Help() {
|
||||
return (
|
||||
<section className="max-w-[1400px] mx-auto bg-white pt-16 px-4 sm:px-6 lg:px-10">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-center mb-6">
|
||||
Need More Help ?
|
||||
</h2>
|
||||
<h2 className="text-2xl sm:text-xl text-center mb-6 text-[#007BAC]">
|
||||
Just Call Tiara
|
||||
</h2>
|
||||
<div className="w-full mb-10 flex justify-center">
|
||||
<Image
|
||||
src="/tiara.png"
|
||||
alt="Lokasi Servis Terdekat"
|
||||
width={500}
|
||||
height={500}
|
||||
className="w-[600px] object-cover rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 pt-10">
|
||||
<div className="text-start px-4 border-l border-gray-300">
|
||||
<div className="mb-14 ml-3">
|
||||
<div className="text-4xl mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g fill="none">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
// stroke-linejoin="round"
|
||||
// stroke-width="2"
|
||||
d="M12 13v7.098c0 .399 0 .598-.129.67c-.129.071-.298-.035-.636-.246L4.94 16.588c-.46-.288-.69-.431-.815-.658C4 15.705 4 15.434 4 14.893V8m8 5L4 8m8 5l5.286-3.304c1.218-.761 1.827-1.142 1.827-1.696s-.609-.935-1.827-1.696L13.06 3.663c-.516-.323-.773-.484-1.06-.484s-.544.161-1.06.484L4 8"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19 12a1 1 0 1 0 2 0zm.875-3.93L19 8.553zM19 9.107V12h2V9.108zm.59-2.544l-3.06-1.912l-1.06 1.696l3.06 1.913zM21 9.109c0-.252.001-.51-.02-.733a2 2 0 0 0-.23-.79l-1.75.97c-.027-.05-.02-.073-.011.01c.01.106.011.254.011.543zm-2.47-.848c.246.154.37.233.454.298c.067.05.043.045.016-.004l1.75-.97a2 2 0 0 0-.549-.614c-.177-.136-.397-.272-.611-.405z"
|
||||
/>
|
||||
<circle
|
||||
cx="17.5"
|
||||
cy="16.5"
|
||||
r="2.5"
|
||||
stroke="currentColor"
|
||||
// stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
// stroke-linecap="round"
|
||||
// stroke-width="2"
|
||||
d="m21 20l-1.5-1.5"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14.53 20.598a1 1 0 0 0-1.06-1.696zM11 20.375l-.53.848zm.937.444l-.063.998zm.126 0L12 19.82zm-.533-1.292l-3-1.875l-1.06 1.696l3 1.875zm1.94-.625l-.5.313l1.06 1.695l.5-.312zm-.5.313l-.5.312l1.06 1.696l.5-.312zm-2.5 2.008c.213.133.429.27.625.368c.214.108.47.206.779.226L12 19.82c.056.003.072.022-.005-.016a7 7 0 0 1-.465-.278zm2-1.696a7 7 0 0 1-.465.278c-.077.038-.061.02-.005.016l.126 1.996c.31-.02.565-.118.779-.226c.196-.099.412-.235.625-.368zm-.596 2.29q.126.008.252 0L12 19.82z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold mb-1"> {">"}TERSEDIA DALAM STOK</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Jelajahi pilihan hebat kami dari mobil Jaecoo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-start px-4 border-l border-r border-gray-300">
|
||||
<div className="mb-14 ml-3">
|
||||
<div className="text-2xl mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7L4 8v10h16V8zm0-2l8-5H4zM4 8V6v12z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold mb-1">{">"}BERITAHU SAYA</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Daftar untuk semua berita terbaru dari Jaecoo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-start px-4 border-r border-gray-300">
|
||||
<div className="mb-14">
|
||||
<div className="text-2xl mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
// fill-rule="evenodd"
|
||||
d="M256 42.667c117.821 0 213.334 95.513 213.334 213.333c0 117.821-95.513 213.334-213.334 213.334c-117.82 0-213.333-95.513-213.333-213.334C42.667 138.18 138.18 42.667 256 42.667M85.334 256c0 87.032 65.145 158.848 149.332 169.346V316.358c-21.87-7.73-38.283-27.01-41.913-50.51L85.636 245.762q-.301 5.081-.302 10.238m341.031-10.238l-107.118 20.086c-3.629 23.5-20.043 42.78-41.913 50.51v108.988C361.523 414.848 426.668 343.032 426.668 256q-.001-5.156-.302-10.238M256 85.334c-76.056 0-140.493 49.75-162.541 118.484l107.16 20.085C211.699 204.827 232.352 192 256 192c23.65 0 44.302 12.827 55.382 31.903l107.16-20.085C396.493 135.084 332.057 85.334 256 85.334"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold mb-1">{">"}PESAN TEST DRIVE</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Atur test drive di jalan melalui Dealer terdekat kami
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: "14.8 Screen with APPLE Carplay & Android Auto",
|
||||
description:
|
||||
"Stay connected and informed with a 14.8 display offering clear visuals and advanced functionality for a seamless driving experience.",
|
||||
image: "/interior-2.png",
|
||||
},
|
||||
{
|
||||
title: "Windshield Heads-Up Display (W-HUD)",
|
||||
description:
|
||||
"Stay informed and focused on the road with the adjustable W-HUD, tailored to align perfectly with your unique sitting position for optimal driving comfort and safety.",
|
||||
image: "/interior-3.png",
|
||||
},
|
||||
{
|
||||
title: "540 degree HD video",
|
||||
description:
|
||||
"The 540-degree HD video system provides comprehensive coverage, ensuring nothing escapes your view.",
|
||||
image: "/interior-4.png",
|
||||
},
|
||||
{
|
||||
title: "Wireless Charging",
|
||||
description:
|
||||
"Stay powered up on the go with Wireless Charging, ensuring your devices are always ready when you are.",
|
||||
image: "/interior-5.png",
|
||||
},
|
||||
];
|
||||
|
||||
const interior = [
|
||||
{
|
||||
title: "Aircraft-style Gear Shift",
|
||||
description:
|
||||
"A seamless blend of style and performance with the Avantgrade Fighter-Inspired Transmission Shifter.",
|
||||
image: "/interior-6.png",
|
||||
},
|
||||
{
|
||||
title: "Heated/Ventilated Seats",
|
||||
description:
|
||||
"Enjoy ultimate comfort with synthetic leather seats featuring heating and ventilation, designed for a luxurious and adaptable driving experience.",
|
||||
image: "/interior-7.png",
|
||||
},
|
||||
{
|
||||
title: "Sony 8-Speaker Audio system",
|
||||
description:
|
||||
"Immerse yourself in rich, high-quality sound with the Sony 8-speaker audio system, delivering an exceptional listening experience on every journey.",
|
||||
image: "/interior-8.png",
|
||||
},
|
||||
];
|
||||
export default function Interior() {
|
||||
const { ref, inView } = useInView({ triggerOnce: true, threshold: 0.2 });
|
||||
|
||||
return (
|
||||
<section className="py-10 px-4 sm:px-6 md:px-20 bg-white" ref={ref}>
|
||||
<motion.h2
|
||||
className="text-2xl mt-5 mb-8"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<span className="text-[#1F6779] font-semibold">Jaecoo 7 AWD</span>{" "}
|
||||
Interior
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
className="relative w-full h-[300px] sm:h-[400px] md:h-[600px]"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={inView ? { opacity: 1, scale: 1 } : {}}
|
||||
transition={{ duration: 0.7 }}
|
||||
>
|
||||
<Image
|
||||
src="/interior-1.png"
|
||||
alt="Interior Hero"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-2 sm:gap-4 mt-5">
|
||||
{features.map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="relative aspect-[4/3] overflow-hidden group"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||
>
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
sizes="(max-width: 768px) 100vw, 25vw"
|
||||
/>
|
||||
<div className="absolute bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||
<h3 className="text-sm sm:text-base font-bold text-white">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-gray-300 mt-1">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 sm:gap-4 mt-5">
|
||||
{interior.map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="relative aspect-[2/1] overflow-hidden group"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||
>
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
sizes="(max-width: 768px) 100vw, 25vw"
|
||||
/>
|
||||
<div className="absolute bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||
<h3 className="text-sm sm:text-base font-bold text-white">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-gray-300 mt-1">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
|
||||
const items = [
|
||||
{
|
||||
image: "/new-car2.png",
|
||||
title: "JAECOO J7 AWD",
|
||||
description: "DELICATE OFF-ROAD SUV",
|
||||
},
|
||||
{
|
||||
image: "/new-car1.png",
|
||||
title: "JAECOO J7 SHS",
|
||||
description: "SUPER HYBRID SYSTEM = SUPER HEV + EV",
|
||||
},
|
||||
{
|
||||
image: "/new-car3.png",
|
||||
title: "JAECOO J8 AWD",
|
||||
description: "FIRST CLASS OFF-ROAD",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Items() {
|
||||
return (
|
||||
<section className="py-10 px-4 sm:px-6 md:px-10 bg-white">
|
||||
<div className="flex flex-col items-center gap-10">
|
||||
{items.map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.7,
|
||||
delay: index * 0.2,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="relative w-full min-h-[400px] sm:min-h-[500px] md:min-h-[600px] overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 768px"
|
||||
priority
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 z-10 flex flex-col justify-between">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.25 + 0.3, duration: 0.5 }}
|
||||
className="mt-3 ml-3 font-semibold text-white text-sm sm:text-xl px-4 py-2 rounded-lg max-w-[80%]"
|
||||
>
|
||||
{item.description}
|
||||
</motion.div>
|
||||
|
||||
<div className="flex flex-col items-center pb-8 bg-gradient-to-t to-transparent">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.25 + 0.4, duration: 0.6 }}
|
||||
className="text-2xl sm:text-3xl md:text-2xl font-semibold text-white mb-4 text-center"
|
||||
>
|
||||
{item.title}
|
||||
</motion.h1>
|
||||
<motion.div
|
||||
className="flex items-center gap-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.25 + 0.6, duration: 0.6 }}
|
||||
>
|
||||
<Link href="/about">
|
||||
<Button className="rounded-full bg-[#155B6E] px-6 py-2 text-white hover:cursor-pointer">
|
||||
TEST DRIVE
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/product">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full border-white text-black px-6 py-2 hover:text-black hover:bg-amber-50 hover:border-white hover:cursor-pointer"
|
||||
>
|
||||
EXPLORE
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function FormJaecoo() {
|
||||
const [consent, setConsent] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="px-4 py-12 md:px-20 bg-white">
|
||||
<div className="max-w-full mx-auto">
|
||||
<h2 className="text-3xl font-bold mb-8 text-black">
|
||||
Get In Touch With Us
|
||||
</h2>
|
||||
<form className="space-y-6">
|
||||
<div>
|
||||
<Label htmlFor="name" className="mb-2">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Type your name here…"
|
||||
className="w-full h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email" className="mb-2">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Rachel@domain.com"
|
||||
className="w-full h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone" className="mb-2">
|
||||
Mobile Number
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="+62 8xxxx"
|
||||
className="w-full h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="city" className="mb-2 block">
|
||||
City
|
||||
</Label>
|
||||
<Select>
|
||||
<SelectTrigger className="w-full h-12 text-base">
|
||||
<SelectValue placeholder="Select city" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="jakarta">Jakarta</SelectItem>
|
||||
<SelectItem value="bandung">Bandung</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="categories" className="mb-2">
|
||||
Categories
|
||||
</Label>
|
||||
<Select>
|
||||
<SelectTrigger className="w-full h-12 text-base">
|
||||
<SelectValue placeholder="Select categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ev">Electric Vehicle</SelectItem>
|
||||
<SelectItem value="suv">SUV</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="series" className="mb-2">
|
||||
Product Series
|
||||
</Label>
|
||||
<Select>
|
||||
<SelectTrigger className="w-full h-12 text-base">
|
||||
<SelectValue placeholder="Select product series" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="e100">E100</SelectItem>
|
||||
<SelectItem value="e200">E200</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="showroom" className="mb-2">
|
||||
Showroom
|
||||
</Label>
|
||||
<Select>
|
||||
<SelectTrigger className="w-full h-12 text-base">
|
||||
<SelectValue placeholder="Select showroom" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="jakarta">Jakarta</SelectItem>
|
||||
<SelectItem value="bandung">Bandung</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="subject" className="mb-2">
|
||||
Subject
|
||||
</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
placeholder="Type your subject here…"
|
||||
className="w-full h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="message" className="mb-2">
|
||||
Message
|
||||
</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
placeholder="Type your query here…"
|
||||
className="w-full h-32 resize-none text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="consent"
|
||||
checked={consent}
|
||||
onCheckedChange={() => setConsent(!consent)}
|
||||
/>
|
||||
<label htmlFor="consent" className="text-sm text-gray-700">
|
||||
By providing your information, you consent to the collection, use
|
||||
and disclosure of your personal data by PT. Inchcape Indomobil
|
||||
Energi Baru and our trusted third parties (our related
|
||||
corporations and affiliates, selected partners, service providers,
|
||||
agents and other Inchcape Indomobil Energi Baru companies) in
|
||||
accordance with the purposes set out in our privacy policy.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
className="bg-[#008bcf] hover:bg-[#0072a8] w-[250px] h-[50px] rounded-full text-white"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
const locations = [
|
||||
{
|
||||
name: "Jaecoo 1S Kelapa Gading",
|
||||
address:
|
||||
"Jl. Boulevard Raya Blok LA 6 No. 34-35, Kelapa Gading, Jakarta Utara 14240",
|
||||
region: "Kelapa Gading, Jakarta",
|
||||
image: "/loc1.png",
|
||||
},
|
||||
{
|
||||
name: "Jaecoo Cihampelas Bandung",
|
||||
address: "Jl. Cihampelas No. 264-268, Bandung, Jawa Barat 40131",
|
||||
region: "Cihampelas, Bandung",
|
||||
image: "/loc2.png",
|
||||
},
|
||||
{
|
||||
name: "Jaecoo 2S Kelapa Gading",
|
||||
address: "Jl. Pegangsaan Dua No.17 B, Kelapa Gading, Jakarta Utara 14250",
|
||||
region: "Kelapa Gading, Jakarta",
|
||||
image: "/loc3.png",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Location() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const perPage = 3;
|
||||
|
||||
const totalPages = Math.ceil(locations.length / perPage);
|
||||
const paginated = locations.slice(
|
||||
(currentPage - 1) * perPage,
|
||||
currentPage * perPage
|
||||
);
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentPage > 1) setCurrentPage(currentPage - 1);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentPage < totalPages) setCurrentPage(currentPage + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="max-w-7xl mx-auto py-16 px-6 md:px-12 bg-white">
|
||||
<div className="flex flex-col md:flex-row mx-2 items-center justify-between mb-10">
|
||||
<h2 className="text-2xl md:text-6xl font-bold text-gray-900 text-center mb-2 md:md-0">
|
||||
Cari Store Lain
|
||||
</h2>
|
||||
<div className="flex flex-row gap-3">
|
||||
<div className=" max-w-xl">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari lokasi..."
|
||||
className="w-full py-3 pl-5 pr-20 border border-gray-300 rounded-full text-gray-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>{" "}
|
||||
<button className=" bg-[#00696e] text-white px-6 py-2 rounded-full font-medium">
|
||||
Cari
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{paginated.map((store, index) => (
|
||||
<div key={index} className="bg-white shadow-md overflow-hidden">
|
||||
<div className="relative w-full h-64">
|
||||
<Image
|
||||
src={store.image}
|
||||
alt={store.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="font-semibold text-black">
|
||||
{store.name}, {store.address}
|
||||
</p>
|
||||
<p className="text-gray-500 mt-1">{store.region}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-4 mt-10">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-full bg-gray-100 hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
{[...Array(totalPages)].map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentPage(idx + 1)}
|
||||
className={`w-8 h-8 rounded-full text-sm font-medium ${
|
||||
currentPage === idx + 1
|
||||
? "bg-[#00696e] text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{idx + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 rounded-full bg-gray-100 hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
"use client";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown, Lock, Menu, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const [showProdukMenu, setShowProdukMenu] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
const isActive = (path: string) =>
|
||||
pathname === path || pathname.startsWith(path + "/");
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => {
|
||||
setShowProdukMenu(false);
|
||||
};
|
||||
|
||||
window.addEventListener("click", handleClickOutside);
|
||||
return () => window.removeEventListener("click", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const produkList = [
|
||||
{
|
||||
name: "JAECOO J7 AWD",
|
||||
img: "/j7awd.png",
|
||||
link: "/produk/jaecoo7",
|
||||
},
|
||||
{
|
||||
name: "JAECOO J7 SHS",
|
||||
img: "/j7shs.png",
|
||||
link: "/produk/jaecoo7",
|
||||
},
|
||||
{
|
||||
name: "JAECOO J8 AWD",
|
||||
img: "/j8awd.png",
|
||||
link: "/produk/jaecoo8",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="relative w-full flex items-center justify-between py-4 px-6 sm:px-10 bg-white z-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<Image
|
||||
src="/masjaecoonav.png"
|
||||
alt="MAS JAECOO Logo"
|
||||
width={300}
|
||||
height={30}
|
||||
className=" object-fill"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="sm:hidden absolute right-6 text-[#1F3D4A]"
|
||||
onClick={() => setIsMobileMenuOpen((prev) => !prev)}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="w-6 h-6" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<ul className="hidden sm:flex mx-auto items-center gap-4 sm:gap-6 text-sm font-medium">
|
||||
<li>
|
||||
<Link href="/">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`hover:cursor-pointer rounded-full px-5 ${
|
||||
isActive("/") ? "bg-[#C2D8E2] text-[#1F3D4A]" : ""
|
||||
}`}
|
||||
>
|
||||
BERANDA
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li className="">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowProdukMenu((prev) => !prev);
|
||||
}}
|
||||
className={`flex items-center gap-1 font-bold text-sm focus:outline-none hover:cursor-pointer rounded-full px-5 py-2 ${
|
||||
isActive("/product") || isActive("/produk")
|
||||
? "bg-[#C2D8E2] text-[#1F3D4A]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
PRODUK <ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showProdukMenu && (
|
||||
<motion.div
|
||||
key="produk-dropdown"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute left-0 right-0 top-[calc(100%+1rem)] z-50 bg-white shadow-xl px-6 sm:px-10 py-6 rounded-xl w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="max-w-screen-xl mx-auto w-full flex flex-col sm:flex-row items-center sm:items-start justify-between gap-y-10 sm:gap-y-0 sm:gap-x-6 text-center sm:text-left">
|
||||
{produkList.map((car, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 30 }}
|
||||
transition={{ delay: 0.2 + i * 0.2, duration: 0.5 }}
|
||||
className="flex flex-col items-center text-center w-full sm:w-auto"
|
||||
>
|
||||
<Image
|
||||
src={car.img}
|
||||
alt={car.name}
|
||||
width={250}
|
||||
height={150}
|
||||
className="object-contain"
|
||||
/>
|
||||
<p className="font-bold mt-4 text-center">{car.name}</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2 mt-2 items-center">
|
||||
<Link href="/about" className="w-[200px]">
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-[#0C5B71] text-white px-4 rounded-full w-full hover:cursor-pointer"
|
||||
>
|
||||
TEST DRIVE
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/product" className="w-[200px]">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full px-4 w-full hover:cursor-pointer hover:bg-amber-50"
|
||||
>
|
||||
JELAJAHI
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link href="/service">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`hover:cursor-pointer rounded-full px-5 ${
|
||||
isActive("/service") ? "bg-[#C2D8E2] text-[#1F3D4A]" : ""
|
||||
}`}
|
||||
>
|
||||
SERVIS
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link href="/about">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`hover:cursor-pointer rounded-full px-5 ${
|
||||
isActive("/about") ? "bg-[#C2D8E2] text-[#1F3D4A]" : ""
|
||||
}`}
|
||||
>
|
||||
TENTANG DEALER JAECOO
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="hidden sm:block">
|
||||
<Link href="/auth">
|
||||
<Button className="bg-[#1F6779]">
|
||||
<Lock className="w-3 h-3 mr-1" />
|
||||
Login
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isMobileMenuOpen && (
|
||||
<div className="absolute top-full left-0 right-0 bg-white px-6 py-4 shadow-md flex flex-col gap-4 text-sm font-medium sm:hidden z-40">
|
||||
<Link href="/" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start ${
|
||||
isActive("/") ? "bg-[#C2D8E2] text-[#1F3D4A]" : ""
|
||||
}`}
|
||||
>
|
||||
BERANDA
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/product" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`flex items-center gap-1 font-bold text-sm focus:outline-none rounded-full px-5 py-2 ${
|
||||
isActive("/product") || isActive("/produk")
|
||||
? "bg-[#C2D8E2] text-[#1F3D4A]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
PRODUK
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/service" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start ${
|
||||
isActive("/service") ? "bg-[#C2D8E2] text-[#1F3D4A]" : ""
|
||||
}`}
|
||||
>
|
||||
SERVIS
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/about" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-start ${
|
||||
isActive("/about") ? "bg-[#C2D8E2] text-[#1F3D4A]" : ""
|
||||
}`}
|
||||
>
|
||||
TENTANG DEALER JAECOO
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/auth" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button className="bg-[#1F6779] w-full justify-start">
|
||||
<Lock className="w-3 h-3 mr-1" />
|
||||
Login
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import Image from "next/image";
|
||||
|
||||
export default function NearestLocation() {
|
||||
return (
|
||||
<section className="max-w-[1400px] mx-auto bg-white py-16 px-4 sm:px-6 lg:px-10">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-center mb-6">
|
||||
Lokasi Servis Terdekat
|
||||
</h2>
|
||||
|
||||
<div className="w-full mb-10">
|
||||
<Image
|
||||
src="/map-service.png"
|
||||
alt="Lokasi Servis Terdekat"
|
||||
width={1200}
|
||||
height={600}
|
||||
className="w-full object-cover rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 pt-10">
|
||||
<div className="text-start px-4 border-l border-gray-300">
|
||||
<div className="text-4xl mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g fill="none">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
// stroke-linejoin="round"
|
||||
// stroke-width="2"
|
||||
d="M12 13v7.098c0 .399 0 .598-.129.67c-.129.071-.298-.035-.636-.246L4.94 16.588c-.46-.288-.69-.431-.815-.658C4 15.705 4 15.434 4 14.893V8m8 5L4 8m8 5l5.286-3.304c1.218-.761 1.827-1.142 1.827-1.696s-.609-.935-1.827-1.696L13.06 3.663c-.516-.323-.773-.484-1.06-.484s-.544.161-1.06.484L4 8"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19 12a1 1 0 1 0 2 0zm.875-3.93L19 8.553zM19 9.107V12h2V9.108zm.59-2.544l-3.06-1.912l-1.06 1.696l3.06 1.913zM21 9.109c0-.252.001-.51-.02-.733a2 2 0 0 0-.23-.79l-1.75.97c-.027-.05-.02-.073-.011.01c.01.106.011.254.011.543zm-2.47-.848c.246.154.37.233.454.298c.067.05.043.045.016-.004l1.75-.97a2 2 0 0 0-.549-.614c-.177-.136-.397-.272-.611-.405z"
|
||||
/>
|
||||
<circle
|
||||
cx="17.5"
|
||||
cy="16.5"
|
||||
r="2.5"
|
||||
stroke="currentColor"
|
||||
// stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
// stroke-linecap="round"
|
||||
// stroke-width="2"
|
||||
d="m21 20l-1.5-1.5"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14.53 20.598a1 1 0 0 0-1.06-1.696zM11 20.375l-.53.848zm.937.444l-.063.998zm.126 0L12 19.82zm-.533-1.292l-3-1.875l-1.06 1.696l3 1.875zm1.94-.625l-.5.313l1.06 1.695l.5-.312zm-.5.313l-.5.312l1.06 1.696l.5-.312zm-2.5 2.008c.213.133.429.27.625.368c.214.108.47.206.779.226L12 19.82c.056.003.072.022-.005-.016a7 7 0 0 1-.465-.278zm2-1.696a7 7 0 0 1-.465.278c-.077.038-.061.02-.005.016l.126 1.996c.31-.02.565-.118.779-.226c.196-.099.412-.235.625-.368zm-.596 2.29q.126.008.252 0L12 19.82z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold mb-1"> {">"}TERSEDIA DALAM STOK</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Jelajahi pilihan hebat kami dari mobil Jaecoo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-start px-4 border-l border-r border-gray-300">
|
||||
<div className="text-2xl mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7L4 8v10h16V8zm0-2l8-5H4zM4 8V6v12z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold mb-1">{">"}BERITAHU SAYA</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Daftar untuk semua berita terbaru dari Jaecoo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-start px-4 border-r border-gray-300">
|
||||
<div className="text-2xl mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
// fill-rule="evenodd"
|
||||
d="M256 42.667c117.821 0 213.334 95.513 213.334 213.333c0 117.821-95.513 213.334-213.334 213.334c-117.82 0-213.333-95.513-213.333-213.334C42.667 138.18 138.18 42.667 256 42.667M85.334 256c0 87.032 65.145 158.848 149.332 169.346V316.358c-21.87-7.73-38.283-27.01-41.913-50.51L85.636 245.762q-.301 5.081-.302 10.238m341.031-10.238l-107.118 20.086c-3.629 23.5-20.043 42.78-41.913 50.51v108.988C361.523 414.848 426.668 343.032 426.668 256q-.001-5.156-.302-10.238M256 85.334c-76.056 0-140.493 49.75-162.541 118.484l107.16 20.085C211.699 204.827 232.352 192 256 192c23.65 0 44.302 12.827 55.382 31.903l107.16-20.085C396.493 135.084 332.057 85.334 256 85.334"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold mb-1">{">"}PESAN TEST DRIVE</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Atur test drive di jalan melalui Dealer terdekat kami
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { useState, Dispatch, SetStateAction } from "react";
|
||||
|
||||
export type OptionProps = {
|
||||
Icon: any;
|
||||
title: string;
|
||||
selected?: string;
|
||||
setSelected?: Dispatch<SetStateAction<string>>;
|
||||
open: boolean;
|
||||
notifs?: number;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
const Option = ({ Icon, title, selected, setSelected, open, notifs, active }: OptionProps) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isActive = active ?? selected === title;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
layout
|
||||
onClick={() => setSelected?.(title)}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
className={`relative flex h-12 w-full px-3 items-center rounded-xl transition-all duration-200 cursor-pointer group ${
|
||||
isActive
|
||||
? "bg-gradient-to-r from-emerald-500 to-green-500 text-white shadow-lg shadow-emerald-500/25"
|
||||
: "text-slate-600 hover:bg-gradient-to-r hover:from-slate-100 hover:to-slate-200/50 hover:text-slate-800"
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="activeIndicator"
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-white rounded-r-full shadow-sm"
|
||||
initial={{ opacity: 0, scaleY: 0 }}
|
||||
animate={{ opacity: 1, scaleY: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
layout
|
||||
className={`h-full flex items-center justify-center ${
|
||||
open ? "w-12" : "w-full"
|
||||
}`}
|
||||
>
|
||||
<div className={`text-lg transition-all duration-200 ${
|
||||
isActive
|
||||
? "text-white"
|
||||
: "text-slate-500 group-hover:text-slate-700"
|
||||
}`}>
|
||||
<Icon />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{open && (
|
||||
<motion.span
|
||||
layout
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.2 }}
|
||||
className={`text-sm font-medium transition-colors duration-200 ${
|
||||
isActive ? "text-white" : "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</motion.span>
|
||||
)}
|
||||
|
||||
{/* Tooltip for collapsed state */}
|
||||
{!open && hovered && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 8, scale: 0.8 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 8, scale: 0.8 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
className="absolute left-full ml-3 whitespace-nowrap rounded-lg bg-slate-800 px-3 py-2 text-sm text-white shadow-xl z-50"
|
||||
>
|
||||
<div className="relative">
|
||||
{title}
|
||||
{/* Tooltip arrow */}
|
||||
<div className="absolute -left-1 top-1/2 -translate-y-1/2 w-2 h-2 bg-slate-800 rotate-45"></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Notification badge */}
|
||||
{notifs && open && (
|
||||
<motion.span
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.3, type: "spring" }}
|
||||
className={`absolute right-3 top-1/2 -translate-y-1/2 size-5 rounded-full text-xs font-semibold flex items-center justify-center ${
|
||||
isActive
|
||||
? "bg-white text-emerald-500"
|
||||
: "bg-red-500 text-white"
|
||||
}`}
|
||||
>
|
||||
{notifs}
|
||||
</motion.span>
|
||||
)}
|
||||
|
||||
{/* Hover effect overlay */}
|
||||
{hovered && !isActive && (
|
||||
<motion.div
|
||||
layoutId="hoverOverlay"
|
||||
className="absolute inset-0 bg-gradient-to-r from-slate-100/50 to-slate-200/50 rounded-xl"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Option;
|
||||
|
|
@ -0,0 +1,605 @@
|
|||
"use client";
|
||||
|
||||
import React, { Dispatch, SetStateAction, useState, useEffect } from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
import DashboardContainer from "../main/dashboard/dashboard-container";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useTheme } from "../layout/theme-context";
|
||||
import Option from "./option";
|
||||
|
||||
interface RetractingSidebarProps {
|
||||
sidebarData: boolean;
|
||||
updateSidebarData: (newData: boolean) => void;
|
||||
}
|
||||
|
||||
const sidebarSections = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
items: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
icon: () => (
|
||||
<Icon icon="material-symbols:dashboard" className="text-lg" />
|
||||
),
|
||||
link: "/admin/dashboard",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Content Management",
|
||||
items: [
|
||||
{
|
||||
title: "Articles",
|
||||
icon: () => <Icon icon="ri:article-line" className="text-lg" />,
|
||||
link: "/admin/article",
|
||||
},
|
||||
{
|
||||
title: "Categories",
|
||||
icon: () => <Icon icon="famicons:list-outline" className="text-lg" />,
|
||||
link: "/admin/master-category",
|
||||
},
|
||||
// {
|
||||
// title: "Majalah",
|
||||
// icon: () => <Icon icon="emojione-monotone:newspaper" className="text-lg" />,
|
||||
// link: "/admin/magazine",
|
||||
// },
|
||||
{
|
||||
title: "Advertisements",
|
||||
icon: () => <Icon icon="ic:round-ads-click" className="text-lg" />,
|
||||
link: "/admin/advertise",
|
||||
},
|
||||
// {
|
||||
// title: "Komentar",
|
||||
// icon: () => <Icon icon="material-symbols:comment-outline-rounded" className="text-lg" />,
|
||||
// link: "/admin/komentar",
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "System",
|
||||
items: [
|
||||
{
|
||||
title: "Static Pages",
|
||||
icon: () => <Icon icon="fluent-mdl2:page-solid" className="text-lg" />,
|
||||
link: "/admin/static-page",
|
||||
},
|
||||
{
|
||||
title: "User Management",
|
||||
icon: () => <Icon icon="ph:users-three-fill" className="text-lg" />,
|
||||
link: "/admin/master-user",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const RetractingSidebar = ({
|
||||
sidebarData,
|
||||
updateSidebarData,
|
||||
}: RetractingSidebarProps) => {
|
||||
const pathname = usePathname();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* DESKTOP SIDEBAR */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.nav
|
||||
key="desktop-sidebar"
|
||||
layout
|
||||
className="hidden md:flex sticky top-0 h-screen shrink-0 bg-gradient-to-b from-slate-50 to-white dark:from-slate-800 dark:to-slate-900 border-r border-slate-200/60 dark:border-slate-700/60 shadow-lg backdrop-blur-sm flex-col justify-between"
|
||||
style={{
|
||||
width: sidebarData ? "280px" : "80px",
|
||||
}}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<SidebarContent
|
||||
open={sidebarData}
|
||||
pathname={pathname}
|
||||
updateSidebarData={updateSidebarData}
|
||||
/>
|
||||
</motion.nav>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Desktop Toggle Button - appears when sidebar is collapsed */}
|
||||
<AnimatePresence>
|
||||
{!sidebarData && (
|
||||
<motion.button
|
||||
key="desktop-toggle"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="hidden md:flex fixed top-4 left-20 z-40 p-3 bg-white rounded-xl shadow-lg border border-slate-200/60 hover:shadow-xl transition-all duration-200 hover:bg-slate-50"
|
||||
onClick={() => updateSidebarData(true)}
|
||||
>
|
||||
<Icon
|
||||
icon="heroicons:chevron-right"
|
||||
className="w-5 h-5 text-slate-600"
|
||||
/>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Mobile Toggle Button */}
|
||||
<AnimatePresence>
|
||||
{!sidebarData && (
|
||||
<motion.button
|
||||
key="mobile-toggle"
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0 }}
|
||||
className="md:hidden fixed top-4 left-4 z-50 p-3 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200/60 dark:border-slate-700/60 hover:shadow-xl transition-all duration-200"
|
||||
onClick={() => updateSidebarData(true)}
|
||||
>
|
||||
<Icon
|
||||
icon="heroicons:chevron-right"
|
||||
className="w-6 h-6 text-slate-600"
|
||||
/>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* MOBILE SIDEBAR */}
|
||||
<AnimatePresence>
|
||||
{sidebarData && (
|
||||
<motion.div
|
||||
key="mobile-sidebar"
|
||||
initial={{ x: "-100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "tween", duration: 0.3 }}
|
||||
className="fixed top-0 left-0 z-50 w-[280px] h-full bg-gradient-to-b from-slate-50 to-white dark:from-slate-800 dark:to-slate-900 p-4 flex flex-col md:hidden shadow-2xl backdrop-blur-sm"
|
||||
>
|
||||
{/* <button onClick={() => updateSidebarData(false)} className="mb-4 self-end text-zinc-500">
|
||||
✕
|
||||
</button> */}
|
||||
<SidebarContent
|
||||
open={true}
|
||||
pathname={pathname}
|
||||
updateSidebarData={updateSidebarData}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SidebarContent = ({
|
||||
open,
|
||||
pathname,
|
||||
updateSidebarData,
|
||||
}: {
|
||||
open: boolean;
|
||||
pathname: string;
|
||||
updateSidebarData: (newData: boolean) => void;
|
||||
}) => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* SCROLLABLE TOP SECTION */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* HEADER SECTION */}
|
||||
<div className="flex flex-col space-y-6">
|
||||
{/* Logo and Toggle */}
|
||||
<div className="flex items-center justify-between px-4 py-6">
|
||||
<Link href="/" className="flex items-center space-x-3">
|
||||
<div className="relative">
|
||||
<img
|
||||
src="/masjaecoo.png"
|
||||
className="w-28 h-10 bg-black p-1 dark:bg-transparent"
|
||||
/>
|
||||
<div className="absolute opacity-20"></div>
|
||||
</div>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<span className="text-lg font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-black dark:text-white">
|
||||
Jaecoo
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">Admin Panel</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{open && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 transition-colors duration-200 group"
|
||||
onClick={() => updateSidebarData(false)}
|
||||
>
|
||||
<Icon
|
||||
icon="heroicons:chevron-left"
|
||||
className="w-5 h-5 text-slate-500 group-hover:text-slate-700 transition-colors"
|
||||
/>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Sections */}
|
||||
<div className="space-y-3 px-3 pb-6">
|
||||
{sidebarSections.map((section, sectionIndex) => (
|
||||
<motion.div
|
||||
key={section.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + sectionIndex * 0.1 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
{open && (
|
||||
<motion.h3
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 + sectionIndex * 0.1 }}
|
||||
className="text-xs font-semibold text-slate-500 uppercase tracking-wider px-3"
|
||||
>
|
||||
{section.title}
|
||||
</motion.h3>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{section.items.map((item, itemIndex) => (
|
||||
<Link href={item.link} key={item.title}>
|
||||
<Option
|
||||
Icon={item.icon}
|
||||
title={item.title}
|
||||
active={pathname === item.link}
|
||||
open={open}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FIXED BOTTOM SECTION */}
|
||||
<div className="flex-shrink-0 space-y-1 border-t border-slate-200/60 dark:border-slate-700/60 bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm">
|
||||
{/* Divider */}
|
||||
{/* <div className="px-3 pb-2">
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-slate-300 to-transparent"></div>
|
||||
</div> */}
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<div className="px-3 pt-1">
|
||||
<motion.button
|
||||
onClick={toggleTheme}
|
||||
className={`relative flex h-12 w-full items-center rounded-xl transition-all duration-200 cursor-pointer group ${
|
||||
open ? "px-3" : "justify-center"
|
||||
} ${
|
||||
theme === "dark"
|
||||
? "bg-gradient-to-r from-emerald-500 to-green-500 text-white shadow-lg shadow-emerald-500/25"
|
||||
: "text-slate-600 hover:bg-gradient-to-r hover:from-slate-100 hover:to-slate-200/50 hover:text-slate-800 dark:text-slate-300 dark:hover:bg-slate-700/50"
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<motion.div
|
||||
className={`h-full flex items-center justify-center ${
|
||||
open ? "w-12" : "w-full"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`text-lg transition-all duration-200 ${
|
||||
theme === "dark"
|
||||
? "text-white"
|
||||
: "text-slate-500 group-hover:text-slate-700 dark:text-slate-400 dark:group-hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Icon icon="solar:sun-bold" className="text-lg" />
|
||||
) : (
|
||||
<Icon icon="solar:moon-bold" className="text-lg" />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{open && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.2 }}
|
||||
className={`text-sm font-medium transition-colors duration-200 ${
|
||||
theme === "dark"
|
||||
? "text-white"
|
||||
: "text-slate-700 dark:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{theme === "dark" ? "Light Mode" : "Dark Mode"}
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className="px-3">
|
||||
<Link href="/settings">
|
||||
<Option
|
||||
Icon={() => (
|
||||
<Icon icon="lets-icons:setting-fill" className="text-lg" />
|
||||
)}
|
||||
title="Settings"
|
||||
active={pathname === "/settings"}
|
||||
open={open}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* User Profile */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="px-3 py-3 border-t border-slate-200/60"
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
open
|
||||
? "flex items-center space-x-3"
|
||||
: "flex items-center justify-center"
|
||||
} p-3 rounded-xl bg-gradient-to-r from-slate-50 to-slate-100/50 hover:from-slate-100 hover:to-slate-200/50 transition-all duration-200 cursor-pointer group`}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-500 to-purple-500 flex items-center justify-center text-white font-semibold text-sm shadow-lg">
|
||||
A
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white"></div>
|
||||
</div>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-800 truncate">
|
||||
admin-mabes
|
||||
</p>
|
||||
<Link href="/auth">
|
||||
<p className="text-xs text-slate-500 hover:text-blue-600 transition-colors duration-200">
|
||||
Sign out
|
||||
</p>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Expand Button for Collapsed State */}
|
||||
{/* {!open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="px-3 pt-2"
|
||||
>
|
||||
<button
|
||||
onClick={() => updateSidebarData(true)}
|
||||
className="w-full p-3 rounded-xl bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white shadow-lg transition-all duration-200 hover:shadow-xl group"
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<Icon
|
||||
icon="heroicons:chevron-right"
|
||||
className="w-5 h-5 group-hover:scale-110 transition-transform duration-200"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</motion.div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Sidebar = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<motion.nav
|
||||
layout
|
||||
className="sticky top-0 h-screen shrink-0 border-r border-slate-300 bg-white p-1 hidden md:flex flex-col justify-between"
|
||||
style={{
|
||||
width: open ? "120px" : "90px",
|
||||
}}
|
||||
>
|
||||
{/* BAGIAN ATAS */}
|
||||
<div>
|
||||
{!open && (
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<button
|
||||
className="w-5 h-5 text-zinc-400 border border-zinc-400 rounded-full flex justify-center items-center"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2.5"
|
||||
d="m10 17l5-5m0 0l-5-5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex ${
|
||||
open ? "justify-between" : "justify-center"
|
||||
} w-full items-center px-2`}
|
||||
>
|
||||
<Link href="/" className="flex flex-row items-center gap-3 font-bold">
|
||||
<img src="/assets/icon/Logo.png" className="w-20" />
|
||||
</Link>
|
||||
{open && (
|
||||
<button
|
||||
className="w-5 h-5 text-zinc-400 border border-zinc-400 rounded-full flex justify-center items-center"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2.5"
|
||||
d="m14 7l-5 5m0 0l5 5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{sidebarSections.map((section) => (
|
||||
<div key={section.title}>
|
||||
<p className="font-bold text-[14px] py-2">{section.title}</p>
|
||||
{section.items.map((item) => (
|
||||
<Link href={item.link} key={item.title}>
|
||||
<Option
|
||||
Icon={item.icon}
|
||||
title={item.title}
|
||||
active={pathname === item.link}
|
||||
open={open}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BAGIAN BAWAH */}
|
||||
<div className="space-y-1">
|
||||
<Option
|
||||
Icon={() => <Icon icon="solar:moon-bold" className="text-lg" />}
|
||||
title="Theme"
|
||||
active={false}
|
||||
open={open}
|
||||
/>
|
||||
<Link href="/settings">
|
||||
<Option
|
||||
Icon={() => (
|
||||
<Icon icon="lets-icons:setting-fill" className="text-lg" />
|
||||
)}
|
||||
title="Settings"
|
||||
active={pathname === "/settings"}
|
||||
open={open}
|
||||
/>
|
||||
</Link>{" "}
|
||||
<div className="flex flex-row gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="34"
|
||||
height="34"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="6" r="4" />
|
||||
<path d="M20 17.5c0 2.485 0 4.5-8 4.5s-8-2.015-8-4.5S7.582 13 12 13s8 2.015 8 4.5Z" />
|
||||
</g>
|
||||
</svg>
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
<p>admin-mabes</p>
|
||||
<p className="underline">Logout</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
const TitleSection = ({ open }: { open: boolean }) => {
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center justify-between rounded-md transition-colors hover:bg-slate-100">
|
||||
<div className="flex items-center">
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 12, scale: 0.5 }}
|
||||
animate={
|
||||
open
|
||||
? { opacity: 1, y: 0, scale: 1 }
|
||||
: { opacity: 1, y: 0, scale: 0.5 }
|
||||
}
|
||||
transition={{ delay: 0.125 }}
|
||||
>
|
||||
<Image
|
||||
src="/assets/icon/Logo.png"
|
||||
alt="logo"
|
||||
width={1920}
|
||||
height={1080}
|
||||
className="w-full h-fit"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
{/* {open && <FiChevronDown className="mr-2" />} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToggleClose = ({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
return (
|
||||
<motion.button layout onClick={() => setOpen((pv) => !pv)}>
|
||||
<div className="flex justify-center items-center pt-2">
|
||||
<motion.div layout className="grid size-10 text-lg">
|
||||
{/* <FiChevronsRight
|
||||
className={`transition-transform ${open && "rotate-180"}`}
|
||||
/> */}
|
||||
</motion.div>
|
||||
{/* {open && (
|
||||
<motion.span layout initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.125 }} className="text-xs font-medium">
|
||||
Hide
|
||||
</motion.span>
|
||||
)} */}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
};
|
||||
|
||||
const ExampleContent = () => (
|
||||
<div>
|
||||
<DashboardContainer />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
import Image from "next/image";
|
||||
import { easeOut, motion } from "framer-motion";
|
||||
|
||||
const services = [
|
||||
{ image: "/s1.png", title: "ELECTRONIC VEHICLE HEALTH CHECK" },
|
||||
{ image: "/s2.png", title: "REQUEST A SERVICE" },
|
||||
{ image: "/s3.png", title: "SERVICE PLANS" },
|
||||
{ image: "/s4.png", title: "BODY AND PAINT" },
|
||||
{ image: "/s5.png", title: "GENUINE PARTS" },
|
||||
{ image: "/s6.png", title: "JAECOO REPAIRS" },
|
||||
];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.15,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: easeOut },
|
||||
},
|
||||
};
|
||||
|
||||
export default function Service() {
|
||||
return (
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-10 bg-white">
|
||||
<div className="w-full mx-auto text-start">
|
||||
<motion.h2
|
||||
className="text-2xl sm:text-3xl font-bold mb-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
Performa Hebat, Layanan Terjamin
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
className="text-gray-700 mb-10 w-full md:w-6/12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
Servis resmi Jaecoo untuk kendaraan Anda dikerjakan oleh teknisi
|
||||
tersertifikasi dengan suku cadang asli dan sistem booking online.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 justify-items-center"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
>
|
||||
{services.map((service, index) => (
|
||||
<motion.div key={index} variants={itemVariants} className="w-full">
|
||||
<Image
|
||||
src={service.image}
|
||||
alt={service.title}
|
||||
width={413}
|
||||
height={170}
|
||||
className="w-full object-contain"
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
"use client";
|
||||
|
||||
export default function Video() {
|
||||
return (
|
||||
<section className="pt-10 bg-white">
|
||||
<div className="relative w-full h-[500px] overflow-hidden">
|
||||
<iframe
|
||||
className="w-full h-full"
|
||||
src="https://www.youtube.com/embed/qEfjAK4gVhU?autoplay=1&mute=1&loop=1&playlist=qEfjAK4gVhU"
|
||||
title="YouTube video player"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { ReactNode } from "react";
|
||||
import { SidebarProvider } from "./sidebar-context";
|
||||
import { ThemeProvider } from "./theme-context";
|
||||
import { Breadcrumbs } from "./breadcrumbs";
|
||||
import { BurgerButtonIcon } from "../icons";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { RetractingSidebar } from "../landing-page/retracting-sidedar";
|
||||
|
||||
export const AdminLayout = ({ children }: { children: ReactNode }) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
|
||||
const updateSidebarData = (newData: boolean) => {
|
||||
setIsOpen(newData);
|
||||
};
|
||||
|
||||
// Hooks
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
}, []);
|
||||
|
||||
// Render loading state until mounted
|
||||
if (!hasMounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-500 dark:via-slate-800 dark:to-slate-900">
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<RetractingSidebar
|
||||
sidebarData={isOpen}
|
||||
updateSidebarData={updateSidebarData}
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="main-content"
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<motion.header
|
||||
className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-b border-slate-200/60 dark:border-slate-700/60 shadow-sm"
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.3 }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
className="md:hidden p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors duration-200"
|
||||
onClick={() => updateSidebarData(true)}
|
||||
>
|
||||
<BurgerButtonIcon />
|
||||
</button>
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
</div>
|
||||
</motion.header>
|
||||
|
||||
{/* Main Content */}
|
||||
<motion.main
|
||||
className="flex-1 overflow-auto"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.3 }}
|
||||
>
|
||||
<div className="h-full">{children}</div>
|
||||
</motion.main>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
ArticleIcon,
|
||||
DashboardIcon,
|
||||
MagazineIcon,
|
||||
MasterCategoryIcon,
|
||||
MasterRoleIcon,
|
||||
MasterUsersIcon,
|
||||
StaticPageIcon,
|
||||
} from "../icons/sidebar-icon";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "../ui/breadcrumb";
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export const Breadcrumbs = () => {
|
||||
const [currentPage, setCurrentPage] = useState<React.Key>("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const pathnameSplit = pathname.split("/");
|
||||
|
||||
pathnameSplit.shift();
|
||||
const pathnameTransformed = pathnameSplit.map((item) => {
|
||||
const words = item.split("-");
|
||||
const capitalizedWords = words.map(
|
||||
(word) => word.charAt(0).toUpperCase() + word.slice(1)
|
||||
);
|
||||
return capitalizedWords.join(" ");
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(pathnameSplit[pathnameSplit.length - 1]);
|
||||
}, [pathnameSplit]);
|
||||
|
||||
const handleAction = (key: any) => {
|
||||
const keyIndex = pathnameSplit.indexOf(key);
|
||||
const combinedPath = pathnameSplit.slice(0, keyIndex + 1).join("/");
|
||||
router.push("/" + combinedPath);
|
||||
};
|
||||
|
||||
const getPageIcon = () => {
|
||||
if (pathname.includes("dashboard")) return <DashboardIcon size={40} />;
|
||||
if (pathname.includes("article")) return <ArticleIcon size={40} />;
|
||||
if (pathname.includes("master-category"))
|
||||
return <MasterCategoryIcon size={40} />;
|
||||
if (pathname.includes("magazine")) return <MagazineIcon size={40} />;
|
||||
if (pathname.includes("static-page")) return <StaticPageIcon size={40} />;
|
||||
if (pathname.includes("master-user")) return <MasterUsersIcon size={40} />;
|
||||
if (pathname.includes("master-role")) return <MasterRoleIcon size={40} />;
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="w-10 h-10 bg-slate-200 rounded-lg animate-pulse"></div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="h-8 w-32 bg-slate-200 rounded animate-pulse"></div>
|
||||
<div className="h-4 w-48 bg-slate-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex items-center space-x-6"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Page Icon */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{getPageIcon()}
|
||||
</motion.div>
|
||||
|
||||
{/* Page Title and Breadcrumbs */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<motion.h1
|
||||
className="text-2xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-black dark:text-white"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.3 }}
|
||||
>
|
||||
{pathnameTransformed[pathnameTransformed.length - 1]}
|
||||
</motion.h1>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.3 }}
|
||||
>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList className="flex items-center space-x-2">
|
||||
{pathnameTransformed
|
||||
?.filter((item) => item !== "Admin")
|
||||
.map((item, index, array) => (
|
||||
<React.Fragment key={pathnameSplit[index]}>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink
|
||||
onClick={() => handleAction(pathnameSplit[index])}
|
||||
className={`text-sm transition-all duration-200 hover:text-blue-600 ${
|
||||
pathnameSplit[index] === currentPage
|
||||
? "font-semibold text-blue-600"
|
||||
: "text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{index < array.length - 1 && (
|
||||
<BreadcrumbSeparator className="text-slate-400">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</BreadcrumbSeparator>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class ChunkErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
// Check if it's a chunk loading error
|
||||
if (error.name === 'ChunkLoadError' || error.message.includes('Loading chunk')) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
return { hasError: false };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Chunk loading error:', error, errorInfo);
|
||||
|
||||
// If it's a chunk loading error, try to reload the page
|
||||
if (error.name === 'ChunkLoadError' || error.message.includes('Loading chunk')) {
|
||||
this.setState({ hasError: true, error });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
// Clear the error state and reload the page
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="text-center p-8 max-w-md">
|
||||
<div className="mb-6">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<RefreshCw className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Chunk Loading Error
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
There was an issue loading a part of the application. This usually happens when the application has been updated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={this.handleRetry}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reload Application
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => window.location.href = '/'}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Go to Homepage
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Error Details (Development)
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ChunkErrorBoundary;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
|
||||
interface CircularProgressProps {
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
value: number; // 0 to 100
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CircularProgress({ size = 48, strokeWidth = 4, value, className }: CircularProgressProps) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (value / 100) * circumference;
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className={className} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle className="text-gray-200" stroke="currentColor" strokeWidth={strokeWidth} fill="transparent" r={radius} cx={size / 2} cy={size / 2} />
|
||||
<circle
|
||||
className="text-primary"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
style={{ transition: "stroke-dashoffset 0.35s" }}
|
||||
/>
|
||||
<text x="50%" y="50%" dy=".3em" textAnchor="middle" className="text-xs fill-primary font-medium">
|
||||
{Math.round(value)}%
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import React from "react";
|
||||
|
||||
type CircularProgressProps = {
|
||||
value: number; // antara 0 - 100
|
||||
size?: number; // diameter lingkaran (px)
|
||||
strokeWidth?: number;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const CustomCircularProgress = ({
|
||||
value,
|
||||
size = 80,
|
||||
strokeWidth = 8,
|
||||
color = "#f59e0b", // shadcn's warning color
|
||||
bgColor = "#e5e7eb", // gray-200
|
||||
label,
|
||||
}: CircularProgressProps) => {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const progress = Math.min(Math.max(value, 0), 100); // jaga antara 0 - 100
|
||||
const offset = circumference - (progress / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size}>
|
||||
<circle
|
||||
stroke={bgColor}
|
||||
fill="transparent"
|
||||
strokeWidth={strokeWidth}
|
||||
r={radius}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
/>
|
||||
<circle
|
||||
stroke={color}
|
||||
fill="transparent"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
r={radius}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
style={{ transition: "stroke-dashoffset 0.35s" }}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-sm font-semibold text-gray-800 dark:text-white">
|
||||
{label ?? `${Math.round(progress)}%`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
"use client";
|
||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function CustomPagination(props: { totalPage: number; onPageChange: (data: number) => void }) {
|
||||
const { totalPage, onPageChange } = props;
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
onPageChange(page);
|
||||
}, [page]);
|
||||
|
||||
const renderPageNumbers = () => {
|
||||
const pageNumbers = [];
|
||||
const halfWindow = Math.floor(5 / 2);
|
||||
let startPage = Math.max(2, page - halfWindow);
|
||||
let endPage = Math.min(totalPage - 1, page + halfWindow);
|
||||
|
||||
if (endPage - startPage + 1 < 5) {
|
||||
if (page <= halfWindow) {
|
||||
endPage = Math.min(totalPage, endPage + (5 - (endPage - startPage + 1)));
|
||||
} else if (page + halfWindow >= totalPage) {
|
||||
startPage = Math.max(1, startPage - (5 - (endPage - startPage + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pageNumbers.push(
|
||||
<PaginationItem key={i} onClick={() => setPage(i)}>
|
||||
<PaginationLink className="cursor-pointer" isActive={page === i}>
|
||||
{i}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
return pageNumbers;
|
||||
};
|
||||
return (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationLink className="cursor-pointer" onClick={() => (page > 10 ? setPage(page - 10) : "")}>
|
||||
{/* <DoubleArrowLeftIcon /> */}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious className="cursor-pointer" onClick={() => (page > 1 ? setPage(page - 1) : "")} />
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink className="cursor-pointer" onClick={() => setPage(1)} isActive={page === 1}>
|
||||
{1}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
{page > 4 && (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis className="cursor-pointer" onClick={() => setPage(page - 1)} />
|
||||
</PaginationItem>
|
||||
)}
|
||||
{renderPageNumbers()}
|
||||
{page < totalPage - 3 && (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis className="cursor-pointer" onClick={() => setPage(page + 1)} />
|
||||
</PaginationItem>
|
||||
)}
|
||||
{totalPage > 1 && (
|
||||
<PaginationItem>
|
||||
<PaginationLink className="cursor-pointer" onClick={() => setPage(totalPage)} isActive={page === totalPage}>
|
||||
{totalPage}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext className="cursor-pointer" onClick={() => (page < totalPage ? setPage(page + 1) : "")} />
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink onClick={() => (page < totalPage - 10 ? setPage(page + 10) : "")}>{/* <DoubleArrowRightIcon /> */}</PaginationLink>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
'use client'
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
interface SidebarContextType {
|
||||
isOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||
|
||||
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isOpen, setIsOpen] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const storedValue = localStorage.getItem('sidebarOpen');
|
||||
return storedValue ? JSON.parse(storedValue) : false;
|
||||
}
|
||||
});
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebarOpen', JSON.stringify(isOpen));
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsOpen(window.innerWidth > 768); // Ganti 768 dengan lebar yang sesuai dengan breakpoint Anda
|
||||
};
|
||||
|
||||
handleResize(); // Pastikan untuk memanggil fungsi handleResize saat komponen dimuat
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ isOpen, toggleSidebar }}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSidebar = () => {
|
||||
const context = useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useSidebarContext = () => {
|
||||
return useContext(SidebarContext);
|
||||
};
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [theme, setThemeState] = useState<Theme>('light');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
// Get theme from localStorage or default to 'light'
|
||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||
if (savedTheme) {
|
||||
setThemeState(savedTheme);
|
||||
} else {
|
||||
// Check system preference
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
setThemeState(systemTheme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
// Update document class and localStorage
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme, mounted]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setThemeState(prev => prev === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
};
|
||||
|
||||
// Prevent hydration mismatch
|
||||
if (!mounted) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
import { getStatisticMonthly } from "@/service/article";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { SafeReactApexChart } from "@/utils/dynamic-import";
|
||||
|
||||
type WeekData = {
|
||||
week: number;
|
||||
days: number[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
type RemainingDays = {
|
||||
days: number[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
function processMonthlyData(count: number[]): {
|
||||
weeks: WeekData[];
|
||||
remaining_days: RemainingDays;
|
||||
} {
|
||||
const weeks: WeekData[] = [];
|
||||
let weekIndex = 1;
|
||||
|
||||
for (let i = 0; i < count.length; i += 7) {
|
||||
const weekData = count.slice(i, i + 7);
|
||||
weeks.push({
|
||||
week: weekIndex,
|
||||
days: weekData,
|
||||
total: weekData.reduce((sum, day) => sum + day, 0),
|
||||
});
|
||||
weekIndex++;
|
||||
}
|
||||
|
||||
const remainingDays: RemainingDays = {
|
||||
days: count.length % 7 === 0 ? [] : count.slice(-count.length % 7),
|
||||
total: count.slice(-count.length % 7).reduce((sum, day) => sum + day, 0),
|
||||
};
|
||||
|
||||
return {
|
||||
weeks,
|
||||
remaining_days: remainingDays,
|
||||
};
|
||||
}
|
||||
|
||||
const ApexChartColumn = (props: {
|
||||
type: string;
|
||||
date: string;
|
||||
view: string[];
|
||||
}) => {
|
||||
const { date, type, view } = props;
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [series, setSeries] = useState<{ name: string; data: number[] }[]>([]);
|
||||
const [seriesComment, setSeriesComment] = useState<number[]>([]);
|
||||
const [seriesView, setSeriesView] = useState<number[]>([]);
|
||||
const [seriesShare, setSeriesShare] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
initFetch();
|
||||
}, [date, type, view]);
|
||||
|
||||
const initFetch = async () => {
|
||||
const splitDate = date.split(" ");
|
||||
|
||||
const res = await getStatisticMonthly(splitDate[1]);
|
||||
const data = res?.data?.data;
|
||||
const getDatas = data?.find(
|
||||
(a: any) =>
|
||||
a.month == Number(splitDate[0]) && a.year === Number(splitDate[1])
|
||||
);
|
||||
if (getDatas) {
|
||||
const temp1 = processMonthlyData(getDatas?.comment);
|
||||
const temp2 = processMonthlyData(getDatas?.view);
|
||||
const temp3 = processMonthlyData(getDatas?.share);
|
||||
|
||||
if (type == "weekly") {
|
||||
setSeriesComment(
|
||||
temp1.weeks.map((list) => {
|
||||
return list.total;
|
||||
})
|
||||
);
|
||||
setSeriesView(
|
||||
temp2.weeks.map((list) => {
|
||||
return list.total;
|
||||
})
|
||||
);
|
||||
setSeriesShare(
|
||||
temp3.weeks.map((list) => {
|
||||
return list.total;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setSeriesComment(getDatas.comment);
|
||||
setSeriesView(getDatas.view);
|
||||
setSeriesShare(getDatas.share);
|
||||
}
|
||||
if (type === "weekly") {
|
||||
const category = [];
|
||||
for (let i = 1; i <= temp1.weeks.length; i++) {
|
||||
category.push(`Week ${i}`);
|
||||
}
|
||||
setCategories(category);
|
||||
}
|
||||
} else {
|
||||
setSeriesComment([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const temp = [
|
||||
{
|
||||
name: "Comment",
|
||||
data: view.includes("comment") ? seriesComment : [],
|
||||
},
|
||||
{
|
||||
name: "View",
|
||||
data: view.includes("view") ? seriesView : [],
|
||||
},
|
||||
{
|
||||
name: "Share",
|
||||
data: view.includes("share") ? seriesShare : [],
|
||||
},
|
||||
];
|
||||
|
||||
console.log("temp", temp);
|
||||
|
||||
setSeries(temp);
|
||||
}, [view, seriesShare, seriesView, seriesComment]);
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div id="chart" className="h-full">
|
||||
<SafeReactApexChart
|
||||
options={{
|
||||
chart: {
|
||||
height: "100%",
|
||||
type: "area",
|
||||
},
|
||||
stroke: {
|
||||
curve: "smooth",
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
categories: type == "weekly" ? categories : [],
|
||||
},
|
||||
}}
|
||||
series={series}
|
||||
type="area"
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
<div id="html-dist"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApexChartColumn;
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
DashboardCommentIcon,
|
||||
DashboardConnectIcon,
|
||||
DashboardShareIcon,
|
||||
DashboardSpeecIcon,
|
||||
DashboardUserIcon,
|
||||
} from "@/components/icons/dashboard-icon";
|
||||
import Cookies from "js-cookie";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Article } from "@/types/globals";
|
||||
import {
|
||||
getListArticle,
|
||||
getStatisticSummary,
|
||||
getTopArticles,
|
||||
getUserLevelDataStat,
|
||||
} from "@/service/article";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Image from "next/image";
|
||||
import { convertDateFormat, convertDateFormatNoTime } from "@/utils/global";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import ApexChartColumn from "@/components/main/dashboard/chart/column-chart";
|
||||
import CustomPagination from "@/components/layout/custom-pagination";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type ArticleData = Article & {
|
||||
no: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
interface TopPages {
|
||||
id: number;
|
||||
no: number;
|
||||
title: string;
|
||||
viewCount: number;
|
||||
}
|
||||
|
||||
interface PostCount {
|
||||
userLevelId: number;
|
||||
no: number;
|
||||
userLevelName: string;
|
||||
totalArticle: number;
|
||||
}
|
||||
|
||||
export default function DashboardContainer() {
|
||||
const username = Cookies.get("username");
|
||||
const fullname = Cookies.get("ufne");
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(1);
|
||||
const [topPagesTotalPage, setTopPagesTotalPage] = useState(1);
|
||||
const [article, setArticle] = useState<ArticleData[]>([]);
|
||||
// const [analyticsView, setAnalyticView] = useState<string[]>(["comment", "view", "share"]);
|
||||
// const [startDateValue, setStartDateValue] = useState(parseDate(convertDateFormatNoTimeV2(new Date())));
|
||||
// const [postContentDate, setPostContentDate] = useState({
|
||||
// startDate: parseDate(convertDateFormatNoTimeV2(new Date(new Date().setDate(new Date().getDate() - 7)))),
|
||||
// endDate: parseDate(convertDateFormatNoTimeV2(new Date())),
|
||||
// });
|
||||
|
||||
const [startDateValue, setStartDateValue] = useState(new Date());
|
||||
const [analyticsView, setAnalyticView] = useState<string[]>([]);
|
||||
const options = [
|
||||
{ label: "Comment", value: "comment" },
|
||||
{ label: "View", value: "view" },
|
||||
{ label: "Share", value: "share" },
|
||||
];
|
||||
const handleChange = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setAnalyticView([...analyticsView, value]);
|
||||
} else {
|
||||
setAnalyticView(analyticsView.filter((v) => v !== value));
|
||||
}
|
||||
};
|
||||
const [postContentDate, setPostContentDate] = useState({
|
||||
startDate: new Date(new Date().setDate(new Date().getDate() - 7)),
|
||||
endDate: new Date(),
|
||||
});
|
||||
|
||||
const [typeDate, setTypeDate] = useState("monthly");
|
||||
const [summary, setSummary] = useState<any>();
|
||||
|
||||
const [topPages, setTopPages] = useState<TopPages[]>([]);
|
||||
const [postCount, setPostCount] = useState<PostCount[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
initState();
|
||||
}, [page]);
|
||||
|
||||
async function initState() {
|
||||
const req = {
|
||||
limit: "4",
|
||||
page: page,
|
||||
search: "",
|
||||
};
|
||||
const res = await getListArticle(req);
|
||||
setArticle(res.data?.data);
|
||||
setTotalPage(res?.data?.meta?.totalPage);
|
||||
}
|
||||
|
||||
async function fetchSummary() {
|
||||
const res = await getStatisticSummary();
|
||||
setSummary(res?.data?.data);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopPages();
|
||||
}, [page]);
|
||||
|
||||
async function fetchTopPages() {
|
||||
const req = {
|
||||
limit: "10",
|
||||
page: page,
|
||||
search: "",
|
||||
};
|
||||
const res = await getTopArticles(req);
|
||||
setTopPages(getTableNumber(10, res.data?.data));
|
||||
setTopPagesTotalPage(res?.data?.meta?.totalPage);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPostCount();
|
||||
}, [postContentDate]);
|
||||
async function fetchPostCount() {
|
||||
const getDate = (data: any) => {
|
||||
return `${data.year}-${data.month < 10 ? `0${data.month}` : data.month}-${
|
||||
data.day < 10 ? `0${data.day}` : data.day
|
||||
}`;
|
||||
};
|
||||
const res = await getUserLevelDataStat(
|
||||
getDate(postContentDate.startDate),
|
||||
getDate(postContentDate.endDate)
|
||||
);
|
||||
setPostCount(getTableNumber(10, res?.data?.data));
|
||||
}
|
||||
|
||||
const getTableNumber = (limit: number, data: any) => {
|
||||
if (data) {
|
||||
const startIndex = limit * (page - 1);
|
||||
let iterate = 0;
|
||||
const newData = data.map((value: any) => {
|
||||
iterate++;
|
||||
value.no = startIndex + iterate;
|
||||
return value;
|
||||
});
|
||||
return newData;
|
||||
}
|
||||
};
|
||||
|
||||
const getMonthYear = (date: any) => {
|
||||
return date.month + " " + date.year;
|
||||
};
|
||||
const getMonthYearName = (date: any) => {
|
||||
const newDate = new Date(date);
|
||||
|
||||
const months = [
|
||||
"Januari",
|
||||
"Februari",
|
||||
"Maret",
|
||||
"April",
|
||||
"Mei",
|
||||
"Juni",
|
||||
"Juli",
|
||||
"Agustus",
|
||||
"September",
|
||||
"Oktober",
|
||||
"November",
|
||||
"Desember",
|
||||
];
|
||||
const year = newDate.getFullYear();
|
||||
|
||||
const month = months[newDate.getMonth()];
|
||||
return month + " " + year;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-6">
|
||||
{/* User Profile Card */}
|
||||
<motion.div
|
||||
className="col-span-1 md:col-span-2 bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-bold text-slate-800">{fullname}</h3>
|
||||
<p className="text-slate-600">{username}</p>
|
||||
<div className="flex space-x-6 pt-2">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{summary?.totalToday}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">Today</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-purple-600">
|
||||
{summary?.totalThisWeek}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">This Week</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gradient-to-br from-blue-50 to-purple-50 rounded-xl">
|
||||
<DashboardUserIcon size={60} className="text-black" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Total Posts */}
|
||||
<motion.div
|
||||
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<div className="p-3 bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl">
|
||||
<DashboardSpeecIcon className="text-black" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-slate-800">
|
||||
{summary?.totalAll}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">Total Posts</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Total Views */}
|
||||
<motion.div
|
||||
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<div className="p-3 bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl">
|
||||
<DashboardConnectIcon className="text-black" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-slate-800">
|
||||
{summary?.totalViews}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">Total Views</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Total Shares */}
|
||||
<motion.div
|
||||
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<div className="p-3 bg-gradient-to-br from-purple-50 to-pink-50 rounded-xl">
|
||||
<DashboardShareIcon className="text-black" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-slate-800">
|
||||
{summary?.totalShares}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">Total Shares</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Total Comments */}
|
||||
<motion.div
|
||||
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6 hover:shadow-xl transition-all duration-300"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<div className="p-3 bg-gradient-to-br from-orange-50 to-red-50 rounded-xl">
|
||||
<DashboardCommentIcon size={40} className="text-black" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-slate-800">
|
||||
{summary?.totalComments}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">Total Comments</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Analytics Chart */}
|
||||
<motion.div
|
||||
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-lg font-semibold text-slate-800">
|
||||
Analytics Overview
|
||||
</h3>
|
||||
<div className="flex space-x-4">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={analyticsView.includes(option.value)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange(option.value, checked as boolean)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-slate-600">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-80">
|
||||
<ApexChartColumn
|
||||
type="monthly"
|
||||
date={`${new Date().getMonth() + 1} ${new Date().getFullYear()}`}
|
||||
view={analyticsView}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Recent Articles */}
|
||||
<motion.div
|
||||
className="bg-white rounded-2xl shadow-lg border border-slate-200/60 p-6"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-lg font-semibold text-slate-800">
|
||||
Recent Articles
|
||||
</h3>
|
||||
<Link href="/admin/article/create">
|
||||
<Button className="bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white shadow-lg">
|
||||
Create Article
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-h-96 overflow-y-auto scrollbar-thin">
|
||||
{article?.map((list: any) => (
|
||||
<motion.div
|
||||
key={list?.id}
|
||||
className="flex space-x-4 p-4 rounded-xl hover:bg-slate-50 transition-colors duration-200"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
>
|
||||
<Image
|
||||
alt="thumbnail"
|
||||
src={list?.thumbnailUrl || `/no-image.jpg`}
|
||||
width={80}
|
||||
height={80}
|
||||
className="h-20 w-20 object-cover rounded-lg shadow-sm flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-slate-800 line-clamp-2 mb-1">
|
||||
{list?.title}
|
||||
</h4>
|
||||
<p className="text-sm text-slate-500">
|
||||
{convertDateFormat(list?.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-center">
|
||||
<CustomPagination
|
||||
totalPage={totalPage}
|
||||
onPageChange={(data) => setPage(data)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { close, error, loading } from "@/config/swal";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import { useRouter } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { createCustomStaticPage } from "@/service/static-page-service";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const CustomEditor = dynamic(
|
||||
() => {
|
||||
return import("@/components/editor/custom-editor");
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const formSchema = z.object({
|
||||
slug: z.string().min(2, {
|
||||
message: "Slug must be at least 2 characters.",
|
||||
}),
|
||||
title: z.string().min(2, {
|
||||
message: "Title must be at least 2 characters.",
|
||||
}),
|
||||
description: z.string().min(2, {
|
||||
message: "Main Keyword must be at least 2 characters.",
|
||||
}),
|
||||
htmlBody: z.string().min(2, {
|
||||
message: "Main Keyword must be at least 2 characters.",
|
||||
}),
|
||||
});
|
||||
|
||||
export default function StaticPageBuilder() {
|
||||
const MySwal = withReactContent(Swal);
|
||||
const router = useRouter();
|
||||
|
||||
const formOptions = {
|
||||
resolver: zodResolver(formSchema),
|
||||
};
|
||||
type UserSettingSchema = z.infer<typeof formSchema>;
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = useForm<UserSettingSchema>(formOptions);
|
||||
|
||||
const content = watch("htmlBody");
|
||||
const generatedPage = useCallback(() => {
|
||||
const sanitizedContent = DOMPurify.sanitize(content);
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.innerHTML = sanitizedContent;
|
||||
return (
|
||||
<Card className="rounded-md border p-4">
|
||||
{/* <div dangerouslySetInnerHTML={{ __html: sanitizedContent }} /> */}
|
||||
<div dangerouslySetInnerHTML={{ __html: textArea.value }} />
|
||||
</Card>
|
||||
);
|
||||
}, [content]);
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
const request = {
|
||||
title: values.title,
|
||||
slug: values.slug,
|
||||
description: values.description,
|
||||
htmlBody: values.htmlBody,
|
||||
};
|
||||
loading();
|
||||
const res = await createCustomStaticPage(request);
|
||||
|
||||
if (res?.error) {
|
||||
error(res.message);
|
||||
return false;
|
||||
}
|
||||
close();
|
||||
|
||||
successSubmit("/admin/static-page");
|
||||
};
|
||||
|
||||
function successSubmit(redirect: any) {
|
||||
MySwal.fire({
|
||||
title: "Sukses",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "OK",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
router.push(redirect);
|
||||
}
|
||||
});
|
||||
}
|
||||
// const title = watch("title");
|
||||
// useEffect(() => {
|
||||
// if (getValues("title")) {
|
||||
// setValue("slug", createSlug(getValues("title")));
|
||||
// }
|
||||
// }, [title]);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-3 px-4"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-1">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="title"
|
||||
placeholder="Title"
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.title?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.title?.message}</p>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-1">
|
||||
<Label htmlFor="slug">Slug</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="slug"
|
||||
placeholder="Slug"
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="w-full space-y-1">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Description"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.description?.message && (
|
||||
<p className="text-red-400 text-sm">{errors.description?.message}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
Editor
|
||||
<Controller
|
||||
control={control}
|
||||
name="htmlBody"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
// <Textarea
|
||||
// variant="bordered"
|
||||
// label=""
|
||||
// labelPlacement="outside"
|
||||
// placeholder=""
|
||||
// className="max-h-[80vh]"
|
||||
// classNames={{
|
||||
// mainWrapper: "h-[80vh] overflow-hidden",
|
||||
// innerWrapper: "h-[80vh] overflow-hidden",
|
||||
// input: "min-h-full",
|
||||
// inputWrapper: "h-full",
|
||||
// }}
|
||||
// value={value}
|
||||
// onChange={onChange}
|
||||
// disableAutosize={false}
|
||||
// />
|
||||
<CustomEditor onChange={onChange} initialData={value} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 flex flex-col gap-2">
|
||||
Preview
|
||||
{generatedPage()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end w-full">
|
||||
<Button type="submit" color="primary" className="w-fit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,606 @@
|
|||
"use client";
|
||||
import {
|
||||
CloudUploadIcon,
|
||||
CreateIconIon,
|
||||
DeleteIcon,
|
||||
DotsYIcon,
|
||||
SearchIcon,
|
||||
TimesIcon,
|
||||
} from "@/components/icons";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { close, error, loading, success } from "@/config/swal";
|
||||
import { Article } from "@/types/globals";
|
||||
import Link from "next/link";
|
||||
import { Fragment, Key, useCallback, useEffect, useState } from "react";
|
||||
import * as z from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import Image from "next/image";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import useDisclosure from "@/components/useDisclosure";
|
||||
import {
|
||||
createMediaFileAdvertise,
|
||||
deleteAdvertise,
|
||||
editAdvertise,
|
||||
editAdvertiseIsActive,
|
||||
getAdvertise,
|
||||
getAdvertiseById,
|
||||
} from "@/service/advertisement";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import CustomPagination from "@/components/layout/custom-pagination";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const columns = [
|
||||
{ name: "No", uid: "no" },
|
||||
{ name: "Judul", uid: "title" },
|
||||
{ name: "Deskripsi", uid: "description" },
|
||||
{ name: "Penempatan", uid: "placement" },
|
||||
{ name: "Link", uid: "redirectLink" },
|
||||
{ name: "Aktif", uid: "isActive" },
|
||||
{ name: "Aksi", uid: "actions" },
|
||||
];
|
||||
|
||||
const createArticleSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
title: z.string().min(2, {
|
||||
message: "Judul harus diisi",
|
||||
}),
|
||||
url: z.string().min(1, {
|
||||
message: "Url harus diisi",
|
||||
}),
|
||||
description: z.string().min(2, {
|
||||
message: "Deskripsi harus diisi",
|
||||
}),
|
||||
file: z.string().optional(),
|
||||
});
|
||||
|
||||
export default function AdvertiseTable(props: { triggerRefresh: boolean }) {
|
||||
const MySwal = withReactContent(Swal);
|
||||
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(1);
|
||||
const [article, setArticle] = useState<any[]>([]);
|
||||
const [showData, setShowData] = useState("10");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const [placement, setPlacement] = useState("banner");
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const formOptions = {
|
||||
resolver: zodResolver(createArticleSchema),
|
||||
defaultValues: { title: "", description: "", url: "", file: "" },
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: (acceptedFiles) => {
|
||||
setFiles(acceptedFiles.map((file) => Object.assign(file)));
|
||||
},
|
||||
maxFiles: 1,
|
||||
accept: {
|
||||
"image/*": [],
|
||||
},
|
||||
});
|
||||
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<UserSettingSchema>(formOptions);
|
||||
|
||||
useEffect(() => {
|
||||
initState();
|
||||
}, [page, showData, props.triggerRefresh, refresh]);
|
||||
|
||||
const handleRemoveFile = (file: File) => {
|
||||
const uploadedFiles = files;
|
||||
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
|
||||
setFiles([...filtered]);
|
||||
};
|
||||
|
||||
async function initState() {
|
||||
const req = {
|
||||
limit: showData,
|
||||
page: page,
|
||||
search: search,
|
||||
|
||||
sort: "desc",
|
||||
sortBy: "created_at",
|
||||
};
|
||||
const res = await getAdvertise(req);
|
||||
getTableNumber(parseInt(showData), res.data?.data);
|
||||
setTotalPage(res?.data?.meta?.totalPage);
|
||||
}
|
||||
|
||||
const getTableNumber = (limit: number, data: Article[]) => {
|
||||
if (data) {
|
||||
const startIndex = limit * (page - 1);
|
||||
let iterate = 0;
|
||||
const newData = data.map((value: any) => {
|
||||
iterate++;
|
||||
value.no = startIndex + iterate;
|
||||
return value;
|
||||
});
|
||||
setArticle(newData);
|
||||
}
|
||||
};
|
||||
|
||||
async function doDelete(id: any) {
|
||||
loading();
|
||||
const resDelete = await deleteAdvertise(id);
|
||||
|
||||
if (resDelete?.error) {
|
||||
error(resDelete.message);
|
||||
return false;
|
||||
}
|
||||
close();
|
||||
success("Berhasil Hapus");
|
||||
setRefresh(!refresh);
|
||||
}
|
||||
|
||||
const handleDelete = (id: any) => {
|
||||
MySwal.fire({
|
||||
title: "Hapus Data",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
cancelButtonColor: "#3085d6",
|
||||
confirmButtonColor: "#d33",
|
||||
confirmButtonText: "Hapus",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
doDelete(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
|
||||
loading();
|
||||
const formData = {
|
||||
id: Number(values.id),
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
placement: placement,
|
||||
redirectLink: values.url,
|
||||
};
|
||||
const res = await editAdvertise(formData);
|
||||
if (res?.error) {
|
||||
error(res?.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
const formFiles = new FormData();
|
||||
formFiles.append("file", files[0]);
|
||||
const resFile = await createMediaFileAdvertise(
|
||||
Number(values.id),
|
||||
formFiles
|
||||
);
|
||||
}
|
||||
|
||||
close();
|
||||
MySwal.fire({
|
||||
title: "Sukses",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "OK",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
setRefresh(!refresh);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const openModal = async (id: number) => {
|
||||
const res = await getAdvertiseById(Number(id));
|
||||
const data = res?.data?.data;
|
||||
setValue("id", String(data?.id));
|
||||
setValue("title", data?.title);
|
||||
setValue("description", data?.description);
|
||||
setValue("url", data?.redirectLink);
|
||||
setPlacement(data?.placement);
|
||||
// setValue("file", data?.thumbnailUrl);
|
||||
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleAdvertise = async (e: boolean, id: number) => {
|
||||
const res = await editAdvertiseIsActive({ id, isActive: e });
|
||||
if (res?.error) {
|
||||
error(res?.message);
|
||||
return false;
|
||||
}
|
||||
setRefresh(!refresh);
|
||||
};
|
||||
|
||||
const renderCell = useCallback(
|
||||
(advertise: any, columnKey: Key) => {
|
||||
const cellValue = advertise[columnKey as keyof any];
|
||||
|
||||
switch (columnKey) {
|
||||
case "redirectLink":
|
||||
return cellValue.includes("https") ? (
|
||||
<Link
|
||||
href={cellValue}
|
||||
target="_blank"
|
||||
className="text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
{cellValue}
|
||||
</Link>
|
||||
) : (
|
||||
<p> {cellValue}</p>
|
||||
);
|
||||
case "placement":
|
||||
return <p className="capitalize">{cellValue}</p>;
|
||||
case "isActive":
|
||||
return (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Switch
|
||||
checked={advertise?.isPublish}
|
||||
onCheckedChange={(e) => handleAdvertise(e, advertise?.id)}
|
||||
/>
|
||||
{advertise?.isPublish ? "Ya" : "Tidak"}
|
||||
</div>
|
||||
);
|
||||
case "actions":
|
||||
return (
|
||||
<div className="relative flex justify-start items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<DotsYIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="lg:min-w-[150px] bg-black text-white shadow border">
|
||||
{/*
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/advertise/detail/${article.id}`}>
|
||||
<EyeIconMdi className="inline mr-2 mb-1" />
|
||||
Detail
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
*/}
|
||||
|
||||
<DropdownMenuItem onClick={() => openModal(advertise.id)}>
|
||||
<CreateIconIon className="inline mr-2 mb-1" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => handleDelete(advertise.id)}>
|
||||
<DeleteIcon
|
||||
color="red"
|
||||
size={18}
|
||||
className="inline ml-1 mr-2 mb-1"
|
||||
/>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return cellValue;
|
||||
}
|
||||
},
|
||||
[article, props.triggerRefresh, refresh]
|
||||
);
|
||||
|
||||
let typingTimer: NodeJS.Timeout;
|
||||
const doneTypingInterval = 1500;
|
||||
|
||||
const handleKeyUp = () => {
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||
};
|
||||
|
||||
const handleKeyDown = () => {
|
||||
clearTimeout(typingTimer);
|
||||
};
|
||||
|
||||
async function doneTyping() {
|
||||
initState();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-3">
|
||||
<div className="flex flex-col items-start rounded-2xl gap-3">
|
||||
<div className="flex flex-col md:flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-1 w-full lg:w-1/3">
|
||||
<p className="font-semibold text-sm">Pencarian</p>
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-base text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
aria-label="Search"
|
||||
type="text"
|
||||
className="pl-10 text-sm bg-muted"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
|
||||
<p className="font-semibold text-sm">Data</p>
|
||||
<Select
|
||||
value={showData}
|
||||
onValueChange={(value) =>
|
||||
value === "" ? "" : setShowData(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Table className="rounded-3xl min-h-[50px] bg-white dark:bg-black border text-black dark:text-white">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
key={column.uid}
|
||||
className="bg-white dark:bg-black text-black dark:text-white border-b text-md"
|
||||
>
|
||||
{column.name}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{article.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="text-center py-4"
|
||||
>
|
||||
No data to display.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
article.map((item: any) => (
|
||||
<TableRow key={item.id}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.uid}>
|
||||
{renderCell(item, column.uid)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="my-2 w-full flex justify-center">
|
||||
{/* <Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color="primary"
|
||||
classNames={{
|
||||
base: "bg-transparent",
|
||||
wrapper: "bg-transparent",
|
||||
}}
|
||||
page={page}
|
||||
total={totalPage}
|
||||
onChange={(page) => setPage(page)}
|
||||
/> */}
|
||||
<CustomPagination
|
||||
totalPage={totalPage}
|
||||
onPageChange={(data) => setPage(data)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Advertise</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Judul</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
type="text"
|
||||
id="title"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.title && (
|
||||
<p className="text-red-400 text-sm">{errors.title?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Deskripsi</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Textarea
|
||||
id="description"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.description && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.description?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Link</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
type="text"
|
||||
id="url"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full border border-gray-300 dark:border-gray-400 rounded-lg"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.url && (
|
||||
<p className="text-red-400 text-sm">{errors.url?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm mt-3">Penempatan</p>
|
||||
<RadioGroup
|
||||
value={placement}
|
||||
onValueChange={setPlacement}
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="banner" id="banner" />
|
||||
<label htmlFor="banner" className="text-sm">
|
||||
Banner
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="jumbotron" id="jumbotron" />
|
||||
<label htmlFor="jumbotron" className="text-sm">
|
||||
Jumbotron
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="file"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm mt-3">Thumbnail</p>
|
||||
|
||||
{files.length < 1 && value === "" && (
|
||||
<Fragment>
|
||||
<div {...getRootProps({ className: "dropzone" })}>
|
||||
<input {...getInputProps()} />
|
||||
<div className="w-full text-center border-dashed border border-gray-300 rounded-md py-[52px] flex items-center flex-col">
|
||||
<CloudUploadIcon />
|
||||
<h4 className="text-2xl font-medium mb-1 mt-3 text-gray-700">
|
||||
Tarik file disini atau klik untuk upload.
|
||||
</h4>
|
||||
<div className="text-xs text-gray-500">
|
||||
( Upload file dengan format .jpg, .jpeg, atau .png.
|
||||
Ukuran maksimal 100mb.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{value !== "" && (
|
||||
<div className="flex flex-row gap-2">
|
||||
<Image
|
||||
src={String(value)}
|
||||
className="w-[30%]"
|
||||
alt="thumbnail"
|
||||
width={480}
|
||||
height={480}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setValue("file", "")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<TimesIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-row gap-2">
|
||||
<img
|
||||
src={URL.createObjectURL(files[0])}
|
||||
className="w-[30%]"
|
||||
alt="thumbnail"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFile(files[0])}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<TimesIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4 flex justify-end gap-2">
|
||||
<Button type="submit">Simpan</Button>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Tutup
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,446 @@
|
|||
"use client";
|
||||
import {
|
||||
BannerIcon,
|
||||
CopyIcon,
|
||||
CreateIconIon,
|
||||
DeleteIcon,
|
||||
DotsYIcon,
|
||||
EyeIconMdi,
|
||||
SearchIcon,
|
||||
} from "@/components/icons";
|
||||
import { close, error, loading, success, successToast } from "@/config/swal";
|
||||
import { Article } from "@/types/globals";
|
||||
import { convertDateFormat } from "@/utils/global";
|
||||
import Link from "next/link";
|
||||
import { Key, useCallback, useEffect, useState } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import Cookies from "js-cookie";
|
||||
import {
|
||||
deleteArticle,
|
||||
getArticleByCategory,
|
||||
getArticlePagination,
|
||||
updateIsBannerArticle,
|
||||
} from "@/service/article";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import CustomPagination from "../layout/custom-pagination";
|
||||
|
||||
const columns = [
|
||||
{ name: "No", uid: "no" },
|
||||
{ name: "Judul", uid: "title" },
|
||||
{ name: "Banner", uid: "isBanner" },
|
||||
{ name: "Kategori", uid: "category" },
|
||||
{ name: "Tanggal Unggah", uid: "createdAt" },
|
||||
{ name: "Kreator", uid: "createdByName" },
|
||||
{ name: "Status", uid: "isPublish" },
|
||||
{ name: "Aksi", uid: "actions" },
|
||||
];
|
||||
const columnsOtherRole = [
|
||||
{ name: "No", uid: "no" },
|
||||
{ name: "Judul", uid: "title" },
|
||||
{ name: "Kategori", uid: "category" },
|
||||
{ name: "Tanggal Unggah", uid: "createdAt" },
|
||||
{ name: "Kreator", uid: "createdByName" },
|
||||
{ name: "Status", uid: "isPublish" },
|
||||
{ name: "Aksi", uid: "actions" },
|
||||
];
|
||||
|
||||
// interface Category {
|
||||
// id: number;
|
||||
// title: string;
|
||||
// }
|
||||
|
||||
export default function ArticleTable() {
|
||||
const MySwal = withReactContent(Swal);
|
||||
const username = Cookies.get("username");
|
||||
const userId = Cookies.get("uie");
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(1);
|
||||
const [article, setArticle] = useState<any[]>([]);
|
||||
const [showData, setShowData] = useState("10");
|
||||
const [search, setSearch] = useState("");
|
||||
const [categories, setCategories] = useState<any>([]);
|
||||
const [selectedCategories, setSelectedCategories] = useState<any>("");
|
||||
const [startDateValue, setStartDateValue] = useState({
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
initState();
|
||||
getCategories();
|
||||
}, []);
|
||||
|
||||
async function getCategories() {
|
||||
const res = await getArticleByCategory();
|
||||
const data = res?.data?.data;
|
||||
setCategories(data);
|
||||
}
|
||||
|
||||
const initState = useCallback(async () => {
|
||||
loading();
|
||||
const req = {
|
||||
limit: showData,
|
||||
page: page,
|
||||
search: search,
|
||||
// startDate:
|
||||
// startDateValue.startDate === null ? "" : startDateValue.startDate,
|
||||
// endDate: startDateValue.endDate === null ? "" : startDateValue.endDate,
|
||||
categorySlug: Array.from(selectedCategories).join(","),
|
||||
sort: "desc",
|
||||
sortBy: "created_at",
|
||||
};
|
||||
const res = await getArticlePagination(req);
|
||||
await getTableNumber(parseInt(showData), res.data?.data);
|
||||
setTotalPage(res?.data?.meta?.totalPage);
|
||||
close();
|
||||
}, [page]);
|
||||
|
||||
const getTableNumber = async (limit: number, data: Article[]) => {
|
||||
if (data) {
|
||||
const startIndex = limit * (page - 1);
|
||||
let iterate = 0;
|
||||
const newData = data.map((value: any) => {
|
||||
iterate++;
|
||||
value.no = startIndex + iterate;
|
||||
return value;
|
||||
});
|
||||
setArticle(newData);
|
||||
} else {
|
||||
setArticle([]);
|
||||
}
|
||||
};
|
||||
|
||||
async function doDelete(id: any) {
|
||||
// loading();
|
||||
const resDelete = await deleteArticle(id);
|
||||
|
||||
if (resDelete?.error) {
|
||||
error(resDelete.message);
|
||||
return false;
|
||||
}
|
||||
close();
|
||||
success("Berhasil Hapus");
|
||||
initState();
|
||||
}
|
||||
|
||||
const handleDelete = (id: any) => {
|
||||
MySwal.fire({
|
||||
title: "Hapus Data",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
cancelButtonColor: "#3085d6",
|
||||
confirmButtonColor: "#d33",
|
||||
confirmButtonText: "Hapus",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
doDelete(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleBanner = async (id: number, status: boolean) => {
|
||||
const res = await updateIsBannerArticle(id, status);
|
||||
if (res?.error) {
|
||||
error(res?.message);
|
||||
return false;
|
||||
}
|
||||
initState();
|
||||
};
|
||||
|
||||
const copyUrlArticle = async (id: number, slug: string) => {
|
||||
const url =
|
||||
`${window.location.protocol}//${window.location.host}` +
|
||||
"/news/detail/" +
|
||||
`${id}-${slug}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
successToast("Success", "Article Copy to Clipboard");
|
||||
setTimeout(() => {}, 1500);
|
||||
} catch (err) {
|
||||
("Failed to copy!");
|
||||
}
|
||||
};
|
||||
|
||||
const renderCell = useCallback(
|
||||
(article: any, columnKey: Key) => {
|
||||
const cellValue = article[columnKey as keyof any];
|
||||
|
||||
switch (columnKey) {
|
||||
case "isPublish":
|
||||
return (
|
||||
// <Chip
|
||||
// className="capitalize "
|
||||
// color={statusColorMap[article.status]}
|
||||
// size="lg"
|
||||
// variant="flat"
|
||||
// >
|
||||
// <div className="flex flex-row items-center gap-2 justify-center">
|
||||
// {article.status}
|
||||
// </div>
|
||||
// </Chip>
|
||||
<p>{article.isPublish ? "Publish" : "Draft"}</p>
|
||||
);
|
||||
case "isBanner":
|
||||
return <p>{article.isBanner ? "Ya" : "Tidak"}</p>;
|
||||
case "createdAt":
|
||||
return <p>{convertDateFormat(article.createdAt)}</p>;
|
||||
case "category":
|
||||
return (
|
||||
<p>
|
||||
{article?.categories?.map((list: any) => list.title).join(", ") +
|
||||
" "}
|
||||
</p>
|
||||
);
|
||||
|
||||
case "actions":
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<DotsYIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={() => copyUrlArticle(article.id, article.slug)}
|
||||
>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
Copy Url Article
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/admin/article/detail/${article.id}`}
|
||||
className="flex items-center"
|
||||
>
|
||||
<EyeIconMdi className="mr-2 h-4 w-4" />
|
||||
Detail
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{(username === "admin-mabes" ||
|
||||
Number(userId) === article.createdById) && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/admin/article/edit/${article.id}`}
|
||||
className="flex items-center"
|
||||
>
|
||||
<CreateIconIon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{username === "admin-mabes" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleBanner(article.id, !article.isBanner)
|
||||
}
|
||||
>
|
||||
<BannerIcon className="mr-2 h-4 w-4" />
|
||||
{article.isBanner
|
||||
? "Hapus dari Banner"
|
||||
: "Jadikan Banner"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{(username === "admin-mabes" ||
|
||||
Number(userId) === article.createdById) && (
|
||||
<DropdownMenuItem onClick={() => handleDelete(article.id)}>
|
||||
<DeleteIcon className="mr-2 h-4 w-4 text-red-500" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return cellValue;
|
||||
}
|
||||
},
|
||||
[article, page]
|
||||
);
|
||||
|
||||
let typingTimer: NodeJS.Timeout;
|
||||
const doneTypingInterval = 1500;
|
||||
|
||||
const handleKeyUp = () => {
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||
};
|
||||
|
||||
const handleKeyDown = () => {
|
||||
clearTimeout(typingTimer);
|
||||
};
|
||||
|
||||
async function doneTyping() {
|
||||
setPage(1);
|
||||
initState();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-3">
|
||||
<div className="flex flex-col items-start rounded-2xl gap-3">
|
||||
<div className="flex flex-col md:flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-1 w-full lg:w-1/3">
|
||||
<p className="font-semibold text-sm">Pencarian</p>
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground h-4 w-4 pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Cari..."
|
||||
className="pl-9 text-sm bg-muted"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
|
||||
<p className="font-semibold text-sm">Data</p>
|
||||
<Select
|
||||
value={showData}
|
||||
onValueChange={(value) => setShowData(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full text-sm border">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="25">25</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-full lg:w-[230px]">
|
||||
<p className="font-semibold text-sm">Kategori</p>
|
||||
<Select
|
||||
value={selectedCategories}
|
||||
onValueChange={(value) => setSelectedCategories(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full text-sm border">
|
||||
<SelectValue placeholder="Kategori" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories
|
||||
?.filter((category: any) => category.slug != null)
|
||||
.map((category: any) => (
|
||||
<SelectItem key={category.slug} value={category.slug}>
|
||||
{category.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* <div className="flex flex-col gap-1 w-full lg:w-[240px]">
|
||||
<p className="font-semibold text-sm">Tanggal</p>
|
||||
<Datepicker
|
||||
value={startDateValue}
|
||||
displayFormat="DD/MM/YYYY"
|
||||
onChange={(e: any) => setStartDateValue(e)}
|
||||
inputClassName="z-50 w-full text-sm bg-transparent border-1 border-gray-200 px-2 py-[6px] rounded-xl h-[40px] text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="w-full overflow-x-hidden">
|
||||
<div className="w-full mx-auto overflow-x-hidden">
|
||||
<Table className="w-full table-fixed border text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{(username === "admin-mabes"
|
||||
? columns
|
||||
: columnsOtherRole
|
||||
).map((column) => (
|
||||
<TableHead
|
||||
key={column.uid}
|
||||
className="truncate bg-white dark:bg-black text-black dark:text-white border-b text-md"
|
||||
>
|
||||
{column.name}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{article.length > 0 ? (
|
||||
article.map((item: any) => (
|
||||
<TableRow key={item.id}>
|
||||
{(username === "admin-mabes"
|
||||
? columns
|
||||
: columnsOtherRole
|
||||
).map((column) => (
|
||||
<TableCell
|
||||
key={column.uid}
|
||||
className="truncate text-black dark:text-white max-w-[200px]"
|
||||
>
|
||||
{renderCell(item, column.uid)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="text-center py-4"
|
||||
>
|
||||
No data to display.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-2 w-full flex justify-center">
|
||||
{/* <Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color="primary"
|
||||
classNames={{
|
||||
base: "bg-transparent",
|
||||
wrapper: "bg-transparent",
|
||||
}}
|
||||
page={page}
|
||||
total={totalPage}
|
||||
onChange={(page) => setPage(page)}
|
||||
/> */}
|
||||
<CustomPagination
|
||||
totalPage={totalPage}
|
||||
onPageChange={(data) => setPage(data)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
"use client";
|
||||
import {
|
||||
CreateIconIon,
|
||||
DeleteIcon,
|
||||
DotsYIcon,
|
||||
EyeIconMdi,
|
||||
SearchIcon,
|
||||
} from "@/components/icons";
|
||||
import { close, error, loading, success } from "@/config/swal";
|
||||
import { getArticleByCategory } from "@/service/article";
|
||||
import { deleteMagazine, getListMagazine } from "@/service/magazine";
|
||||
import { Article } from "@/types/globals";
|
||||
import { convertDateFormat } from "@/utils/global";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { Key, useCallback, useEffect, useState } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import CustomPagination from "@/components/layout/custom-pagination";
|
||||
import { Chip, ChipProps } from "@/components/ui/chip";
|
||||
|
||||
const columns = [
|
||||
{ name: "No", uid: "no" },
|
||||
{ name: "Judul", uid: "title" },
|
||||
// { name: "Kategori", uid: "categoryName" },
|
||||
{ name: "Tanggal Unggah", uid: "createdAt" },
|
||||
{ name: "Kreator", uid: "createdByName" },
|
||||
{ name: "Aksi", uid: "actions" },
|
||||
];
|
||||
|
||||
type ArticleData = Article & {
|
||||
no: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function MagazineTable() {
|
||||
const MySwal = withReactContent(Swal);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(1);
|
||||
const [article, setArticle] = useState<ArticleData[]>([]);
|
||||
const [showData, setShowData] = useState("10");
|
||||
const [search, setSearch] = useState("");
|
||||
const [, setCategoies] = useState<any>([]);
|
||||
const [startDateValue] = useState({
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
initState();
|
||||
}, [page, showData, startDateValue]);
|
||||
|
||||
useEffect(() => {
|
||||
getCategories();
|
||||
}, []);
|
||||
|
||||
async function getCategories() {
|
||||
const res = await getArticleByCategory();
|
||||
const data = res?.data?.data;
|
||||
console.log("datass", res?.data?.data);
|
||||
setCategoies(data);
|
||||
}
|
||||
|
||||
async function initState() {
|
||||
const req = {
|
||||
limit: showData,
|
||||
page: page,
|
||||
search: search,
|
||||
startDate:
|
||||
startDateValue.startDate === null ? "" : startDateValue.startDate,
|
||||
endDate: startDateValue.endDate === null ? "" : startDateValue.endDate,
|
||||
};
|
||||
const res = await getListMagazine(req);
|
||||
getTableNumber(parseInt(showData), res.data?.data);
|
||||
console.log("res.data?.data magz", res.data);
|
||||
setTotalPage(res?.data?.meta?.totalPage);
|
||||
}
|
||||
|
||||
const getTableNumber = (limit: number, data: Article[]) => {
|
||||
if (data) {
|
||||
const startIndex = limit * (page - 1);
|
||||
let iterate = 0;
|
||||
const newData = data.map((value: any) => {
|
||||
iterate++;
|
||||
value.no = startIndex + iterate;
|
||||
return value;
|
||||
});
|
||||
console.log("daata", data);
|
||||
setArticle(newData);
|
||||
} else {
|
||||
setArticle([]);
|
||||
}
|
||||
};
|
||||
|
||||
async function doDelete(id: any) {
|
||||
loading();
|
||||
const resDelete = await deleteMagazine(id);
|
||||
|
||||
if (resDelete?.error) {
|
||||
error(resDelete.message);
|
||||
return false;
|
||||
}
|
||||
close();
|
||||
success("Berhasil Hapus");
|
||||
initState();
|
||||
}
|
||||
|
||||
const handleDelete = (id: any) => {
|
||||
MySwal.fire({
|
||||
title: "Hapus Data",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
cancelButtonColor: "#3085d6",
|
||||
confirmButtonColor: "#d33",
|
||||
confirmButtonText: "Hapus",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
doDelete(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const renderCell = useCallback((article: ArticleData, columnKey: Key) => {
|
||||
const cellValue = article[columnKey as keyof ArticleData];
|
||||
const statusColorMap: Record<string, ChipProps["color"]> = {
|
||||
active: "primary",
|
||||
cancel: "danger",
|
||||
pending: "success",
|
||||
};
|
||||
|
||||
switch (columnKey) {
|
||||
case "status":
|
||||
return (
|
||||
<Chip
|
||||
className="capitalize "
|
||||
color={statusColorMap[article.status]}
|
||||
size="lg"
|
||||
variant="flat"
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2 justify-center">
|
||||
{cellValue}
|
||||
</div>
|
||||
</Chip>
|
||||
);
|
||||
case "createdAt":
|
||||
return <p>{convertDateFormat(article.createdAt)}</p>;
|
||||
|
||||
case "actions":
|
||||
return (
|
||||
<div className="relative flex justify-start items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<DotsYIcon className="text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="bg-black text-white border shadow-lg">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/admin/magazine/detail/${article.id}`}
|
||||
className="flex items-center"
|
||||
>
|
||||
<EyeIconMdi className="inline mr-2 mb-1" />
|
||||
Detail
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/admin/magazine/edit/${article.id}`}
|
||||
className="flex items-center"
|
||||
>
|
||||
<CreateIconIon className="inline mr-2 mb-1" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(article.id)}
|
||||
className="text-red-500"
|
||||
>
|
||||
<DeleteIcon
|
||||
width={20}
|
||||
height={16}
|
||||
className="inline mr-2 mb-1"
|
||||
/>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return cellValue;
|
||||
}
|
||||
}, []);
|
||||
|
||||
let typingTimer: NodeJS.Timeout;
|
||||
const doneTypingInterval = 1500;
|
||||
|
||||
const handleKeyUp = () => {
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||
};
|
||||
|
||||
const handleKeyDown = () => {
|
||||
clearTimeout(typingTimer);
|
||||
};
|
||||
|
||||
async function doneTyping() {
|
||||
initState();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-3 w-full">
|
||||
<div className="flex flex-col items-start rounded-2xl gap-3">
|
||||
<div className="flex flex-col md:flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-1 w-full lg:w-1/3">
|
||||
<p className="font-semibold text-sm">Pencarian</p>
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-base text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
className="pl-10 text-sm bg-muted"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
|
||||
<p className="font-semibold text-sm">Data</p>
|
||||
<Select
|
||||
onValueChange={(value) => setShowData(value)}
|
||||
value={showData}
|
||||
>
|
||||
<SelectTrigger className="w-full border">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* <div className="flex flex-col gap-1 w-[230px]">
|
||||
<p className="font-semibold text-sm">Kategori</p>
|
||||
<Select
|
||||
label=""
|
||||
variant="bordered"
|
||||
labelPlacement="outside"
|
||||
placeholder="Select"
|
||||
selectionMode="multiple"
|
||||
selectedKeys={[selectedCategories]}
|
||||
className="w-full"
|
||||
classNames={{ trigger: "border-1" }}
|
||||
onChange={(e) => {
|
||||
e.target.value === ""
|
||||
? ""
|
||||
: setSelectedCategories(e.target.value);
|
||||
}}
|
||||
>
|
||||
{categories?.map((category: any) => (
|
||||
<SelectItem key={category?.id} value={category?.id}>
|
||||
{category?.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div> */}
|
||||
{/* <div className="flex flex-col gap-1 w-full lg:w-[240px]">
|
||||
<p className="font-semibold text-sm">Tanggal</p>
|
||||
<Datepicker
|
||||
value={startDateValue}
|
||||
displayFormat="DD/MM/YYYY"
|
||||
onChange={(e: any) => setStartDateValue(e)}
|
||||
inputClassName="z-50 w-full text-sm bg-transparent border-1 border-gray-200 px-2 py-[6px] rounded-xl h-[40px] text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="rounded-3xl border overflow-hidden w-full">
|
||||
<Table>
|
||||
<thead className="bg-white dark:bg-black text-black dark:text-white border-b">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.uid} className="text-md">
|
||||
{column.name}
|
||||
</TableHead>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{article.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="text-center py-4"
|
||||
>
|
||||
No data to display.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
article.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.uid}>
|
||||
{renderCell(item, column.uid)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="my-2 w-full flex justify-center">
|
||||
{/* <Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color="primary"
|
||||
classNames={{
|
||||
base: "bg-transparent",
|
||||
wrapper: "bg-transparent",
|
||||
}}
|
||||
page={page}
|
||||
total={totalPage}
|
||||
onChange={(page) => setPage(page)}
|
||||
/> */}
|
||||
<CustomPagination
|
||||
totalPage={totalPage}
|
||||
onPageChange={(data) => setPage(data)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,712 @@
|
|||
"use client";
|
||||
import {
|
||||
CloudUploadIcon,
|
||||
CreateIconIon,
|
||||
DeleteIcon,
|
||||
DotsYIcon,
|
||||
EyeIconMdi,
|
||||
SearchIcon,
|
||||
} from "@/components/icons";
|
||||
import { Article } from "@/types/globals";
|
||||
import { convertDateFormat } from "@/utils/global";
|
||||
import { Key, useCallback, useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import ReactSelect from "react-select";
|
||||
import makeAnimated from "react-select/animated";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { close, error, loading, success } from "@/config/swal";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import * as z from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Image from "next/image";
|
||||
import useDisclosure from "@/components/useDisclosure";
|
||||
import { getArticleByCategory, getCategoryPagination } from "@/service/article";
|
||||
import {
|
||||
deleteCategory,
|
||||
getCategoryById,
|
||||
updateCategory,
|
||||
uploadCategoryThumbnail,
|
||||
} from "@/service/master-categories";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import CustomPagination from "@/components/layout/custom-pagination";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const columns = [
|
||||
{ name: "No", uid: "no" },
|
||||
{ name: "Kategori", uid: "title" },
|
||||
{ name: "Deskripsi", uid: "description" },
|
||||
{ name: "Tag Terkait", uid: "tags" },
|
||||
{ name: "Dibuat ", uid: "createdAt" },
|
||||
|
||||
{ name: "Aksi", uid: "actions" },
|
||||
];
|
||||
|
||||
interface CategoryType {
|
||||
id: number;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
type ArticleData = Article & {
|
||||
no: number;
|
||||
createdAt: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
// const categorySchema = z.object({
|
||||
// id: z.number(),
|
||||
// label: z.string(),
|
||||
// value: z.number(),
|
||||
// });
|
||||
|
||||
const createArticleSchema = z.object({
|
||||
id: z.string().min(1, {
|
||||
message: "Id harus valid",
|
||||
}),
|
||||
title: z.string().min(2, {
|
||||
message: "Judul harus diisi",
|
||||
}),
|
||||
description: z.string().min(2, {
|
||||
message: "Deskripsi harus diisi",
|
||||
}),
|
||||
tags: z.array(z.string()),
|
||||
|
||||
file: z.string(),
|
||||
});
|
||||
|
||||
export default function CategoriesTable(props: { triggerRefresh: boolean }) {
|
||||
const MySwal = withReactContent(Swal);
|
||||
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
|
||||
const animatedComponents = makeAnimated();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(1);
|
||||
const [categories, setCategories] = useState<ArticleData[]>([]);
|
||||
const [showData, setShowData] = useState("10");
|
||||
const [search, setSearch] = useState("");
|
||||
const [listCategory, setListCategory] = useState<CategoryType[]>([]);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [isDetail, setIsDetail] = useState(false);
|
||||
const [tag, setTag] = useState("");
|
||||
const formOptions = {
|
||||
resolver: zodResolver(createArticleSchema),
|
||||
defaultValues: { title: "", description: "", category: [], tags: [] },
|
||||
};
|
||||
const [selectedParent, setSelectedParent] = useState<any>();
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: (acceptedFiles) => {
|
||||
setFiles(acceptedFiles.map((file) => Object.assign(file)));
|
||||
},
|
||||
maxFiles: 1,
|
||||
accept: {
|
||||
"image/*": [],
|
||||
},
|
||||
});
|
||||
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
getValues,
|
||||
setError,
|
||||
clearErrors,
|
||||
} = useForm<UserSettingSchema>(formOptions);
|
||||
|
||||
useEffect(() => {
|
||||
initState();
|
||||
}, [page, showData, props.triggerRefresh]);
|
||||
|
||||
async function initState() {
|
||||
const req = {
|
||||
limit: showData,
|
||||
page: page,
|
||||
search: search,
|
||||
};
|
||||
const res = await getCategoryPagination(req);
|
||||
getTableNumber(parseInt(showData), res.data?.data);
|
||||
setTotalPage(res?.data?.meta?.totalPage);
|
||||
}
|
||||
|
||||
const getTableNumber = (limit: number, data: Article[]) => {
|
||||
if (data) {
|
||||
const startIndex = limit * (page - 1);
|
||||
let iterate = 0;
|
||||
const newData = data.map((value: any) => {
|
||||
iterate++;
|
||||
value.no = startIndex + iterate;
|
||||
return value;
|
||||
});
|
||||
setCategories(newData);
|
||||
} else {
|
||||
setCategories([]);
|
||||
}
|
||||
};
|
||||
|
||||
async function doDelete(id: number) {
|
||||
// loading();
|
||||
const resDelete = await deleteCategory(id);
|
||||
|
||||
if (resDelete?.error) {
|
||||
error(resDelete.message);
|
||||
return false;
|
||||
}
|
||||
close();
|
||||
success("Berhasil Hapus");
|
||||
initState();
|
||||
}
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
MySwal.fire({
|
||||
title: "Hapus Data",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
cancelButtonColor: "#3085d6",
|
||||
confirmButtonColor: "#d33",
|
||||
confirmButtonText: "Hapus",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
doDelete(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategory();
|
||||
}, []);
|
||||
|
||||
const fetchCategory = async () => {
|
||||
const res = await getArticleByCategory();
|
||||
if (res?.data?.data) {
|
||||
setupCategory(res?.data?.data);
|
||||
}
|
||||
};
|
||||
|
||||
const setupCategory = (data: any) => {
|
||||
const temp = [];
|
||||
for (const element of data) {
|
||||
temp.push({
|
||||
id: element.id,
|
||||
label: element.title,
|
||||
value: element.id,
|
||||
});
|
||||
}
|
||||
setListCategory(temp);
|
||||
};
|
||||
|
||||
const openModal = async (id: number | string, detail: boolean) => {
|
||||
setIsDetail(detail);
|
||||
const res = await getCategoryById(Number(id));
|
||||
const data = res?.data?.data;
|
||||
setValue("id", String(data?.id));
|
||||
setValue("title", data?.title);
|
||||
setValue("description", data?.description);
|
||||
setValue("tags", data?.tags);
|
||||
setValue("file", data?.thumbnailUrl);
|
||||
findParent(data?.parentId);
|
||||
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const findParent = (parent: number | undefined) => {
|
||||
const finded = listCategory?.find((a: any) => a.id === parent);
|
||||
if (finded) {
|
||||
setSelectedParent(finded);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCell = useCallback(
|
||||
(category: ArticleData, columnKey: Key) => {
|
||||
const cellValue = category[columnKey as keyof ArticleData];
|
||||
// const statusColorMap: Record<string, ChipProps["color"]> = {
|
||||
// active: "primary",
|
||||
// cancel: "danger",
|
||||
// pending: "success",
|
||||
// };
|
||||
|
||||
// const findRelated = (parent: number | string) => {
|
||||
// const filter = listCategory?.filter((a) => a.id == parent);
|
||||
// return filter[0]?.label;
|
||||
// };
|
||||
|
||||
switch (columnKey) {
|
||||
case "tags":
|
||||
return (
|
||||
<div className="flex flex-row gap-1">
|
||||
{category.tags
|
||||
? category.tags.map((value) => value).join(", ")
|
||||
: "-"}
|
||||
</div>
|
||||
);
|
||||
case "createdAt":
|
||||
return <p>{convertDateFormat(category.createdAt)}</p>;
|
||||
|
||||
case "actions":
|
||||
return (
|
||||
<div className="relative flex justify-star items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<DotsYIcon className="text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="lg:min-w-[150px] bg-black text-white shadow border">
|
||||
<DropdownMenuItem
|
||||
onClick={() => openModal(category.id, true)}
|
||||
>
|
||||
<EyeIconMdi className="inline mr-2 mb-1" />
|
||||
Detail
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openModal(category.id, false)}
|
||||
>
|
||||
<CreateIconIon className="inline mr-2 mb-1" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(category.id)}>
|
||||
<DeleteIcon
|
||||
color="red"
|
||||
size={20}
|
||||
className="inline mr-3 mb-1"
|
||||
/>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return cellValue;
|
||||
}
|
||||
},
|
||||
[listCategory]
|
||||
);
|
||||
|
||||
let typingTimer: NodeJS.Timeout;
|
||||
const doneTypingInterval = 1500;
|
||||
|
||||
const handleKeyUp = () => {
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||
};
|
||||
|
||||
const handleKeyDown = () => {
|
||||
clearTimeout(typingTimer);
|
||||
};
|
||||
|
||||
async function doneTyping() {
|
||||
initState();
|
||||
}
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof createArticleSchema>) => {
|
||||
loading();
|
||||
const formData = {
|
||||
id: Number(values.id),
|
||||
title: values.title,
|
||||
statusId: 1,
|
||||
parentId: selectedParent ? selectedParent.id : 0,
|
||||
tags: values.tags.join(","),
|
||||
description: values.description,
|
||||
};
|
||||
|
||||
const response = await updateCategory(values.id, formData);
|
||||
|
||||
if (response?.error) {
|
||||
error(response.message);
|
||||
return false;
|
||||
}
|
||||
if (files?.length > 0) {
|
||||
const formFiles = new FormData();
|
||||
|
||||
formFiles.append("files", files[0]);
|
||||
const resFile = await uploadCategoryThumbnail(values.id, formFiles);
|
||||
if (resFile?.error) {
|
||||
error(resFile.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setFiles([]);
|
||||
close();
|
||||
initState();
|
||||
MySwal.fire({
|
||||
title: "Sukses",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "OK",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFile = (file: File) => {
|
||||
const uploadedFiles = files;
|
||||
const filtered = uploadedFiles.filter((i) => i.name !== file.name);
|
||||
setFiles([...filtered]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-3 w-full">
|
||||
<div className="flex flex-col items-start rounded-2xl gap-3 w-full">
|
||||
<div className="flex flex-col md:flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-1 w-full lg:w-1/3">
|
||||
<p className="font-semibold text-sm">Pencarian</p>
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-base pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Cari..."
|
||||
aria-label="Search"
|
||||
className="pl-10 text-sm bg-muted"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
|
||||
<p className="font-semibold text-sm">Data</p>
|
||||
<Select
|
||||
value={showData}
|
||||
onValueChange={(value) =>
|
||||
value === "" ? "" : setShowData(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="25">25</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* <Table
|
||||
aria-label="micro issue table"
|
||||
className="rounded-3xl"
|
||||
classNames={{
|
||||
th: "bg-white dark:bg-black text-black dark:text-white border-b-1 text-md",
|
||||
base: "bg-white dark:bg-black border",
|
||||
wrapper: "min-h-[50px] bg-transpararent text-black dark:text-white ",
|
||||
}}
|
||||
>
|
||||
<TableHeader columns={columns}>{(column) => <TableColumn key={column.uid}>{column.name}</TableColumn>}</TableHeader>
|
||||
<TableBody items={categories} emptyContent={"No data to display."} loadingContent={<Spinner label="Loading..." />}>
|
||||
{(item) => <TableRow key={item.id}>{(columnKey) => <TableCell>{renderCell(item, columnKey)}</TableCell>}</TableRow>}
|
||||
</TableBody>
|
||||
</Table> */}
|
||||
<div className="rounded-3xl border bg-white dark:bg-black text-black dark:text-white w-full">
|
||||
<Table className="min-h-[50px] w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
key={column.uid}
|
||||
className="text-md border-b bg-white dark:bg-black text-black dark:text-white"
|
||||
>
|
||||
{column.name}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categories.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="text-center py-4"
|
||||
>
|
||||
No data to display.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
categories.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.uid}>
|
||||
{renderCell(item, column.uid)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="my-2 w-full flex justify-center">
|
||||
{/* <Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color="primary"
|
||||
classNames={{
|
||||
base: "bg-transparent",
|
||||
wrapper: "bg-transparent",
|
||||
}}
|
||||
page={page}
|
||||
total={totalPage}
|
||||
onChange={(page) => setPage(page)}
|
||||
/> */}
|
||||
<CustomPagination
|
||||
totalPage={totalPage}
|
||||
onPageChange={(data) => setPage(data)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kategori Baru</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[70vh] pr-4">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Nama Kategori</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readOnly={isDetail}
|
||||
className="rounded-lg border dark:border-gray-400"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.title && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.title?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Deskripsi</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Textarea
|
||||
id="description"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readOnly={isDetail}
|
||||
className="rounded-lg border dark:border-gray-400"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.description && (
|
||||
<p className="text-red-400 text-sm">
|
||||
{errors.description?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm mt-3">Parent</p>
|
||||
<ReactSelect
|
||||
className="text-black z-50"
|
||||
classNames={{
|
||||
control: () =>
|
||||
"rounded-lg bg-white border border-gray-200 dark:border-stone-500",
|
||||
}}
|
||||
classNamePrefix="select"
|
||||
value={selectedParent}
|
||||
isDisabled={isDetail}
|
||||
onChange={setSelectedParent}
|
||||
closeMenuOnSelect={false}
|
||||
components={animatedComponents}
|
||||
isClearable
|
||||
isSearchable
|
||||
isMulti={false}
|
||||
placeholder="Kategori..."
|
||||
name="sub-module"
|
||||
options={listCategory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm mt-3">Tag Terkait</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="tags"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="tags"
|
||||
type="text"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (tag.trim() !== "") {
|
||||
setValue("tags", [...value, tag.trim()]);
|
||||
setTag("");
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}}
|
||||
readOnly={isDetail}
|
||||
className="rounded-lg border dark:border-gray-400 h-[45px]"
|
||||
/>
|
||||
<div className="absolute top-2 left-3 flex gap-1">
|
||||
{value?.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-blue-100 text-blue-700 px-2 py-1 text-xs rounded flex items-center gap-1"
|
||||
>
|
||||
{item}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const filtered = value.filter(
|
||||
(tag) => tag !== item
|
||||
);
|
||||
if (filtered.length === 0) {
|
||||
setError("tags", {
|
||||
type: "manual",
|
||||
message: "Tags tidak boleh kosong",
|
||||
});
|
||||
} else {
|
||||
clearErrors("tags");
|
||||
setValue(
|
||||
"tags",
|
||||
filtered as [string, ...string[]]
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isDetail ? (
|
||||
<img
|
||||
src={getValues("file")}
|
||||
className="w-[30%]"
|
||||
alt="thumbnail"
|
||||
width={480}
|
||||
height={480}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
control={control}
|
||||
name="file"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm mt-3">Thumbnail</p>
|
||||
{files.length < 1 && value === "" && (
|
||||
<div {...getRootProps({ className: "dropzone" })}>
|
||||
<input {...getInputProps()} />
|
||||
<div className="w-full text-center border-dashed border rounded-md py-[52px] flex items-center flex-col">
|
||||
<CloudUploadIcon />
|
||||
<h4 className="text-2xl font-medium mb-1 mt-3">
|
||||
Tarik file disini atau klik untuk upload.
|
||||
</h4>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
( Upload file .jpg, .jpeg, .png. Maks 100mb )
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{value !== "" && (
|
||||
<div className="flex gap-2">
|
||||
<img
|
||||
src={value}
|
||||
className="w-[30%]"
|
||||
alt="thumbnail"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setValue("file", "")}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Image
|
||||
src={URL.createObjectURL(files[0])}
|
||||
className="w-[30%]"
|
||||
alt="thumbnail"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveFile(files[0])}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 mt-4">
|
||||
{!isDetail && <Button type="submit">Simpan</Button>}
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Tutup
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
"use client";
|
||||
import { CreateIconIon, DeleteIcon, DotsYIcon } from "@/components/icons";
|
||||
import { close, error } from "@/config/swal";
|
||||
import { deleteMasterUser, listMasterUsers } from "@/service/master-user";
|
||||
import { MasterUser } from "@/types/globals";
|
||||
import Link from "next/link";
|
||||
import { Key, useCallback, useEffect, useState } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import CustomPagination from "../layout/custom-pagination";
|
||||
|
||||
const columns = [
|
||||
{ name: "No", uid: "no" },
|
||||
{ name: "Username", uid: "username" },
|
||||
{ name: "Fullname", uid: "fullname" },
|
||||
{ name: "Email", uid: "email" },
|
||||
{ name: "Identity Type", uid: "identityType" },
|
||||
{ name: "Identity Number", uid: "identityNumber" },
|
||||
// { name: "Users", uid: "users" },
|
||||
// { name: "Status", uid: "status" },
|
||||
{ name: "Aksi", uid: "actions" },
|
||||
];
|
||||
|
||||
export default function MasterUserTable() {
|
||||
const MySwal = withReactContent(Swal);
|
||||
const [user, setUser] = useState<MasterUser[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
initState();
|
||||
}, [page]);
|
||||
|
||||
async function initState() {
|
||||
const res = await listMasterUsers({ page: page, limit: 10 });
|
||||
getTableNumber(10, res?.data?.data);
|
||||
setTotalPage(res?.data?.meta?.totalPage);
|
||||
}
|
||||
const getTableNumber = (limit: number, data?: any) => {
|
||||
if (data) {
|
||||
const startIndex = limit * (page - 1);
|
||||
let iterate = 0;
|
||||
const newData = data.map((value: any) => {
|
||||
iterate++;
|
||||
value.no = startIndex + iterate;
|
||||
return value;
|
||||
});
|
||||
setUser(newData);
|
||||
}
|
||||
};
|
||||
|
||||
async function doDelete(id: string) {
|
||||
// loading();
|
||||
const resDelete = await deleteMasterUser(id);
|
||||
|
||||
if (resDelete?.error) {
|
||||
error(resDelete.message);
|
||||
return false;
|
||||
}
|
||||
close();
|
||||
successSubmit();
|
||||
}
|
||||
|
||||
const handleDelete = (id: any) => {
|
||||
MySwal.fire({
|
||||
title: "Hapus Data",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
cancelButtonColor: "#3085d6",
|
||||
confirmButtonColor: "#d33",
|
||||
confirmButtonText: "Hapus",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
doDelete(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function successSubmit() {
|
||||
MySwal.fire({
|
||||
title: "Sukses",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3085d6",
|
||||
confirmButtonText: "OK",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
initState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const renderCell = useCallback((user: MasterUser, columnKey: Key) => {
|
||||
const cellValue = user[columnKey as keyof MasterUser];
|
||||
// const statusColorMap: Record<string, ChipProps["color"]> = {
|
||||
// active: "primary",
|
||||
// cancel: "danger",
|
||||
// pending: "success",
|
||||
// };
|
||||
|
||||
switch (columnKey) {
|
||||
case "id":
|
||||
return <div>{user.id}</div>;
|
||||
|
||||
case "status":
|
||||
return (
|
||||
<div></div>
|
||||
// <Chip
|
||||
// className="capitalize "
|
||||
// // color={statusColorMap[user.status]}
|
||||
// size="lg"
|
||||
// variant="flat"
|
||||
// >
|
||||
// <div className="flex flex-row items-center gap-2 justify-center">{cellValue}</div>
|
||||
// </Chip>
|
||||
);
|
||||
|
||||
case "actions":
|
||||
return (
|
||||
<div className="relative flex justify-start items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<DotsYIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/admin/master-user/edit/${user.id}`}
|
||||
className="flex items-center"
|
||||
>
|
||||
<CreateIconIon className="inline mr-2 mb-1" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<DeleteIcon
|
||||
width={20}
|
||||
height={16}
|
||||
className="inline mr-2 mb-1"
|
||||
/>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return cellValue;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-3 my-5">
|
||||
<div className="flex flex-col items-center rounded-2xl">
|
||||
<Table className="rounded-2xl text-black dark:text-white bg-white dark:bg-black min-h-[50px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
key={column.uid}
|
||||
className="bg-white dark:bg-black text-black dark:text-white border-b text-md"
|
||||
>
|
||||
{column.name}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{user.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center">
|
||||
No data to display.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
user.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.uid}>
|
||||
{renderCell(item, column.uid)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="my-2 w-full flex justify-center">
|
||||
{/* <Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color="primary"
|
||||
classNames={{
|
||||
base: "bg-transparent",
|
||||
wrapper: "bg-transparent",
|
||||
}}
|
||||
page={page}
|
||||
total={totalPage}
|
||||
onChange={(page) => setPage(page)}
|
||||
/> */}
|
||||
<CustomPagination
|
||||
totalPage={totalPage}
|
||||
onPageChange={(data) => setPage(data)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
"use client";
|
||||
import {
|
||||
CreateIconIon,
|
||||
DeleteIcon,
|
||||
DotsYIcon,
|
||||
SearchIcon,
|
||||
} from "@/components/icons";
|
||||
import { close, error, success } from "@/config/swal";
|
||||
import { deleteArticle } from "@/service/article";
|
||||
import { getCustomStaticPage } from "@/service/static-page-service";
|
||||
import { Article } from "@/types/globals";
|
||||
import Link from "next/link";
|
||||
import { Key, useCallback, useEffect, useState } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import withReactContent from "sweetalert2-react-content";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "../ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "../ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import CustomPagination from "../layout/custom-pagination";
|
||||
|
||||
const columns = [
|
||||
{ name: "No", uid: "no" },
|
||||
{ name: "Judul", uid: "title" },
|
||||
{ name: "Slug", uid: "slug" },
|
||||
{ name: "Deskripsi", uid: "description" },
|
||||
{ name: "Aksi", uid: "actions" },
|
||||
];
|
||||
|
||||
type ArticleData = Article & {
|
||||
no: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function StaticPageTable() {
|
||||
const MySwal = withReactContent(Swal);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(1);
|
||||
const [article, setArticle] = useState<ArticleData[]>([]);
|
||||
const [showData, setShowData] = useState("10");
|
||||
const [search, setSearch] = useState("");
|
||||
const [startDateValue] = useState({
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
initState();
|
||||
}, [page, showData, startDateValue]);
|
||||
|
||||
async function initState() {
|
||||
const req = {
|
||||
limit: showData,
|
||||
page: page,
|
||||
search: search,
|
||||
};
|
||||
const res = await getCustomStaticPage(req);
|
||||
getTableNumber(parseInt(showData), res.data?.data);
|
||||
console.log("res.data?.data", res.data);
|
||||
setTotalPage(res?.data?.meta?.totalPage);
|
||||
}
|
||||
|
||||
const getTableNumber = (limit: number, data: Article[]) => {
|
||||
if (data) {
|
||||
const startIndex = limit * (page - 1);
|
||||
let iterate = 0;
|
||||
const newData = data.map((value: any) => {
|
||||
iterate++;
|
||||
value.no = startIndex + iterate;
|
||||
return value;
|
||||
});
|
||||
console.log("daata", data);
|
||||
setArticle(newData);
|
||||
}
|
||||
};
|
||||
|
||||
async function doDelete(id: string) {
|
||||
// loading();
|
||||
const resDelete = await deleteArticle(id);
|
||||
|
||||
if (resDelete?.error) {
|
||||
error(resDelete.message);
|
||||
return false;
|
||||
}
|
||||
close();
|
||||
success("Success Deleted");
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
MySwal.fire({
|
||||
title: "Hapus Data",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
cancelButtonColor: "#3085d6",
|
||||
confirmButtonColor: "#d33",
|
||||
confirmButtonText: "Hapus",
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
doDelete(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// function successSubmit() {
|
||||
// MySwal.fire({
|
||||
// title: "Sukses",
|
||||
// icon: "success",
|
||||
// confirmButtonColor: "#3085d6",
|
||||
// confirmButtonText: "OK",
|
||||
// }).then((result) => {
|
||||
// if (result.isConfirmed) {
|
||||
// // initStete();
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
const renderCell = useCallback((article: ArticleData, columnKey: Key) => {
|
||||
const cellValue = article[columnKey as keyof ArticleData];
|
||||
|
||||
switch (columnKey) {
|
||||
case "actions":
|
||||
return (
|
||||
<div className="relative flex justify-start items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="p-0 h-auto w-auto"
|
||||
>
|
||||
<DotsYIcon className="text-black dark:text-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-black text-white border">
|
||||
{/* <DropdownMenuItem>
|
||||
<Link href={`/admin/static-page/detail/${article.id}`} className="flex items-center">
|
||||
<EyeIconMdi className="inline mr-2 mb-1" />
|
||||
Detail
|
||||
</Link>
|
||||
</DropdownMenuItem> */}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/admin/static-page/edit/${article.id}`}
|
||||
className="flex items-center"
|
||||
>
|
||||
<CreateIconIon className="inline mr-2 mb-1" size={20} />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(article.id)}
|
||||
className="flex items-center text-red-500"
|
||||
>
|
||||
<DeleteIcon
|
||||
color="red"
|
||||
width={20}
|
||||
height={16}
|
||||
className="inline mr-2 mb-1"
|
||||
/>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return cellValue;
|
||||
}
|
||||
}, []);
|
||||
|
||||
let typingTimer: NodeJS.Timeout;
|
||||
const doneTypingInterval = 1500;
|
||||
|
||||
const handleKeyUp = () => {
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||
};
|
||||
|
||||
const handleKeyDown = () => {
|
||||
clearTimeout(typingTimer);
|
||||
};
|
||||
|
||||
async function doneTyping() {
|
||||
initState();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-3">
|
||||
<div className="flex flex-col items-start rounded-2xl gap-3">
|
||||
<div className="flex flex-col md:flex-row gap-3 w-full">
|
||||
<div className="flex flex-col gap-1 w-full lg:w-1/3">
|
||||
<Label className="font-semibold text-sm">Pencarian</Label>
|
||||
<div className="relative">
|
||||
<span className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<SearchIcon className="text-base text-muted-foreground" />
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
aria-label="Pencarian..."
|
||||
placeholder="Pencarian..."
|
||||
className="pl-10 text-sm bg-muted"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-full lg:w-[72px]">
|
||||
<Label className="font-semibold text-sm">Data</Label>
|
||||
<Select
|
||||
value={showData}
|
||||
onValueChange={(value) =>
|
||||
value === "" ? "" : setShowData(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* <div className="flex flex-col gap-1 w-full lg:w-[340px]">
|
||||
<p className="font-semibold text-sm">Tanggal</p>
|
||||
<Datepicker
|
||||
value={startDateValue}
|
||||
displayFormat="DD/MM/YYYY"
|
||||
onChange={(e: any) => setStartDateValue(e)}
|
||||
inputClassName="z-50 w-full text-sm bg-transparent border-1 border-gray-200 px-2 py-[6px] rounded-xl h-[40px] text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
<Table className="rounded-3xl overflow-hidden border border-gray-200 dark:border-gray-800 shadow-sm bg-white dark:bg-black text-black dark:text-white min-h-[50px]">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-white dark:bg-black border-b dark:border-gray-800">
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
key={column.uid}
|
||||
className="text-left font-semibold text-sm text-black dark:text-white px-4 py-3"
|
||||
>
|
||||
{column.name}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{article.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="text-center py-4 text-sm"
|
||||
>
|
||||
No data to display.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
article.map((item) => (
|
||||
<TableRow key={item.id} className="transition-colors">
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.uid}
|
||||
className="px-4 py-3 text-sm border-none"
|
||||
>
|
||||
{renderCell(item, column.uid)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="my-2 w-full flex justify-center">
|
||||
{/* <Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color="primary"
|
||||
classNames={{
|
||||
base: "bg-transparent",
|
||||
wrapper: "bg-transparent",
|
||||
}}
|
||||
page={page}
|
||||
total={totalPage}
|
||||
onChange={(page) => setPage(page)}
|
||||
/> */}
|
||||
<CustomPagination
|
||||
totalPage={totalPage}
|
||||
onPageChange={(data) => setPage(data)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export type ChipColor = "default" | "primary" | "success" | "danger";
|
||||
|
||||
export interface ChipProps {
|
||||
children: React.ReactNode;
|
||||
color?: ChipColor;
|
||||
className?: string;
|
||||
size?: string;
|
||||
variant?: string;
|
||||
}
|
||||
|
||||
const colorMap: Record<ChipColor, string> = {
|
||||
default: "bg-gray-200 text-gray-800",
|
||||
primary: "bg-blue-100 text-blue-800",
|
||||
success: "bg-green-100 text-green-800",
|
||||
danger: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
export const Chip: React.FC<ChipProps> = ({ children, color = "default", className }) => {
|
||||
return <span className={clsx("inline-flex items-center px-3 py-1 rounded-full text-sm font-medium", colorMap[color], className)}>{children}</span>;
|
||||
};
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue