Sinkronisasi UI & Admin - Submenu Perpustakaan Digital

This commit is contained in:
2025-08-30 12:22:32 +08:00
parent 9f9a0fb451
commit 22ec8d942d
23 changed files with 740 additions and 272 deletions

View File

@@ -333,10 +333,11 @@ const lembagaPendidikan = proxy({
Prisma.LembagaGetPayload<{ Prisma.LembagaGetPayload<{
include: { include: {
jenjangPendidikan: true; jenjangPendidikan: true;
siswa: true;
pengajar: true;
}; };
}> }> & {
siswa?: [];
pengajar?: [];
}
> | null, > | null,
page: 1, page: 1,
totalPages: 1, totalPages: 1,
@@ -363,13 +364,18 @@ const lembagaPendidikan = proxy({
console.log('API Response:', res); console.log('API Response:', res);
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
lembagaPendidikan.findMany.data = Array.isArray(res.data.data) ? res.data.data : []; const data = Array.isArray(res.data.data) ? res.data.data : [];
lembagaPendidikan.findMany.total = typeof res.data.total === 'number' ? res.data.total : 0; const total = typeof res.data.total === 'number' ? res.data.total : 0;
lembagaPendidikan.findMany.totalPages = typeof res.data.totalPages === 'number' ? res.data.totalPages : 1; const totalPages = typeof res.data.totalPages === 'number' ? res.data.totalPages : 1;
lembagaPendidikan.findMany.data = data;
lembagaPendidikan.findMany.total = total;
lembagaPendidikan.findMany.totalPages = totalPages;
console.log('Successfully loaded lembaga data:', { console.log('Successfully loaded lembaga data:', {
count: lembagaPendidikan.findMany.data.length, count: data.length,
total: lembagaPendidikan.findMany.total, total,
totalPages: lembagaPendidikan.findMany.totalPages totalPages
}); });
} else { } else {
console.error( console.error(

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ApiFetch from "@/lib/api-fetch"; import ApiFetch from "@/lib/api-fetch";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -54,23 +55,46 @@ const dataPerpustakaan = proxy({
}, },
}, },
findMany: { findMany: {
data: [] as Prisma.DataPerpustakaanGetPayload<{ data: null as
include: { | Prisma.DataPerpustakaanGetPayload<{
kategori: true; include: {
image: true; image: true;
}; kategori: true;
}>[], };
loading: false, }>[]
async load() { | null,
const res = page: 1,
await ApiFetch.api.pendidikan.perpustakaandigital.dataperpustakaan[ totalPages: 1,
"findMany" loading: false,
].get(); search: "",
if (res.status === 200) { load: async (page = 1, limit = 10, search = "", kategori = "") => {
dataPerpustakaan.findMany.data = res.data?.data ?? []; dataPerpustakaan.findMany.loading = true; // ✅ Akses langsung via nama path
} dataPerpustakaan.findMany.page = page;
dataPerpustakaan.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
if (kategori) query.kategori = kategori;
const res = await ApiFetch.api.pendidikan.perpustakaandigital.dataperpustakaan["findMany"].get({ query });
if (res.status === 200 && res.data?.success) {
dataPerpustakaan.findMany.data = res.data.data ?? [];
dataPerpustakaan.findMany.totalPages = res.data.totalPages ?? 1;
} else {
dataPerpustakaan.findMany.data = [];
dataPerpustakaan.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch data perpustakaan paginated:", err);
dataPerpustakaan.findMany.data = [];
dataPerpustakaan.findMany.totalPages = 1;
} finally {
dataPerpustakaan.findMany.loading = false;
}
},
}, },
},
findUnique: { findUnique: {
data: null as Prisma.DataPerpustakaanGetPayload<{ data: null as Prisma.DataPerpustakaanGetPayload<{
include: { include: {

View File

@@ -46,13 +46,11 @@ async function lembagaPendidikanFindMany(context: Context) {
} }
} }
// Tambahkan pencarian (jika ada) // Add search functionality
if (search) { if (search) {
where.OR = [ where.OR = [
{ nama: { contains: search, mode: "insensitive" } }, { nama: { contains: search, mode: 'insensitive' } },
{ siswa: { nama: { contains: search, mode: "insensitive" } } }, { jenjangPendidikan: { nama: { contains: search, mode: 'insensitive' } } },
{ pengajar: { nama: { contains: search, mode: "insensitive" } } },
{ jenjangPendidikan: { nama: { contains: search, mode: "insensitive" } } },
]; ];
} }
@@ -61,12 +59,10 @@ async function lembagaPendidikanFindMany(context: Context) {
where, where,
include: { include: {
jenjangPendidikan: true, jenjangPendidikan: true,
siswa: true,
pengajar: true,
}, },
skip, skip,
take: limit, take: limit,
orderBy: { jenjangPendidikan: { nama: 'asc' } }, orderBy: { nama: 'asc' },
}), }),
prisma.lembaga.count({ prisma.lembaga.count({
where, where,

View File

@@ -67,7 +67,7 @@ async function pengajarFindMany(context: Context) {
}, },
skip, skip,
take: limit, take: limit,
orderBy: { lembaga: { jenjangPendidikan: { nama: 'asc' } } }, orderBy: { nama: 'asc' },
}), }),
prisma.pengajar.count({ prisma.pengajar.count({
where, where,

View File

@@ -47,10 +47,10 @@ async function siswaFindMany(context: Context) {
} }
} }
// Tambahkan pencarian (jika ada) // Add search functionality
if (search) { if (search) {
where.OR = [ where.OR = [
{ nama: { contains: search, mode: "insensitive" } }, { nama: { contains: search, mode: 'insensitive' } },
{ lembaga: { nama: { contains: search, mode: 'insensitive' } } } { lembaga: { nama: { contains: search, mode: 'insensitive' } } }
]; ];
} }
@@ -67,7 +67,7 @@ async function siswaFindMany(context: Context) {
}, },
skip, skip,
take: limit, take: limit,
orderBy: { lembaga: { jenjangPendidikan: { nama: 'asc' } } }, orderBy: { nama: 'asc' },
}), }),
prisma.siswa.count({ prisma.siswa.count({
where, where,

View File

@@ -1,16 +1,68 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// /api/berita/findManyPaginated.ts
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function dataPerpustakaanFindMany() { async function dataPerpustakaanFindMany(context: Context) {
const data = await prisma.dataPerpustakaan.findMany({ // Ambil parameter dari query
include: { const page = Number(context.query.page) || 1;
kategori: true, const limit = Number(context.query.limit) || 10;
image: true, const search = (context.query.search as string) || '';
}, const kategori = (context.query.kategori as string) || ''; // 🔥 Parameter kategori baru
}); const skip = (page - 1) * limit;
return { // Buat where clause
success: true, const where: any = { isActive: true };
message: "Success get all data perpustakaan",
data, // Filter berdasarkan kategori (jika ada)
}; if (kategori) {
where.kategori = {
name: {
equals: kategori,
mode: 'insensitive' // Tidak case-sensitive
}
};
}
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ judul: { contains: search, mode: 'insensitive' } },
];
}
try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([
prisma.dataPerpustakaan.findMany({
where,
include: {
image: true,
kategori: true,
},
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.dataPerpustakaan.count({ where }),
]);
return {
success: true,
message: "Berhasil ambil data perpustakaan dengan pagination",
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di findMany paginated:", e);
return {
success: false,
message: "Gagal mengambil data perpustakaan",
};
}
} }
export default dataPerpustakaanFindMany;

View File

@@ -1,11 +1,11 @@
'use client' 'use client'
import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud'; import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import { Box, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, TextInput, Title } from '@mantine/core';
import { IconSchool, IconLayersSubtract } from '@tabler/icons-react'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { useShallowEffect } from '@mantine/hooks'; import { IconChalkboard, IconLayersSubtract, IconSearch } from '@tabler/icons-react';
import { use, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { use } from 'react';
interface PageProps { interface PageProps {
@@ -13,6 +13,8 @@ interface PageProps {
} }
function Page({ params }: PageProps) { function Page({ params }: PageProps) {
const [search, setSearch] = useState("")
const [debouncedSearch] = useDebouncedValue(search, 500); // 500ms delay
const stateList = useProxy(infoSekolahPaud.lembagaPendidikan) const stateList = useProxy(infoSekolahPaud.lembagaPendidikan)
const { jenjangPendidikan } = use(params); const { jenjangPendidikan } = use(params);
@@ -27,8 +29,8 @@ function Page({ params }: PageProps) {
useShallowEffect(() => { useShallowEffect(() => {
// Decode the URL parameter and pass it to load // Decode the URL parameter and pass it to load
const decodedJenjang = decodeURIComponent(jenjangPendidikan).toLowerCase() const decodedJenjang = decodeURIComponent(jenjangPendidikan).toLowerCase()
load(page, 10, '', decodedJenjang === 'semua' ? '' : decodedJenjang) load(page, 10, debouncedSearch, decodedJenjang === 'semua' ? '' : decodedJenjang)
}, [page, jenjangPendidikan]) }, [page, jenjangPendidikan, debouncedSearch])
const filteredData = data || [] const filteredData = data || []
@@ -52,9 +54,15 @@ function Page({ params }: PageProps) {
> >
<Group justify="space-between" align="center" mb="md"> <Group justify="space-between" align="center" mb="md">
<Group gap="sm"> <Group gap="sm">
<IconSchool size={28} stroke={1.5} color={colors['blue-button']} /> <IconChalkboard size={28} stroke={1.5} color={colors['blue-button']} />
<Title order={2} fz="xl">Daftar Lembaga Pendidikan</Title> <Title order={2} fz="xl">Daftar Lembaga Pendidikan</Title>
</Group> </Group>
<TextInput
placeholder='pencarian'
leftSection={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group> </Group>
{filteredData.length === 0 ? ( {filteredData.length === 0 ? (

View File

@@ -1,11 +1,11 @@
'use client' 'use client'
import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud'; import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import { Box, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, TextInput, Title } from '@mantine/core';
import { IconSchool, IconLayersSubtract } from '@tabler/icons-react'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { useShallowEffect } from '@mantine/hooks'; import { IconLayersSubtract, IconMicroscope, IconSearch } from '@tabler/icons-react';
import { use, useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { use } from 'react';
interface PageProps { interface PageProps {
@@ -13,6 +13,8 @@ interface PageProps {
} }
function Page({ params }: PageProps) { function Page({ params }: PageProps) {
const [search, setSearch] = useState("")
const [debouncedSearch] = useDebouncedValue(search, 500); // 500ms delay
const stateList = useProxy(infoSekolahPaud.pengajar) const stateList = useProxy(infoSekolahPaud.pengajar)
const { jenjangPendidikan } = use(params); const { jenjangPendidikan } = use(params);
@@ -27,8 +29,8 @@ function Page({ params }: PageProps) {
useShallowEffect(() => { useShallowEffect(() => {
// Decode the URL parameter and pass it to load // Decode the URL parameter and pass it to load
const decodedJenjang = decodeURIComponent(jenjangPendidikan).toLowerCase() const decodedJenjang = decodeURIComponent(jenjangPendidikan).toLowerCase()
load(page, 10, '', decodedJenjang === 'semua' ? '' : decodedJenjang) load(page, 10, debouncedSearch, decodedJenjang === 'semua' ? '' : decodedJenjang)
}, [page, jenjangPendidikan]) }, [page, jenjangPendidikan, debouncedSearch])
const filteredData = data || [] const filteredData = data || []
@@ -52,9 +54,15 @@ function Page({ params }: PageProps) {
> >
<Group justify="space-between" align="center" mb="md"> <Group justify="space-between" align="center" mb="md">
<Group gap="sm"> <Group gap="sm">
<IconSchool size={28} stroke={1.5} color={colors['blue-button']} /> <IconMicroscope size={28} stroke={1.5} color={colors['blue-button']} />
<Title order={2} fz="xl">Daftar Pengajar</Title> <Title order={2} fz="xl">Daftar Pengajar</Title>
</Group> </Group>
<TextInput
placeholder='pencarian'
leftSection={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group> </Group>
{filteredData.length === 0 ? ( {filteredData.length === 0 ? (

View File

@@ -1,11 +1,11 @@
'use client' 'use client'
import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud'; import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import { Box, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, TextInput, Title } from '@mantine/core';
import { IconSchool, IconLayersSubtract } from '@tabler/icons-react'; import { IconSchool, IconLayersSubtract, IconSearch } from '@tabler/icons-react';
import { useShallowEffect } from '@mantine/hooks'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { use } from 'react'; import { use, useState } from 'react';
interface PageProps { interface PageProps {
@@ -13,6 +13,8 @@ interface PageProps {
} }
function Page({ params }: PageProps) { function Page({ params }: PageProps) {
const [search, setSearch] = useState("")
const [debouncedSearch] = useDebouncedValue(search, 500); // 500ms delay
const stateList = useProxy(infoSekolahPaud.siswa) const stateList = useProxy(infoSekolahPaud.siswa)
const { jenjangPendidikan } = use(params); const { jenjangPendidikan } = use(params);
@@ -27,8 +29,8 @@ function Page({ params }: PageProps) {
useShallowEffect(() => { useShallowEffect(() => {
// Decode the URL parameter and pass it to load // Decode the URL parameter and pass it to load
const decodedJenjang = decodeURIComponent(jenjangPendidikan).toLowerCase() const decodedJenjang = decodeURIComponent(jenjangPendidikan).toLowerCase()
load(page, 10, '', decodedJenjang === 'semua' ? '' : decodedJenjang) load(page, 10, debouncedSearch, decodedJenjang === 'semua' ? '' : decodedJenjang)
}, [page, jenjangPendidikan]) }, [page, jenjangPendidikan, debouncedSearch])
const filteredData = data || [] const filteredData = data || []
@@ -55,6 +57,12 @@ function Page({ params }: PageProps) {
<IconSchool size={28} stroke={1.5} color={colors['blue-button']} /> <IconSchool size={28} stroke={1.5} color={colors['blue-button']} />
<Title order={2} fz="xl">Daftar Siswa</Title> <Title order={2} fz="xl">Daftar Siswa</Title>
</Group> </Group>
<TextInput
placeholder='pencarian'
leftSection={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group> </Group>
{filteredData.length === 0 ? ( {filteredData.length === 0 ? (

View File

@@ -9,12 +9,11 @@ import {
Paper, Paper,
Stack, Stack,
Text, Text,
TextInput, VisuallyHidden
VisuallyHidden,
} from '@mantine/core'; } from '@mantine/core';
import { IconArrowLeft, IconSearch } from '@tabler/icons-react'; import { IconArrowLeft } from '@tabler/icons-react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
type LayoutSekolahProps = { type LayoutSekolahProps = {
title?: string; title?: string;
@@ -29,38 +28,12 @@ export default function LayoutSekolah({
}: LayoutSekolahProps) { }: LayoutSekolahProps) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const pathname = usePathname();
const initialQuery = searchParams.get('search') || '';
const initialJenjangPendidikan = searchParams.get('jenjangPendidikan') || 'Semua'; const initialJenjangPendidikan = searchParams.get('jenjangPendidikan') || 'Semua';
const [query, setQuery] = useState(initialQuery);
const [jenjangPendidikanAktif, setJenjangPendidikanAktif] = useState(initialJenjangPendidikan); const [jenjangPendidikanAktif, setJenjangPendidikanAktif] = useState(initialJenjangPendidikan);
const [searchTimeout, setSearchTimeout] = useState<number | null>(null);
// Cleanup timeout // Cleanup timeout
useEffect(() => {
return () => {
if (searchTimeout) clearTimeout(searchTimeout);
};
}, [searchTimeout]);
// Handle Search with debounce
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val);
if (searchTimeout) clearTimeout(searchTimeout);
const t = window.setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (val) params.set('search', val);
else params.delete('search');
params.set('jenjangPendidikan', jenjangPendidikanAktif);
router.push(`${pathname}?${params.toString()}`);
}, 500);
setSearchTimeout(t);
};
// Handle jenjang pendidikan click // Handle jenjang pendidikan click
const handleJenjangPendidikanChange = (k: string) => { const handleJenjangPendidikanChange = (k: string) => {
@@ -90,15 +63,9 @@ export default function LayoutSekolah({
<Stack gap="md"> <Stack gap="md">
<Text ta="center" fw={800} fz={28}>{title}</Text> <Text ta="center" fw={800} fz={28}>{title}</Text>
<TextInput <Text ta="center" fz={"md"} c="black">
value={query} Temukan data lengkap mengenai lembaga pendidikan, jumlah siswa terdaftar, dan tenaga pengajar berdasarkan jenjang pendidikan yang tersedia (TK, SD, SMP, SMA). Gunakan tombol di bawah untuk melihat detail sesuai kebutuhanmu.
onChange={handleSearchChange} </Text>
placeholder="Cari sekolah..."
leftSection={<IconSearch size={18} />}
radius="xl"
size="md"
/>
<Group justify="center" gap="xs" wrap="wrap"> <Group justify="center" gap="xs" wrap="wrap">
{jenjangPendidikanList.map((k) => { {jenjangPendidikanList.map((k) => {
const aktif = k === jenjangPendidikanAktif; const aktif = k === jenjangPendidikanAktif;

View File

@@ -1,12 +1,15 @@
'use client' 'use client'
import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud'; import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import { Box, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, TextInput, Title } from '@mantine/core';
import { IconSchool, IconLayersSubtract } from '@tabler/icons-react'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { useShallowEffect } from '@mantine/hooks'; import { IconChalkboard, IconLayersSubtract, IconSearch } from '@tabler/icons-react';
import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function Page() { function Page() {
const [search, setSearch] = useState("")
const [debouncedSearch] = useDebouncedValue(search, 500); // 500ms delay
const stateList = useProxy(infoSekolahPaud.lembagaPendidikan) const stateList = useProxy(infoSekolahPaud.lembagaPendidikan)
const { const {
@@ -18,8 +21,8 @@ function Page() {
} = stateList.findMany } = stateList.findMany
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10) load(page, 10, debouncedSearch)
}, [page]) }, [page, debouncedSearch])
const filteredData = data || [] const filteredData = data || []
@@ -43,9 +46,15 @@ function Page() {
> >
<Group justify="space-between" align="center" mb="md"> <Group justify="space-between" align="center" mb="md">
<Group gap="sm"> <Group gap="sm">
<IconSchool size={28} stroke={1.5} color={colors['blue-button']} /> <IconChalkboard size={28} stroke={1.5} color={colors['blue-button']} />
<Title order={2} fz="xl">Daftar Lembaga Pendidikan</Title> <Title order={2} fz="xl">Daftar Lembaga Pendidikan</Title>
</Group> </Group>
<TextInput
placeholder='pencarian'
leftSection={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group> </Group>
{filteredData.length === 0 ? ( {filteredData.length === 0 ? (

View File

@@ -1,14 +1,16 @@
'use client' 'use client'
import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud'; import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import { Box, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, TextInput, Title } from '@mantine/core';
import { IconSchool, IconLayersSubtract } from '@tabler/icons-react'; import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { useShallowEffect } from '@mantine/hooks'; import { IconLayersSubtract, IconMicroscope, IconSearch } from '@tabler/icons-react';
import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function Page() { function Page() {
const stateList = useProxy(infoSekolahPaud.pengajar) const stateList = useProxy(infoSekolahPaud.pengajar)
const [search, setSearch] = useState("")
const [debouncedSearch] = useDebouncedValue(search, 500); // 500ms delay
const { const {
data, data,
page, page,
@@ -18,8 +20,8 @@ function Page() {
} = stateList.findMany } = stateList.findMany
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10) load(page, 10, debouncedSearch)
}, [page]) }, [page, debouncedSearch])
const filteredData = data || [] const filteredData = data || []
@@ -43,9 +45,15 @@ function Page() {
> >
<Group justify="space-between" align="center" mb="md"> <Group justify="space-between" align="center" mb="md">
<Group gap="sm"> <Group gap="sm">
<IconSchool size={28} stroke={1.5} color={colors['blue-button']} /> <IconMicroscope size={28} stroke={1.5} color={colors['blue-button']} />
<Title order={2} fz="xl">Daftar Pengajar</Title> <Title order={2} fz="xl">Daftar Pengajar</Title>
</Group> </Group>
<TextInput
placeholder='pencarian'
leftSection={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group> </Group>
{filteredData.length === 0 ? ( {filteredData.length === 0 ? (

View File

@@ -1,12 +1,15 @@
'use client' 'use client'
import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud'; import infoSekolahPaud from '@/app/admin/(dashboard)/_state/pendidikan/info-sekolah-paud';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import { Box, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, TextInput, Title } from '@mantine/core';
import { IconSchool, IconLayersSubtract } from '@tabler/icons-react'; import { IconSchool, IconLayersSubtract, IconSearch } from '@tabler/icons-react';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect, useDebouncedValue } from '@mantine/hooks';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { useState } from 'react';
function Page() { function Page() {
const [search, setSearch] = useState("")
const [debouncedSearch] = useDebouncedValue(search, 500); // 500ms delay
const stateList = useProxy(infoSekolahPaud.siswa) const stateList = useProxy(infoSekolahPaud.siswa)
const { const {
@@ -18,8 +21,8 @@ function Page() {
} = stateList.findMany } = stateList.findMany
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10) load(page, 10, debouncedSearch)
}, [page]) }, [page, debouncedSearch])
const filteredData = data || [] const filteredData = data || []
@@ -46,6 +49,12 @@ function Page() {
<IconSchool size={28} stroke={1.5} color={colors['blue-button']} /> <IconSchool size={28} stroke={1.5} color={colors['blue-button']} />
<Title order={2} fz="xl">Daftar Siswa</Title> <Title order={2} fz="xl">Daftar Siswa</Title>
</Group> </Group>
<TextInput
placeholder='pencarian'
leftSection={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group> </Group>
{filteredData.length === 0 ? ( {filteredData.length === 0 ? (

View File

@@ -0,0 +1,168 @@
'use client'
import perpustakaanDigitalState from '@/app/admin/(dashboard)/_state/pendidikan/perpustakaan-digital';
import colors from '@/con/colors';
import { ActionIcon, Box, Center, Group, Image, Paper, SimpleGrid, Skeleton, Spoiler, Stack, Text, Tooltip, Badge } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconBook2, IconRefresh } from '@tabler/icons-react';
import { motion } from 'framer-motion';
import { useSearchParams } from 'next/navigation';
import { useCallback, useState } from 'react';
import { useProxy } from 'valtio/utils';
function Content({ kategoriBuku }: { kategoriBuku: string }) {
const state = useProxy(perpustakaanDigitalState);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const searchParams = useSearchParams();
const searchQuery = searchParams.get('search') || '';
const decodedKategoriBuku = decodeURIComponent(kategoriBuku);
const kategoriFilter = decodedKategoriBuku.toLowerCase() === 'semua' ? '' : decodedKategoriBuku;
const loadData = useCallback(async (searchQuery: string = '') => {
try {
setIsLoading(true);
await state.dataPerpustakaan.findMany.load(1, 100, searchQuery, kategoriFilter);
} finally {
setIsLoading(false);
}
}, [kategoriFilter, state.dataPerpustakaan.findMany]);
useShallowEffect(() => {
loadData(searchQuery);
}, [searchQuery, loadData]);
const handleRefresh = () => {
loadData();
};
if (isLoading || !state.dataPerpustakaan.findMany.load || !state.dataPerpustakaan.findMany.data) {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
<Box px={{ base: 'md', md: 100 }} pb={50}>
<Skeleton h={50} radius="xl" />
<Skeleton h={180} mt="lg" radius="md" />
</Box>
</Stack>
);
}
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
<Box px={{ base: 'md', md: 100 }} pb={20}>
<Group justify="space-between" mb="lg">
<Group gap="xs">
<IconBook2 size={28} color={colors['blue-button']} />
<Text fw={700} size="xl" c={colors['blue-button']}>
Koleksi Buku
</Text>
</Group>
<Tooltip label="Muat ulang koleksi" withArrow>
<ActionIcon
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
onClick={handleRefresh}
loading={isLoading}
radius="xl"
size="lg"
>
<IconRefresh size={20} />
</ActionIcon>
</Tooltip>
</Group>
{!state.dataPerpustakaan.findMany.data || state.dataPerpustakaan.findMany.data.length === 0 ? (
<Center py="xl">
<Stack gap="xs" align="center">
<Image src="/empty-books.svg" alt="Kosong" w={140} h="auto" />
<Text c="dimmed" fz="sm">Belum ada buku yang tersedia dalam kategori ini</Text>
</Stack>
</Center>
) : (
<SimpleGrid
cols={{ base: 1, sm: 2, md: 3 }}
spacing="lg"
verticalSpacing="lg"
>
{state.dataPerpustakaan.findMany.data?.map((v, k) => (
<motion.div
key={k}
whileHover={{ scale: 1.03 }}
transition={{ duration: 0.2 }}
style={{ width: '100%', height: '100%' }}
>
<Paper
p="lg"
radius="2xl"
shadow="md"
bg={colors['white-trans-1']}
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between'
}}
>
<Stack gap="md" style={{ flex: 1 }}>
<Center>
<Image
src={v.image?.link}
alt={v.judul}
h={180}
w="auto"
fit="contain"
fallbackSrc="/placeholder-book.jpg"
radius="md"
/>
</Center>
<Stack gap={4} align="center">
<Text
c={colors["blue-button"]}
ta="center"
fw={700}
fz={{ base: "md", md: "lg" }}
lineClamp={2}
>
{v.judul}
</Text>
{v.kategori && (
<Badge color="cyan" radius="sm" variant="light" size="sm">
{v.kategori.name}
</Badge>
)}
</Stack>
<Spoiler
maxHeight={80}
showLabel={
<Text fw={600} fz="sm" c={colors['blue-button']} ta="center" mt="xs">
Lihat deskripsi
</Text>
}
hideLabel={
<Text fw={600} fz="sm" c={colors['blue-button']} ta="center" mt="xs">
Sembunyikan deskripsi
</Text>
}
expanded={expandedId === v.id}
onExpandedChange={(isExpanded) => setExpandedId(isExpanded ? v.id : null)}
>
<Text
ta="justify"
fz="sm"
lh={1.5}
c="dimmed"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
/>
</Spoiler>
</Stack>
</Paper>
</motion.div>
))}
</SimpleGrid>
)}
</Box>
</Stack>
);
}
export default Content;

View File

@@ -0,0 +1,14 @@
// src/app/darmasaba/(pages)/desa/berita/[kategori]/page.tsx
import { Suspense } from "react";
import Content from "./content";
export default async function Page({ params }: { params: Promise<{ kategoriBuku: string }> }) {
const { kategoriBuku } = await params;
return (
<Suspense fallback={<div>Loading...</div>}>
<Content kategoriBuku={kategoriBuku} />
</Suspense>
);
}

View File

@@ -0,0 +1,140 @@
'use client'
import { useEffect, useState } from 'react';
import { ActionIcon, Box, Flex, Grid, GridCol, Stack, Tabs, TabsList, TabsTab, Text, TextInput } from '@mantine/core';
import { IconSearch, IconUser } from '@tabler/icons-react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import BackButton from '../../../desa/layanan/_com/BackButto';
import colors from '@/con/colors';
type LayoutBukuProps = {
placeholder?: string;
searchIcon?: React.ReactNode;
value?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
children?: React.ReactNode;
};
function LayoutTabs({
placeholder = 'Cari buku digital...',
searchIcon = <IconSearch size={20} />,
children,
}: LayoutBukuProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const activeTab = pathname.split('/').pop() || 'semua';
const initialSearch = searchParams.get('search') || '';
const [searchValue, setSearchValue] = useState(initialSearch);
const [searchTimeout, setSearchTimeout] = useState<number | null>(null);
const [activeTabState, setActiveTabState] = useState(activeTab);
useEffect(() => {
setActiveTabState(activeTab);
}, [activeTab]);
useEffect(() => {
return () => {
if (searchTimeout !== null) clearTimeout(searchTimeout);
};
}, [searchTimeout]);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setSearchValue(value);
if (searchTimeout !== null) clearTimeout(searchTimeout);
const updateSearch = () => {
const params = new URLSearchParams();
if (value) params.set('search', value);
router.push(
`/darmasaba/pendidikan/perpustakaan-digital/${activeTab}${params.toString() ? `?${params.toString()}` : ''}`
);
};
if (value === '') {
updateSearch();
} else {
const newTimeout = window.setTimeout(updateSearch, 350);
setSearchTimeout(newTimeout);
}
};
const tabs = [
{ label: 'Semua', value: 'semua', href: '/darmasaba/pendidikan/perpustakaan-digital/semua' },
{ label: 'Dokumenter', value: 'dokumenter', href: '/darmasaba/pendidikan/perpustakaan-digital/dokumenter' },
{ label: 'Sayuran', value: 'sayuran', href: '/darmasaba/pendidikan/perpustakaan-digital/sayuran' },
{ label: 'Dongeng', value: 'dongeng', href: '/darmasaba/pendidikan/perpustakaan-digital/dongeng' },
];
const handleTabChange = (value: string | null) => {
if (!value) return;
const params = new URLSearchParams(searchParams.toString());
router.push(`/darmasaba/pendidikan/perpustakaan-digital/${value}${params.toString() ? `?${params.toString()}` : ''}`);
};
return (
<Stack pos="relative" bg="var(--mantine-color-gray-0)" py="xl" gap="22">
<Box px={{ base: 'md', md: 100 }}>
<Flex justify="space-between" align="center">
<BackButton />
<ActionIcon
variant="light"
component={Link}
href="/login"
radius="xl"
size="lg"
aria-label="Masuk ke akun"
>
<IconUser size={26} stroke={1.5} />
</ActionIcon>
</Flex>
</Box>
<Box pb={20}>
<Text ta="center" fz={{ base: '1.6rem', md: '2.4rem' }} fw={700} c={colors['blue-button']}>
Perpustakaan Digital Darmasaba
</Text>
<Tabs color="blue" variant="pills" value={activeTabState} onChange={handleTabChange}>
<Box px={{ base: 'md', md: 100 }} py="md" bg="var(--mantine-color-gray-1)" style={{ borderRadius: 16 }}>
<Grid align="center" gutter="md">
<GridCol span={{ base: 12, md: 9 }}>
<TabsList>
{tabs.map((tab) => (
<TabsTab
color={colors['blue-button']}
key={tab.value}
value={tab.value}
onClick={() => router.push(tab.href)}
style={{ fontWeight: 500 }}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</GridCol>
<GridCol span={{ base: 12, md: 3 }}>
<TextInput
radius="xl"
size="md"
placeholder={placeholder}
leftSection={searchIcon}
w="100%"
value={searchValue}
onChange={handleSearchChange}
aria-label="Cari judul buku"
/>
</GridCol>
</Grid>
</Box>
{children}
</Tabs>
</Box>
</Stack>
);
}
export default LayoutTabs;

View File

@@ -0,0 +1,15 @@
'use client'
import React, { Suspense } from 'react';
import LayoutTabs from './_lib/layoutTabs';
function Layout({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<LayoutTabs>
{children}
</LayoutTabs>
</Suspense>
);
}
export default Layout;

View File

@@ -1,142 +0,0 @@
'use client'
import colors from '@/con/colors';
import { ActionIcon, Box, Button, Center, Flex, Group, Image, Paper, SimpleGrid, Skeleton, Spoiler, Stack, Text, TextInput } from '@mantine/core';
import { IconSearch, IconUser } from '@tabler/icons-react';
import { motion } from 'framer-motion';
import BackButton from '../../desa/layanan/_com/BackButto';
import Link from 'next/link';
import { useProxy } from 'valtio/utils';
import perpustakaanDigitalState from '@/app/admin/(dashboard)/_state/pendidikan/perpustakaan-digital';
import { useShallowEffect } from '@mantine/hooks';
import { useState } from 'react';
function Page() {
const state = useProxy(perpustakaanDigitalState)
const [expandedId, setExpandedId] = useState<string | null>(null);
useShallowEffect(() => {
state.dataPerpustakaan.findMany.load()
}, [])
if (!state.dataPerpustakaan.findMany.load)
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}>
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }} pb={50}>
<Skeleton h={60} radius="xl" />
<Skeleton h={200} mt="lg" radius="md" />
</Box>
</Stack>
)
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}>
<Box px={{ base: 'md', md: 100 }}>
<Flex justify={'space-between'} align={'center'}>
<BackButton />
<ActionIcon variant='transparent' component={Link} href={'/login'}>
<IconUser color={colors["blue-button"]} size={30} />
</ActionIcon>
</Flex>
</Box>
<Box px={{ base: 'md', md: 100 }} pb={20}>
<Text ta={"center"} fz={{ base: "h1", md: "2.5rem" }} c={colors["blue-button"]} fw={"bold"}>
E-Book Desa Darmasaba
</Text>
<Group justify='center' pb={20}>
<TextInput
w={{ base: "50%", md: "70%" }}
placeholder='Cari Buku...'
rightSection={
<Button
size="xs"
style={{ height: '80%', marginRight: '5px' }}
bg={colors["blue-button"]}
>
Cari
</Button>
}
rightSectionWidth={70}
leftSection={<IconSearch size={20} />}
/>
</Group>
<Group mb={20} gap="md" justify='center' wrap="wrap">
<Paper bg={colors['blue-button']} radius="xl" py={5} px={20}>
<Text c={colors['white-1']} size="sm">
Semua
</Text>
</Paper>
{['Non Fiksi', 'Sejarah', 'Edukasi', 'Fiksi'].map((kategori) => (
<Paper key={kategori} bg={'gray'} radius="xl" py={5} px={20}>
<Text c={colors['white-1']} size="sm">
{kategori}
</Text>
</Paper>
))}
</Group>
<SimpleGrid
cols={{
base: 1,
md: 3
}}
style={{
alignItems: 'stretch'
}}
>
{state.dataPerpustakaan.findMany.data.map((v, k) => {
return (
<Box key={k} style={{ height: '100%' }}>
<motion.div
whileHover={{ scale: 1.05 }}
style={{ width: '100%', height: '100%' }}
>
<Paper
p={"xl"}
bg={colors['white-trans-1']}
w={{ base: "100%", md: "100%" }}
style={{
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<Stack style={{ flex: 1 }}>
<Center>
<Image src={v.image.link} alt='' w={{ base: 390, md: 1000 }} />
</Center>
<Text c={colors["blue-button"]} ta={'center'} fw={'bold'} fz={{ base: "h2", md: "h1" }}>{v.judul}</Text>
<Spoiler
showLabel={
<Text fw="bold" fz="sm" c={colors['blue-button']}>
Show more
</Text>
}
hideLabel={
<Text fw="bold" fz="sm" c={colors['blue-button']}>
Hide details
</Text>
}
expanded={expandedId === v.id}
onExpandedChange={(isExpanded) =>
setExpandedId(isExpanded ? v.id : null)
}
>
<Text
ta="justify"
fz="sm"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
/>
</Spoiler>
</Stack>
</Paper>
</motion.div>
</Box>
)
})}
</SimpleGrid>
</Box>
</Stack>
);
}
export default Page;

View File

@@ -0,0 +1,163 @@
'use client'
import perpustakaanDigitalState from '@/app/admin/(dashboard)/_state/pendidikan/perpustakaan-digital';
import colors from '@/con/colors';
import { Badge, Box, Center, Group, Image, Paper, SimpleGrid, Skeleton, Spoiler, Stack, Text, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { motion } from 'framer-motion';
import { useCallback, useState } from 'react';
import { useProxy } from 'valtio/utils';
import { IconBook2, IconInfoCircle } from '@tabler/icons-react';
type ContentProps = {
searchQuery: string;
};
function Content({ searchQuery }: ContentProps) {
const state = useProxy(perpustakaanDigitalState);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const loadData = useCallback(
async (query: string = '') => {
try {
setIsLoading(true);
await state.dataPerpustakaan.findMany.load(1, 100, query, '');
} catch (error) {
console.error('Gagal memuat data:', error);
} finally {
setIsLoading(false);
}
},
[state.dataPerpustakaan.findMany]
);
useShallowEffect(() => {
loadData(searchQuery);
}, [searchQuery, loadData]);
if (
isLoading ||
!state.dataPerpustakaan.findMany.load ||
!state.dataPerpustakaan.findMany.data
) {
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
<Box px={{ base: 'md', md: 100 }} pb={50}>
<Skeleton h={60} radius="xl" />
<Skeleton h={200} mt="lg" radius="md" />
</Box>
</Stack>
);
}
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
<Box px={{ base: 'md', md: 100 }} pb={20}>
<Group justify="space-between" mb="lg">
<Group gap="xs">
<IconBook2 size={28} color={colors['blue-button']} />
<Text fw={700} size="xl" c={colors['blue-button']}>
Koleksi Semua Buku
</Text>
</Group>
<Tooltip label="Temukan buku favorit Anda di sini" withArrow>
<IconInfoCircle size={22} color={colors['blue-button']} />
</Tooltip>
</Group>
{!state.dataPerpustakaan.findMany.data ||
state.dataPerpustakaan.findMany.data.length === 0 ? (
<Center py="xl">
<Stack gap="xs" align="center">
<Image src="/empty-books.svg" alt="Kosong" w={140} h="auto" />
<Text c="dimmed" fz="sm">Belum ada buku yang tersedia</Text>
</Stack>
</Center>
) : (
<SimpleGrid
cols={{ base: 1, sm: 2, md: 3 }}
spacing="lg"
verticalSpacing="lg"
>
{state.dataPerpustakaan.findMany.data?.map((v, k) => (
<motion.div
key={k}
whileHover={{ scale: 1.03 }}
transition={{ duration: 0.2 }}
style={{ width: '100%', height: '100%' }}
>
<Paper
p="lg"
radius="2xl"
shadow="md"
bg="white"
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between'
}}
>
<Stack gap="md" style={{ flex: 1 }}>
<Center>
<Image
src={v.image?.link}
alt={v.judul}
h={180}
w="auto"
fit="contain"
fallbackSrc="/placeholder-book.jpg"
radius="md"
/>
</Center>
<Stack gap={4} align="center">
<Text
c={colors["blue-button"]}
ta="center"
fw={700}
fz={{ base: "md", md: "lg" }}
lineClamp={2}
>
{v.judul}
</Text>
{v.kategori && (
<Badge color="cyan" radius="sm" variant="light" size="sm">
{v.kategori.name}
</Badge>
)}
</Stack>
<Spoiler
maxHeight={80}
showLabel={
<Text fw={600} fz="sm" c={colors['blue-button']} ta="center" mt="xs">
Lihat deskripsi
</Text>
}
hideLabel={
<Text fw={600} fz="sm" c={colors['blue-button']} ta="center" mt="xs">
Sembunyikan deskripsi
</Text>
}
expanded={expandedId === v.id}
onExpandedChange={(isExpanded) => setExpandedId(isExpanded ? v.id : null)}
>
<Text
ta="justify"
fz="sm"
lh={1.5}
c="dimmed"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
/>
</Spoiler>
</Stack>
</Paper>
</motion.div>
))}
</SimpleGrid>
)}
</Box>
</Stack>
);
}
export default Content;

View File

@@ -0,0 +1,13 @@
// src/app/darmasaba/(pages)/desa/berita/[kategori]/page.tsx
import { Suspense } from "react";
import Content from "./content";
export default async function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Content searchQuery="" />
</Suspense>
);
}

View File

@@ -2,7 +2,8 @@ import colors from '@/con/colors';
import { Box, Button, Center, Flex, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import { Box, Button, Center, Flex, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { IconUserFilled } from '@tabler/icons-react'; import { IconUserFilled } from '@tabler/icons-react';
import Link from 'next/link'; import Link from 'next/link';
import BackButton from '../../../desa/layanan/_com/BackButto'; import BackButton from '../(pages)/desa/layanan/_com/BackButto';
function Page() { function Page() {
return ( return (

View File

@@ -1,7 +1,8 @@
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Checkbox, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import { Box, Button, Center, Checkbox, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import Link from 'next/link'; import Link from 'next/link';
import BackButton from '../../../desa/layanan/_com/BackButto'; import BackButton from '../(pages)/desa/layanan/_com/BackButto';
function Page() { function Page() {
return ( return (

View File

@@ -332,7 +332,7 @@ const navbarListMenu = [
{ {
id: "8.6", id: "8.6",
name: "Perpustakaan Digital", name: "Perpustakaan Digital",
href: "/darmasaba/pendidikan/perpustakaan-digital" href: "/darmasaba/pendidikan/perpustakaan-digital/semua"
}, },
{ {
id: "8.7", id: "8.7",