Membuat database menu desa: Berita & Pengummuman

This commit is contained in:
2025-04-22 21:48:01 +08:00
parent 7b0fb9332e
commit 9a6f8dc7f6
27 changed files with 838 additions and 101 deletions

View File

@@ -0,0 +1,91 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch";
import { Prisma } from "@prisma/client";
import { toast } from "react-toastify";
import { proxy } from "valtio";
import { z } from "zod";
const templateForm = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
image: z.string().url().min(3, "Image minimal 3 karakter"),
content: z.string().min(3, "Content minimal 3 karakter"),
katagoryBeritaId: z.string().nonempty(),
});
const category = proxy({
findMany: {
data: null as
| null
| Prisma.KatagoryBeritaGetPayload<{ omit: { isActive: true } }>[],
async load() {
const res = await ApiFetch.api.desa.berita.category["find-many"].get();
if (res.status === 200) {
category.findMany.data = res.data?.data ?? [];
}
},
},
});
type BeritaForm = Prisma.BeritaGetPayload<{
select: {
judul: true;
deskripsi: true;
image: true;
content: true;
katagoryBeritaId: true;
};
}>;
const berita = proxy({
create: {
form: {} as BeritaForm,
loading: false,
async create() {
berita.create.form.image =
"https://www.shutterstock.com/image-vector/lower-news-live-streaming-breaking-600nw-2535984111.jpg";
const cek = templateForm.safeParse(berita.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
berita.create.loading = true;
const res = await ApiFetch.api.desa.berita["create"].post(
berita.create.form
);
if (res.status === 200) {
berita.findMany.load();
return toast.success("succes create");
}
return toast.error("failed create");
} catch (error) {
console.log((error as Error).message);
} finally {
berita.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.BeritaGetPayload<{ omit: { isActive: true } }>[]
| null,
async load() {
const res = await ApiFetch.api.desa.berita["find-many"].get();
console.log(res)
if (res.status === 200) {
berita.findMany.data = (res.data?.data as any) ?? [];
}
},
},
});
const stateDashboardBerita = proxy({
category,
berita,
});
export default stateDashboardBerita;

View File

@@ -0,0 +1,83 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch"
import { Prisma } from "@prisma/client"
import { toast } from "react-toastify"
import { proxy } from "valtio"
import { z } from "zod"
const templateFormPengumuman = z.object({
judul: z.string().min(3, "Judul minimal 3 karakter"),
deskripsi: z.string().min(3, "Deskripsi minimal 3 karakter"),
content: z.string().min(3, "Content minimal 3 karakter"),
categoryPengumumanId: z.string().nonempty(),
})
const category = proxy ({
findMany: {
data: null as
| null
| Prisma.CategoryPengumumanGetPayload<{ omit: { isActive: true } }>[],
async load() {
const res = await ApiFetch.api.desa.pengumuman.category["find-many"].get();
if (res.status === 200) {
category.findMany.data = res.data?.data as any ?? [];
}
}
}
})
type PengumumanForm = Prisma.PengumumanGetPayload<{
select: {
judul: true;
deskripsi: true;
content: true;
categoryPengumumanId: true;
}
}>
const pengumuman = proxy({
create: {
form: {} as PengumumanForm,
loading: false,
async create() {
const cek = templateFormPengumuman.safeParse(pengumuman.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
pengumuman.create.loading = true;
const res = await ApiFetch.api.desa.pengumuman["create"].post(pengumuman.create.form)
if (res.status === 200) {
pengumuman.findMany.load();
return toast.success("success create");
}
console.log(res)
return toast.error("failed create");
} catch (error) {
console.log((error as Error).message);
} finally{
pengumuman.create.loading = false;
}
}
},
findMany: {
data: null as
| Prisma.PengumumanGetPayload<{omit: {isActive: true}}>[]
| null,
async load () {
const res = await ApiFetch.api.desa.pengumuman["find-many"].get();
console.log(res)
if (res.status === 200) {
pengumuman.findMany.data = res.data?.data ?? [];
}
}
}
})
const stateDesaPengumuman = proxy({
category,
pengumuman
})
export default stateDesaPengumuman

View File

@@ -0,0 +1,85 @@
'use client'
import { RichTextEditor, Link } from '@mantine/tiptap';
import { useEditor } from '@tiptap/react';
import Highlight from '@tiptap/extension-highlight';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Superscript from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript';
import { Button, Stack } from '@mantine/core';
const content =
'<h2 style="text-align: center;">Welcome to Mantine rich text editor</h2><p><code>RichTextEditor</code> component focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users. <code>RichTextEditor</code> is based on <a href="https://tiptap.dev/" rel="noopener noreferrer" target="_blank">Tiptap.dev</a> and supports all of its features:</p><ul><li>General text formatting: <strong>bold</strong>, <em>italic</em>, <u>underline</u>, <s>strike-through</s> </li><li>Headings (h1-h6)</li><li>Sub and super scripts (<sup>&lt;sup /&gt;</sup> and <sub>&lt;sub /&gt;</sub> tags)</li><li>Ordered and bullet lists</li><li>Text align&nbsp;</li><li>And all <a href="https://tiptap.dev/extensions" target="_blank" rel="noopener noreferrer">other extensions</a></li></ul>';
export function BeritaEditor({ onSubmit }: { onSubmit: (val: string) => void }) {
const editor = useEditor({
extensions: [
StarterKit,
Underline,
Link,
Superscript,
SubScript,
Highlight,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
],
content,
});
return (
<Stack>
<RichTextEditor editor={editor}>
<RichTextEditor.Toolbar sticky stickyOffset={60}>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Bold />
<RichTextEditor.Italic />
<RichTextEditor.Underline />
<RichTextEditor.Strikethrough />
<RichTextEditor.ClearFormatting />
<RichTextEditor.Highlight />
<RichTextEditor.Code />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.H1 />
<RichTextEditor.H2 />
<RichTextEditor.H3 />
<RichTextEditor.H4 />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Blockquote />
<RichTextEditor.Hr />
<RichTextEditor.BulletList />
<RichTextEditor.OrderedList />
<RichTextEditor.Subscript />
<RichTextEditor.Superscript />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Link />
<RichTextEditor.Unlink />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.AlignLeft />
<RichTextEditor.AlignCenter />
<RichTextEditor.AlignJustify />
<RichTextEditor.AlignRight />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Undo />
<RichTextEditor.Redo />
</RichTextEditor.ControlsGroup>
</RichTextEditor.Toolbar>
<RichTextEditor.Content />
</RichTextEditor>
<Button onClick={() => {
if (!editor) return
onSubmit(editor?.getHTML())
}}>Submit</Button>
</Stack>
);
}

View File

@@ -1,11 +1,88 @@
import React from 'react';
'use client'
import { Center, Group, Select, SimpleGrid, Skeleton, Stack, Text, TextInput } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { Prisma } from '@prisma/client';
import { IconImageInPicture } from '@tabler/icons-react';
import { useProxy } from 'valtio/utils';
import stateDashboardBerita from '../../_state/berita';
import { BeritaEditor } from './_com/BeritaEditor';
function Page() {
return (
<div>
Berita
</div>
<Stack>
<SimpleGrid cols={2}>
<BeritaList />
<BeritaCreate />
</SimpleGrid>
</Stack>
);
}
function BeritaList() {
const beritaState = useProxy(stateDashboardBerita)
useShallowEffect(() => {
beritaState.berita.findMany.load()
}, [])
if (!beritaState.berita.findMany.data) return <Stack>
{Array.from({ length: 10 }).map((v, k) => <Skeleton key={k} h={40} />)}
</Stack>
return <Stack>
<Text>News List</Text>
{beritaState.berita.findMany.data?.map((item) => (
<Text key={item.id}>{item.judul}</Text>
))}
</Stack>
}
function BeritaCreate() {
const beritaState = useProxy(stateDashboardBerita)
return <Stack gap={"md"}>
<Text>Create Some News</Text>
<SelectCategory onChange={(val) => {
beritaState.berita.create.form.katagoryBeritaId = val.id
}} />
<TextInput onChange={(val) => {
beritaState.berita.create.form.judul = val.target.value
}} label={"Judul"} placeholder='masukkan judul' />
<TextInput onChange={(val) => {
beritaState.berita.create.form.deskripsi = val.target.value
}} label={"Deskripsi"} placeholder='masukkan deskripsi' />
<Center w={200} h={200} bg={"gray"}>
<IconImageInPicture />
</Center>
<BeritaEditor onSubmit={(val) => {
beritaState.berita.create.form.content = val
beritaState.berita.create.create()
}} />
</Stack>
}
function SelectCategory({ onChange }: {
onChange: (value: Prisma.KatagoryBeritaGetPayload<{
select: {
name: true,
id: true
}
}>) => void
}) {
const beritaState = useProxy(stateDashboardBerita)
useShallowEffect(() => {
beritaState.category.findMany.load()
}, [])
if (!beritaState.category.findMany.data) return <Skeleton h={40} />
return <Group>
<Select placeholder='pilih katagori' label={"select katagori"} data={beritaState.category.findMany.data.map((item) => ({
value: item.id,
label: item.name
}))} onChange={(v) => {
const data = beritaState.category.findMany.data?.find((item) => item.id === v)
if (!data) return
onChange(data)
}} />
</Group>
}
export default Page;

View File

@@ -1,11 +1,89 @@
'use client'
import { Group, Select, SimpleGrid, Skeleton, Stack, Text, TextInput } from '@mantine/core';
import React from 'react';
import { useProxy } from 'valtio/utils';
import stateDesaPengumuman from '../../_state/pengumuman';
import { useShallowEffect } from '@mantine/hooks';
import { Prisma } from '@prisma/client';
import { BeritaEditor } from '../berita/_com/BeritaEditor';
function Page() {
return (
<div>
Pengumuman
</div>
<Stack>
<SimpleGrid cols={{
base: 1, md: 2
}}>
<PengumumanList />
<PengumumanCreate />
</SimpleGrid>
</Stack>
);
}
function PengumumanList() {
const pengumumanState = useProxy(stateDesaPengumuman)
useShallowEffect(() => {
pengumumanState.pengumuman.findMany.load()
}, [])
if (!pengumumanState.pengumuman.findMany.data) return <Stack>
{Array.from({ length: 10 }).map((v, k) => <Skeleton key={k} h={40} />)}
</Stack>
return <Stack>
<Text>Announcement List</Text>
{pengumumanState.pengumuman.findMany.data?.map((item) => (
<Text key={item.id}>{item.judul}</Text>
))}
</Stack>;
}
function PengumumanCreate() {
const pengumumanState = useProxy(stateDesaPengumuman)
return <Stack gap={"md"}>
<Text>Create Some Announcement</Text>
<SelectCategory onChange={(val) => {
pengumumanState.pengumuman.create.form.categoryPengumumanId = val.id
}} />
<TextInput onChange={(val) => {
pengumumanState.pengumuman.create.form.judul = val.target.value
}} label={"Judul"} placeholder='masukkan judul' />
<TextInput onChange={(val) => {
pengumumanState.pengumuman.create.form.deskripsi = val.target.value
}} label={"Deskripsi"} placeholder='masukkan deskripsi' />
<BeritaEditor onSubmit={(val) => {
pengumumanState.pengumuman.create.form.content = val
pengumumanState.pengumuman.create.create()
}} />
</Stack>
}
function SelectCategory({ onChange }: {
onChange: (value: Prisma.CategoryPengumumanGetPayload<{
select: {
name: true,
id: true
}
}>) => void
}) {
const pengumumanState = useProxy(stateDesaPengumuman)
useShallowEffect(() => {
pengumumanState.category.findMany.load()
}, [])
if (!pengumumanState.category.findMany.data) return <Skeleton h={40} />
return <Group>
{/* {JSON.stringify(pengumumanState.category.findMany.data)} */}
<Select placeholder='pilih kategori' label={"select katagori"} data={pengumumanState.category.findMany.data.map((item) => ({
value: item.id,
label: item.name
}))} onChange={(v) => {
const data = pengumumanState.category.findMany.data?.find((item) => item.id === v)
if (!data) return
onChange(data)
}}
/>
</Group>
}
export default Page;

View File

@@ -1,12 +0,0 @@
import React from 'react';
import { Text } from '@mantine/core';
function CompTextLengh({input, maxLength}: {input: string, maxLength: number}) {
return (
<div>
<Text fz={"xs"}>{input.length}/{maxLength}</Text>
</div>
);
}
export default CompTextLengh;

View File

@@ -1 +0,0 @@
export const maxLength = 2000

View File

@@ -1,13 +1,8 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Group, Stack, Text, Textarea, Title } from '@mantine/core';
import { useState } from 'react';
import CompTextLengh from './_comp_textLengh';
import { maxLength } from './gs_maxLength';
function Page(
) {
const [value, setValue] = useState("")
function Page() {
return (
<Box>
<Stack gap={"xs"}>
@@ -15,13 +10,10 @@ function Page(
<Textarea
label={<Text>Deskripsi</Text>}
placeholder='tambah deskripsi'
maxLength={maxLength}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<CompTextLengh input={value} maxLength={maxLength}/>
<Group>
<Button bg={colors['blue-button']} fz={'md'}>Submit</Button>
<Button
bg={colors['blue-button']} fz={'md'}>Submit</Button>
</Group>
</Stack>
</Box>

View File

@@ -1,67 +1,87 @@
'use client'
import colors from "@/con/colors";
import { ActionIcon, AppShell, AppShellHeader, AppShellMain, AppShellNavbar, Burger, Group, Image, NavLink, ScrollArea, Stack, Text } from "@mantine/core";
import { ActionIcon, AppShell, AppShellHeader, AppShellMain, AppShellNavbar, Burger, Group, Image, NavLink, ScrollArea, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import _ from 'lodash';
import Link from "next/link";
import { useState } from "react";
import { useSelectedLayoutSegments } from "next/navigation";
import { navBar } from "./_com/list_PageAdmin";
export default function Layout({ children }: { children: React.ReactNode }) {
const [opened, { toggle }] = useDisclosure();
const [active, setActive] = useState(navBar[0]?.id || 0);
const isClient = typeof window !== 'undefined';
const [desktopOpened, { toggle: toggleDesktop }] =
useDisclosure(true);
const segments = useSelectedLayoutSegments()
return (
<Stack h={"100%"}>
<AppShell
header={{ height: 60 }}
navbar={{
width: 300, breakpoint: 'sm', collapsed: { mobile: !opened }
}}
padding={'md'}
>
<AppShellHeader bg={colors["white-trans-1"]}>
<Group px={10} align="center">
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size={'sm'} />
<ActionIcon w={50} h={50} variant="transparent" component={Link} href="/admin">
<AppShell
suppressHydrationWarning
header={{ height: 60 }}
navbar={{
width: 300, breakpoint: 'sm', collapsed: { mobile: !opened, desktop: !desktopOpened }
}}
padding={'md'}
>
<AppShellHeader bg={colors["white-trans-1"]}>
<Group px={10} align="center">
{!desktopOpened && <ActionIcon variant="light" onClick={toggleDesktop}><IconChevronRight /></ActionIcon>}
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size={'sm'} />
<ActionIcon w={50} h={50} variant="transparent" component={Link} href="/admin">
<Image py={5} src={'/assets/images/darmasaba-icon.png'} alt="" width={50} height={50} />
</ActionIcon>
<Text fw={'bold'} c={colors["blue-button"]} fz={'lg'}>Dashboard Admin</Text>
</Group>
</AppShellHeader>
<AppShellNavbar
c={colors["blue-button"]}
component={ScrollArea}
>
{navBar.map((v,k) => {
</ActionIcon>
<Text fw={'bold'} c={colors["blue-button"]} fz={'lg'}>Dashboard Admin</Text>
</Group>
</AppShellHeader>
<AppShellNavbar
c={colors["blue-button"]}
component={ScrollArea}
>
<AppShell.Section h={100} bg={"gray.1"}>
<Text c={colors["blue-button"]}>Heder Navbar</Text>
</AppShell.Section>
<AppShell.Section >
{navBar.map((v, k) => {
return (
<NavLink
c={colors["blue-button"]}
c={_.lowerCase(v.name) == segments[1] ? colors["blue-button"] : "grey"}
key={k}
active={isClient && k === active}
onClick={() => setActive(k)}
label={<Text>{v.name}</Text>}
defaultOpened={_.lowerCase(v.name) == segments[1]}
// onClick={() => setActive(k)}
label={<Text
style={{ fontWeight: _.lowerCase(v.name) == segments[1] ? "bold" : "normal" }}
>{v.name}</Text>}
>
{v.children.map((child) => {
{v.children.map((child, key) => {
return (
<NavLink
c={_.lowerCase(child.name) == _.lowerCase(segments[2]) ? colors["blue-button"] : "grey"}
key={key}
href={child.path}
key={child.id}
active={isClient && Number(child.id) === active}
onClick={() => setActive(Number(child.id))}
label={<Text>{child.name}</Text>}
// active={isClient && Number(child.id) === active}
// onClick={() => setActive(Number(child.id))}
label={<Text
style={{ fontWeight: _.lowerCase(child.name) == _.lowerCase(segments[2]) ? "bold" : "normal" }}
>{child.name}</Text>}
/>
)
})}
</NavLink>
)
})}
</AppShellNavbar>
<AppShellMain bg={colors.Bg}>
{children}
</AppShellMain>
</AppShell>
</Stack>
</AppShell.Section>
<AppShell.Section>
<Group justify="end">
<ActionIcon variant="light" onClick={toggleDesktop}><IconChevronLeft /></ActionIcon>
</Group>
</AppShell.Section>
</AppShellNavbar>
<AppShellMain bg={colors.Bg}>
{children}
</AppShellMain>
</AppShell>
);
}