diff --git a/src/app/admin/(dashboard)/_state/desa/gallery.ts b/src/app/admin/(dashboard)/_state/desa/gallery.ts index 3405a6ba..61faa3fd 100644 --- a/src/app/admin/(dashboard)/_state/desa/gallery.ts +++ b/src/app/admin/(dashboard)/_state/desa/gallery.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import ApiFetch from "@/lib/api-fetch"; import { Prisma } from "@prisma/client"; import { toast } from "react-toastify"; @@ -68,10 +69,34 @@ const foto = proxy({ }; }>[] | null, - async load() { - const res = await ApiFetch.api.desa.gallery.foto["find-many"].get(); - if (res.status === 200) { - foto.findMany.data = res.data?.data ?? []; + page: 1, + totalPages: 1, + loading: false, + search: "", + load: async (page = 1, limit = 10, search = "") => { + foto.findMany.loading = true; // ✅ Akses langsung via nama path + foto.findMany.page = page; + foto.findMany.search = search; + + try { + const query: any = { page, limit }; + if (search) query.search = search; + + const res = await ApiFetch.api.desa.gallery.foto["find-many"].get({ query }); + + if (res.status === 200 && res.data?.success) { + foto.findMany.data = res.data.data ?? []; + foto.findMany.totalPages = res.data.totalPages ?? 1; + } else { + foto.findMany.data = []; + foto.findMany.totalPages = 1; + } + } catch (err) { + console.error("Gagal fetch foto paginated:", err); + foto.findMany.data = []; + foto.findMany.totalPages = 1; + } finally { + foto.findMany.loading = false; } }, }, @@ -215,6 +240,28 @@ const foto = proxy({ foto.update.form = { ...defaultFormFoto }; }, }, + findRecent: { + data: [] as Prisma.GalleryFotoGetPayload<{ + include: { + imageGalleryFoto: true; + }; + }>[], + loading: false, + + async load() { + try { + this.loading = true; + const res = await ApiFetch.api.desa.gallery.foto["find-recent"].get(); + if (res.status === 200 && res.data?.success) { + this.data = res.data.data ?? []; + } + } catch (error) { + console.error("Gagal fetch foto recent:", error); + } finally { + this.loading = false; + } + }, + }, }); const video = proxy({ @@ -257,10 +304,34 @@ const video = proxy({ }; }>[] | null, - async load() { - const res = await ApiFetch.api.desa.gallery.video["find-many"].get(); - if (res.status === 200) { - video.findMany.data = res.data?.data ?? []; + page: 1, + totalPages: 1, + loading: false, + search: "", + load: async (page = 1, limit = 10, search = "") => { + video.findMany.loading = true; // ✅ Akses langsung via nama path + video.findMany.page = page; + video.findMany.search = search; + + try { + const query: any = { page, limit }; + if (search) query.search = search; + + const res = await ApiFetch.api.desa.gallery.video["find-many"].get({ query }); + + if (res.status === 200 && res.data?.success) { + video.findMany.data = res.data.data ?? []; + video.findMany.totalPages = res.data.totalPages ?? 1; + } else { + video.findMany.data = []; + video.findMany.totalPages = 1; + } + } catch (err) { + console.error("Gagal fetch video paginated:", err); + video.findMany.data = []; + video.findMany.totalPages = 1; + } finally { + video.findMany.loading = false; } }, }, diff --git a/src/app/admin/(dashboard)/_state/state-file-storage.ts b/src/app/admin/(dashboard)/_state/state-file-storage.ts new file mode 100644 index 00000000..98c0677e --- /dev/null +++ b/src/app/admin/(dashboard)/_state/state-file-storage.ts @@ -0,0 +1,63 @@ +import ApiFetch from "@/lib/api-fetch"; +import { proxy } from "valtio"; + +interface FileItem { + id: string; + name: string; + path: string; + link: string; + mimeType: string; + category: string; + realName: string; + isActive: boolean; + createdAt: string | Date; + updatedAt: string | Date; + deletedAt: string | Date | null; +} + +const stateFileStorage = proxy<{ + list: FileItem[] | null; + page: number; + limit: number; + total: number | undefined; + load: (params?: { search?: string }) => Promise; + del: (params: { id: string }) => Promise; +}>({ + list: null, + page: 1, + limit: 10, + total: undefined, + async load(params?: { search?: string }) { + const { search = "" } = params ?? {}; + try { + const { data } = await ApiFetch.api.fileStorage.findMany.get({ + query: { + page: this.page, + limit: this.limit, + search, + category: 'image' + }, + }); + + if (data?.data) { + this.list = data.data as FileItem[]; + this.total = data.meta?.totalPages; + } + } catch (error) { + console.error('Error loading files:', error); + this.list = []; + this.total = 0; + } + }, + async del({ id }: { id: string }) { + try { + await ApiFetch.api.fileStorage.delete({ id }); + await this.load(); + } catch (error) { + console.error('Error deleting file:', error); + throw error; + } + }, +}); + +export default stateFileStorage; diff --git a/src/app/admin/(dashboard)/desa/gallery/foto/[id]/edit/page.tsx b/src/app/admin/(dashboard)/desa/gallery/foto/[id]/edit/page.tsx index 9fb7f3ff..4e635c6c 100644 --- a/src/app/admin/(dashboard)/desa/gallery/foto/[id]/edit/page.tsx +++ b/src/app/admin/(dashboard)/desa/gallery/foto/[id]/edit/page.tsx @@ -4,8 +4,9 @@ import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery'; import colors from '@/con/colors'; import ApiFetch from '@/lib/api-fetch'; -import { Box, Button, Center, FileInput, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; -import { IconArrowBack, IconImageInPicture } from '@tabler/icons-react'; +import { Box, Button, Center, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; +import { Dropzone } from '@mantine/dropzone'; +import { IconArrowBack, IconImageInPicture, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { useParams, useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import { toast } from 'react-toastify'; @@ -18,6 +19,11 @@ function EditFoto() { const params = useParams(); const [previewImage, setPreviewImage] = useState(null); const [file, setFile] = useState(null); + const [formData, setFormData] = useState({ + name: fotoState.update.form.name || '', + deskripsi: fotoState.update.form.deskripsi || '', + imagesId: fotoState.update.form.imagesId || '' + }); useEffect(() => { const loadFoto = async () => { @@ -26,6 +32,11 @@ function EditFoto() { try { const data = await fotoState.update.load(id); if (data) { + setFormData({ + name: data.name || '', + deskripsi: data.deskripsi || '', + imagesId: data.imageGalleryFoto?.id || '' + }); if (data?.imageGalleryFoto?.link) { setPreviewImage(data.imageGalleryFoto.link); } @@ -40,6 +51,12 @@ function EditFoto() { const handleSubmit = async () => { try { + fotoState.update.form = { + ...fotoState.update.form, + name: formData.name, + deskripsi: formData.deskripsi, + imagesId: formData.imagesId + }; if (file) { const res = await ApiFetch.api.fileStorage.create.post({ file, @@ -74,30 +91,55 @@ function EditFoto() { Judul Foto} placeholder='Masukkan judul foto' - value={fotoState.update.form.name} + value={formData.name} onChange={(e) => - (fotoState.update.form.name = e.target.value) + (formData.name = e.target.value) } /> - Upload Gambar} - value={file} - onChange={async (e) => { - if (!e) return; - setFile(e); - const base64 = await e.arrayBuffer().then((buf) => - "data:image/png;base64," + Buffer.from(buf).toString("base64") - ); - setPreviewImage(base64); - }} - /> - {previewImage ? ( - - ) : ( -
- -
- )} + + Upload Foto + { + const selectedFile = files[0]; // Ambil file pertama + if (selectedFile) { + setFile(selectedFile); + setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview + } + }} + onReject={() => toast.error('File tidak valid.')} + maxSize={5 * 1024 ** 2} // Maks 5MB + accept={{ 'image/*': [] }} + > + + + + + + + + + + + +
+ + Drag gambar ke sini atau klik untuk pilih file + + + Maksimal 5MB dan harus format gambar + +
+
+
+ + {previewImage ? ( + + ) : ( +
+ +
+ )} +
Deskripsi Foto - Upload Gambar} - value={file} - onChange={async (e) => { - if (!e) return; - setFile(e); - const base64 = await e.arrayBuffer().then((buf) => - "data:image/png;base64," + Buffer.from(buf).toString("base64") - ); - setPreviewImage(base64); - }} - /> - {previewImage ? ( - - ) : ( -
- -
- )} + + Gambar + + { + const selectedFile = files[0]; // Ambil file pertama + if (selectedFile) { + setFile(selectedFile); + setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview + } + }} + onReject={() => toast.error('File tidak valid.')} + maxSize={5 * 1024 ** 2} // Maks 5MB + accept={{ 'image/*': [] }} + > + + + + + + + + + + + +
+ + Drag gambar ke sini atau klik untuk pilih file + + + Maksimal 5MB dan harus format gambar + +
+
+
+ + {/* Tampilkan preview kalau ada */} + {previewImage && ( + + Preview + + )} + +
+
Deskripsi Foto - } - value={search} - onChange={(e) => setSearch(e.currentTarget.value)} - /> - - - ); -} - -function ListFoto({ search }: { search: string }) { - const fotoState = useProxy(stateGallery.foto) - const router = useRouter(); +export default function ListImage() { + const { list, total } = useSnapshot(stateFileStorage); useShallowEffect(() => { - fotoState.findMany.load() - }, []) + stateFileStorage.load(); + }, []); - const filteredData = (fotoState.findMany.data || []).filter(item => { - const keyword = search.toLowerCase(); - return ( - item.name.toLowerCase().includes(keyword) || - item.deskripsi.toLowerCase().includes(keyword) - ); - }); - - if (!fotoState.findMany.data) { - return ( - - - - ) - } - + let timeOut: NodeJS.Timer; return ( - - - } + + + List Foto + } + rightSection={ + { + stateFileStorage.load(); + }} + > + + + } + placeholder="Pencarian" + onChange={(e) => { + if (timeOut) clearTimeout(timeOut); + timeOut = setTimeout(() => { + stateFileStorage.load({ search: e.target.value }); + }, 200); + }} /> - - - - Judul Foto - Tanggal Foto - Deskripsi Foto - Detail - - - - {filteredData.map((item) => ( - - {item.name} - {new Date(item.createdAt).toDateString()} - - - - - - - - ))} - -
+
+ + + {list && + list.map((v, k) => { + return ( + + + { + // copy to clipboard + navigator.clipboard.writeText(v.url); + toast("Berhasil disalin"); + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.8 }} + > + {v.name} + + + + {v.name} + + + + { + stateFileStorage.del({ name: v.name }).finally(() => { + toast("Berhasil dihapus"); + }); + }} + /> + + + + ); + })} + -
+ {total && ( + { + stateFileStorage.page = e; + stateFileStorage.load(); + }} + /> + )} + ); } - -export default Foto; diff --git a/src/app/admin/(dashboard)/desa/gallery/layout.tsx b/src/app/admin/(dashboard)/desa/gallery/layout.tsx index 78a5c467..fbaf56c0 100644 --- a/src/app/admin/(dashboard)/desa/gallery/layout.tsx +++ b/src/app/admin/(dashboard)/desa/gallery/layout.tsx @@ -1,5 +1,5 @@ 'use client' -import LayoutTabsGallery from "../../ppid/_com/layoutTabsGallery" +import LayoutTabsGallery from "./lib/layoutTabs" export default function Layout({ children }: { children: React.ReactNode }) { return ( diff --git a/src/app/admin/(dashboard)/desa/gallery/lib/layoutTabs.tsx b/src/app/admin/(dashboard)/desa/gallery/lib/layoutTabs.tsx new file mode 100644 index 00000000..7a148d73 --- /dev/null +++ b/src/app/admin/(dashboard)/desa/gallery/lib/layoutTabs.tsx @@ -0,0 +1,62 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +'use client' +import colors from '@/con/colors'; +import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; +import { usePathname, useRouter } from 'next/navigation'; +import React, { useEffect, useState } from 'react'; + +function LayoutTabsGallery({ children }: { children: React.ReactNode }) { + const router = useRouter() + const pathname = usePathname() + const tabs = [ + { + label: "Foto", + value: "foto", + href: "/admin/desa/gallery/foto" + }, + { + label: "Video", + value: "video", + href: "/admin/desa/gallery/video" + }, + ]; + const curentTab = tabs.find(tab => tab.href === pathname) + const [activeTab, setActiveTab] = useState(curentTab?.value || tabs[0].value); + + const handleTabChange = (value: string | null) => { + const tab = tabs.find(t => t.value === value) + if (tab) { + router.push(tab.href) + } + setActiveTab(value) + } + + useEffect(() => { + const match = tabs.find(tab => tab.href === pathname) + if (match) { + setActiveTab(match.value) + } + }, [pathname]) + + return ( + + Gallery + + + {tabs.map((e, i) => ( + {e.label} + ))} + + {tabs.map((e, i) => ( + + {/* Konten dummy, bisa diganti tergantung routing */} + <> + + ))} + + {children} + + ); +} + +export default LayoutTabsGallery; \ No newline at end of file diff --git a/src/app/admin/(dashboard)/desa/gallery/video/page.tsx b/src/app/admin/(dashboard)/desa/gallery/video/page.tsx index c0a3edf2..3b33aad8 100644 --- a/src/app/admin/(dashboard)/desa/gallery/video/page.tsx +++ b/src/app/admin/(dashboard)/desa/gallery/video/page.tsx @@ -1,14 +1,14 @@ 'use client' import colors from '@/con/colors'; -import { Box, Button, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; +import { Box, Button, Center, Pagination, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; +import { useShallowEffect } from '@mantine/hooks'; import { IconDeviceImac, IconSearch } from '@tabler/icons-react'; import { useRouter } from 'next/navigation'; -import JudulListTab from '../../../_com/judulListTab'; -import { useProxy } from 'valtio/utils'; -import stateGallery from '../../../_state/desa/gallery'; -import { useShallowEffect } from '@mantine/hooks'; -import HeaderSearch from '../../../_com/header'; import { useState } from 'react'; +import { useProxy } from 'valtio/utils'; +import HeaderSearch from '../../../_com/header'; +import JudulList from '../../../_com/judulList'; +import stateGallery from '../../../_state/desa/gallery'; function Video() { const [search, setSearch] = useState(""); @@ -29,35 +29,34 @@ function Video() { function ListVideo({ search }: { search: string }) { const videoState = useProxy(stateGallery.video) const router = useRouter(); + const { + data, + page, + totalPages, + loading, + load, + } = videoState.findMany; useShallowEffect(() => { - videoState.findMany.load() - }, []) + load(page, 10, search) + }, [page, search]) - const filteredData = (videoState.findMany.data || []).filter(item => { - const keyword = search.toLowerCase(); - return ( - item.name.toLowerCase().includes(keyword) || - item.deskripsi.toLowerCase().includes(keyword) - ); - }); + const filteredData = (data || []) - if (!videoState.findMany.data) { + if (loading || !data) { return ( ) } - + return ( - } /> @@ -71,10 +70,25 @@ function ListVideo({ search }: { search: string }) { {filteredData.map((item) => ( - {item.name} - {new Date(item.createdAt).toDateString()} - + + {item.name} + + + + + + {new Date(item.createdAt).toLocaleDateString('id-ID', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} + + + + + +
+
+ load(newPage)} // ini penting! + total={totalPages} + mt="md" + mb="md" + /> +
); } diff --git a/src/app/api/[[...slugs]]/_lib/desa/gallery/foto/find-many.ts b/src/app/api/[[...slugs]]/_lib/desa/gallery/foto/find-many.ts index bd880117..3279b66c 100644 --- a/src/app/api/[[...slugs]]/_lib/desa/gallery/foto/find-many.ts +++ b/src/app/api/[[...slugs]]/_lib/desa/gallery/foto/find-many.ts @@ -1,25 +1,57 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// /api/berita/findManyPaginated.ts import prisma from "@/lib/prisma"; +import { Context } from "elysia"; -async function galleryFotoFindMany() { - try { - const data = await prisma.galleryFoto.findMany({ - where: { isActive: true }, - include: { - imageGalleryFoto: true, - }, - }); +async function galleryFotoFindMany(context: Context) { + // Ambil parameter dari query + const page = Number(context.query.page) || 1; + const limit = Number(context.query.limit) || 10; + const search = (context.query.search as string) || ''; + const skip = (page - 1) * limit; - return { - success: true, - message: "Success fetch gallery foto", - data, - }; - } catch (e) { - console.error("Find many error:", e); - return { - success: false, - message: "Failed fetch gallery foto", - }; - } + // Buat where clause + const where: any = { isActive: true }; + + // Tambahkan pencarian (jika ada) + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } }, + { deskripsi: { contains: search, mode: 'insensitive' } }, + ]; + } + + try { + // Ambil data dan total count secara paralel + const [data, total] = await Promise.all([ + prisma.galleryFoto.findMany({ + where, + include: { + imageGalleryFoto: true, + }, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }), + prisma.galleryFoto.count({ where }), + ]); + + return { + success: true, + message: "Berhasil ambil foto 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 foto", + }; + } } -export default galleryFotoFindMany \ No newline at end of file + +export default galleryFotoFindMany; \ No newline at end of file diff --git a/src/app/api/[[...slugs]]/_lib/desa/gallery/foto/findRecent.ts b/src/app/api/[[...slugs]]/_lib/desa/gallery/foto/findRecent.ts new file mode 100644 index 00000000..7c9b1b5a --- /dev/null +++ b/src/app/api/[[...slugs]]/_lib/desa/gallery/foto/findRecent.ts @@ -0,0 +1,18 @@ +import prisma from "@/lib/prisma"; + +export default async function galleryFotoFindRecent() { + const result = await prisma.galleryFoto.findMany({ + orderBy: { + createdAt: "desc", + }, + take: 3, // ambil 4 data terbaru + include: { + imageGalleryFoto: true, + }, + }); + + return { + success: true, + data: result, + }; +} diff --git a/src/app/api/[[...slugs]]/_lib/desa/gallery/foto/index.ts b/src/app/api/[[...slugs]]/_lib/desa/gallery/foto/index.ts index bb19aa97..6a8d1d17 100644 --- a/src/app/api/[[...slugs]]/_lib/desa/gallery/foto/index.ts +++ b/src/app/api/[[...slugs]]/_lib/desa/gallery/foto/index.ts @@ -4,6 +4,7 @@ import galleryFotoDelete from "./del"; import galleryFotoFindMany from "./find-many"; import galleryFotoUpdate from "./updt"; import galleryFotoFindUnique from "./findUnique"; +import galleryFotoFindRecent from "./findRecent"; const GalleryFoto = new Elysia({ prefix: "/gallery/foto", tags: ["Desa/Gallery/Foto"] }) .get("/find-many", galleryFotoFindMany) @@ -18,6 +19,7 @@ const GalleryFoto = new Elysia({ prefix: "/gallery/foto", tags: ["Desa/Gallery/F imagesId: t.String(), }), }) + .get("/find-recent", galleryFotoFindRecent) .delete("/del/:id", galleryFotoDelete) .put("/:id", async (context) => { const response = await galleryFotoUpdate(context); diff --git a/src/app/api/[[...slugs]]/_lib/desa/gallery/video/find-many.ts b/src/app/api/[[...slugs]]/_lib/desa/gallery/video/find-many.ts index e171715e..9a758fe7 100644 --- a/src/app/api/[[...slugs]]/_lib/desa/gallery/video/find-many.ts +++ b/src/app/api/[[...slugs]]/_lib/desa/gallery/video/find-many.ts @@ -1,22 +1,53 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// /api/berita/findManyPaginated.ts import prisma from "@/lib/prisma"; +import { Context } from "elysia"; -async function galleryVideoFindMany() { - try { - const data = await prisma.galleryVideo.findMany({ - where: { isActive: true }, - }); +async function galleryVideoFindMany(context: Context) { + // Ambil parameter dari query + const page = Number(context.query.page) || 1; + const limit = Number(context.query.limit) || 10; + const search = (context.query.search as string) || ''; + const skip = (page - 1) * limit; - return { - success: true, - message: "Success fetch gallery video", - data, - }; - } catch (e) { - console.error("Find many error:", e); - return { - success: false, - message: "Failed fetch gallery video", - }; - } + // Buat where clause + const where: any = { isActive: true }; + + // Tambahkan pencarian (jika ada) + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } } + ]; + } + + try { + // Ambil data dan total count secara paralel + const [data, total] = await Promise.all([ + prisma.galleryVideo.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }), + prisma.galleryVideo.count({ where }), + ]); + + return { + success: true, + message: "Berhasil ambil video 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 video", + }; + } } + export default galleryVideoFindMany; \ No newline at end of file diff --git a/src/app/api/[[...slugs]]/_lib/fileStorage/_lib/findMany.ts b/src/app/api/[[...slugs]]/_lib/fileStorage/_lib/findMany.ts index bf1628e1..dfc3c8c9 100644 --- a/src/app/api/[[...slugs]]/_lib/fileStorage/_lib/findMany.ts +++ b/src/app/api/[[...slugs]]/_lib/fileStorage/_lib/findMany.ts @@ -1,12 +1,65 @@ import prisma from "@/lib/prisma"; import { Context } from "elysia"; -export const fileStorageFindMany = async (context: Context) => { - const category = context.query?.category as string | undefined; - - const data = await prisma.fileStorage.findMany({ - where: category ? { category } : {}, - }); - - return { data }; +type WhereClause = { + category?: string; + isActive?: boolean; + OR?: Array<{ + name?: { contains: string; mode: 'insensitive' }; + realName?: { contains: string; mode: 'insensitive' }; + }>; +}; + +export const fileStorageFindMany = async (context: Context) => { + try { + // Get query parameters with defaults + const page = Math.max(Number(context.query?.page) || 1, 1); + const limit = 10; // Fixed at 10 items per page + const category = context.query?.category as string | undefined; + const search = context.query?.search as string | undefined; + const skip = (page - 1) * limit; + + // Build where clause with proper TypeScript types + const where: WhereClause = { isActive: true }; + + if (category) where.category = category; + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } }, + { realName: { contains: search, mode: 'insensitive' } }, + ]; + } + + // Get paginated data and total count + const [data, total] = await Promise.all([ + prisma.fileStorage.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }), + prisma.fileStorage.count({ where }), + ]); + + return { + data, + meta: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } catch (error) { + console.error('Error in fileStorageFindMany:', error); + return { + data: [], + meta: { + page: 1, + limit: 10, + total: 0, + totalPages: 0, + }, + }; + } }; diff --git a/src/app/darmasaba/(pages)/desa/galery/(tabs)/foto.tsx b/src/app/darmasaba/(pages)/desa/galery/(tabs)/foto.tsx deleted file mode 100644 index c6f3a363..00000000 --- a/src/app/darmasaba/(pages)/desa/galery/(tabs)/foto.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import colors from '@/con/colors'; -import { SimpleGrid, Box, Paper, Center, Stack, Image, Text } from '@mantine/core'; -import React from 'react'; - -const data = [ - { - id: 1, - image: "/api/img/galeri-1.png", - title: "Pendapatan", - tanggal: "3 Mar 2025", - judul: "Pemasangan Wifi Gratis Di Publik Desa", - - }, - { - id: 2, - image: "/api/img/galeri-2.png", - title: "Belanja", - tanggal: "4 Mar 2025", - judul: "Panen raya Desa Darmasaba", - }, - { - id: 3, - image: "/api/img/galeri-3.png", - title: "Pembiayaan", - tanggal: "5 Mar 2025", - judul: "Kegiatan Pembangunan Pelinggih", - } -] -function Foto() { - return ( - - - {data.map((v, k) => { - return ( - - - -
- -
-
- - - {v.tanggal} - {v.judul} - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Fusce sagittis nec arcu ac ornare. Praesent a porttitor - felis. Proin varius ex nisl, in hendrerit odio tristique vel. - - -
-
- ) - })} -
-
- ); -} - -export default Foto; diff --git a/src/app/darmasaba/(pages)/desa/galery/(tabs)/video.tsx b/src/app/darmasaba/(pages)/desa/galery/(tabs)/video.tsx deleted file mode 100644 index 9f61ab04..00000000 --- a/src/app/darmasaba/(pages)/desa/galery/(tabs)/video.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import colors from '@/con/colors'; -import { Box, Center, Paper, SimpleGrid, Stack, Text } from '@mantine/core'; - -const data = [ - { - id: 1, - video: "https://www.youtube.com/embed/J2uZcZlvL7g?si=3pWy0ho77dW0E2Gt", - tanggal: "3 Mar 2025", - judul: "MENERIMA KUNJUNGAN STUDI TIRU DARI PEMERINTAH DESA TUA SULAWESI SELATAN", - - }, - { - id: 2, - video: "https://www.youtube.com/embed/GX4sqS5zAzw?si=rulOAa2Ylbs4_R82", - tanggal: "4 Mar 2025", - judul: "Sosialisasi Pengelolaan Sampah di SD NO 3 Desa Darmasaba", - }, - { - id: 3, - video: "https://www.youtube.com/embed/HCY4H6ODmeA?si=0epW8PAtd6Jum90k", - tanggal: "5 Mar 2025", - judul: "Posyandu dan Senam Lansia Banjar Gulingan", - } -] -function Video() { - return ( - - - {data.map((v, k) => { - return ( - - - -
- - - -
-
- - - {v.tanggal} - {v.judul} - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Fusce sagittis nec arcu ac ornare. Praesent a porttitor - felis. Proin varius ex nisl, in hendrerit odio tristique vel. - - -
-
- ) - })} -
-
- ); -} - -export default Video; diff --git a/src/app/darmasaba/(pages)/desa/galery/_lib/layoutTabs.tsx b/src/app/darmasaba/(pages)/desa/galery/_lib/layoutTabs.tsx new file mode 100644 index 00000000..af66f9d0 --- /dev/null +++ b/src/app/darmasaba/(pages)/desa/galery/_lib/layoutTabs.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { Box, Container, Grid, GridCol, Stack, Tabs, TabsList, TabsTab, Text } from '@mantine/core'; +import BackButton from '../../layanan/_com/BackButto'; +import dynamic from 'next/dynamic'; +import type { SearchBarProps } from './searchBar'; +import colors from '@/con/colors'; + +// Define tabs outside the component to ensure consistency between server and client +const TABS = [ + { + label: "Foto", + value: "foto", + href: "/darmasaba/desa/galery/foto", + }, + { + label: "Video", + value: "video", + href: "/darmasaba/desa/galery/video", + }, +] as const; + +const SearchBar = dynamic( + () => import('./searchBar').then(mod => mod.SearchBar), + { ssr: false } +); + +type HeaderSearchProps = { + children?: React.ReactNode; +}; + +function LayoutTabsGalery({ children }: HeaderSearchProps) { + const router = useRouter(); + const pathname = usePathname(); + const [isClient, setIsClient] = useState(false); + + // Set default active tab to empty string to prevent hydration mismatch + const [activeTab, setActiveTab] = useState(''); + + // Set client flag on mount + useEffect(() => { + setIsClient(true); + }, []); + + // Update active tab based on current route - only on client side + useEffect(() => { + if (!isClient) return; + + const currentTab = TABS.find(tab => pathname.includes(tab.value)); + if (currentTab) { + setActiveTab(currentTab.value); + } else { + // Default to first tab if no match found + setActiveTab(TABS[0].value); + } + }, [pathname, isClient]); + + const handleTabChange = (value: string | null) => { + if (!value) return; + const tab = TABS.find(tab => tab.value === value); + if (tab) { + // Only update if we're on the client + if (typeof window !== 'undefined') { + setActiveTab(value); + router.push(tab.href); + } + } + }; + + return ( + + {/* Header */} + + + + + + + Galeri Kegiatan Desa Darmasaba + + + + + + + + + + {TABS.map((tab) => ( + + {tab.label} + + ))} + + + + + + + + + + {children} + + + + ); +} + +export default LayoutTabsGalery; \ No newline at end of file diff --git a/src/app/darmasaba/(pages)/desa/galery/_lib/searchBar.tsx b/src/app/darmasaba/(pages)/desa/galery/_lib/searchBar.tsx new file mode 100644 index 00000000..f8ff0583 --- /dev/null +++ b/src/app/darmasaba/(pages)/desa/galery/_lib/searchBar.tsx @@ -0,0 +1,54 @@ +// src/app/darmasaba/(pages)/desa/galery/SearchBar.tsx + +'use client'; + +import { TextInput } from '@mantine/core'; +import { IconSearch } from '@tabler/icons-react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import React, { useState } from 'react'; + +export type SearchBarProps = { + placeholder?: string; + searchIcon?: React.ReactNode; +}; + +export function SearchBar({ + placeholder = "pencarian", + searchIcon = , +}: SearchBarProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Get initial search value from URL + const [searchValue, setSearchValue] = useState(searchParams.get('search') || ''); + + // Handle search input change with debounce + const handleSearchChange = (event: React.ChangeEvent) => { + const value = event.target.value; + setSearchValue(value); + + // Update URL with debounce + const params = new URLSearchParams(searchParams.toString()); + if (value) { + params.set('search', value); + } else { + params.delete('search'); + } + + // Only update URL if the search value has actually changed + if (params.toString() !== searchParams.toString()) { + router.push(`?${params.toString()}`); + } + }; + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/darmasaba/(pages)/desa/galery/foto/Content.tsx b/src/app/darmasaba/(pages)/desa/galery/foto/Content.tsx new file mode 100644 index 00000000..8c4deb7c --- /dev/null +++ b/src/app/darmasaba/(pages)/desa/galery/foto/Content.tsx @@ -0,0 +1,151 @@ +'use client'; + +import colors from '@/con/colors'; +import { Box, Center, Image, Pagination, Paper, SimpleGrid, Stack, Text } from '@mantine/core'; +import { useEffect, useState } from 'react'; +import ApiFetch from '@/lib/api-fetch'; + +interface FileItem { + id: string; + name: string; + link: string; + realName: string; + createdAt: string | Date; + category: string; + path: string; + mimeType: string; + } + + export default function FotoContent() { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + + // ✅ Load data function + const load = async (pageNum: number, limit: number, searchTerm: string) => { + setLoading(true); + try { + const query: Record = { + category: 'image', + page: pageNum.toString(), + limit: limit.toString(), + }; + if (searchTerm) query.search = searchTerm; + + const response = await ApiFetch.api.fileStorage.findMany.get({ query }); + + if (response.status === 200 && response.data) { + setFiles(response.data.data || []); + setTotalPages(response.data.meta?.totalPages || 1); + } else { + setFiles([]); + } + } catch (err) { + console.error('Load error:', err); + setFiles([]); + } finally { + setLoading(false); + } + }; + + // ✅ Baca dari URL — AMAN karena ssr: false + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const urlSearch = urlParams.get('search') || ''; + setSearch(urlSearch); + load(1, 10, urlSearch.trim()); + }, []); + + // ✅ Fetch data + useEffect(() => { + const fetchFiles = async () => { + setLoading(true); + try { + const query: Record = { + category: 'image', + page: page.toString(), + limit: '10', + }; + if (search) query.search = search; + + const response = await ApiFetch.api.fileStorage.findMany.get({ query }); + + if (response.status === 200 && response.data) { + setFiles(response.data.data || []); + setTotalPages(response.data.meta?.totalPages || 1); + } else { + setFiles([]); + } + } catch (err) { + console.error('Fetch error:', err); + setFiles([]); + } finally { + setLoading(false); + } + }; + + if (page > 0) fetchFiles(); // jangan fetch jika page belum valid + }, [search, page]); + + // ✅ Update URL + const updateURL = (newSearch: string, newPage: number) => { + const url = new URL(window.location.href); + if (newSearch) url.searchParams.set('search', newSearch); + else url.searchParams.delete('search'); + if (newPage > 1) url.searchParams.set('page', newPage.toString()); + else url.searchParams.delete('page'); + window.history.pushState({}, '', url); + }; + + const handlePageChange = (newPage: number) => { + setPage(newPage); + updateURL(search, newPage); + }; + + if (loading && files.length === 0) { + return
Memuat data...
; + } + + if (files.length === 0) { + return
Tidak ada foto ditemukan
; + } + + return ( + + + {files.map((file) => ( + + + {file.realName + + + + + {file.realName || file.name} + + + {new Date(file.createdAt).toLocaleDateString('id-ID', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} + + + + + ))} + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/app/darmasaba/(pages)/desa/galery/foto/page.tsx b/src/app/darmasaba/(pages)/desa/galery/foto/page.tsx new file mode 100644 index 00000000..4b9d287b --- /dev/null +++ b/src/app/darmasaba/(pages)/desa/galery/foto/page.tsx @@ -0,0 +1,25 @@ +'use client' + +import dynamic from 'next/dynamic'; +import { Suspense } from 'react'; + +// ✅ Load komponen tanpa SSR +const FotoContent = dynamic( + () => import('./Content'), + { + ssr: false, + loading: () =>
Memuat konten...
+ } +); + +function PageContent() { + return ( + Memuat...}> + + + ); +} + +export default function Page() { + return ; +} \ No newline at end of file diff --git a/src/app/darmasaba/(pages)/desa/galery/layout.tsx b/src/app/darmasaba/(pages)/desa/galery/layout.tsx new file mode 100644 index 00000000..005a2f5b --- /dev/null +++ b/src/app/darmasaba/(pages)/desa/galery/layout.tsx @@ -0,0 +1,9 @@ +import LayoutTabsGalery from "./_lib/layoutTabs"; + +export default function LayoutGalery({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/src/app/darmasaba/(pages)/desa/galery/page.tsx b/src/app/darmasaba/(pages)/desa/galery/page.tsx deleted file mode 100644 index d2e080ef..00000000 --- a/src/app/darmasaba/(pages)/desa/galery/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import colors from '@/con/colors'; -import { Box, Container, Grid, GridCol, Group, Stack, Tabs, TabsList, TabsPanel, TabsTab, Text, TextInput } from '@mantine/core'; -import { IconPhoto, IconSearch, IconVideo } from '@tabler/icons-react'; -import BackButton from '../layanan/_com/BackButto'; -import Foto from './(tabs)/foto'; -import Video from './(tabs)/video'; - - -function Page() { - return ( - - {/* Header */} - - - - - - - Galeri Kegiatan Desa Darmasaba - - - - - - - - - - }> - Foto - - }> - Video - - - - - - } - /> - - - - - - - - - - - - - - - ); -} - -export default Page; diff --git a/src/app/darmasaba/(pages)/desa/galery/video/Content.tsx b/src/app/darmasaba/(pages)/desa/galery/video/Content.tsx new file mode 100644 index 00000000..2c084de0 --- /dev/null +++ b/src/app/darmasaba/(pages)/desa/galery/video/Content.tsx @@ -0,0 +1,125 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +'use client'; + +import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery'; +import colors from '@/con/colors'; +import { Box, Center, Pagination, Paper, SimpleGrid, Spoiler, Stack, Text } from '@mantine/core'; +import { useEffect, useState } from 'react'; +import { useSnapshot } from 'valtio'; + +export default function VideoContent() { + const [expanded, setExpanded] = useState(false); + const [currentSearch, setCurrentSearch] = useState(''); + const videoState = useSnapshot(stateGallery.video); + const { + data, + page, + totalPages, + loading, + load, + } = videoState.findMany; + + // ✅ Baca dari URL hanya di client + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const urlSearch = urlParams.get('search') || ''; + setCurrentSearch(urlSearch); + load(1, 10, urlSearch.trim()); + }, []); + + const handlePageChange = (newPage: number) => { + load(newPage, 10, currentSearch.trim()); + }; + + const dataVideo = data || []; + + if (loading && !data) { + return ( + + Memuat Video... + + ); + } + + return ( + + + {dataVideo.map((v, k) => ( + + + +
+ +
+
+ + + + {new Date(v.createdAt).toLocaleDateString('id-ID', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} + + + {v.name} + + + Show more + + } + hideLabel={ + + Hide details + + } + expanded={expanded} + onExpandedChange={setExpanded} + > + + + + +
+
+ ))} +
+
+ +
+
+ ); +} + +// ✅ Fix: HAPUS SPASI BERLEBIH DI URL +function convertToEmbedUrl(youtubeUrl: string): string { + try { + const url = new URL(youtubeUrl); + const videoId = url.searchParams.get('v'); + if (!videoId) return youtubeUrl; + return `https://www.youtube.com/embed/${videoId}`; // ✅ tanpa spasi! + } catch (err) { + console.error('Error converting YouTube URL to embed:', err); + return youtubeUrl; + } +} \ No newline at end of file diff --git a/src/app/darmasaba/(pages)/desa/galery/video/page.tsx b/src/app/darmasaba/(pages)/desa/galery/video/page.tsx new file mode 100644 index 00000000..ba14a9a1 --- /dev/null +++ b/src/app/darmasaba/(pages)/desa/galery/video/page.tsx @@ -0,0 +1,12 @@ +'use client' +import dynamic from 'next/dynamic'; + +// ✅ Load komponen tanpa SSR +const VideoContent = dynamic( + () => import('./Content'), + { ssr: false, loading: () =>
Memuat...
} +); + +export default function Page() { + return ; +} \ No newline at end of file diff --git a/src/con/navbar-list-menu.ts b/src/con/navbar-list-menu.ts index 5f466853..224fd420 100644 --- a/src/con/navbar-list-menu.ts +++ b/src/con/navbar-list-menu.ts @@ -76,7 +76,7 @@ const navbarListMenu = [ { id: "2.5", name: "Gallery", - href: "/darmasaba/desa/galery" + href: "/darmasaba/desa/galery/foto" }, { id: "2.6",