Compare commits
3 Commits
fix/slider
...
fix/rombak
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8a026b94 | |||
| a5bd91b580 | |||
| ae3187804e |
@@ -61,7 +61,8 @@ model FileStorage {
|
||||
isActive Boolean @default(true)
|
||||
link String
|
||||
category String // "image" / "document" / "audio" / "other"
|
||||
Berita Berita[]
|
||||
Berita Berita[] @relation("BeritaFeaturedImage")
|
||||
BeritaImages Berita[] @relation("BeritaImages")
|
||||
PotensiDesa PotensiDesa[]
|
||||
Posyandu Posyandu[]
|
||||
StrukturPPID StrukturPPID[]
|
||||
@@ -612,15 +613,19 @@ model Berita {
|
||||
id String @id @default(cuid())
|
||||
judul String
|
||||
deskripsi String
|
||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
||||
image FileStorage? @relation("BeritaFeaturedImage", fields: [imageId], references: [id])
|
||||
imageId String?
|
||||
images FileStorage[] @relation("BeritaImages")
|
||||
content String @db.Text
|
||||
linkVideo String? @db.VarChar(500)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id])
|
||||
kategoriBeritaId String?
|
||||
|
||||
@@index([kategoriBeritaId])
|
||||
}
|
||||
|
||||
model KategoriBerita {
|
||||
|
||||
@@ -12,6 +12,8 @@ const templateForm = z.object({
|
||||
content: z.string().min(3, "Content minimal 3 karakter"),
|
||||
kategoriBeritaId: z.string().nonempty(),
|
||||
imageId: z.string().nonempty(),
|
||||
imageIds: z.array(z.string()),
|
||||
linkVideo: z.string().optional(),
|
||||
});
|
||||
|
||||
// 2. Default value form berita (hindari uncontrolled input)
|
||||
@@ -21,6 +23,8 @@ const defaultForm = {
|
||||
imageId: "",
|
||||
content: "",
|
||||
kategoriBeritaId: "",
|
||||
imageIds: [] as string[],
|
||||
linkVideo: "",
|
||||
};
|
||||
|
||||
// 4. Berita proxy
|
||||
@@ -62,14 +66,7 @@ const berita = proxy({
|
||||
// State untuk berita utama (hanya 1)
|
||||
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.BeritaGetPayload<{
|
||||
include: {
|
||||
image: true;
|
||||
kategoriBerita: true;
|
||||
};
|
||||
}>[]
|
||||
| null,
|
||||
data: null as any[] | null,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
@@ -79,14 +76,14 @@ const berita = proxy({
|
||||
berita.findMany.loading = true;
|
||||
berita.findMany.page = page;
|
||||
berita.findMany.search = search;
|
||||
|
||||
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
if (kategori) query.kategori = kategori;
|
||||
|
||||
|
||||
const res = await ApiFetch.api.desa.berita["find-many"].get({ query });
|
||||
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
berita.findMany.data = res.data.data ?? [];
|
||||
berita.findMany.totalPages = res.data.totalPages ?? 1;
|
||||
@@ -103,18 +100,19 @@ const berita = proxy({
|
||||
const elapsed = Date.now() - startTime;
|
||||
const minDelay = 300;
|
||||
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
berita.findMany.loading = false;
|
||||
}, delay);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
findUnique: {
|
||||
data: null as Prisma.BeritaGetPayload<{
|
||||
include: {
|
||||
image: true;
|
||||
images: true;
|
||||
kategoriBerita: true;
|
||||
};
|
||||
}> | null,
|
||||
@@ -199,6 +197,8 @@ const berita = proxy({
|
||||
content: data.content,
|
||||
kategoriBeritaId: data.kategoriBeritaId || "",
|
||||
imageId: data.imageId || "",
|
||||
imageIds: data.images?.map((img: any) => img.id) || [],
|
||||
linkVideo: data.linkVideo || "",
|
||||
};
|
||||
return data; // Return the loaded data
|
||||
} else {
|
||||
@@ -237,6 +237,8 @@ const berita = proxy({
|
||||
content: this.form.content,
|
||||
kategoriBeritaId: this.form.kategoriBeritaId || null,
|
||||
imageId: this.form.imageId,
|
||||
imageIds: this.form.imageIds,
|
||||
linkVideo: this.form.linkVideo,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ const ApbdesItemSchema = z.object({
|
||||
|
||||
const ApbdesFormSchema = z.object({
|
||||
tahun: z.number().int().min(2000, "Tahun tidak valid"),
|
||||
name: z.string().optional(),
|
||||
deskripsi: z.string().optional(),
|
||||
jumlah: z.string().optional(),
|
||||
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
||||
fileId: z.string().min(1, "File wajib diunggah"),
|
||||
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
||||
@@ -27,6 +30,9 @@ const ApbdesFormSchema = z.object({
|
||||
// --- Default Form ---
|
||||
const defaultApbdesForm = {
|
||||
tahun: new Date().getFullYear(),
|
||||
name: "",
|
||||
deskripsi: "",
|
||||
jumlah: "",
|
||||
imageId: "",
|
||||
fileId: "",
|
||||
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
||||
@@ -244,6 +250,9 @@ const apbdes = proxy({
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
name: data.name || "",
|
||||
deskripsi: data.deskripsi || "",
|
||||
jumlah: data.jumlah || "",
|
||||
imageId: data.imageId || "",
|
||||
fileId: data.fileId || "",
|
||||
items: (data.items || []).map((item: any) => ({
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Loader
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import {
|
||||
@@ -25,19 +27,51 @@ import {
|
||||
IconPhoto,
|
||||
IconUpload,
|
||||
IconX,
|
||||
IconVideo,
|
||||
IconTrash,
|
||||
} 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";
|
||||
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
|
||||
|
||||
interface ExistingImage {
|
||||
id: string;
|
||||
link: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface BeritaData {
|
||||
id: string;
|
||||
judul: string;
|
||||
deskripsi: string;
|
||||
content: string;
|
||||
kategoriBeritaId: string | null;
|
||||
imageId: string | null;
|
||||
image?: { link: string } | null;
|
||||
images?: ExistingImage[];
|
||||
linkVideo?: string | null;
|
||||
}
|
||||
|
||||
function EditBerita() {
|
||||
const beritaState = useProxy(stateDashboardBerita);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
|
||||
// Featured image state
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
// Gallery images state
|
||||
const [existingGalleryImages, setExistingGalleryImages] = useState<ExistingImage[]>([]);
|
||||
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
|
||||
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
|
||||
|
||||
// YouTube link state
|
||||
const [youtubeLink, setYoutubeLink] = useState('');
|
||||
const [originalYoutubeLink, setOriginalYoutubeLink] = useState('');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
judul: "",
|
||||
deskripsi: "",
|
||||
@@ -48,9 +82,17 @@ function EditBerita() {
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
judul: "",
|
||||
deskripsi: "",
|
||||
kategoriBeritaId: "",
|
||||
content: "",
|
||||
imageId: "",
|
||||
imageUrl: ""
|
||||
});
|
||||
|
||||
// Helper function to check if HTML content is empty
|
||||
const isHtmlEmpty = (html: string) => {
|
||||
// Remove all HTML tags and check if there's any text content
|
||||
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||
return textContent === '';
|
||||
};
|
||||
@@ -61,21 +103,12 @@ function EditBerita() {
|
||||
formData.judul?.trim() !== '' &&
|
||||
formData.kategoriBeritaId !== '' &&
|
||||
!isHtmlEmpty(formData.deskripsi) &&
|
||||
(file !== null || originalData.imageId !== '') && // Either a new file is selected or an existing image exists
|
||||
(file !== null || originalData.imageId !== '') &&
|
||||
!isHtmlEmpty(formData.content)
|
||||
);
|
||||
};
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
judul: "",
|
||||
deskripsi: "",
|
||||
kategoriBeritaId: "",
|
||||
content: "",
|
||||
imageId: "",
|
||||
imageUrl: ""
|
||||
});
|
||||
|
||||
// Load kategori + berita
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
beritaState.kategoriBerita.findMany.load();
|
||||
|
||||
@@ -84,7 +117,7 @@ function EditBerita() {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const data = await stateDashboardBerita.berita.edit.load(id);
|
||||
const data = await stateDashboardBerita.berita.edit.load(id) as BeritaData | null;
|
||||
if (data) {
|
||||
setFormData({
|
||||
judul: data.judul || "",
|
||||
@@ -106,6 +139,17 @@ function EditBerita() {
|
||||
if (data?.image?.link) {
|
||||
setPreviewImage(data.image.link);
|
||||
}
|
||||
|
||||
// Load gallery images
|
||||
if (data?.images && data.images.length > 0) {
|
||||
setExistingGalleryImages(data.images);
|
||||
}
|
||||
|
||||
// Load YouTube link
|
||||
if (data?.linkVideo) {
|
||||
setYoutubeLink(data.linkVideo);
|
||||
setOriginalYoutubeLink(data.linkVideo);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading berita:", error);
|
||||
@@ -120,27 +164,59 @@ function EditBerita() {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleGalleryDrop = (files: File[]) => {
|
||||
const maxImages = 10;
|
||||
const currentCount = existingGalleryImages.length + galleryFiles.length;
|
||||
const availableSlots = maxImages - currentCount;
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
toast.warn('Maksimal 10 gambar untuk galeri');
|
||||
return;
|
||||
}
|
||||
|
||||
const newFiles = files.slice(0, availableSlots);
|
||||
|
||||
if (newFiles.length === 0) {
|
||||
toast.warn('Tidak ada slot tersisa untuk gambar galeri');
|
||||
return;
|
||||
}
|
||||
|
||||
setGalleryFiles([...galleryFiles, ...newFiles]);
|
||||
|
||||
const newPreviews = newFiles.map((f) => URL.createObjectURL(f));
|
||||
setGalleryPreviews([...galleryPreviews, ...newPreviews]);
|
||||
};
|
||||
|
||||
const removeGalleryImage = (index: number, isExisting: boolean = false) => {
|
||||
if (isExisting) {
|
||||
setExistingGalleryImages(existingGalleryImages.filter((_, i) => i !== index));
|
||||
} else {
|
||||
setGalleryFiles(galleryFiles.filter((_, i) => i !== index));
|
||||
setGalleryPreviews(galleryPreviews.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.judul?.trim()) {
|
||||
toast.error('Judul wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!formData.kategoriBeritaId) {
|
||||
toast.error('Kategori wajib dipilih');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (isHtmlEmpty(formData.deskripsi)) {
|
||||
toast.error('Deskripsi singkat wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!file && !originalData.imageId) {
|
||||
toast.error('Gambar wajib dipilih');
|
||||
toast.error('Gambar utama wajib dipilih');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (isHtmlEmpty(formData.content)) {
|
||||
toast.error('Konten wajib diisi');
|
||||
return;
|
||||
@@ -148,12 +224,14 @@ function EditBerita() {
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// Update global state hanya sekali di sini
|
||||
|
||||
// Update global state
|
||||
beritaState.berita.edit.form = {
|
||||
...beritaState.berita.edit.form,
|
||||
...formData,
|
||||
};
|
||||
|
||||
// Upload new featured image if changed
|
||||
if (file) {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file,
|
||||
@@ -162,12 +240,33 @@ function EditBerita() {
|
||||
const uploaded = res.data?.data;
|
||||
|
||||
if (!uploaded?.id) {
|
||||
return toast.error("Gagal upload gambar");
|
||||
return toast.error("Gagal upload gambar utama");
|
||||
}
|
||||
|
||||
beritaState.berita.edit.form.imageId = uploaded.id;
|
||||
}
|
||||
|
||||
// Upload new gallery images
|
||||
const newGalleryIds: string[] = [];
|
||||
for (const galleryFile of galleryFiles) {
|
||||
const galleryRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: galleryFile,
|
||||
name: galleryFile.name,
|
||||
});
|
||||
const galleryUploaded = galleryRes.data?.data;
|
||||
if (galleryUploaded?.id) {
|
||||
newGalleryIds.push(galleryUploaded.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine existing (not removed) and new gallery images
|
||||
const remainingExistingIds = existingGalleryImages.map(img => img.id);
|
||||
beritaState.berita.edit.form.imageIds = [...remainingExistingIds, ...newGalleryIds];
|
||||
|
||||
// Set YouTube link
|
||||
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||
beritaState.berita.edit.form.linkVideo = embedLink || '';
|
||||
|
||||
await beritaState.berita.edit.update();
|
||||
toast.success("Berita berhasil diperbarui!");
|
||||
router.push("/admin/desa/berita/list-berita");
|
||||
@@ -189,9 +288,12 @@ function EditBerita() {
|
||||
});
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setFile(null);
|
||||
setYoutubeLink(originalYoutubeLink);
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header */}
|
||||
@@ -219,6 +321,7 @@ function EditBerita() {
|
||||
style={{ border: "1px solid #e0e0e0" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Judul */}
|
||||
<TextInput
|
||||
label="Judul"
|
||||
placeholder="Masukkan judul"
|
||||
@@ -227,6 +330,7 @@ function EditBerita() {
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Kategori */}
|
||||
<Select
|
||||
value={formData.kategoriBeritaId}
|
||||
onChange={(val) => handleChange("kategoriBeritaId", val || "")}
|
||||
@@ -241,9 +345,9 @@ function EditBerita() {
|
||||
clearable
|
||||
searchable
|
||||
required
|
||||
error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined}
|
||||
/>
|
||||
|
||||
{/* Deskripsi */}
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold">
|
||||
Deskripsi Singkat
|
||||
@@ -256,11 +360,10 @@ function EditBerita() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Upload Gambar */}
|
||||
{/* Featured Image */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar Berita
|
||||
Gambar Utama (Featured)
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
@@ -274,17 +377,13 @@ function EditBerita() {
|
||||
toast.error("File tidak valid, gunakan format gambar")
|
||||
}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ "image/*": [] }}
|
||||
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={180}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload
|
||||
size={48}
|
||||
color={colors["blue-button"]}
|
||||
stroke={1.5}
|
||||
/>
|
||||
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={48} color="red" stroke={1.5} />
|
||||
@@ -292,14 +391,6 @@ function EditBerita() {
|
||||
<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>
|
||||
|
||||
@@ -328,9 +419,7 @@ function EditBerita() {
|
||||
setPreviewImage(null);
|
||||
setFile(null);
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
@@ -338,6 +427,138 @@ function EditBerita() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Gallery Images */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Galeri Gambar (Opsional - Maksimal 10)
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleGalleryDrop}
|
||||
onReject={() => toast.error("File tidak valid, gunakan format gambar")}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="md"
|
||||
multiple
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={120}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload size={40} color={colors["blue-button"]} stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={40} color="red" stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={40} color="#868e96" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
</Group>
|
||||
<Text ta="center" mt="sm" size="xs" color="dimmed">
|
||||
Seret gambar untuk menambahkan ke galeri
|
||||
</Text>
|
||||
</Dropzone>
|
||||
|
||||
{/* Existing Gallery Images */}
|
||||
{existingGalleryImages.length > 0 && (
|
||||
<Box mt="sm">
|
||||
<Text fz="xs" fw="bold" mb={6} c="dimmed">
|
||||
Gambar Existing ({existingGalleryImages.length})
|
||||
</Text>
|
||||
<Grid gutter="sm">
|
||||
{existingGalleryImages.map((img, index) => (
|
||||
<Grid.Col span={4} key={img.id}>
|
||||
<Card p="xs" radius="md" withBorder>
|
||||
<Image src={img.link} alt={img.name} radius="sm" height={100} fit="cover" />
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => removeGalleryImage(index, true)}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* New Gallery Images */}
|
||||
{galleryPreviews.length > 0 && (
|
||||
<Box mt="sm">
|
||||
<Text fz="xs" fw="bold" mb={6} c="dimmed">
|
||||
Gambar Baru ({galleryPreviews.length})
|
||||
</Text>
|
||||
<Grid gutter="sm">
|
||||
{galleryPreviews.map((preview, index) => (
|
||||
<Grid.Col span={4} key={index}>
|
||||
<Card p="xs" radius="md" withBorder>
|
||||
<Image src={preview} alt={`New ${index}`} radius="sm" height={100} fit="cover" />
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => removeGalleryImage(index, false)}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* YouTube Video */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Link Video YouTube (Opsional)
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
value={youtubeLink}
|
||||
onChange={(e) => setYoutubeLink(e.currentTarget.value)}
|
||||
leftSection={<IconVideo size={18} />}
|
||||
rightSection={
|
||||
youtubeLink && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setYoutubeLink('')}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{embedLink && (
|
||||
<Box mt="sm" pos="relative">
|
||||
<iframe
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
width: '100%',
|
||||
height: 250,
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
src={embedLink}
|
||||
title="Preview Video"
|
||||
allowFullScreen
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Konten */}
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold">
|
||||
@@ -351,9 +572,8 @@ function EditBerita() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Action */}
|
||||
{/* Action Buttons */}
|
||||
<Group justify="right">
|
||||
{/* Tombol Batal */}
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
@@ -363,8 +583,6 @@ function EditBerita() {
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { Box, Button, Card, Grid, Group, Image, Paper, Skeleton, Stack, Text, Badge, AspectRatio } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { IconArrowBack, IconEdit, IconTrash, IconVideo } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
@@ -10,6 +10,23 @@ import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirma
|
||||
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
||||
import colors from '@/con/colors';
|
||||
|
||||
interface ExistingImage {
|
||||
id: string;
|
||||
link: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface BeritaDetail {
|
||||
id: string;
|
||||
judul: string;
|
||||
deskripsi: string;
|
||||
content: string;
|
||||
image?: { link: string } | null;
|
||||
images?: ExistingImage[];
|
||||
linkVideo?: string | null;
|
||||
kategoriBerita?: { name: string } | null;
|
||||
}
|
||||
|
||||
function DetailBerita() {
|
||||
const beritaState = useProxy(stateDashboardBerita);
|
||||
const [modalHapus, setModalHapus] = useState(false);
|
||||
@@ -38,7 +55,7 @@ function DetailBerita() {
|
||||
);
|
||||
}
|
||||
|
||||
const data = beritaState.berita.findUnique.data;
|
||||
const data = beritaState.berita.findUnique.data as unknown as BeritaDetail;
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||
@@ -68,71 +85,131 @@ function DetailBerita() {
|
||||
|
||||
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
||||
<Stack gap="sm">
|
||||
{/* Kategori */}
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Kategori</Text>
|
||||
<Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Judul */}
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Judul</Text>
|
||||
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Deskripsi */}
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Deskripsi</Text>
|
||||
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }} />
|
||||
<Text
|
||||
fz="md"
|
||||
c="dimmed"
|
||||
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Gambar Utama (Featured) */}
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Gambar</Text>
|
||||
<Text fz="lg" fw="bold">Gambar Utama</Text>
|
||||
{data.image?.link ? (
|
||||
<Image
|
||||
src={data.image.link}
|
||||
alt={data.judul || 'Gambar Berita'}
|
||||
w={200}
|
||||
h={200}
|
||||
w={{ base: '100%', md: 400 }}
|
||||
h={300}
|
||||
radius="md"
|
||||
fit="cover"
|
||||
loading='lazy'
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
|
||||
<Text fz="sm" c="dimmed">Tidak ada gambar utama</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Gallery Images */}
|
||||
{data.images && data.images.length > 0 && (
|
||||
<Box>
|
||||
<Group gap="xs" mb="sm">
|
||||
<Text fz="lg" fw="bold">Galeri Gambar</Text>
|
||||
<Badge color="blue" variant="light">
|
||||
{data.images.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Grid gutter="md">
|
||||
{data.images.map((img, index) => (
|
||||
<Grid.Col span={{ base: 6, md: 4 }} key={img.id}>
|
||||
<Card p="xs" radius="md" withBorder>
|
||||
<Image
|
||||
src={img.link}
|
||||
alt={img.name || `Gallery ${index + 1}`}
|
||||
h={150}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* YouTube Video */}
|
||||
{data.linkVideo && (
|
||||
<Box>
|
||||
<Group gap="xs" mb="sm">
|
||||
<Text fz="lg" fw="bold">Video YouTube</Text>
|
||||
<IconVideo size={20} color={colors['blue-button']} />
|
||||
</Group>
|
||||
<AspectRatio ratio={16 / 9} mah={400}>
|
||||
<iframe
|
||||
src={data.linkVideo}
|
||||
title="YouTube Video"
|
||||
allowFullScreen
|
||||
style={{ borderRadius: 10, border: '1px solid #ddd' }}
|
||||
/>
|
||||
</AspectRatio>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Konten */}
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Konten</Text>
|
||||
<Text
|
||||
fz="md"
|
||||
c="dimmed"
|
||||
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
||||
/>
|
||||
<Paper bg="white" p="md" radius="md" mt="xs">
|
||||
<Text
|
||||
fz="md"
|
||||
c="dimmed"
|
||||
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Action Button */}
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setSelectedId(data.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
{/* Action Buttons */}
|
||||
<Group gap="sm" mt="md">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setSelectedId(data.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<IconTrash size={20} />}
|
||||
>
|
||||
Hapus
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<IconEdit size={20} />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -15,26 +15,38 @@ import {
|
||||
TextInput,
|
||||
Title,
|
||||
Loader,
|
||||
ActionIcon
|
||||
ActionIcon,
|
||||
Grid,
|
||||
Card,
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconVideo, IconTrash } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
|
||||
|
||||
export default function CreateBerita() {
|
||||
const beritaState = useProxy(stateDashboardBerita);
|
||||
const router = useRouter();
|
||||
|
||||
// Featured image state
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Gallery images state
|
||||
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
|
||||
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
|
||||
|
||||
// YouTube link state
|
||||
const [youtubeLink, setYoutubeLink] = useState('');
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Helper function to check if HTML content is empty
|
||||
const isHtmlEmpty = (html: string) => {
|
||||
// Remove all HTML tags and check if there's any text content
|
||||
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||
return textContent === '';
|
||||
};
|
||||
@@ -61,9 +73,35 @@ export default function CreateBerita() {
|
||||
kategoriBeritaId: '',
|
||||
imageId: '',
|
||||
content: '',
|
||||
imageIds: [],
|
||||
linkVideo: '',
|
||||
};
|
||||
setPreviewImage(null);
|
||||
setFile(null);
|
||||
setGalleryFiles([]);
|
||||
setGalleryPreviews([]);
|
||||
setYoutubeLink('');
|
||||
};
|
||||
|
||||
const handleGalleryDrop = (files: File[]) => {
|
||||
const newFiles = files.filter(
|
||||
(_, index) => galleryFiles.length + index < 10 // Max 10 images
|
||||
);
|
||||
|
||||
if (newFiles.length === 0) {
|
||||
toast.warn('Maksimal 10 gambar untuk galeri');
|
||||
return;
|
||||
}
|
||||
|
||||
setGalleryFiles([...galleryFiles, ...newFiles]);
|
||||
|
||||
const newPreviews = newFiles.map((f) => URL.createObjectURL(f));
|
||||
setGalleryPreviews([...galleryPreviews, ...newPreviews]);
|
||||
};
|
||||
|
||||
const removeGalleryImage = (index: number) => {
|
||||
setGalleryFiles(galleryFiles.filter((_, i) => i !== index));
|
||||
setGalleryPreviews(galleryPreviews.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -71,22 +109,22 @@ export default function CreateBerita() {
|
||||
toast.error('Judul wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!beritaState.berita.create.form.kategoriBeritaId) {
|
||||
toast.error('Kategori wajib dipilih');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) {
|
||||
toast.error('Deskripsi singkat wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!file) {
|
||||
toast.error('Gambar wajib dipilih');
|
||||
toast.error('Gambar utama wajib dipilih');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (isHtmlEmpty(beritaState.berita.create.form.content)) {
|
||||
toast.error('Konten wajib diisi');
|
||||
return;
|
||||
@@ -94,21 +132,37 @@ export default function CreateBerita() {
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
if (!file) {
|
||||
return toast.warn('Silakan pilih file gambar terlebih dahulu');
|
||||
}
|
||||
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
// Upload featured image
|
||||
const featuredRes = 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');
|
||||
const featuredUploaded = featuredRes.data?.data;
|
||||
if (!featuredUploaded?.id) {
|
||||
return toast.error('Gagal mengunggah gambar utama');
|
||||
}
|
||||
beritaState.berita.create.form.imageId = featuredUploaded.id;
|
||||
|
||||
beritaState.berita.create.form.imageId = uploaded.id;
|
||||
// Upload gallery images
|
||||
const galleryIds: string[] = [];
|
||||
for (const galleryFile of galleryFiles) {
|
||||
const galleryRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: galleryFile,
|
||||
name: galleryFile.name,
|
||||
});
|
||||
const galleryUploaded = galleryRes.data?.data;
|
||||
if (galleryUploaded?.id) {
|
||||
galleryIds.push(galleryUploaded.id);
|
||||
}
|
||||
}
|
||||
beritaState.berita.create.form.imageIds = galleryIds;
|
||||
|
||||
// Set YouTube link if provided
|
||||
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||
if (embedLink) {
|
||||
beritaState.berita.create.form.linkVideo = embedLink;
|
||||
}
|
||||
|
||||
await beritaState.berita.create.create();
|
||||
|
||||
@@ -122,16 +176,13 @@ export default function CreateBerita() {
|
||||
}
|
||||
};
|
||||
|
||||
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header dengan tombol kembali */}
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="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">
|
||||
@@ -148,6 +199,7 @@ export default function CreateBerita() {
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Judul */}
|
||||
<TextInput
|
||||
label="Judul"
|
||||
placeholder="Masukkan judul berita"
|
||||
@@ -156,6 +208,7 @@ export default function CreateBerita() {
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Kategori */}
|
||||
<Select
|
||||
label="Kategori"
|
||||
placeholder="Pilih kategori"
|
||||
@@ -182,6 +235,7 @@ export default function CreateBerita() {
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Deskripsi */}
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold" mb={6}>
|
||||
Deskripsi Singkat
|
||||
@@ -194,9 +248,10 @@ export default function CreateBerita() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Featured Image */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar Berita
|
||||
Gambar Utama (Featured)
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
@@ -232,17 +287,11 @@ export default function CreateBerita() {
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Gambar"
|
||||
alt="Preview Gambar Utama"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 200,
|
||||
objectFit: 'contain',
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* Tombol hapus (pojok kanan atas) */}
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
@@ -255,9 +304,7 @@ export default function CreateBerita() {
|
||||
setPreviewImage(null);
|
||||
setFile(null);
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
@@ -265,6 +312,102 @@ export default function CreateBerita() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Gallery Images */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Galeri Gambar (Opsional - Maksimal 10)
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleGalleryDrop}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="md"
|
||||
multiple
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={120}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload size={40} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={40} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={40} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
</Group>
|
||||
<Text ta="center" mt="sm" size="xs" color="dimmed">
|
||||
Seret gambar atau klik untuk menambahkan ke galeri
|
||||
</Text>
|
||||
</Dropzone>
|
||||
|
||||
{galleryPreviews.length > 0 && (
|
||||
<Grid mt="sm" gutter="sm">
|
||||
{galleryPreviews.map((preview, index) => (
|
||||
<Grid.Col span={4} key={index}>
|
||||
<Card p="xs" radius="md" withBorder>
|
||||
<Image src={preview} alt={`Gallery ${index}`} radius="sm" height={100} fit="cover" />
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => removeGalleryImage(index)}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* YouTube Video */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Link Video YouTube (Opsional)
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
value={youtubeLink}
|
||||
onChange={(e) => setYoutubeLink(e.currentTarget.value)}
|
||||
leftSection={<IconVideo size={18} />}
|
||||
rightSection={
|
||||
youtubeLink && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setYoutubeLink('')}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{embedLink && (
|
||||
<Box mt="sm" pos="relative">
|
||||
<iframe
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
width: '100%',
|
||||
height: 250,
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
src={embedLink}
|
||||
title="Preview Video"
|
||||
allowFullScreen
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Konten */}
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold" mb={6}>
|
||||
Konten
|
||||
@@ -277,6 +420,7 @@ export default function CreateBerita() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Buttons */}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -287,8 +431,6 @@ export default function CreateBerita() {
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
|
||||
@@ -79,6 +79,9 @@ function EditAPBDes() {
|
||||
// Simpan data original untuk reset form
|
||||
const [originalData, setOriginalData] = useState({
|
||||
tahun: 0,
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
jumlah: '',
|
||||
imageId: '',
|
||||
fileId: '',
|
||||
imageUrl: '',
|
||||
@@ -103,6 +106,9 @@ function EditAPBDes() {
|
||||
// Simpan data original untuk reset
|
||||
setOriginalData({
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
name: data.name || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
jumlah: data.jumlah || '',
|
||||
imageId: data.imageId || '',
|
||||
fileId: data.fileId || '',
|
||||
imageUrl: data.image?.link || '',
|
||||
@@ -112,6 +118,9 @@ function EditAPBDes() {
|
||||
// Set form dengan data lama (termasuk imageId dan fileId)
|
||||
apbdesState.edit.form = {
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
name: data.name || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
jumlah: data.jumlah || '',
|
||||
imageId: data.imageId || '',
|
||||
fileId: data.fileId || '',
|
||||
items: (data.items || []).map((item: any) => ({
|
||||
@@ -238,9 +247,12 @@ function EditAPBDes() {
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Reset ke data original (tahun, imageId, fileId)
|
||||
// Reset ke data original (tahun, name, deskripsi, jumlah, imageId, fileId)
|
||||
apbdesState.edit.form = {
|
||||
tahun: originalData.tahun,
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
jumlah: originalData.jumlah,
|
||||
imageId: originalData.imageId,
|
||||
fileId: originalData.fileId,
|
||||
items: [...apbdesState.edit.form.items], // keep existing items
|
||||
@@ -249,11 +261,11 @@ function EditAPBDes() {
|
||||
// Reset preview ke data original
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setPreviewDoc(originalData.fileUrl || null);
|
||||
|
||||
|
||||
// Reset file uploads
|
||||
setImageFile(null);
|
||||
setDocFile(null);
|
||||
|
||||
|
||||
// Reset new item form
|
||||
setNewItem({
|
||||
kode: '',
|
||||
@@ -263,7 +275,7 @@ function EditAPBDes() {
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
});
|
||||
|
||||
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
};
|
||||
|
||||
@@ -288,6 +300,33 @@ function EditAPBDes() {
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Header Form */}
|
||||
<TextInput
|
||||
label="Nama APBDes"
|
||||
placeholder="Contoh: APBDes Tahun 2025"
|
||||
value={apbdesState.edit.form.name}
|
||||
onChange={(e) =>
|
||||
(apbdesState.edit.form.name = e.target.value)
|
||||
}
|
||||
description="Opsional - akan diisi otomatis jika kosong"
|
||||
/>
|
||||
<TextInput
|
||||
label="Deskripsi"
|
||||
placeholder="Deskripsi APBDes (opsional)"
|
||||
value={apbdesState.edit.form.deskripsi}
|
||||
onChange={(e) =>
|
||||
(apbdesState.edit.form.deskripsi = e.target.value)
|
||||
}
|
||||
description="Opsional"
|
||||
/>
|
||||
<TextInput
|
||||
label="Jumlah Total"
|
||||
placeholder="Contoh: Rp 1.000.000.000"
|
||||
value={apbdesState.edit.form.jumlah}
|
||||
onChange={(e) =>
|
||||
(apbdesState.edit.form.jumlah = e.target.value)
|
||||
}
|
||||
description="Opsional - total keseluruhan anggaran"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Tahun"
|
||||
value={apbdesState.edit.form.tahun || new Date().getFullYear()}
|
||||
|
||||
@@ -94,7 +94,7 @@ function DetailAPBDes() {
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Nama APBDes</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data.name || '-'}
|
||||
{data.name || `APBDes Tahun ${data.tahun}`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -105,6 +105,24 @@ function DetailAPBDes() {
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{data.deskripsi && (
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Deskripsi</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data.deskripsi}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{data.jumlah && (
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Jumlah Total</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data.jumlah}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Gambar</Text>
|
||||
{data.image?.link ? (
|
||||
|
||||
@@ -117,9 +117,9 @@ function CreateAPBDes() {
|
||||
toast.success("Berhasil menambahkan APBDes");
|
||||
resetForm();
|
||||
router.push("/admin/landing-page/apbdes");
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Gagal submit:", error);
|
||||
toast.error("Gagal menyimpan data");
|
||||
toast.error(error?.message || "Gagal menyimpan data");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -334,6 +334,27 @@ function CreateAPBDes() {
|
||||
</Stack>
|
||||
|
||||
{/* Form Header */}
|
||||
<TextInput
|
||||
label="Nama APBDes"
|
||||
placeholder="Contoh: APBDes Tahun 2025"
|
||||
value={stateAPBDes.create.form.name}
|
||||
onChange={(e) => (stateAPBDes.create.form.name = e.target.value)}
|
||||
description="Opsional - akan diisi otomatis jika kosong"
|
||||
/>
|
||||
<TextInput
|
||||
label="Deskripsi"
|
||||
placeholder="Deskripsi APBDes (opsional)"
|
||||
value={stateAPBDes.create.form.deskripsi}
|
||||
onChange={(e) => (stateAPBDes.create.form.deskripsi = e.target.value)}
|
||||
description="Opsional"
|
||||
/>
|
||||
<TextInput
|
||||
label="Jumlah Total"
|
||||
placeholder="Contoh: Rp 1.000.000.000"
|
||||
value={stateAPBDes.create.form.jumlah}
|
||||
onChange={(e) => (stateAPBDes.create.form.jumlah = e.target.value)}
|
||||
description="Opsional - total keseluruhan anggaran"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Tahun"
|
||||
value={stateAPBDes.create.form.tahun || new Date().getFullYear()}
|
||||
|
||||
@@ -45,7 +45,7 @@ function APBDes() {
|
||||
function ListAPBDes({ search }: { search: string }) {
|
||||
const listState = useProxy(apbdes);
|
||||
const router = useRouter();
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
|
||||
const { data, page, totalPages, loading, load } = listState.findMany;
|
||||
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Context } from "elysia";
|
||||
|
||||
type FormCreate = Prisma.BeritaGetPayload<{
|
||||
select: {
|
||||
judul: true;
|
||||
deskripsi: true;
|
||||
content: true;
|
||||
kategoriBeritaId: true;
|
||||
imageId: true;
|
||||
};
|
||||
}>;
|
||||
type FormCreate = {
|
||||
judul: string;
|
||||
deskripsi: string;
|
||||
content: string;
|
||||
kategoriBeritaId: string;
|
||||
imageId: string; // Featured image
|
||||
imageIds?: string[]; // Multiple images for gallery
|
||||
linkVideo?: string; // YouTube link
|
||||
};
|
||||
|
||||
async function beritaCreate(context: Context) {
|
||||
const body = context.body as FormCreate;
|
||||
|
||||
await prisma.berita.create({
|
||||
data: {
|
||||
data: {
|
||||
content: body.content,
|
||||
deskripsi: body.deskripsi,
|
||||
imageId: body.imageId,
|
||||
judul: body.judul,
|
||||
kategoriBeritaId: body.kategoriBeritaId,
|
||||
// Connect multiple images if provided
|
||||
linkVideo: body.linkVideo,
|
||||
images: body.imageIds && body.imageIds.length > 0
|
||||
? {
|
||||
connect: body.imageIds.map((id) => ({ id })),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export default async function handler(
|
||||
where: { id },
|
||||
include: {
|
||||
image: true,
|
||||
images: true,
|
||||
kategoriBerita: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
|
||||
imageId: t.String(),
|
||||
content: t.String(),
|
||||
kategoriBeritaId: t.Union([t.String(), t.Null()]),
|
||||
imageIds: t.Array(t.String()),
|
||||
linkVideo: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
.get("/find-first", beritaFindFirst)
|
||||
@@ -39,6 +41,8 @@ const Berita = new Elysia({ prefix: "/berita", tags: ["Desa/Berita"] })
|
||||
imageId: t.String(),
|
||||
content: t.String(),
|
||||
kategoriBeritaId: t.Union([t.String(), t.Null()]),
|
||||
imageIds: t.Array(t.String()),
|
||||
linkVideo: t.Optional(t.String()),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -4,52 +4,48 @@ import { Prisma } from "@prisma/client";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
type FormUpdate = Prisma.BeritaGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
judul: true;
|
||||
deskripsi: true;
|
||||
content: true;
|
||||
kategoriBeritaId: true;
|
||||
imageId: true;
|
||||
};
|
||||
}>;
|
||||
type FormUpdate = {
|
||||
id: string;
|
||||
judul: string;
|
||||
deskripsi: string;
|
||||
content: string;
|
||||
kategoriBeritaId: string;
|
||||
imageId: string; // Featured image
|
||||
imageIds?: string[]; // Multiple images for gallery
|
||||
linkVideo?: string; // YouTube link
|
||||
};
|
||||
|
||||
async function beritaUpdate(context: Context) {
|
||||
try {
|
||||
const id = context.params?.id as string; // ambil dari URL
|
||||
const id = context.params?.id as string;
|
||||
const body = (await context.body) as Omit<FormUpdate, "id">;
|
||||
|
||||
const {
|
||||
judul,
|
||||
deskripsi,
|
||||
content,
|
||||
kategoriBeritaId,
|
||||
imageId,
|
||||
} = body;
|
||||
|
||||
|
||||
const { judul, deskripsi, content, kategoriBeritaId, imageId, imageIds, linkVideo } = body;
|
||||
|
||||
if (!id) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "ID tidak boleh kosong" }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const existing = await prisma.berita.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
image: true,
|
||||
images: true, // Include gallery images
|
||||
kategoriBerita: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (!existing) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Berita tidak ditemukan" }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Delete old featured image if changed
|
||||
if (existing.imageId && existing.imageId !== imageId) {
|
||||
const oldImage = existing.image;
|
||||
if (oldImage) {
|
||||
@@ -64,35 +60,60 @@ async function beritaUpdate(context: Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Build update data
|
||||
const updateData: Prisma.BeritaUpdateInput = {
|
||||
judul,
|
||||
deskripsi,
|
||||
content,
|
||||
kategoriBerita: kategoriBeritaId ? { connect: { id: kategoriBeritaId } } : { disconnect: true },
|
||||
image: imageId ? { connect: { id: imageId } } : { disconnect: true },
|
||||
linkVideo,
|
||||
};
|
||||
|
||||
// Handle multiple images update
|
||||
if (imageIds !== undefined) {
|
||||
// Disconnect all existing images first
|
||||
updateData.images = {
|
||||
set: [],
|
||||
};
|
||||
|
||||
// Connect new images if provided
|
||||
if (imageIds.length > 0) {
|
||||
updateData.images = {
|
||||
...updateData.images,
|
||||
connect: imageIds.map((id) => ({ id })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.berita.update({
|
||||
where: { id },
|
||||
data: {
|
||||
judul,
|
||||
deskripsi,
|
||||
content,
|
||||
kategoriBeritaId: kategoriBeritaId || null,
|
||||
imageId,
|
||||
data: updateData,
|
||||
include: {
|
||||
image: true,
|
||||
images: true,
|
||||
kategoriBerita: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: "Berita berhasil diupdate",
|
||||
data: updated,
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error updating berita:", error);
|
||||
console.error("Error updating berita:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Terjadi kesalahan saat mengupdate berita",
|
||||
}),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
export default beritaUpdate;
|
||||
export default beritaUpdate;
|
||||
|
||||
@@ -17,6 +17,9 @@ type APBDesItemInput = {
|
||||
|
||||
type FormCreate = {
|
||||
tahun: number;
|
||||
name?: string;
|
||||
deskripsi?: string;
|
||||
jumlah?: string;
|
||||
imageId: string;
|
||||
fileId: string;
|
||||
items: APBDesItemInput[];
|
||||
@@ -48,7 +51,9 @@ export default async function apbdesCreate(context: Context) {
|
||||
const apbdes = await prisma.aPBDes.create({
|
||||
data: {
|
||||
tahun: body.tahun,
|
||||
name: `APBDes Tahun ${body.tahun}`,
|
||||
name: body.name || `APBDes Tahun ${body.tahun}`,
|
||||
deskripsi: body.deskripsi,
|
||||
jumlah: body.jumlah,
|
||||
imageId: body.imageId,
|
||||
fileId: body.fileId,
|
||||
},
|
||||
|
||||
@@ -47,7 +47,13 @@ export default async function apbdesFindMany(context: Context) {
|
||||
include: {
|
||||
image: true,
|
||||
file: true,
|
||||
items: true,
|
||||
items: {
|
||||
where: { isActive: true },
|
||||
orderBy: { kode: "asc" },
|
||||
include: {
|
||||
parent: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.aPBDes.count({ where }),
|
||||
|
||||
@@ -48,11 +48,14 @@ export default async function apbdesFindUnique(context: Context) {
|
||||
include: {
|
||||
items: {
|
||||
where: { isActive: true },
|
||||
orderBy: { kode: 'asc' }
|
||||
orderBy: { kode: 'asc' },
|
||||
include: {
|
||||
parent: true, // Include parent item for hierarchy
|
||||
},
|
||||
},
|
||||
image: true,
|
||||
file: true
|
||||
}
|
||||
file: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result || !result.isActive) {
|
||||
|
||||
@@ -33,6 +33,9 @@ const APBDes = new Elysia({
|
||||
.post("/create", apbdesCreate, {
|
||||
body: t.Object({
|
||||
tahun: t.Number(),
|
||||
name: t.Optional(t.String()),
|
||||
deskripsi: t.Optional(t.String()),
|
||||
jumlah: t.Optional(t.String()),
|
||||
imageId: t.String(),
|
||||
fileId: t.String(),
|
||||
items: t.Array(ApbdesItemSchema),
|
||||
@@ -44,6 +47,9 @@ const APBDes = new Elysia({
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
tahun: t.Number(),
|
||||
name: t.Optional(t.String()),
|
||||
deskripsi: t.Optional(t.String()),
|
||||
jumlah: t.Optional(t.String()),
|
||||
imageId: t.String(),
|
||||
fileId: t.String(),
|
||||
items: t.Array(ApbdesItemSchema),
|
||||
|
||||
@@ -15,6 +15,9 @@ type APBDesItemInput = {
|
||||
|
||||
type FormUpdateBody = {
|
||||
tahun: number;
|
||||
name?: string;
|
||||
deskripsi?: string;
|
||||
jumlah?: string;
|
||||
imageId: string;
|
||||
fileId: string;
|
||||
items: APBDesItemInput[];
|
||||
@@ -79,6 +82,9 @@ export default async function apbdesUpdate(context: Context) {
|
||||
where: { id },
|
||||
data: {
|
||||
tahun: body.tahun,
|
||||
name: body.name || `APBDes Tahun ${body.tahun}`,
|
||||
deskripsi: body.deskripsi,
|
||||
jumlah: body.jumlah,
|
||||
imageId: body.imageId,
|
||||
fileId: body.fileId,
|
||||
},
|
||||
|
||||
@@ -3,10 +3,43 @@
|
||||
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
||||
import NewsReader from '@/app/darmasaba/_com/NewsReader';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Center, Container, Group, Image, Skeleton, Stack, Text, Title } from '@mantine/core';
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Image,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Grid,
|
||||
Card,
|
||||
AspectRatio,
|
||||
Badge,
|
||||
Divider,
|
||||
} from '@mantine/core';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { IconVideo } from '@tabler/icons-react';
|
||||
|
||||
interface ExistingImage {
|
||||
id: string;
|
||||
link: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface BeritaDetail {
|
||||
id: string;
|
||||
judul: string;
|
||||
deskripsi: string;
|
||||
content: string;
|
||||
image?: { link: string } | null;
|
||||
images?: ExistingImage[];
|
||||
linkVideo?: string | null;
|
||||
kategoriBerita?: { name: string } | null;
|
||||
}
|
||||
|
||||
function Page() {
|
||||
const params = useParams<{ id: string }>();
|
||||
@@ -45,13 +78,30 @@ function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
const data = state.findUnique.data as unknown as BeritaDetail;
|
||||
|
||||
return (
|
||||
<Stack pos="relative" bg={colors.Bg} pb="xl" gap="xs" px={{ base: 'md', md: 0 }}>
|
||||
<Group px={{ base: 'md', md: 100 }}>
|
||||
<NewsReader />
|
||||
</Group>
|
||||
<Container w={{ base: '100%', md: '50%' }}>
|
||||
|
||||
<Container w={{ base: '100%', md: '60%' }}>
|
||||
<Box pb={20}>
|
||||
{/* Kategori Badge */}
|
||||
{data.kategoriBerita?.name && (
|
||||
<Badge
|
||||
color={colors['blue-button']}
|
||||
variant="light"
|
||||
size="lg"
|
||||
mb="md"
|
||||
style={{ textTransform: 'uppercase' }}
|
||||
>
|
||||
{data.kategoriBerita.name}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Judul */}
|
||||
<Title
|
||||
id="news-title"
|
||||
order={1}
|
||||
@@ -59,41 +109,108 @@ function Page() {
|
||||
c={colors['blue-button']}
|
||||
fw="bold"
|
||||
lh={{ base: 1.2, md: 1.25 }}
|
||||
mb="md"
|
||||
>
|
||||
{state.findUnique.data.judul}
|
||||
</Title>
|
||||
<Title
|
||||
order={2}
|
||||
ta="center"
|
||||
fw="bold"
|
||||
fz={{ base: 'md', md: 'lg' }}
|
||||
lh={{ base: 1.3, md: 1.35 }}
|
||||
>
|
||||
Informasi dan Pelayanan Administrasi Digital
|
||||
{data.judul}
|
||||
</Title>
|
||||
|
||||
<Divider my="xs" />
|
||||
</Box>
|
||||
<Image src={state.findUnique.data.image?.link || ''} alt="" w="100%" loading="lazy" />
|
||||
</Container>
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<Stack gap="xs">
|
||||
|
||||
{/* Featured Image */}
|
||||
{data.image?.link && (
|
||||
<Image
|
||||
src={data.image.link}
|
||||
alt={data.judul}
|
||||
w="100%"
|
||||
h={{ base: 300, md: 400 }}
|
||||
radius="md"
|
||||
loading="lazy"
|
||||
fit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<Box mt="xl">
|
||||
<Title order={3} c={colors['blue-button']} mb="md">
|
||||
Deskripsi Berita
|
||||
</Title>
|
||||
<Text
|
||||
id="news-content"
|
||||
py={20}
|
||||
px={{ base: 0, md: 'sm' }}
|
||||
fz={{ base: 'sm', md: 'md' }}
|
||||
lh={{ base: 1.6, md: 1.8 }}
|
||||
lh={{ base: 1.8, md: 2 }}
|
||||
ta="justify"
|
||||
c="dimmed"
|
||||
style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: state.findUnique.data.content || '',
|
||||
__html: data.content || '',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Gallery Images */}
|
||||
{data.images && data.images.length > 0 && (
|
||||
<Box mt="xl">
|
||||
<Group gap="xs" mb="md">
|
||||
<Title order={3} c={colors['blue-button']}>
|
||||
Galeri Foto
|
||||
</Title>
|
||||
<Badge color={colors['blue-button']} variant="light">
|
||||
{data.images.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Grid gutter="md">
|
||||
{data.images.map((img, index) => (
|
||||
<Grid.Col span={{ base: 6, md: 4 }} key={img.id}>
|
||||
<Card p="xs" radius="md" withBorder>
|
||||
<Image
|
||||
src={img.link}
|
||||
alt={img.name || `Foto ${index + 1}`}
|
||||
h={180}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* YouTube Video */}
|
||||
{data.linkVideo && (
|
||||
<Box mt="xl">
|
||||
<Group gap="xs" mb="md">
|
||||
<Title order={3} c={colors['blue-button']}>
|
||||
Video
|
||||
</Title>
|
||||
<IconVideo size={24} color={colors['blue-button']} />
|
||||
</Group>
|
||||
<AspectRatio ratio={16 / 9} mah={500}>
|
||||
<iframe
|
||||
src={data.linkVideo}
|
||||
title="YouTube Video"
|
||||
allowFullScreen
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e0e0e0',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
/>
|
||||
</AspectRatio>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
</Container>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
export default Page;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
export function seekTo(
|
||||
audioRef: React.RefObject<HTMLAudioElement | null>,
|
||||
audioRef: React.RefObject<HTMLAudioElement>,
|
||||
time: number,
|
||||
setCurrentTime?: (v: number) => void
|
||||
) {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
|
||||
// Validasi: jangan seek melebihi durasi atau negatif
|
||||
const duration = audioRef.current.duration || 0;
|
||||
const safeTime = Math.min(Math.max(0, time), duration);
|
||||
|
||||
// Set waktu audio
|
||||
audioRef.current.currentTime = time;
|
||||
|
||||
audioRef.current.currentTime = safeTime;
|
||||
|
||||
// Update state jika provided
|
||||
if (setCurrentTime) {
|
||||
setCurrentTime(Math.round(time));
|
||||
setCurrentTime(Math.floor(safeTime));
|
||||
}
|
||||
}
|
||||
|
||||
145
src/app/darmasaba/(pages)/musik/lib/useAudioProgress.ts
Normal file
145
src/app/darmasaba/(pages)/musik/lib/useAudioProgress.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook untuk smooth audio progress update menggunakan requestAnimationFrame
|
||||
* Lebih smooth dan reliable dibanding onTimeUpdate event
|
||||
*/
|
||||
export function useAudioProgress(
|
||||
audioRef: React.RefObject<HTMLAudioElement>,
|
||||
isPlaying: boolean,
|
||||
setCurrentTime: (time: number) => void,
|
||||
isSeekingRef: React.RefObject<boolean>
|
||||
) {
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastTimeRef = useRef<number>(0);
|
||||
|
||||
const updateProgress = useCallback(() => {
|
||||
if (!audioRef.current || audioRef.current.paused || isSeekingRef.current) {
|
||||
rafRef.current = requestAnimationFrame(updateProgress);
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = audioRef.current;
|
||||
const currentTime = Math.floor(audio.currentTime);
|
||||
|
||||
// Hanya update state jika waktu berubah
|
||||
if (currentTime !== lastTimeRef.current) {
|
||||
lastTimeRef.current = currentTime;
|
||||
setCurrentTime(currentTime);
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(updateProgress);
|
||||
}, [audioRef, setCurrentTime, isSeekingRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
rafRef.current = requestAnimationFrame(updateProgress);
|
||||
} else if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, updateProgress]);
|
||||
|
||||
return rafRef;
|
||||
}
|
||||
|
||||
// 'use client'
|
||||
// import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
// export function useAudioEngine() {
|
||||
// const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
// const rafRef = useRef<number | null>(null);
|
||||
// const isSeekingRef = useRef(false);
|
||||
|
||||
// const [isPlaying, setIsPlaying] = useState(false);
|
||||
// const [currentTime, setCurrentTime] = useState(0);
|
||||
// const [duration, setDuration] = useState(0);
|
||||
|
||||
// const load = useCallback((src: string) => {
|
||||
// if (!audioRef.current) return;
|
||||
// audioRef.current.src = src;
|
||||
// audioRef.current.load();
|
||||
// setCurrentTime(0);
|
||||
// }, []);
|
||||
|
||||
// const play = async () => {
|
||||
// if (!audioRef.current) return;
|
||||
// await audioRef.current.play();
|
||||
// setIsPlaying(true);
|
||||
// };
|
||||
|
||||
// const pause = () => {
|
||||
// if (!audioRef.current) return;
|
||||
// audioRef.current.pause();
|
||||
// setIsPlaying(false);
|
||||
// };
|
||||
|
||||
// const toggle = () => {
|
||||
// if (!audioRef.current) return;
|
||||
// audioRef.current.paused ? play() : pause();
|
||||
// };
|
||||
|
||||
// const seek = (time: number) => {
|
||||
// if (!audioRef.current) return;
|
||||
// isSeekingRef.current = true;
|
||||
// audioRef.current.currentTime = time;
|
||||
// setCurrentTime(time);
|
||||
// requestAnimationFrame(() => {
|
||||
// isSeekingRef.current = false;
|
||||
// });
|
||||
// };
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!audioRef.current) return;
|
||||
// const audio = audioRef.current;
|
||||
|
||||
// const onLoaded = () => {
|
||||
// setDuration(Math.floor(audio.duration));
|
||||
// };
|
||||
|
||||
// const onEnded = () => {
|
||||
// setIsPlaying(false);
|
||||
// setCurrentTime(0);
|
||||
// };
|
||||
|
||||
// audio.addEventListener('loadedmetadata', onLoaded);
|
||||
// audio.addEventListener('ended', onEnded);
|
||||
|
||||
// return () => {
|
||||
// audio.removeEventListener('loadedmetadata', onLoaded);
|
||||
// audio.removeEventListener('ended', onEnded);
|
||||
// };
|
||||
// }, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// const loop = () => {
|
||||
// if (
|
||||
// audioRef.current &&
|
||||
// !audioRef.current.paused &&
|
||||
// !isSeekingRef.current
|
||||
// ) {
|
||||
// setCurrentTime(Math.floor(audioRef.current.currentTime));
|
||||
// }
|
||||
// rafRef.current = requestAnimationFrame(loop);
|
||||
// };
|
||||
// rafRef.current = requestAnimationFrame(loop);
|
||||
// return () => {
|
||||
// if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
// };
|
||||
// }, []);
|
||||
|
||||
// return {
|
||||
// audioRef,
|
||||
// isPlaying,
|
||||
// currentTime,
|
||||
// duration,
|
||||
// load,
|
||||
// toggle,
|
||||
// seek,
|
||||
// };
|
||||
// }
|
||||
@@ -6,9 +6,9 @@ import BackButton from '../../desa/layanan/_com/BackButto';
|
||||
import { togglePlayPause } from '../lib/playPause';
|
||||
import { getNextIndex, getPrevIndex } from '../lib/nextPrev';
|
||||
import { handleRepeatOrNext } from '../lib/repeat';
|
||||
import { seekTo } from '../lib/seek';
|
||||
import { toggleShuffle } from '../lib/shuffle';
|
||||
import { setAudioVolume, toggleMute as toggleMuteUtil } from '../lib/volume';
|
||||
import { useAudioProgress } from '../lib/useAudioProgress';
|
||||
|
||||
interface MusicFile {
|
||||
id: string;
|
||||
@@ -44,8 +44,13 @@ const MusicPlayer = () => {
|
||||
const [musikData, setMusikData] = useState<Musik[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const isSeekingRef = useRef(false);
|
||||
const lastPlayedSongIdRef = useRef<string | null>(null);
|
||||
const lastSeekTimeRef = useRef<number>(0); // Track last seek time
|
||||
|
||||
// Smooth progress update dengan requestAnimationFrame
|
||||
useAudioProgress(audioRef as React.RefObject<HTMLAudioElement>, isPlaying, setCurrentTime, isSeekingRef);
|
||||
|
||||
// Fetch musik data from API
|
||||
useEffect(() => {
|
||||
@@ -102,26 +107,55 @@ const MusicPlayer = () => {
|
||||
// };
|
||||
// }, [isPlaying]);
|
||||
|
||||
// Update duration when song changes
|
||||
// Update duration when song changes (HANYA saat ganti lagu, bukan saat isPlaying berubah)
|
||||
useEffect(() => {
|
||||
if (currentSong && audioRef.current) {
|
||||
// Gunakan durasi dari database sebagai acuan utama
|
||||
const durationParts = currentSong.durasi.split(':');
|
||||
const durationInSeconds = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
|
||||
setDuration(durationInSeconds);
|
||||
|
||||
// Reset audio currentTime ke 0 hanya untuk lagu baru
|
||||
audioRef.current.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(err => {
|
||||
console.error('Error playing audio:', err);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
// Cek apakah ini benar-benar lagu baru
|
||||
const isNewSong = lastPlayedSongIdRef.current !== currentSong.id;
|
||||
|
||||
if (isNewSong) {
|
||||
// Gunakan durasi dari database sebagai acuan utama
|
||||
const durationParts = currentSong.durasi.split(':');
|
||||
const durationInSeconds = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
|
||||
setDuration(durationInSeconds);
|
||||
|
||||
// Reset audio currentTime ke 0 untuk lagu baru
|
||||
audioRef.current.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
|
||||
// Update ref
|
||||
lastPlayedSongIdRef.current = currentSong.id;
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(err => {
|
||||
console.error('Error playing audio:', err);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Jika bukan lagu baru, jangan reset currentTime (biar seek tidak kembali ke 0)
|
||||
}
|
||||
}, [currentSong?.id, currentSongIndex]);
|
||||
}, [currentSong?.id]); // Intentional: hanya depend on song ID, bukan isPlaying
|
||||
|
||||
// Sync duration dari audio element jika berbeda signifikan (> 1 detik)
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !currentSong) return;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
const audioDuration = Math.floor(audio.duration);
|
||||
const durationParts = currentSong.durasi.split(':');
|
||||
const dbDuration = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
|
||||
|
||||
// Jika perbedaan > 2 detik, gunakan audio duration (lebih akurat)
|
||||
if (Math.abs(audioDuration - dbDuration) > 2) {
|
||||
setDuration(audioDuration);
|
||||
}
|
||||
};
|
||||
|
||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
return () => audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
}, [currentSong?.id]); // Intentional: hanya depend on song ID
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -202,24 +236,9 @@ const MusicPlayer = () => {
|
||||
{/* Hidden audio element - gunakan key yang stabil untuk mencegah remount */}
|
||||
{currentSong?.audioFile && (
|
||||
<audio
|
||||
key={`audio-${currentSong.id}`}
|
||||
ref={audioRef}
|
||||
src={currentSong.audioFile.link}
|
||||
src={currentSong?.audioFile?.link}
|
||||
muted={isMuted}
|
||||
onLoadedMetadata={(e) => {
|
||||
// Jangan override duration dari database
|
||||
// Audio element duration bisa berbeda beberapa ms
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={() => {
|
||||
if (!audioRef.current || isSeeking) return;
|
||||
// Gunakan pembulatan yang lebih smooth
|
||||
const time = audioRef.current.currentTime;
|
||||
const roundedTime = Math.round(time);
|
||||
setCurrentTime(roundedTime);
|
||||
}}
|
||||
onEnded={handleSongEnd}
|
||||
/>
|
||||
)}
|
||||
@@ -278,15 +297,29 @@ const MusicPlayer = () => {
|
||||
value={currentTime}
|
||||
max={duration}
|
||||
onChange={(v) => {
|
||||
setIsSeeking(true);
|
||||
isSeekingRef.current = true;
|
||||
setCurrentTime(v);
|
||||
}}
|
||||
onChangeEnd={(v) => {
|
||||
// Validasi: jangan seek melebihi durasi
|
||||
const seekTime = Math.min(Math.max(0, v), duration);
|
||||
// Set seeking false DULUAN sebelum seekTo
|
||||
setIsSeeking(false);
|
||||
seekTo(audioRef, seekTime, setCurrentTime);
|
||||
|
||||
if (audioRef.current) {
|
||||
// Set audio currentTime
|
||||
audioRef.current.currentTime = seekTime;
|
||||
setCurrentTime(seekTime);
|
||||
lastSeekTimeRef.current = seekTime;
|
||||
|
||||
// Jika audio tidak sedang playing, mainkan
|
||||
if (!audioRef.current.paused && !isPlaying) {
|
||||
audioRef.current.play().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set seeking false SETELAH semua operasi selesai
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false;
|
||||
}, 0);
|
||||
}}
|
||||
color="#0B4F78"
|
||||
size="sm"
|
||||
@@ -423,15 +456,29 @@ const MusicPlayer = () => {
|
||||
value={currentTime}
|
||||
max={duration}
|
||||
onChange={(v) => {
|
||||
setIsSeeking(true);
|
||||
isSeekingRef.current = true;
|
||||
setCurrentTime(v); // preview - update UI saja
|
||||
}}
|
||||
onChangeEnd={(v) => {
|
||||
// Validasi: jangan seek melebihi durasi
|
||||
const seekTime = Math.min(Math.max(0, v), duration);
|
||||
// Set seeking false DULUAN sebelum seekTo
|
||||
setIsSeeking(false);
|
||||
seekTo(audioRef, seekTime, setCurrentTime);
|
||||
|
||||
if (audioRef.current) {
|
||||
// Set audio currentTime
|
||||
audioRef.current.currentTime = seekTime;
|
||||
setCurrentTime(seekTime);
|
||||
lastSeekTimeRef.current = seekTime;
|
||||
|
||||
// Jika audio tidak sedang playing, mainkan
|
||||
if (!audioRef.current.paused && !isPlaying) {
|
||||
audioRef.current.play().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set seeking false SETELAH semua operasi selesai
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false;
|
||||
}, 0);
|
||||
}}
|
||||
color="#0B4F78"
|
||||
size="xs"
|
||||
@@ -460,4 +507,86 @@ const MusicPlayer = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default MusicPlayer;
|
||||
export default MusicPlayer;
|
||||
|
||||
// 'use client'
|
||||
// import {
|
||||
// Box, Paper, Group, Stack, Text, Slider, ActionIcon
|
||||
// } from '@mantine/core';
|
||||
// import {
|
||||
// IconPlayerPlayFilled,
|
||||
// IconPlayerPauseFilled
|
||||
// } from '@tabler/icons-react';
|
||||
// import { useEffect, useState } from 'react';
|
||||
// import { useAudioEngine } from '../lib/useAudioProgress';
|
||||
|
||||
// interface Musik {
|
||||
// id: string;
|
||||
// judul: string;
|
||||
// artis: string;
|
||||
// audioFile: { link: string };
|
||||
// }
|
||||
|
||||
// export default function MusicPlayer() {
|
||||
// const {
|
||||
// audioRef,
|
||||
// isPlaying,
|
||||
// currentTime,
|
||||
// duration,
|
||||
// load,
|
||||
// toggle,
|
||||
// seek,
|
||||
// } = useAudioEngine();
|
||||
|
||||
// const [songs, setSongs] = useState<Musik[]>([]);
|
||||
// const [index, setIndex] = useState(0);
|
||||
|
||||
// useEffect(() => {
|
||||
// fetch('/api/desa/musik/find-many?page=1&limit=50')
|
||||
// .then(r => r.json())
|
||||
// .then(r => setSongs(r.data ?? []));
|
||||
// }, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!songs[index]) return;
|
||||
// load(songs[index].audioFile.link);
|
||||
// }, [songs, index, load]);
|
||||
|
||||
// const format = (n: number) => {
|
||||
// const m = Math.floor(n / 60);
|
||||
// const s = Math.floor(n % 60);
|
||||
// return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <Box p="xl">
|
||||
// <audio ref={audioRef} />
|
||||
|
||||
// <Paper p="lg">
|
||||
// <Stack>
|
||||
// <Text fw={700}>{songs[index]?.judul}</Text>
|
||||
// <Text size="sm">{songs[index]?.artis}</Text>
|
||||
|
||||
// <Group>
|
||||
// <Text size="xs">{format(currentTime)}</Text>
|
||||
|
||||
// <Slider
|
||||
// value={currentTime}
|
||||
// max={duration}
|
||||
// onChange={seek}
|
||||
// style={{ flex: 1 }}
|
||||
// />
|
||||
|
||||
// <Text size="xs">{format(duration)}</Text>
|
||||
// </Group>
|
||||
|
||||
// <ActionIcon size={56} radius="xl" onClick={toggle}>
|
||||
// {isPlaying
|
||||
// ? <IconPlayerPauseFilled />
|
||||
// : <IconPlayerPlayFilled />}
|
||||
// </ActionIcon>
|
||||
// </Stack>
|
||||
// </Paper>
|
||||
// </Box>
|
||||
// );
|
||||
// }
|
||||
@@ -150,13 +150,13 @@ export default function Page() {
|
||||
<Box id="page-root">
|
||||
<Stack bg={colors.grey[1]} gap={0}>
|
||||
<LandingPage />
|
||||
<Apbdes />
|
||||
<Penghargaan />
|
||||
<Layanan />
|
||||
<Potensi />
|
||||
<DesaAntiKorupsi />
|
||||
<Kepuasan />
|
||||
<SDGS />
|
||||
<Apbdes />
|
||||
<Prestasi />
|
||||
<ScrollToTopButton />
|
||||
<NewsReaderLanding />
|
||||
|
||||
Reference in New Issue
Block a user