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 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";
|
||||||
@@ -68,10 +69,34 @@ const foto = proxy({
|
|||||||
};
|
};
|
||||||
}>[]
|
}>[]
|
||||||
| null,
|
| null,
|
||||||
async load() {
|
page: 1,
|
||||||
const res = await ApiFetch.api.desa.gallery.foto["find-many"].get();
|
totalPages: 1,
|
||||||
if (res.status === 200) {
|
loading: false,
|
||||||
foto.findMany.data = res.data?.data ?? [];
|
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 };
|
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({
|
const video = proxy({
|
||||||
@@ -257,10 +304,34 @@ const video = proxy({
|
|||||||
};
|
};
|
||||||
}>[]
|
}>[]
|
||||||
| null,
|
| null,
|
||||||
async load() {
|
page: 1,
|
||||||
const res = await ApiFetch.api.desa.gallery.video["find-many"].get();
|
totalPages: 1,
|
||||||
if (res.status === 200) {
|
loading: false,
|
||||||
video.findMany.data = res.data?.data ?? [];
|
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 stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import ApiFetch from '@/lib/api-fetch';
|
import ApiFetch from '@/lib/api-fetch';
|
||||||
import { Box, Button, Center, FileInput, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
|
import { Box, Button, Center, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||||
import { IconArrowBack, IconImageInPicture } from '@tabler/icons-react';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
|
import { IconArrowBack, IconImageInPicture, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -18,6 +19,11 @@ function EditFoto() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [file, setFile] = useState<File | 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(() => {
|
useEffect(() => {
|
||||||
const loadFoto = async () => {
|
const loadFoto = async () => {
|
||||||
@@ -26,6 +32,11 @@ function EditFoto() {
|
|||||||
try {
|
try {
|
||||||
const data = await fotoState.update.load(id);
|
const data = await fotoState.update.load(id);
|
||||||
if (data) {
|
if (data) {
|
||||||
|
setFormData({
|
||||||
|
name: data.name || '',
|
||||||
|
deskripsi: data.deskripsi || '',
|
||||||
|
imagesId: data.imageGalleryFoto?.id || ''
|
||||||
|
});
|
||||||
if (data?.imageGalleryFoto?.link) {
|
if (data?.imageGalleryFoto?.link) {
|
||||||
setPreviewImage(data.imageGalleryFoto.link);
|
setPreviewImage(data.imageGalleryFoto.link);
|
||||||
}
|
}
|
||||||
@@ -40,6 +51,12 @@ function EditFoto() {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
|
fotoState.update.form = {
|
||||||
|
...fotoState.update.form,
|
||||||
|
name: formData.name,
|
||||||
|
deskripsi: formData.deskripsi,
|
||||||
|
imagesId: formData.imagesId
|
||||||
|
};
|
||||||
if (file) {
|
if (file) {
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
file,
|
file,
|
||||||
@@ -74,23 +91,47 @@ function EditFoto() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label={<Text fw={"bold"} fz={"sm"}>Judul Foto</Text>}
|
label={<Text fw={"bold"} fz={"sm"}>Judul Foto</Text>}
|
||||||
placeholder='Masukkan judul foto'
|
placeholder='Masukkan judul foto'
|
||||||
value={fotoState.update.form.name}
|
value={formData.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
(fotoState.update.form.name = e.target.value)
|
(formData.name = e.target.value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FileInput
|
<Box>
|
||||||
label={<Text fz={"sm"} fw={"bold"}>Upload Gambar</Text>}
|
<Text>Upload Foto</Text>
|
||||||
value={file}
|
<Dropzone
|
||||||
onChange={async (e) => {
|
onDrop={(files) => {
|
||||||
if (!e) return;
|
const selectedFile = files[0]; // Ambil file pertama
|
||||||
setFile(e);
|
if (selectedFile) {
|
||||||
const base64 = await e.arrayBuffer().then((buf) =>
|
setFile(selectedFile);
|
||||||
"data:image/png;base64," + Buffer.from(buf).toString("base64")
|
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview
|
||||||
);
|
}
|
||||||
setPreviewImage(base64);
|
|
||||||
}}
|
}}
|
||||||
/>
|
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 ? (
|
{previewImage ? (
|
||||||
<Image alt="" src={previewImage} w={200} h={200} />
|
<Image alt="" src={previewImage} w={200} h={200} />
|
||||||
) : (
|
) : (
|
||||||
@@ -98,6 +139,7 @@ function EditFoto() {
|
|||||||
<IconImageInPicture />
|
<IconImageInPicture />
|
||||||
</Center>
|
</Center>
|
||||||
)}
|
)}
|
||||||
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw={"bold"} fz={"sm"}>Deskripsi Foto</Text>
|
<Text fw={"bold"} fz={"sm"}>Deskripsi Foto</Text>
|
||||||
<EditEditor
|
<EditEditor
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
|
|||||||
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
import ApiFetch from '@/lib/api-fetch';
|
import ApiFetch from '@/lib/api-fetch';
|
||||||
import { Box, Button, Center, FileInput, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
|
import { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||||
import { IconArrowBack, IconImageInPicture } from '@tabler/icons-react';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
|
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -69,25 +70,62 @@ function CreateFoto() {
|
|||||||
fotoState.create.form.name = val.target.value;
|
fotoState.create.form.name = val.target.value;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FileInput
|
<Box>
|
||||||
label={<Text fz={"sm"} fw={"bold"}>Upload Gambar</Text>}
|
<Text fz={"md"} fw={"bold"}>Gambar</Text>
|
||||||
value={file}
|
<Box>
|
||||||
onChange={async (e) => {
|
<Dropzone
|
||||||
if (!e) return;
|
onDrop={(files) => {
|
||||||
setFile(e);
|
const selectedFile = files[0]; // Ambil file pertama
|
||||||
const base64 = await e.arrayBuffer().then((buf) =>
|
if (selectedFile) {
|
||||||
"data:image/png;base64," + Buffer.from(buf).toString("base64")
|
setFile(selectedFile);
|
||||||
);
|
setPreviewImage(URL.createObjectURL(selectedFile)); // Buat preview
|
||||||
setPreviewImage(base64);
|
}
|
||||||
|
}}
|
||||||
|
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 ? (
|
</Box>
|
||||||
<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>
|
<Text fw={"bold"} fz={"sm"}>Deskripsi Foto</Text>
|
||||||
<CreateEditor
|
<CreateEditor
|
||||||
|
|||||||
@@ -1,93 +1,124 @@
|
|||||||
'use client'
|
"use client";
|
||||||
import colors from '@/con/colors';
|
import colors from "@/con/colors";
|
||||||
import { Box, Button, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core';
|
import stateFileStorage from "@/state/state-list-image";
|
||||||
import { IconDeviceImac, IconSearch } from '@tabler/icons-react';
|
import {
|
||||||
import { useRouter } from 'next/navigation';
|
ActionIcon,
|
||||||
import JudulListTab from '../../../_com/judulListTab';
|
Box,
|
||||||
import { useProxy } from 'valtio/utils';
|
Flex,
|
||||||
import stateGallery from '../../../_state/desa/gallery';
|
Group,
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
Image,
|
||||||
import HeaderSearch from '../../../_com/header';
|
Pagination,
|
||||||
import { useState } from 'react';
|
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() {
|
export default function ListImage() {
|
||||||
const [search, setSearch] = useState("");
|
const { list, total } = useSnapshot(stateFileStorage);
|
||||||
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();
|
|
||||||
|
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
fotoState.findMany.load()
|
stateFileStorage.load();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const filteredData = (fotoState.findMany.data || []).filter(item => {
|
let timeOut: NodeJS.Timer;
|
||||||
const keyword = search.toLowerCase();
|
|
||||||
return (
|
return (
|
||||||
item.name.toLowerCase().includes(keyword) ||
|
<Stack p={"lg"}>
|
||||||
item.deskripsi.toLowerCase().includes(keyword)
|
<Flex justify="space-between">
|
||||||
);
|
<Title order={3}>List Foto</Title>
|
||||||
});
|
<TextInput
|
||||||
|
radius={"lg"}
|
||||||
if (!fotoState.findMany.data) {
|
leftSection={<IconSearch />}
|
||||||
return (
|
rightSection={
|
||||||
<Box py={10}>
|
<ActionIcon
|
||||||
<Skeleton h={500} />
|
variant="transparent"
|
||||||
</Box>
|
onClick={() => {
|
||||||
)
|
stateFileStorage.load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX />
|
||||||
|
</ActionIcon>
|
||||||
}
|
}
|
||||||
|
placeholder="Pencarian"
|
||||||
return (
|
onChange={(e) => {
|
||||||
<Box py={10}>
|
if (timeOut) clearTimeout(timeOut);
|
||||||
<Paper bg={colors['white-1']} p={'md'}>
|
timeOut = setTimeout(() => {
|
||||||
<JudulListTab
|
stateFileStorage.load({ search: e.target.value });
|
||||||
title='List Foto'
|
}, 200);
|
||||||
href='/admin/desa/gallery/foto/create'
|
}}
|
||||||
placeholder='pencarian'
|
|
||||||
searchIcon={<IconSearch size={16} />}
|
|
||||||
/>
|
/>
|
||||||
<Table striped withTableBorder withRowBorders>
|
</Flex>
|
||||||
<TableThead>
|
<Paper bg={colors['white-1']} p={'md'}>
|
||||||
<TableTr>
|
<SimpleGrid
|
||||||
<TableTh>Judul Foto</TableTh>
|
cols={{
|
||||||
<TableTh>Tanggal Foto</TableTh>
|
base: 3,
|
||||||
<TableTh>Deskripsi Foto</TableTh>
|
md: 5,
|
||||||
<TableTh>Detail</TableTh>
|
lg: 10,
|
||||||
</TableTr>
|
}}
|
||||||
</TableThead>
|
>
|
||||||
<TableTbody>
|
{list &&
|
||||||
{filteredData.map((item) => (
|
list.map((v, k) => {
|
||||||
<TableTr key={item.id}>
|
return (
|
||||||
<TableTd>{item.name}</TableTd>
|
<Paper key={k} shadow="sm">
|
||||||
<TableTd>{new Date(item.createdAt).toDateString()}</TableTd>
|
<Stack pos={"relative"} gap={0} justify="space-between">
|
||||||
<TableTd>
|
<motion.div
|
||||||
<Text dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
|
onClick={() => {
|
||||||
</TableTd>
|
// copy to clipboard
|
||||||
<TableTd>
|
navigator.clipboard.writeText(v.url);
|
||||||
<Button onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}>
|
toast("Berhasil disalin");
|
||||||
<IconDeviceImac size={20} />
|
}}
|
||||||
</Button>
|
whileHover={{ scale: 1.05 }}
|
||||||
</TableTd>
|
whileTap={{ scale: 0.8 }}
|
||||||
</TableTr>
|
>
|
||||||
))}
|
<Image
|
||||||
</TableTbody>
|
h={100}
|
||||||
</Table>
|
src={v.url + "?size=100"}
|
||||||
</Paper>
|
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>
|
</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'
|
'use client'
|
||||||
import LayoutTabsGallery from "../../ppid/_com/layoutTabsGallery"
|
import LayoutTabsGallery from "./lib/layoutTabs"
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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'
|
'use client'
|
||||||
import colors from '@/con/colors';
|
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 { IconDeviceImac, IconSearch } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { 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() {
|
function Video() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -29,20 +29,21 @@ function Video() {
|
|||||||
function ListVideo({ search }: { search: string }) {
|
function ListVideo({ search }: { search: string }) {
|
||||||
const videoState = useProxy(stateGallery.video)
|
const videoState = useProxy(stateGallery.video)
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
loading,
|
||||||
|
load,
|
||||||
|
} = videoState.findMany;
|
||||||
|
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
videoState.findMany.load()
|
load(page, 10, search)
|
||||||
}, [])
|
}, [page, search])
|
||||||
|
|
||||||
const filteredData = (videoState.findMany.data || []).filter(item => {
|
const filteredData = (data || [])
|
||||||
const keyword = search.toLowerCase();
|
|
||||||
return (
|
|
||||||
item.name.toLowerCase().includes(keyword) ||
|
|
||||||
item.deskripsi.toLowerCase().includes(keyword)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!videoState.findMany.data) {
|
if (loading || !data) {
|
||||||
return (
|
return (
|
||||||
<Box py={10}>
|
<Box py={10}>
|
||||||
<Skeleton h={500} />
|
<Skeleton h={500} />
|
||||||
@@ -53,11 +54,9 @@ function ListVideo({ search }: { search: string }) {
|
|||||||
return (
|
return (
|
||||||
<Box py={10}>
|
<Box py={10}>
|
||||||
<Paper bg={colors['white-1']} p={'md'}>
|
<Paper bg={colors['white-1']} p={'md'}>
|
||||||
<JudulListTab
|
<JudulList
|
||||||
title='List Video'
|
title='List Video'
|
||||||
href='/admin/desa/gallery/video/create'
|
href='/admin/desa/gallery/video/create'
|
||||||
placeholder='pencarian'
|
|
||||||
searchIcon={<IconSearch size={16} />}
|
|
||||||
/>
|
/>
|
||||||
<Table striped withTableBorder withRowBorders>
|
<Table striped withTableBorder withRowBorders>
|
||||||
<TableThead>
|
<TableThead>
|
||||||
@@ -71,10 +70,25 @@ function ListVideo({ search }: { search: string }) {
|
|||||||
<TableTbody>
|
<TableTbody>
|
||||||
{filteredData.map((item) => (
|
{filteredData.map((item) => (
|
||||||
<TableTr key={item.id}>
|
<TableTr key={item.id}>
|
||||||
<TableTd>{item.name}</TableTd>
|
|
||||||
<TableTd>{new Date(item.createdAt).toDateString()}</TableTd>
|
|
||||||
<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>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
<Button onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}>
|
<Button onClick={() => router.push(`/admin/desa/gallery/video/${item.id}`)}>
|
||||||
@@ -86,6 +100,15 @@ function ListVideo({ search }: { search: string }) {
|
|||||||
</TableTbody>
|
</TableTbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
<Center>
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={(newPage) => load(newPage)} // ini penting!
|
||||||
|
total={totalPages}
|
||||||
|
mt="md"
|
||||||
|
mb="md"
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,57 @@
|
|||||||
|
/* 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";
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const data = await prisma.galleryFoto.findMany({
|
// Ambil data dan total count secara paralel
|
||||||
where: { isActive: true },
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.galleryFoto.findMany({
|
||||||
|
where,
|
||||||
include: {
|
include: {
|
||||||
imageGalleryFoto: true,
|
imageGalleryFoto: true,
|
||||||
},
|
},
|
||||||
});
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
prisma.galleryFoto.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Success fetch gallery foto",
|
message: "Berhasil ambil foto dengan pagination",
|
||||||
data,
|
data,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Find many error:", e);
|
console.error("Error di findMany paginated:", e);
|
||||||
return {
|
return {
|
||||||
success: false,
|
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 galleryFotoFindMany from "./find-many";
|
||||||
import galleryFotoUpdate from "./updt";
|
import galleryFotoUpdate from "./updt";
|
||||||
import galleryFotoFindUnique from "./findUnique";
|
import galleryFotoFindUnique from "./findUnique";
|
||||||
|
import galleryFotoFindRecent from "./findRecent";
|
||||||
|
|
||||||
const GalleryFoto = new Elysia({ prefix: "/gallery/foto", tags: ["Desa/Gallery/Foto"] })
|
const GalleryFoto = new Elysia({ prefix: "/gallery/foto", tags: ["Desa/Gallery/Foto"] })
|
||||||
.get("/find-many", galleryFotoFindMany)
|
.get("/find-many", galleryFotoFindMany)
|
||||||
@@ -18,6 +19,7 @@ const GalleryFoto = new Elysia({ prefix: "/gallery/foto", tags: ["Desa/Gallery/F
|
|||||||
imagesId: t.String(),
|
imagesId: t.String(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
.get("/find-recent", galleryFotoFindRecent)
|
||||||
.delete("/del/:id", galleryFotoDelete)
|
.delete("/del/:id", galleryFotoDelete)
|
||||||
.put("/:id", async (context) => {
|
.put("/:id", async (context) => {
|
||||||
const response = await galleryFotoUpdate(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 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 {
|
try {
|
||||||
const data = await prisma.galleryVideo.findMany({
|
// Ambil data dan total count secara paralel
|
||||||
where: { isActive: true },
|
const [data, total] = await Promise.all([
|
||||||
});
|
prisma.galleryVideo.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
prisma.galleryVideo.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Success fetch gallery video",
|
message: "Berhasil ambil video dengan pagination",
|
||||||
data,
|
data,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Find many error:", e);
|
console.error("Error di findMany paginated:", e);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Failed fetch gallery video",
|
message: "Gagal mengambil data video",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default galleryVideoFindMany;
|
export default galleryVideoFindMany;
|
||||||
@@ -1,12 +1,65 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
export const fileStorageFindMany = async (context: Context) => {
|
type WhereClause = {
|
||||||
const category = context.query?.category as string | undefined;
|
category?: string;
|
||||||
|
isActive?: boolean;
|
||||||
const data = await prisma.fileStorage.findMany({
|
OR?: Array<{
|
||||||
where: category ? { category } : {},
|
name?: { contains: string; mode: 'insensitive' };
|
||||||
});
|
realName?: { contains: string; mode: 'insensitive' };
|
||||||
|
}>;
|
||||||
return { data };
|
};
|
||||||
|
|
||||||
|
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",
|
id: "2.5",
|
||||||
name: "Gallery",
|
name: "Gallery",
|
||||||
href: "/darmasaba/desa/galery"
|
href: "/darmasaba/desa/galery/foto"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2.6",
|
id: "2.6",
|
||||||
|
|||||||
Reference in New Issue
Block a user