Sinkronisasi UI & API Admin - User Menu Desa, Submenu Gallery
This commit is contained in:
@@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
63
src/app/admin/(dashboard)/_state/state-file-storage.ts
Normal file
63
src/app/admin/(dashboard)/_state/state-file-storage.ts
Normal file
@@ -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<void>;
|
||||
del: (params: { id: string }) => Promise<void>;
|
||||
}>({
|
||||
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;
|
||||
@@ -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<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(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,23 +91,47 @@ function EditFoto() {
|
||||
<TextInput
|
||||
label={<Text fw={"bold"} fz={"sm"}>Judul Foto</Text>}
|
||||
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)
|
||||
}
|
||||
/>
|
||||
<FileInput
|
||||
label={<Text fz={"sm"} fw={"bold"}>Upload Gambar</Text>}
|
||||
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);
|
||||
<Box>
|
||||
<Text>Upload Foto</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
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/*': [] }}
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
|
||||
<div>
|
||||
<Text size="xl" inline>
|
||||
Drag gambar ke sini atau klik untuk pilih file
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" inline mt={7}>
|
||||
Maksimal 5MB dan harus format gambar
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
{previewImage ? (
|
||||
<Image alt="" src={previewImage} w={200} h={200} />
|
||||
) : (
|
||||
@@ -98,6 +139,7 @@ function EditFoto() {
|
||||
<IconImageInPicture />
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={"bold"} fz={"sm"}>Deskripsi Foto</Text>
|
||||
<EditEditor
|
||||
|
||||
@@ -3,8 +3,9 @@ import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
|
||||
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, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -69,25 +70,62 @@ function CreateFoto() {
|
||||
fotoState.create.form.name = val.target.value;
|
||||
}}
|
||||
/>
|
||||
<FileInput
|
||||
label={<Text fz={"sm"} fw={"bold"}>Upload Gambar</Text>}
|
||||
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);
|
||||
<Box>
|
||||
<Text fz={"md"} fw={"bold"}>Gambar</Text>
|
||||
<Box>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
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/*': [] }}
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
|
||||
<div>
|
||||
<Text size="xl" inline>
|
||||
Drag gambar ke sini atau klik untuk pilih file
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" inline mt={7}>
|
||||
Maksimal 5MB dan harus format gambar
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
{/* Tampilkan preview kalau ada */}
|
||||
{previewImage && (
|
||||
<Box mt="sm">
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '200px',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
/>
|
||||
{previewImage ? (
|
||||
<Image alt="" src={previewImage} w={200} h={200} />
|
||||
) : (
|
||||
<Center w={200} h={200} bg={"gray"}>
|
||||
<IconImageInPicture />
|
||||
</Center>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={"bold"} fz={"sm"}>Deskripsi Foto</Text>
|
||||
<CreateEditor
|
||||
|
||||
@@ -1,93 +1,124 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core';
|
||||
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';
|
||||
"use client";
|
||||
import colors from "@/con/colors";
|
||||
import stateFileStorage from "@/state/state-list-image";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Pagination,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title
|
||||
} from "@mantine/core";
|
||||
import { useShallowEffect } from "@mantine/hooks";
|
||||
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import { motion } from "framer-motion";
|
||||
import toast from "react-simple-toasts";
|
||||
import { useSnapshot } from "valtio";
|
||||
|
||||
function Foto() {
|
||||
const [search, setSearch] = useState("");
|
||||
return (
|
||||
<Box>
|
||||
<HeaderSearch
|
||||
title='Posisi Organisasi'
|
||||
placeholder='pencarian'
|
||||
searchIcon={<IconSearch size={20} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<ListFoto search={search} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
let timeOut: NodeJS.Timer;
|
||||
return (
|
||||
item.name.toLowerCase().includes(keyword) ||
|
||||
item.deskripsi.toLowerCase().includes(keyword)
|
||||
);
|
||||
});
|
||||
|
||||
if (!fotoState.findMany.data) {
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Skeleton h={500} />
|
||||
</Box>
|
||||
)
|
||||
<Stack p={"lg"}>
|
||||
<Flex justify="space-between">
|
||||
<Title order={3}>List Foto</Title>
|
||||
<TextInput
|
||||
radius={"lg"}
|
||||
leftSection={<IconSearch />}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
onClick={() => {
|
||||
stateFileStorage.load();
|
||||
}}
|
||||
>
|
||||
<IconX />
|
||||
</ActionIcon>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Paper bg={colors['white-1']} p={'md'}>
|
||||
<JudulListTab
|
||||
title='List Foto'
|
||||
href='/admin/desa/gallery/foto/create'
|
||||
placeholder='pencarian'
|
||||
searchIcon={<IconSearch size={16} />}
|
||||
placeholder="Pencarian"
|
||||
onChange={(e) => {
|
||||
if (timeOut) clearTimeout(timeOut);
|
||||
timeOut = setTimeout(() => {
|
||||
stateFileStorage.load({ search: e.target.value });
|
||||
}, 200);
|
||||
}}
|
||||
/>
|
||||
<Table striped withTableBorder withRowBorders>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Judul Foto</TableTh>
|
||||
<TableTh>Tanggal Foto</TableTh>
|
||||
<TableTh>Deskripsi Foto</TableTh>
|
||||
<TableTh>Detail</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd>{item.name}</TableTd>
|
||||
<TableTd>{new Date(item.createdAt).toDateString()}</TableTd>
|
||||
<TableTd>
|
||||
<Text dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Button onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}>
|
||||
<IconDeviceImac size={20} />
|
||||
</Button>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Flex>
|
||||
<Paper bg={colors['white-1']} p={'md'}>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 3,
|
||||
md: 5,
|
||||
lg: 10,
|
||||
}}
|
||||
>
|
||||
{list &&
|
||||
list.map((v, k) => {
|
||||
return (
|
||||
<Paper key={k} shadow="sm">
|
||||
<Stack pos={"relative"} gap={0} justify="space-between">
|
||||
<motion.div
|
||||
onClick={() => {
|
||||
// copy to clipboard
|
||||
navigator.clipboard.writeText(v.url);
|
||||
toast("Berhasil disalin");
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.8 }}
|
||||
>
|
||||
<Image
|
||||
h={100}
|
||||
src={v.url + "?size=100"}
|
||||
alt={v.name}
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
objectPosition: "center",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
<Box p={"md"} h={54}>
|
||||
<Text lineClamp={2} fz={"xs"}>
|
||||
{v.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Group justify="end">
|
||||
<IconTrash
|
||||
color="red"
|
||||
onClick={() => {
|
||||
stateFileStorage.del({ name: v.name }).finally(() => {
|
||||
toast("Berhasil dihapus");
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
{total && (
|
||||
<Pagination
|
||||
total={total}
|
||||
onChange={(e) => {
|
||||
stateFileStorage.page = e;
|
||||
stateFileStorage.load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Foto;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
62
src/app/admin/(dashboard)/desa/gallery/lib/layoutTabs.tsx
Normal file
62
src/app/admin/(dashboard)/desa/gallery/lib/layoutTabs.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<Stack>
|
||||
<Title order={3}>Gallery</Title>
|
||||
<Tabs color={colors['blue-button']} variant='pills' value={activeTab} onChange={handleTabChange}>
|
||||
<TabsList p={"xs"} bg={"#BBC8E7FF"}>
|
||||
{tabs.map((e, i) => (
|
||||
<TabsTab key={i} value={e.value}>{e.label}</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((e, i) => (
|
||||
<TabsPanel key={i} value={e.value}>
|
||||
{/* Konten dummy, bisa diganti tergantung routing */}
|
||||
<></>
|
||||
</TabsPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default LayoutTabsGallery;
|
||||
@@ -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,20 +29,21 @@ 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 (
|
||||
<Box py={10}>
|
||||
<Skeleton h={500} />
|
||||
@@ -53,11 +54,9 @@ function ListVideo({ search }: { search: string }) {
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Paper bg={colors['white-1']} p={'md'}>
|
||||
<JudulListTab
|
||||
<JudulList
|
||||
title='List Video'
|
||||
href='/admin/desa/gallery/video/create'
|
||||
placeholder='pencarian'
|
||||
searchIcon={<IconSearch size={16} />}
|
||||
/>
|
||||
<Table striped withTableBorder withRowBorders>
|
||||
<TableThead>
|
||||
@@ -71,10 +70,25 @@ function ListVideo({ search }: { search: string }) {
|
||||
<TableTbody>
|
||||
{filteredData.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd>{item.name}</TableTd>
|
||||
<TableTd>{new Date(item.createdAt).toDateString()}</TableTd>
|
||||
<TableTd>
|
||||
<Text dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
|
||||
<Box w={200}>
|
||||
<Text lineClamp={1}>{item.name}</Text>
|
||||
</Box>
|
||||
</TableTd>
|
||||
|
||||
<TableTd>
|
||||
<Box w={200}>
|
||||
{new Date(item.createdAt).toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Box>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Box w={200}>
|
||||
<Text lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
|
||||
</Box>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Button onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}>
|
||||
@@ -86,6 +100,15 @@ function ListVideo({ search }: { search: string }) {
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => load(newPage)} // ini penting!
|
||||
total={totalPages}
|
||||
mt="md"
|
||||
mb="md"
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(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;
|
||||
|
||||
// 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' } },
|
||||
];
|
||||
}
|
||||
|
||||
async function galleryFotoFindMany() {
|
||||
try {
|
||||
const data = await prisma.galleryFoto.findMany({
|
||||
where: { isActive: true },
|
||||
// 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: "Success fetch gallery foto",
|
||||
message: "Berhasil ambil foto dengan pagination",
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Find many error:", e);
|
||||
console.error("Error di findMany paginated:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed fetch gallery foto",
|
||||
message: "Gagal mengambil data foto",
|
||||
};
|
||||
}
|
||||
}
|
||||
export default galleryFotoFindMany
|
||||
|
||||
export default galleryFotoFindMany;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(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;
|
||||
|
||||
// Buat where clause
|
||||
const where: any = { isActive: true };
|
||||
|
||||
// Tambahkan pencarian (jika ada)
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } }
|
||||
];
|
||||
}
|
||||
|
||||
async function galleryVideoFindMany() {
|
||||
try {
|
||||
const data = await prisma.galleryVideo.findMany({
|
||||
where: { isActive: true },
|
||||
});
|
||||
// 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: "Success fetch gallery video",
|
||||
message: "Berhasil ambil video dengan pagination",
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Find many error:", e);
|
||||
console.error("Error di findMany paginated:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed fetch gallery video",
|
||||
message: "Gagal mengambil data video",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default galleryVideoFindMany;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<Box pt={20}>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
md: 3,
|
||||
}}>
|
||||
{data.map((v, k) => {
|
||||
return (
|
||||
<Box key={k}>
|
||||
<Paper mb={50} p={"md"} radius={26} bg={colors['white-trans-1']} w={{ base: "100%", md: "100%" }}>
|
||||
<Box>
|
||||
<Center>
|
||||
<Image src={v.image} alt='' />
|
||||
</Center>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack gap={"sm"} py={10}>
|
||||
<Text fz={{ base: "sm", md: "sm" }}>{v.tanggal}</Text>
|
||||
<Text fw={"bold"} fz={{ base: "sm", md: "sm" }}>{v.judul}</Text>
|
||||
<Text ta={"justify"} fz={{ base: "sm", md: "sm" }}>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. </Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Foto;
|
||||
@@ -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 (
|
||||
<Box pt={20}>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
md: 3,
|
||||
}}>
|
||||
{data.map((v, k) => {
|
||||
return (
|
||||
<Box key={k}>
|
||||
<Paper mb={50} p={"md"} radius={26} bg={colors['white-trans-1']} w={{ base: "100%", md: "100%" }}>
|
||||
<Box>
|
||||
<Center>
|
||||
<Box style={{ maxWidth: "560px", width: "100%", aspectRatio: "16/9" }}>
|
||||
<iframe style={{ borderRadius: "16px" }} width="100%"
|
||||
height="100%"
|
||||
src={v.video} title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" ></iframe>
|
||||
</Box>
|
||||
</Center>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack gap={"sm"} py={10}>
|
||||
<Text fz={{ base: "sm", md: "sm" }}>{v.tanggal}</Text>
|
||||
<Text fw={"bold"} fz={{ base: "sm", md: "sm" }} lineClamp={1}>{v.judul}</Text>
|
||||
<Text ta={"justify"} fz={{ base: "sm", md: "sm" }}>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. </Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Video;
|
||||
124
src/app/darmasaba/(pages)/desa/galery/_lib/layoutTabs.tsx
Normal file
124
src/app/darmasaba/(pages)/desa/galery/_lib/layoutTabs.tsx
Normal file
@@ -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<SearchBarProps>(
|
||||
() => 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 (
|
||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
|
||||
{/* Header */}
|
||||
<Box px={{ base: "md", md: 100 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
<Container size="lg" px="md">
|
||||
<Stack align="center" gap="0">
|
||||
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
|
||||
Galeri Kegiatan Desa Darmasaba
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
|
||||
<Tabs
|
||||
value={isClient ? activeTab : undefined}
|
||||
defaultValue={TABS[0].value}
|
||||
onChange={handleTabChange}
|
||||
color={colors['blue-button']}
|
||||
variant="pills"
|
||||
keepMounted={false}
|
||||
>
|
||||
<Box px={{ base: "md", md: 100 }} py="md" bg={colors['BG-trans']}>
|
||||
<Grid>
|
||||
<GridCol span={{ base: 12, md: 9, lg: 8, xl: 9 }}>
|
||||
<TabsList>
|
||||
{TABS.map((tab) => (
|
||||
<TabsTab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
component="button"
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 3, lg: 4, xl: 3 }}>
|
||||
<SearchBar />
|
||||
</GridCol>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Container size={'xl'}>
|
||||
{children}
|
||||
</Container>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default LayoutTabsGalery;
|
||||
54
src/app/darmasaba/(pages)/desa/galery/_lib/searchBar.tsx
Normal file
54
src/app/darmasaba/(pages)/desa/galery/_lib/searchBar.tsx
Normal file
@@ -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 = <IconSearch size={20} />,
|
||||
}: 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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<TextInput
|
||||
radius="lg"
|
||||
placeholder={placeholder}
|
||||
leftSection={searchIcon}
|
||||
w="100%"
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
151
src/app/darmasaba/(pages)/desa/galery/foto/Content.tsx
Normal file
151
src/app/darmasaba/(pages)/desa/galery/foto/Content.tsx
Normal file
@@ -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<FileItem[]>([]);
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 <Center>Memuat data...</Center>;
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return <Center>Tidak ada foto ditemukan</Center>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pt={20} px={{ base: 'md', md: 100 }}>
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }}>
|
||||
{files.map((file) => (
|
||||
<Paper key={file.id} mb={50} p="md" radius={26} bg={colors['white-trans-1']} style={{ height: '100%' }}>
|
||||
<Box style={{ height: '250px', overflow: 'hidden', borderRadius: '12px' }}>
|
||||
<Image
|
||||
src={file.link}
|
||||
alt={file.realName || file.name}
|
||||
height={250}
|
||||
width="100%"
|
||||
style={{ objectFit: 'cover', height: '100%', width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack gap="sm" py={10}>
|
||||
<Text fw="bold" fz={{ base: 'h4', md: 'h3' }}>
|
||||
{file.realName || file.name}
|
||||
</Text>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{new Date(file.createdAt).toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Center mt="xl">
|
||||
<Pagination total={totalPages} value={page} onChange={handlePageChange} />
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
25
src/app/darmasaba/(pages)/desa/galery/foto/page.tsx
Normal file
25
src/app/darmasaba/(pages)/desa/galery/foto/page.tsx
Normal file
@@ -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: () => <div>Memuat konten...</div>
|
||||
}
|
||||
);
|
||||
|
||||
function PageContent() {
|
||||
return (
|
||||
<Suspense fallback={<div>Memuat...</div>}>
|
||||
<FotoContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <PageContent />;
|
||||
}
|
||||
9
src/app/darmasaba/(pages)/desa/galery/layout.tsx
Normal file
9
src/app/darmasaba/(pages)/desa/galery/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import LayoutTabsGalery from "./_lib/layoutTabs";
|
||||
|
||||
export default function LayoutGalery({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<LayoutTabsGalery>
|
||||
{children}
|
||||
</LayoutTabsGalery>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap="md">
|
||||
{/* Header */}
|
||||
<Box px={{ base: "md", md: 100 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
<Container size="lg" px="md">
|
||||
<Stack align="center" gap="xs" mb="xl">
|
||||
<Text fz={{ base: "2rem", md: "3.4rem" }} c={colors["blue-button"]} fw="bold" ta="center">
|
||||
Galeri Kegiatan Desa Darmasaba
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
<Box px={{ base: "md", md: 100 }}>
|
||||
<Tabs color={colors['blue-button']} defaultValue="foto">
|
||||
<Grid align='center'>
|
||||
<GridCol span={{ base: 12, md: 9, lg: 8, xl: 9 }}>
|
||||
<Group>
|
||||
<TabsList>
|
||||
<TabsTab style={{ color: colors['blue-button'] }} fz={"xl"} value="foto" leftSection={<IconPhoto color={colors['blue-button']} size={45} />}>
|
||||
Foto
|
||||
</TabsTab>
|
||||
<TabsTab style={{ color: colors['blue-button'] }} fz={"xl"} value="video" leftSection={<IconVideo color={colors['blue-button']} size={45} />}>
|
||||
Video
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
</Group>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 3, lg: 4, xl: 3 }}>
|
||||
<TextInput
|
||||
w={{ base: "100%", md: "100%" }}
|
||||
radius="lg"
|
||||
placeholder="Cari Berita"
|
||||
leftSection={<IconSearch size={18} />}
|
||||
/>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
|
||||
<TabsPanel value="foto">
|
||||
<Foto />
|
||||
</TabsPanel>
|
||||
|
||||
<TabsPanel value="video">
|
||||
<Video />
|
||||
</TabsPanel>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
125
src/app/darmasaba/(pages)/desa/galery/video/Content.tsx
Normal file
125
src/app/darmasaba/(pages)/desa/galery/video/Content.tsx
Normal file
@@ -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 (
|
||||
<Box py={10}>
|
||||
<Text>Memuat Video...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pt={20} px={{ base: 'md', md: 100 }}>
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }}>
|
||||
{dataVideo.map((v, k) => (
|
||||
<Box key={k}>
|
||||
<Paper mb={50} p="md" radius={26} bg={colors['white-trans-1']} w={{ base: '100%', md: '100%' }}>
|
||||
<Box>
|
||||
<Center>
|
||||
<Box
|
||||
component="iframe"
|
||||
src={convertToEmbedUrl(v.linkVideo)}
|
||||
width="100%"
|
||||
height={300}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack gap="sm" py={10}>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{new Date(v.createdAt).toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
<Text fw="bold" fz="sm" lineClamp={1}>
|
||||
{v.name}
|
||||
</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={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
>
|
||||
<Text
|
||||
ta="justify"
|
||||
fz="sm"
|
||||
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
|
||||
/>
|
||||
</Spoiler>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={handlePageChange}
|
||||
total={totalPages}
|
||||
mt="md"
|
||||
mb="md"
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ 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;
|
||||
}
|
||||
}
|
||||
12
src/app/darmasaba/(pages)/desa/galery/video/page.tsx
Normal file
12
src/app/darmasaba/(pages)/desa/galery/video/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// ✅ Load komponen tanpa SSR
|
||||
const VideoContent = dynamic(
|
||||
() => import('./Content'),
|
||||
{ ssr: false, loading: () => <div>Memuat...</div> }
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
return <VideoContent />;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ const navbarListMenu = [
|
||||
{
|
||||
id: "2.5",
|
||||
name: "Gallery",
|
||||
href: "/darmasaba/desa/galery"
|
||||
href: "/darmasaba/desa/galery/foto"
|
||||
},
|
||||
{
|
||||
id: "2.6",
|
||||
|
||||
Reference in New Issue
Block a user