Compare commits

...

1 Commits

Author SHA1 Message Date
dad44c0537 Fix Menu Gallery : Gallery Foto
Fix detail berita
2025-12-05 10:56:03 +08:00
11 changed files with 1306 additions and 355 deletions

View File

@@ -0,0 +1,303 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
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 {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import {
IconArrowBack,
IconPhoto,
IconUpload,
IconX,
} from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { useProxy } from "valtio/utils";
function EditFoto() {
const FotoState = useProxy(stateGallery.foto);
const router = useRouter();
const params = useParams();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({
name: "",
deskripsi: "",
imagesId: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
deskripsi: "",
imagesId: "",
imageUrl: "",
});
// Load kategori + Foto
useEffect(() => {
FotoState.findMany.load();
const loadFoto = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await FotoState.update.load(id);
if (data) {
setFormData({
name: data.name || "",
deskripsi: data.deskripsi || "",
imagesId: data.imagesId || "",
});
setOriginalData({
name: data.name || "",
deskripsi: data.deskripsi || "",
imagesId: data.imagesId || "",
imageUrl: data.imageGalleryFoto?.link || ""
});
if (data?.imageGalleryFoto?.link) {
setPreviewImage(data.imageGalleryFoto.link);
}
}
} catch (error) {
console.error("Error loading Foto:", error);
toast.error("Gagal memuat data Foto");
}
};
loadFoto();
}, [params?.id]);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// Update global state hanya sekali di sini
FotoState.update.form = {
...FotoState.update.form,
...formData,
};
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error("Gagal upload gambar");
}
FotoState.update.form.imagesId = uploaded.id;
}
await FotoState.update.update();
toast.success("Foto berhasil diperbarui!");
router.push("/admin/desa/gallery/foto");
} catch (error) {
console.error("Error updating foto:", error);
toast.error("Terjadi kesalahan saat memperbarui foto");
} finally {
setIsSubmitting(false);
}
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
imagesId: originalData.imagesId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
return (
<Box px={{ base: "sm", md: "lg" }} py="md">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Foto
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: "100%", md: "50%" }}
bg={colors["white-1"]}
p="lg"
radius="md"
shadow="sm"
style={{ border: "1px solid #e0e0e0" }}
>
<Stack gap="md">
<TextInput
label="Judul Foto"
placeholder="Masukkan judul foto"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
required
/>
{/* Upload Gambar */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Foto
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() =>
toast.error("File tidak valid, gunakan format gambar")
}
maxSize={5 * 1024 ** 2}
accept={{ "image/*": [] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload
size={48}
color={colors["blue-button"]}
stroke={1.5}
/>
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 220,
objectFit: "contain",
border: `1px solid ${colors["blue-button"]}`,
}}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Deskripsi */}
<Box>
<Text fz="sm" fw="bold">
Deskripsi Foto
</Text>
<EditEditor
value={formData.deskripsi}
onChange={(htmlContent) =>
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
}
/>
</Box>
{/* Action */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditFoto;

View File

@@ -0,0 +1,175 @@
'use client';
import { Box, Button, Group, Paper, Skeleton, Stack, Text, Alert } from '@mantine/core';
import Image from 'next/image';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash, IconPhoto } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import colors from '@/con/colors';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
function DetailFoto() {
const FotoState = useProxy(stateGallery.foto);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
const params = useParams();
const router = useRouter();
useShallowEffect(() => {
FotoState.findUnique.load(params?.id as string);
}, []);
const handleHapus = () => {
if (selectedId) {
FotoState.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push("/admin/desa/gallery/foto");
}
};
if (!FotoState.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = FotoState.findUnique.data;
const imageUrl = data.imageGalleryFoto?.link;
return (
<Box py={10}>
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
<Paper
withBorder
// Gunakan max-width agar tidak terlalu lebar di desktop
maw={800}
w="100%"
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz={{ base: 'xl', md: '2xl' }} fw="bold" c={colors['blue-button']}>
Detail Foto
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Judul Foto</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi</Text>
<Text
fz="md"
c="dimmed"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
/>
</Box>
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
{imageUrl ? (
<Box
pos="relative"
style={{
width: '100%',
maxWidth: '600px', // Set a maximum width
margin: '0 auto', // Center the container
aspectRatio: '16/9', // Use 16:9 aspect ratio
borderRadius: 8,
overflow: 'hidden',
position: 'relative'
}}
>
<Image
src={imageUrl}
alt={data.name || 'Gambar Foto'}
fill
style={{
objectFit: 'contain', // Changed from 'cover' to 'contain' to show full image
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0
}}
loading="lazy"
onError={() => setImageError(true)}
/>
</Box>
) : imageError ? (
<Alert
color="orange"
icon={<IconPhoto size={16} />}
title="Gagal memuat gambar"
radius="md"
>
Gambar tidak dapat ditampilkan.
</Alert>
) : (
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
)}
</Box>
{/* Action Buttons */}
<Group gap="sm" justify="flex-start">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/desa/gallery/foto/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus foto ini?"
/>
</Box>
);
}
export default DetailFoto;

View File

@@ -0,0 +1,228 @@
'use client';
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 {
ActionIcon,
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Loader,
Image
} 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';
import { useProxy } from 'valtio/utils';
function CreateFoto() {
const FotoState = useProxy(stateGallery.foto);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const resetForm = () => {
FotoState.create.form = {
name: '',
deskripsi: '',
imagesId: '',
};
setPreviewImage(null);
setFile(null);
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
FotoState.create.form.imagesId = uploaded.id;
await FotoState.create.create();
resetForm();
router.push('/admin/desa/gallery/foto');
} catch (error) {
console.error('Error creating foto:', error);
toast.error('Terjadi kesalahan saat membuat foto');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header Back Button + Title */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Foto
</Title>
</Group>
{/* Card Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Judul */}
<TextInput
label="Judul Foto"
placeholder="Masukkan judul Foto"
value={FotoState.create.form.name}
onChange={(e) => {
FotoState.create.form.name = e.currentTarget.value;
}}
required
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Berita
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Deskripsi */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Deskripsi Foto
</Text>
<CreateEditor
value={FotoState.create.form.deskripsi}
onChange={(val) => {
FotoState.create.form.deskripsi = val;
}}
/>
</Box>
{/* Button Submit */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateFoto;

View File

@@ -1,157 +1,163 @@
"use client";
import stateFileStorage from "@/state/state-list-image";
'use client'
import colors from '@/con/colors';
import {
ActionIcon,
Box,
Card,
Flex,
Button,
Center,
Group,
Image,
Pagination,
Paper,
SimpleGrid,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
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";
export default function ListImage() {
const { list, total } = useSnapshot(stateFileStorage);
useShallowEffect(() => {
stateFileStorage.load();
}, []);
let timeOut: NodeJS.Timer;
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import stateGallery from '../../../_state/desa/gallery';
function Foto() {
const [search, setSearch] = useState("");
return (
<Stack p="lg" gap="lg">
<Flex justify="space-between" align="center" wrap="wrap" gap="md">
<Title order={2} fw={700}>
Galeri Foto
</Title>
<TextInput
radius="xl"
size="md"
placeholder="Cari foto berdasarkan nama..."
leftSection={<IconSearch size={18} />}
rightSection={
<ActionIcon
variant="light"
color="gray"
radius="xl"
onClick={() => stateFileStorage.load()}
>
<IconX size={18} />
</ActionIcon>
}
onChange={(e) => {
if (timeOut) clearTimeout(timeOut);
timeOut = setTimeout(() => {
stateFileStorage.load({ search: e.target.value });
}, 300);
}}
/>
</Flex>
<Paper withBorder radius="lg" p="md" shadow="sm">
{list && list.length > 0 ? (
<SimpleGrid
cols={{ base: 2, sm: 3, md: 5, lg: 8 }}
spacing="md"
verticalSpacing="md"
>
{list.map((v, k) => (
<Card
key={k}
withBorder
radius="md"
shadow="sm"
className="hover:shadow-md transition-all duration-200"
>
<Stack gap="xs">
<motion.div
onClick={() => {
navigator.clipboard.writeText(v.url);
toast("Tautan foto berhasil disalin");
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
style={{ cursor: "pointer" }}
>
<Image
src={`${v.url}?size=200`}
alt={v.name}
radius="md"
h={120}
fit="cover"
loading="lazy"
/>
</motion.div>
<Box>
<Text size="sm" fw={500} lineClamp={2}>
{v.name}
</Text>
</Box>
<Group justify="space-between" align="center" pt="xs">
<ActionIcon
variant="subtle"
color="red"
radius="md"
onClick={() => {
stateFileStorage
.del({ id: v.id })
.finally(() => toast("Foto berhasil dihapus"));
}}
>
<IconTrash size={18} />
</ActionIcon>
</Group>
</Stack>
</Card>
))}
</SimpleGrid>
) : (
<Stack align="center" justify="center" py="xl" gap="sm">
<Image
src="https://cdn-icons-png.flaticon.com/512/4076/4076549.png"
alt="Kosong"
w={120}
h={120}
fit="contain"
opacity={0.7}
loading="lazy"
/>
<Text c="dimmed" ta="center">
Belum ada foto yang tersedia
</Text>
</Stack>
)}
</Paper>
{total && total > 1 && (
<Flex justify="center">
<Pagination
total={total}
value={stateFileStorage.page} // Changed from page to value
size="md"
radius="md"
withEdges
onChange={(page) => {
stateFileStorage.load({ page });
}}
/>
</Flex>
)}
</Stack>
<Box>
<HeaderSearch
title='Foto'
placeholder='Cari judul atau deskripsi foto...'
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();
const {
data,
page,
totalPages,
loading,
load,
} = FotoState.findMany;
useShallowEffect(() => {
load(page, 10, search)
}, [page, search])
const filteredData = data || []
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
)
}
return (
<Box py={10}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Foto</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/gallery/foto/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}>Judul Foto</TableTh>
<TableTh style={{ width: '20%' }}>Tanggal</TableTh>
<TableTh style={{ width: '30%' }}>Deskripsi</TableTh>
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%' }}>
<Box w={200}>
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
</Box>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Box w={200}>
<Text fz="sm" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Box>
</TableTd>
<TableTd style={{ width: '30%' }}>
<Box w={200}>
<Text fz="sm" truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Button
variant="light"
color="blue"
onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}
>
<IconDeviceImac size={20} />
<Text ml={5}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed">Tidak ada foto yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default Foto;

View File

@@ -49,7 +49,7 @@ function Page() {
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"} px={{ base: "md", md: 0 }}>
<Stack pos={"relative"} bg={colors.Bg} pb={"xl"} gap={"xs"} px={{ base: "md", md: 0 }}>
<Group px={{ base: "md", md: 100 }}>
<NewsReader />
</Group>

View File

@@ -1,12 +1,44 @@
// app/desa/berita/BeritaLayoutClient.tsx
'use client'
import dynamic from 'next/dynamic';
// app/darmasaba/(pages)/desa/berita/layout.tsx
'use client';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import dynamic from 'next/dynamic';
import { Box } from '@mantine/core';
import BackButton from '../layanan/_com/BackButto';
import colors from '@/con/colors';
const LayoutTabsBerita = dynamic(
() => import('./_lib/layoutTabs'),
{ ssr: false }
);
export default function BeritaLayoutClient({ children }: { children: React.ReactNode }) {
export default function BeritaLayoutClient({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length === 5; // [darmasaba, desa, berita, kategori, id]
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box bg={colors.Bg}>
<Box pt={33} px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
{children}
</Box>
);
}
// Tampilkan dengan tab menu (untuk /semua atau /kategori)
return <LayoutTabsBerita>{children}</LayoutTabsBerita>;
}

View File

@@ -1,150 +0,0 @@
'use client';
import colors from '@/con/colors';
import { Box, Center, Image, Pagination, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
import { useCallback, 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);
const limit = 9; // ✅ ambil 12 data per page
const loadData = useCallback(async (pageNum: 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);
}
}, []);
// ✅ Initial load + update when URL/search changes
useEffect(() => {
const handleRouteChange = () => {
const urlParams = new URLSearchParams(window.location.search);
const urlSearch = urlParams.get('search') || '';
const urlPage = parseInt(urlParams.get('page') || '1');
setSearch(urlSearch);
setPage(urlPage);
loadData(urlPage, urlSearch);
};
const handleSearchUpdate = (e: Event) => {
const { search } = (e as CustomEvent).detail;
setSearch(search);
setPage(1);
loadData(1, search);
};
handleRouteChange();
window.addEventListener('popstate', handleRouteChange);
window.addEventListener('searchUpdate', handleSearchUpdate as EventListener);
return () => {
window.removeEventListener('popstate', handleRouteChange);
window.removeEventListener('searchUpdate', handleSearchUpdate as EventListener);
};
}, [loadData]);
// ✅ Update when page/search changes
useEffect(() => {
loadData(page, search);
}, [page, search, loadData]);
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%' }}
loading="lazy"
/>
</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>
</Paper>
))}
</SimpleGrid>
<Center mt="xl">
<Pagination total={totalPages} value={page} onChange={handlePageChange} />
</Center>
</Box>
);
}

View File

@@ -1,25 +1,168 @@
'use client'
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import {
Box,
Center,
Grid,
Image,
Pagination,
Paper,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconPhoto } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
// ✅ Load komponen tanpa SSR
const FotoContent = dynamic(
() => import('./Content'),
{
ssr: false,
loading: () => <div>Memuat konten...</div>
}
);
// Komponen kartu foto
function FotoCard({ item }: { item: any }) {
const router = useRouter();
const handleClick = () => {
router.push(`/darmasaba/galeri/foto/${item.id}`);
};
function PageContent() {
return (
<Suspense fallback={<div>Memuat...</div>}>
<FotoContent />
</Suspense>
<Grid.Col span={{ base: 12, xs: 6, md: 4 }}>
<Paper
shadow="sm"
radius="md"
p={0}
onClick={handleClick}
style={{ cursor: 'pointer', transition: 'transform 0.2s' }}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.02)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
>
{item.imageGalleryFoto?.link ? (
<Box
pos="relative"
style={{
paddingBottom: '100%', // ✅ Ubah ke 1:1 (square) — atau sesuaikan
overflow: 'hidden',
borderRadius: '4px 4px 0 0',
backgroundColor: '#f9f9f9', // ✅ background netral
}}
>
<Image
radius="lg"
src={item.imageGalleryFoto.link}
alt={item.name || 'Foto Galeri'}
p={10}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'contain', // ✅ Tampilkan utuh, jangan crop
objectPosition: 'center', // rata tengah
}}
loading="lazy"
/>
</Box>
) : (
<Center h={180} bg="gray.1">
<IconPhoto size={40} color="gray" />
</Center>
)}
<Stack p="md" gap={4}>
<Text fw={600} lineClamp={1}>
{item.name || 'Tanpa Judul'}
</Text>
{item.deskripsi && (
<Text fz="sm" c="dimmed" lineClamp={2} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
)}
<Text fz="xs" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</Text>
</Stack>
</Paper>
</Grid.Col>
);
}
export default function Page() {
return <PageContent />;
// Komponen utama
export default function GaleriFotoUser() {
const [search] = useState('');
return (
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
{/* Header */}
<Title order={2} c={colors['blue-button']} mb="lg">
Galeri Foto Desa Darmasaba
</Title>
{/* Daftar Foto */}
<FotoList search={search} />
</Box>
);
}
function FotoList({ search }: { search: string }) {
const FotoState = useProxy(stateGallery.foto);
const { data, page, totalPages, loading, load } = FotoState.findMany;
useShallowEffect(() => {
load(page, 3, search); // ✅ 9 item per halaman
}, [page, search]);
if (loading) {
return (
<Grid mt="md">
{Array.from({ length: 3 }).map((_, i) => (
<Grid.Col key={i} span={{ base: 12, xs: 6, md: 4 }}>
<Skeleton height={280} radius="md" />
</Grid.Col>
))}
</Grid>
);
}
if (!data || data.length === 0) {
return (
<Center py="xl">
<Stack align="center" c="dimmed">
<IconPhoto size={48} />
<Text>Tidak ada foto ditemukan</Text>
</Stack>
</Center>
);
}
return (
<Stack mt="md" gap="xl">
<Grid>
{data.map((item) => (
<FotoCard key={item.id} item={item} />
))}
</Grid>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 3, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
</Stack>
);
}

View File

@@ -1,9 +1,36 @@
'use client'
import { usePathname } from "next/navigation";
import { ReactNode } from "react";
import LayoutTabsGalery from "./_lib/layoutTabs";
export default function LayoutGalery({ children }: { children: React.ReactNode }) {
return (
<LayoutTabsGalery>
{children}
</LayoutTabsGalery>
)
// export default function LayoutGalery({ children }: { children: React.ReactNode }) {
// return (
// <LayoutTabsGalery>
// {children}
// </LayoutTabsGalery>
// )
// }
export default function BeritaLayoutClient({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length === 5; // [darmasaba, desa, berita, kategori, id]
if (isDetailPage) {
// Tampilkan tanpa tab menu
return <>{children}</>
}
// Tampilkan dengan tab menu (untuk /semua atau /kategori)
return <LayoutTabsGalery>{children}</LayoutTabsGalery>;
}

View File

@@ -4,26 +4,27 @@ import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
SimpleGrid,
Spoiler,
Stack,
Text,
Text
} from '@mantine/core';
import { useCallback, useEffect, useState } from 'react';
import { useTransitionRouter } from 'next-view-transitions';
import { useCallback, useEffect } from 'react';
import { useSnapshot } from 'valtio';
export default function VideoContent() {
// ✅ expanded state per index
const [expandedMap, setExpandedMap] = useState<Record<number, boolean>>({});
const videoState = useSnapshot(stateGallery.video);
const router = useTransitionRouter()
const { data, page, totalPages, loading } = videoState.findMany;
// Handle search and pagination changes
const loadData = useCallback((pageNum: number, searchTerm: string) => {
stateGallery.video.findMany.load(pageNum, 10, searchTerm.trim());
stateGallery.video.findMany.load(pageNum, 3, searchTerm.trim());
}, []);
// Initial load and URL change handler
@@ -56,12 +57,6 @@ export default function VideoContent() {
loadData(newPage, search);
};
const toggleExpanded = (index: number, value: boolean) => {
setExpandedMap((prev) => ({
...prev,
[index]: value,
}));
};
const dataVideo = data || [];
@@ -110,27 +105,22 @@ export default function VideoContent() {
<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={expandedMap[k] || false}
onExpandedChange={(val) => toggleExpanded(k, val)}
<Text
ta="justify"
fz="sm"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
lineClamp={3}
truncate="end"
/>
<Group justify={"right"}>
<Button
onClick={() => router.push(`/darmasaba/desa/galery/video/${v.id}`)}
bg={colors['blue-button']}
>
<Text
ta="justify"
fz="sm"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}}
/>
</Spoiler>
Detail
</Button>
</Group>
</Stack>
</Box>
</Paper>

View File

@@ -0,0 +1,197 @@
'use client';
import colors from '@/con/colors';
import {
Alert,
Box,
Button,
Card,
Group,
Paper,
Skeleton,
Stack,
Text,
ThemeIcon,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconInfoCircle, IconVideo } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery'; // pastikan state bisa dipakai di publik
import BackButton from '../../../layanan/_com/BackButto';
// Fungsi helper: aman dan tanpa spasi
function convertToEmbedUrl(youtubeUrl: string): string {
try {
const url = new URL(youtubeUrl);
let videoId = '';
if (url.hostname === 'youtu.be') {
videoId = url.pathname.slice(1);
} else if (url.hostname.includes('youtube.com')) {
videoId = url.searchParams.get('v') || '';
}
return videoId ? `https://www.youtube.com/embed/${videoId}` : youtubeUrl;
} catch {
return youtubeUrl;
}
}
export default function DetailVideoUser() {
const params = useParams<{ id: string }>();
const router = useRouter();
const videoState = useProxy(stateGallery.video);
const [videoError, setVideoError] = useState(false);
const id = Array.isArray(params.id) ? params.id[0] : params.id;
useShallowEffect(() => {
if (id) {
videoState.findUnique.load(id);
}
}, [id]);
const data = videoState.findUnique.data;
if (!videoState.findUnique && !id) {
return (
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
<Skeleton height={400} radius="md" />
</Box>
);
}
if (!data) {
return (
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
<Alert
icon={<IconInfoCircle size={20} />}
title="Video tidak ditemukan"
color="red"
radius="md"
>
Video yang Anda cari tidak tersedia.
</Alert>
<Button
leftSection={<IconArrowBack size={16} />}
mt="md"
onClick={() => router.push('/darmasaba/galeri/video')}
variant="outline"
>
Kembali ke Galeri
</Button>
</Box>
);
}
const embedUrl = data.linkVideo ? convertToEmbedUrl(data.linkVideo) : null;
return (
<Box py="xl" px={{ base: 'md', md: 100 }}>
{/* Tombol Kembali */}
<Box >
<BackButton />
</Box>
{/* Header */}
<Text
ta="center"
fz={{ base: 'xl', md: '2xl' }}
fw={700}
c={colors['blue-button']}
mb="lg"
>
{data.name || 'Video Galeri Desa'}
</Text>
{/* Konten Utama */}
<Card
shadow="sm"
radius="md"
p={{ base: 'md', md: 'xl' }}
bg={colors['white-1']}
>
<Stack gap="lg">
{/* Video */}
{embedUrl ? (
<Box
pos="relative"
style={{ paddingBottom: '56.25%', height: 0, overflow: 'hidden' }} // 16:9 aspect ratio
>
<iframe
src={embedUrl}
title={data.name}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: 8,
border: 'none',
}}
onError={() => setVideoError(true)}
/>
</Box>
) : videoError ? (
<Alert
color="orange"
icon={<IconVideo size={20} />}
title="Gagal memuat video"
radius="md"
>
Mohon maaf, video tidak dapat diputar.
</Alert>
) : (
<Alert
color="gray"
icon={<IconInfoCircle size={20} />}
title="Tidak ada video"
radius="md"
>
Konten video belum tersedia.
</Alert>
)}
{/* Informasi Tambahan */}
{data.createdAt && (
<Group gap="xs" justify="center" wrap="nowrap">
<ThemeIcon variant="light" size="sm" radius="xl">
<IconInfoCircle size={14} />
</ThemeIcon>
<Text fz="sm" c="dimmed">
Diunggah pada{' '}
{new Date(data.createdAt).toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Text>
</Group>
)}
{/* Deskripsi */}
{data.deskripsi && (
<Paper p="md" bg="gray.0" radius="md">
<Text
fz="md"
c="dark"
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
lineHeight: 1.6,
}}
/>
</Paper>
)}
</Stack>
</Card>
</Box>
);
}